Files
rikx-beheer/app/projecten/page.tsx
T
remco-vermeer 53bf667f54 Voeg Module 4 experts, waardebepalingspagina en projectverbeteringen toe
Experts (Module 4)
- Pagina Experts (CRM): experts uitnodigen per e-mail (zonder SMTP wordt de
  link getoond), rondes koppelen, opnieuw uitnodigen, verwijderen en
  "Inloggen als" (alleen lezen) om mee te kijken
- Uitnodigingspagina waar een expert zijn wachtwoord instelt (token 7 dagen
  geldig, eenmalig, alleen als hash opgeslagen)
- Expertpagina: projecten in waardebepaling uit de eigen rondes, project plan
  lezen en eigen beoordeling geven (3 scores, advies Bonus/Malus, toelichting)
- Rollen admin/expert: alle beheerpagina's en -acties controleren de rol
- Rikx calculatie gebruikt de beoordelingen van de experts in plaats van de
  handmatige expertkolommen; experts koppelen ook vanaf de rondepagina

Waardebepaling projecten
- Nieuwe pagina met projecten in waardebepaling op kosten/impact (laag naar
  hoog), filter op ronde, Rikx Waarde-veld dat live doorrekent en opslaat in
  de ronde, en een 75%-lijn bij een gekozen ronde

Projecten
- Rikx calculatie als tab, zichtbaar vanaf status Waardebepaling, met keuze
  rekenmethode (Arbeid; Jeugd nog niet beschikbaar) en beoordeling
  goedgekeurd/afgekeurd met reden
- Impactmaker gekoppeld aan een sociaal ondernemer
- Opslaan-knop bovenaan; ongeldige velden op een verborgen tab worden getoond
- Project toevoegen maakt direct een project aan; aparte pagina verwijderd
- Waarderingssystematiek Rikx juni 2023 toegevoegd aan _doc

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:42:22 +02:00

166 lines
6.5 KiB
TypeScript

import Link from "next/link";
import { redirect } from "next/navigation";
import { requireAdmin } from "../../lib/auth";
import { getDb, listRikxRondes, type ProjectListItem } from "../../lib/db";
import { PROJECT_STATUSES, isProjectStatus } from "../../lib/projectStatus";
import { AppHeader } from "../AppHeader";
import { createProject } from "./actions";
import { AutoSubmitSelect } from "./AutoSubmitSelect";
import { ProjectPipeline } from "./ProjectPipeline";
import { ProjectTable } from "./ProjectTable";
const fieldClass =
"w-full h-10 rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 text-sm text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-rikx-orange";
const labelClass = "block text-sm text-zinc-700 dark:text-zinc-300 mb-1";
export default async function ProjectenPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; status?: string; ronde?: string; weergave?: string }>;
}) {
const session = await requireAdmin();
const params = await searchParams;
const q = (params.q ?? "").trim();
const status = isProjectStatus(params.status) ? params.status : "";
const rondes = listRikxRondes();
// "geen" = projecten zonder ronde; anders het id van een bestaande ronde
const ronde =
params.ronde === "geen" || rondes.some((r) => String(r.id) === params.ronde) ? params.ronde! : "";
// Pipeline is altijd de standaardweergave; de tabel alleen als die expliciet gekozen is.
const view = params.weergave === "tabel" ? "tabel" : "pipeline";
const where: string[] = [];
const args: (string | number)[] = [];
if (q) {
where.push("p.name LIKE ? ESCAPE '\\'");
args.push(`%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`);
}
if (status) {
where.push("p.status = ?");
args.push(status);
}
if (ronde === "geen") {
where.push("p.ronde_id IS NULL");
} else if (ronde) {
where.push("p.ronde_id = ?");
args.push(Number(ronde));
}
const projects = getDb()
.prepare(
`SELECT p.*, r.label AS ronde_label
FROM projects p LEFT JOIN rikx_rondes r ON r.id = p.ronde_id
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
ORDER BY p.created_at DESC, p.id DESC`
)
.all(...args) as ProjectListItem[];
const isFiltered = Boolean(q || status || ronde);
function viewHref(target: "pipeline" | "tabel") {
const sp = new URLSearchParams();
if (target === "tabel") sp.set("weergave", "tabel");
if (status) sp.set("status", status);
if (ronde) sp.set("ronde", ronde);
if (q) sp.set("q", q);
const qs = sp.toString();
return qs ? `/projecten?${qs}` : "/projecten";
}
const toggleClass = (isActive: boolean) =>
isActive
? "rounded-lg bg-zinc-900 dark:bg-zinc-100 px-5 py-2.5 text-sm font-medium text-white dark:text-zinc-900"
: "rounded-lg px-5 py-2.5 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors";
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<AppHeader email={session.email} active="projecten" />
<main className="max-w-[96rem] mx-auto px-4 sm:px-6 py-6 flex flex-col gap-6">
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-4 sm:p-5 flex flex-wrap items-end gap-4">
<nav aria-label="Weergave" className="flex items-center gap-1">
<Link href={viewHref("pipeline")} className={toggleClass(view === "pipeline")} aria-current={view === "pipeline" ? "page" : undefined}>
Pipeline
</Link>
<Link href={viewHref("tabel")} className={toggleClass(view === "tabel")} aria-current={view === "tabel" ? "page" : undefined}>
Tabel
</Link>
</nav>
<form action="/projecten" className="contents">
{view === "tabel" && <input type="hidden" name="weergave" value="tabel" />}
<div className="w-full sm:w-56">
<label htmlFor="filter-status" className={labelClass}>
Status
</label>
<AutoSubmitSelect id="filter-status" name="status" defaultValue={status} className={fieldClass}>
<option value="">Alle</option>
{PROJECT_STATUSES.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</AutoSubmitSelect>
</div>
<div className="w-full sm:w-56">
<label htmlFor="filter-ronde" className={labelClass}>
Rikx Ronde
</label>
<AutoSubmitSelect id="filter-ronde" name="ronde" defaultValue={ronde} className={fieldClass}>
<option value="">Alle</option>
{rondes.map((r) => (
<option key={r.id} value={r.id}>
{r.label}
</option>
))}
<option value="geen">Zonder ronde</option>
</AutoSubmitSelect>
</div>
<div className="w-full sm:w-auto sm:flex-1 sm:min-w-56">
<label htmlFor="filter-q" className={labelClass}>
Zoeken
</label>
<input
id="filter-q"
type="search"
name="q"
defaultValue={q}
placeholder="Projectnaam"
className={fieldClass}
/>
</div>
</form>
<form action={createProject}>
<button
type="submit"
className="rounded-lg bg-rikx-maroon hover:bg-rikx-maroon-light px-5 py-2.5 text-sm font-medium text-white transition-colors whitespace-nowrap"
>
+ Project toevoegen
</button>
</form>
</div>
{isFiltered && (
<div className="flex items-center gap-4 text-sm">
<p className="text-zinc-500">
{projects.length} {projects.length === 1 ? "project" : "projecten"} gevonden
</p>
<Link
href={view === "tabel" ? "/projecten?weergave=tabel" : "/projecten"}
className="text-rikx-orange-dark hover:underline"
>
Filters wissen
</Link>
</div>
)}
{view === "pipeline" ? (
<ProjectPipeline projects={projects} status={status} />
) : (
<ProjectTable projects={projects} isFiltered={isFiltered} />
)}
</main>
</div>
);
}