Voeg Module 1 Projecten toe met project plan, Rikx calculatie en Rikx rondes

- Projectenpagina met pipeline (standaard) en tabelweergave, filters op
  status, Rikx Ronde en zoeken
- Projectpagina in stappen: Project plan (velden uit de Rikx-aanvraag) en
  Rikx calculatie achter een schuif, live berekend volgens de rekenmethode
- Rikx rondes beheren (label, Rikx waarde, Rikx prijs) en koppelen aan projecten
- Oranje Rikx-menubalk met menu per module, gedeeld door alle pagina's
- Oude offerte-tabellen opgeruimd: leeg wordt verwijderd, met data hernoemd
  naar legacy_*
- CLAUDE.md bijgewerkt naar de huidige stand

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 10:26:30 +02:00
parent 46eecc31a4
commit 9f0bd87c40
25 changed files with 2161 additions and 55 deletions
+8
View File
@@ -0,0 +1,8 @@
"use client";
import type { ComponentProps } from "react";
// Select die het omringende formulier direct verstuurt bij een wijziging.
export function AutoSubmitSelect(props: ComponentProps<"select">) {
return <select {...props} onChange={(e) => e.currentTarget.form?.requestSubmit()} />;
}
+42
View File
@@ -0,0 +1,42 @@
import Link from "next/link";
import type { ProjectListItem } from "../../lib/db";
import { PROJECT_STATUSES } from "../../lib/projectStatus";
// Kanban met één kolom per status, zoals de pipeline in het oude Rikx-portaal.
export function ProjectPipeline({ projects, status }: { projects: ProjectListItem[]; status: string }) {
const columns = status ? PROJECT_STATUSES.filter((s) => s.id === status) : PROJECT_STATUSES;
return (
<div className="flex gap-4 overflow-x-auto pb-4 -mx-4 px-4 sm:mx-0 sm:px-0">
{columns.map((column) => {
const items = projects.filter((p) => p.status === column.id);
return (
<section
key={column.id}
className={`${column.columnClass} rounded-xl p-3 w-72 shrink-0 flex flex-col gap-3 min-h-96`}
>
<h2 className="flex items-center justify-between px-1 text-sm font-semibold text-zinc-800">
{column.label}
<span className="font-normal text-zinc-600">{items.length}</span>
</h2>
{items.map((p) => (
<Link
key={p.id}
href={`/projecten/${p.id}`}
className="block rounded-lg bg-white dark:bg-zinc-900 p-3 shadow-sm ring-1 ring-black/5 hover:ring-rikx-orange transition-shadow"
>
<p className="font-medium text-sm text-zinc-900 dark:text-zinc-100 break-words">{p.name}</p>
{p.ronde_label && (
<p className="mt-1 text-xs font-medium text-rikx-orange-dark">{p.ronde_label}</p>
)}
<p className="mt-1 text-xs text-zinc-500">
Aangemaakt {new Date(p.created_at).toLocaleDateString("nl-NL")}
</p>
</Link>
))}
</section>
);
})}
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import Link from "next/link";
import type { ProjectListItem } from "../../lib/db";
import { projectStatusBadgeClass, projectStatusLabel } from "../../lib/projectStatus";
export function ProjectTable({ projects, isFiltered }: { projects: ProjectListItem[]; isFiltered: boolean }) {
if (projects.length === 0) {
return (
<p className="text-sm text-zinc-500">
{isFiltered ? "Geen projecten gevonden met deze filters." : "Nog geen projecten toegevoegd."}
</p>
);
}
return (
<div className="rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-zinc-50 dark:bg-zinc-900 text-zinc-500 dark:text-zinc-400">
<tr>
<th className="text-left px-4 py-3 font-medium">Projectnaam</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th className="text-left px-4 py-3 font-medium">Rikx Ronde</th>
<th className="text-left px-4 py-3 font-medium">Aangemaakt</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{projects.map((p) => (
<tr key={p.id} className="bg-white dark:bg-zinc-950 hover:bg-orange-50/60 dark:hover:bg-zinc-900 transition-colors">
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
<Link href={`/projecten/${p.id}`} className="hover:text-rikx-orange-dark hover:underline">
{p.name}
</Link>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap ${projectStatusBadgeClass(p.status)}`}
>
{projectStatusLabel(p.status)}
</span>
</td>
<td className="px-4 py-3 text-zinc-700 dark:text-zinc-300 whitespace-nowrap">{p.ronde_label ?? "—"}</td>
<td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">
{new Date(p.created_at).toLocaleDateString("nl-NL")}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,92 @@
"use client";
import { useEffect, useRef, useState } from "react";
// Dropdown waarin meerdere opties aangevinkt kunnen worden. De keuzes gaan als
// checkboxes met dezelfde `name` mee in het formulier (FormData.getAll).
export function MultiSelectDropdown({
id,
name,
options,
defaultValue,
onChange,
className,
}: {
id: string;
name: string;
options: string[];
defaultValue: string[];
onChange?: (values: string[]) => void;
className: string;
}) {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>(defaultValue);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent | KeyboardEvent) => {
if (e instanceof KeyboardEvent ? e.key === "Escape" : !rootRef.current?.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", close);
document.addEventListener("keydown", close);
return () => {
document.removeEventListener("mousedown", close);
document.removeEventListener("keydown", close);
};
}, [open]);
function toggle(option: string, checked: boolean) {
const next = checked ? options.filter((o) => o === option || selected.includes(o)) : selected.filter((o) => o !== option);
setSelected(next);
onChange?.(next);
}
const summary =
selected.length === 0 ? "Maak een keuze" : selected.length === 1 ? selected[0] : `${selected.length} geselecteerd`;
return (
<div ref={rootRef} className="relative">
<button
id={id}
type="button"
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
className={`${className} flex items-center justify-between gap-2 text-left`}
>
<span className={`truncate ${selected.length ? "" : "text-zinc-400"}`}>{summary}</span>
<svg viewBox="0 0 10 6" aria-hidden className="w-2.5 h-2.5 shrink-0 fill-zinc-500">
<path d="M0 0h10L5 6z" />
</svg>
</button>
{/* Altijd gerenderd zodat de checkboxes meegaan in het formulier, ook als de lijst dicht is. */}
<div
hidden={!open}
className="absolute left-0 right-0 top-full z-30 mt-1 max-h-72 overflow-y-auto rounded-lg bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-black/10 py-1"
>
{options.map((option) => (
<label
key={option}
className="flex items-start gap-2 px-3 py-2 text-sm text-zinc-800 dark:text-zinc-200 hover:bg-orange-50 dark:hover:bg-zinc-800 cursor-pointer"
>
<input
type="checkbox"
name={name}
value={option}
checked={selected.includes(option)}
onChange={(e) => toggle(option, e.target.checked)}
className="mt-0.5 accent-rikx-orange"
/>
{option}
</label>
))}
</div>
{selected.length > 1 && (
<p className="mt-1 text-xs text-zinc-500">{selected.join(" · ")}</p>
)}
</div>
);
}
+313
View File
@@ -0,0 +1,313 @@
"use client";
import Link from "next/link";
import { useRef, useState, useTransition } from "react";
import type { Project, ProjectExpertScore, RikxRonde } from "../../../lib/db";
import { PROJECT_STATUSES } from "../../../lib/projectStatus";
import {
BONUS_MALUS_OPTIONS,
EXPERT_COUNT,
EXPERT_CRITERIA,
calculateRikx,
type ExpertScore,
} from "../../../lib/rikxCalculatie";
import { parseProjectPlanJson } from "../../../lib/projectPlan";
import { setRikxCalculatie, updateProject } from "../actions";
import { ProjectPlanFields } from "./ProjectPlanFields";
const inputClass =
"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 focus:outline-none focus:ring-2 focus:ring-rikx-orange";
const labelClass = "text-sm font-medium text-zinc-700 dark:text-zinc-300";
const cardClass = "bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6";
function toNumber(value: FormDataEntryValue | null): number | null {
if (typeof value !== "string" || value.trim() === "") return null;
const n = Number(value.replace(",", "."));
return Number.isFinite(n) ? n : null;
}
function rondeById(rondes: RikxRonde[], id: number | null) {
return rondes.find((r) => r.id === id) ?? null;
}
function readInput(form: HTMLFormElement, rondes: RikxRonde[]) {
const fd = new FormData(form);
const ronde = rondeById(rondes, Number(fd.get("ronde_id")) || null);
return {
ronde_id: ronde?.id ?? null,
projectkosten: toNumber(fd.get("projectkosten")),
deelnemers_werk: toNumber(fd.get("deelnemers_werk")),
deelnemers_skills: toNumber(fd.get("deelnemers_skills")),
financiering_extern_pct: toNumber(fd.get("financiering_extern_pct")),
bonus_malus: (fd.get("bonus_malus") as string) || null,
rikx_waarde: ronde?.rikx_waarde ?? null,
rikx_prijs: ronde?.rikx_prijs ?? null,
expert_scores: Array.from({ length: EXPERT_COUNT }, (_, i) =>
Object.fromEntries(EXPERT_CRITERIA.map((c) => [c.id, toNumber(fd.get(`expert_${i + 1}_${c.id}`))]))
) as ExpertScore[],
};
}
const nl = (digits: number) => new Intl.NumberFormat("nl-NL", { maximumFractionDigits: digits });
const euro = new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" });
const fmt = (v: number | null, kind: "getal" | "euro" | "geheel" = "getal") =>
v === null ? "—" : kind === "euro" ? euro.format(v) : nl(kind === "geheel" ? 0 : 2).format(v);
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<label className="flex flex-col gap-1">
<span className={labelClass}>{label}</span>
{children}
{hint && <span className="text-xs text-zinc-500">{hint}</span>}
</label>
);
}
function StepHeading({ step, title, description }: { step: number; title: string; description: string }) {
return (
<div className="flex items-start gap-3 mb-5">
<span className="flex items-center justify-center w-7 h-7 shrink-0 rounded-full bg-rikx-orange text-white text-sm font-semibold">
{step}
</span>
<div>
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100">{title}</h2>
<p className="text-sm text-zinc-500">{description}</p>
</div>
</div>
);
}
export function ProjectDetailForm({
project,
expertScores,
rondes,
}: {
project: Project;
expertScores: ProjectExpertScore[];
rondes: RikxRonde[];
}) {
const formRef = useRef<HTMLFormElement>(null);
const [calcOn, setCalcOn] = useState(project.rikx_calculatie_aan === 1);
const [pending, startTransition] = useTransition();
const initialRonde = rondeById(rondes, project.ronde_id);
const [input, setInput] = useState(() => ({
ronde_id: initialRonde?.id ?? null,
projectkosten: project.projectkosten,
deelnemers_werk: project.deelnemers_werk,
deelnemers_skills: project.deelnemers_skills,
financiering_extern_pct: project.financiering_extern_pct,
bonus_malus: project.bonus_malus,
rikx_waarde: initialRonde?.rikx_waarde ?? null,
rikx_prijs: initialRonde?.rikx_prijs ?? null,
expert_scores: Array.from({ length: EXPERT_COUNT }, (_, i) => {
const s = expertScores.find((e) => e.expert === i + 1);
return {
duur_impact: s?.duur_impact ?? null,
levensdomeinen: s?.levensdomeinen ?? null,
basissituatie: s?.basissituatie ?? null,
};
}),
}));
const result = calculateRikx(input);
const ronde = rondeById(rondes, input.ronde_id);
function toggleCalc() {
const next = !calcOn;
setCalcOn(next);
startTransition(() => setRikxCalculatie(project.id, next));
}
const results: { label: string; value: string; strong?: boolean }[] = [
{ label: "N-score", value: fmt(result.n_score) },
{ label: "Scale", value: fmt(result.scale, "geheel") },
{ label: "Impactscore M", value: fmt(result.impactscore_m) },
{ label: "IS +/- BM", value: fmt(result.is_bm) },
{ label: "Impactwaarde", value: fmt(result.impactwaarde) },
{ label: "Minus cofinanciering", value: fmt(result.minus_cofinanciering) },
{ label: "I - cofin", value: fmt(result.i_cofin) },
{ label: "Kosten/impact", value: fmt(result.kosten_impact) },
{ label: "Totale impactwaarde", value: fmt(result.totale_impactwaarde, "euro"), strong: true },
{ label: "Totaal aantal Rikx", value: fmt(result.totaal_aantal_rikx), strong: true },
{ label: "Meerwaarde", value: fmt(result.meerwaarde, "euro"), strong: true },
{ label: "Efficiëntiewaarde", value: fmt(result.efficientie_waarde), strong: true },
];
return (
<form
ref={formRef}
action={updateProject}
onInput={() => formRef.current && setInput(readInput(formRef.current, rondes))}
className="flex flex-col gap-6"
>
<input type="hidden" name="id" value={project.id} />
<section className={`${cardClass} grid grid-cols-1 sm:grid-cols-3 gap-4`}>
<Field label="Projectnaam">
<input name="name" required defaultValue={project.name} className={inputClass} />
</Field>
<Field label="Status">
<select name="status" defaultValue={project.status} className={inputClass}>
{PROJECT_STATUSES.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</Field>
<Field label="Rikx Ronde">
<select name="ronde_id" defaultValue={project.ronde_id ?? ""} className={inputClass}>
<option value="">Geen ronde</option>
{rondes.map((r) => (
<option key={r.id} value={r.id}>
{r.label}
</option>
))}
</select>
</Field>
</section>
<section className={cardClass}>
<StepHeading step={1} title="Project plan" description="In te vullen door de sociaal ondernemer." />
<ProjectPlanFields project={project} plan={parseProjectPlanJson(project.project_plan)} />
</section>
<div className="flex items-center justify-between gap-4 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-6 py-4">
<div>
<p className="font-medium text-zinc-900 dark:text-zinc-100">Rikx calculatie</p>
<p className="text-sm text-zinc-500">Toon de waardebepaling volgens de Rikx-rekenmethode.</p>
</div>
<button
type="button"
role="switch"
aria-checked={calcOn}
aria-label="Rikx calculatie tonen"
onClick={toggleCalc}
disabled={pending}
className={`relative inline-flex h-7 w-12 shrink-0 items-center rounded-full transition-colors disabled:opacity-60 ${
calcOn ? "bg-rikx-orange" : "bg-zinc-300 dark:bg-zinc-700"
}`}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-white shadow transition-transform ${
calcOn ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
{/* Verborgen in plaats van weggelaten, zodat de waarden bij opslaan bewaard blijven. */}
<section className={cardClass} hidden={!calcOn}>
<StepHeading
step={2}
title="Rikx calculatie"
description="Waardebepaling door het expertteam en Rikx. Uitkomsten worden live berekend."
/>
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Expertscores (1–10)</h3>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm">
<thead>
<tr className="text-zinc-500">
<th className="text-left font-medium py-2 pr-3">Criterium</th>
{Array.from({ length: EXPERT_COUNT }, (_, i) => (
<th key={i} className="text-left font-medium py-2 px-1 min-w-24">
Expert {i + 1}
</th>
))}
</tr>
</thead>
<tbody>
{EXPERT_CRITERIA.map((c) => (
<tr key={c.id}>
<td className="py-1 pr-3 text-zinc-700 dark:text-zinc-300 whitespace-nowrap">{c.label}</td>
{Array.from({ length: EXPERT_COUNT }, (_, i) => (
<td key={i} className="py-1 px-1">
<input
name={`expert_${i + 1}_${c.id}`}
type="number"
min={1}
max={10}
step="0.5"
aria-label={`${c.label}, expert ${i + 1}`}
defaultValue={input.expert_scores[i][c.id] ?? ""}
className={inputClass}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
<p className="text-xs text-zinc-500 mt-1">Voorlopig handmatig; met Module 4 vullen de experts dit zelf in.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<Field label="Bonus / Malus" hint="Collectief oordeel van de experts: ++ 1,2 · + 1,1 · geen 1,0 · - 0,9 · -- 0,8">
<select name="bonus_malus" defaultValue={project.bonus_malus ?? ""} className={inputClass}>
{BONUS_MALUS_OPTIONS.map((o) => (
<option key={o.label} value={o.id}>
{o.label}
</option>
))}
</select>
</Field>
<div className="flex flex-col gap-1">
<span className={labelClass}>Rikx waarde en Rikx prijs</span>
{ronde ? (
<p className="h-10 flex items-center gap-4 rounded-lg bg-zinc-50 dark:bg-zinc-800/50 px-3 text-sm tabular-nums text-zinc-800 dark:text-zinc-200">
<span>Waarde {fmt(ronde.rikx_waarde)}</span>
<span>Prijs {fmt(ronde.rikx_prijs, "euro")}</span>
</p>
) : (
<p className="h-10 flex items-center rounded-lg bg-zinc-50 dark:bg-zinc-800/50 px-3 text-sm text-zinc-500">
Kies eerst een Rikx Ronde
</p>
)}
<span className="text-xs text-zinc-500">
Uit de Rikx Ronde{ronde ? ` "${ronde.label}"` : ""}; aan te passen bij{" "}
<Link href="/rondes" className="underline hover:text-rikx-orange-dark">
Rikx rondes
</Link>
.
</span>
</div>
</div>
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Uitkomsten</h3>
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 rounded-lg bg-zinc-50 dark:bg-zinc-800/50 px-4 py-2">
{results.map((r) => (
<div key={r.label} className="flex items-center justify-between gap-4 py-2 border-b border-zinc-200/70 dark:border-zinc-700/70">
<dt className="text-sm text-zinc-600 dark:text-zinc-400">{r.label}</dt>
<dd className={`text-sm tabular-nums ${r.strong ? "font-semibold text-zinc-900 dark:text-zinc-100" : "text-zinc-800 dark:text-zinc-200"}`}>
{r.value}
</dd>
</div>
))}
</dl>
</section>
<div className="flex gap-3">
<button
type="submit"
className="rounded-lg bg-rikx-maroon hover:bg-rikx-maroon-light px-5 py-2 text-sm font-medium text-white transition-colors"
>
Opslaan
</button>
<Link
href="/projecten"
className="rounded-lg border border-zinc-300 dark:border-zinc-700 px-5 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
>
Annuleren
</Link>
</div>
</form>
);
}
+152
View File
@@ -0,0 +1,152 @@
"use client";
import { useState } from "react";
import type { Project } from "../../../lib/db";
import {
ANDERS,
PROJECT_PLAN_GROUPS,
andersKey,
type PlanField,
type ProjectPlan,
} from "../../../lib/projectPlan";
import { MultiSelectDropdown } from "./MultiSelectDropdown";
const inputClass =
"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 focus:outline-none focus:ring-2 focus:ring-rikx-orange";
const textareaClass =
"w-full min-h-28 rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-rikx-orange";
type ColumnKey = "projectkosten" | "deelnemers_werk" | "deelnemers_skills" | "financiering_extern_bedrag" | "financiering_extern_pct";
function FieldInput({
field,
project,
plan,
onSelectChange,
}: {
field: PlanField;
project: Project;
plan: ProjectPlan;
onSelectChange: (key: string, value: string) => void;
}) {
const id = `plan-${field.key}`;
const saved = plan[field.key];
const [anders, setAnders] = useState(Array.isArray(saved) && saved.includes(ANDERS));
switch (field.kind) {
case "textarea":
return <textarea id={id} name={field.key} defaultValue={typeof saved === "string" ? saved : ""} className={textareaClass} />;
case "number": {
const value = field.column ? project[field.key as ColumnKey] : saved;
return (
<input
id={id}
name={field.key}
type="number"
min={field.min}
max={field.max}
step={field.step}
defaultValue={typeof value === "number" ? value : ""}
className={inputClass}
/>
);
}
case "date":
return <input id={id} name={field.key} type="date" defaultValue={typeof saved === "string" ? saved : ""} className={inputClass} />;
case "select":
return (
<select
id={id}
name={field.key}
defaultValue={typeof saved === "string" ? saved : ""}
onChange={(e) => onSelectChange(field.key, e.target.value)}
className={inputClass}
>
<option value="">Maak een keuze</option>
{field.options.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
);
case "multiselect": {
const andersValue = plan[andersKey(field.key)];
return (
<>
<MultiSelectDropdown
id={id}
name={field.key}
options={field.options}
defaultValue={Array.isArray(saved) ? saved : []}
onChange={(values) => setAnders(values.includes(ANDERS))}
className={inputClass}
/>
{anders && (
<input
name={andersKey(field.key)}
aria-label={`${field.label} – anders, namelijk`}
placeholder="Anders, namelijk…"
defaultValue={typeof andersValue === "string" ? andersValue : ""}
className={`${inputClass} mt-2`}
/>
)}
</>
);
}
}
}
// Stap 1 "Project plan": alle velden uit lib/projectPlan.ts, gegroepeerd.
export function ProjectPlanFields({ project, plan }: { project: Project; plan: ProjectPlan }) {
// Huidige waarden van selects, voor velden met showIf (bv. "Zo ja, licht toe")
const [selects, setSelects] = useState<Record<string, string>>(() =>
Object.fromEntries(
PROJECT_PLAN_GROUPS.flatMap((g) => g.fields)
.filter((f) => f.kind === "select")
.map((f) => [f.key, typeof plan[f.key] === "string" ? (plan[f.key] as string) : ""])
)
);
return (
<div className="flex flex-col gap-8">
{PROJECT_PLAN_GROUPS.map((group) => (
<fieldset key={group.title} className="flex flex-col gap-4">
<legend className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-3 pb-2 border-b border-zinc-200 dark:border-zinc-800 w-full">
{group.title}
</legend>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-5">
{group.fields
.filter((f) => !f.showIf || selects[f.showIf.key] === f.showIf.equals)
.map((field) => (
<div
key={field.key}
className={[
"flex flex-col gap-1",
field.wide ? "sm:col-span-2" : "",
field.newRow ? "sm:col-start-1" : "",
field.followUp ? "-mt-3 ml-2 pl-4 border-l-2 border-rikx-orange" : "",
].join(" ")}
>
{/* Minimaal twee regels hoog, zodat velden naast elkaar uitlijnen bij labels van ongelijke lengte */}
<label
htmlFor={`plan-${field.key}`}
className={`text-sm font-medium text-zinc-700 dark:text-zinc-300 ${field.wide || field.newRow ? "" : "sm:min-h-10"}`}
>
{field.label}
</label>
<FieldInput
field={field}
project={project}
plan={plan}
onSelectChange={(key, value) => setSelects((s) => ({ ...s, [key]: value }))}
/>
{field.hint && <span className="text-xs text-zinc-500">{field.hint}</span>}
</div>
))}
</div>
</fieldset>
))}
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getSession } from "../../../lib/auth";
import { getDb, listRikxRondes, type Project, type ProjectExpertScore } from "../../../lib/db";
import { projectStatusBadgeClass, projectStatusLabel } from "../../../lib/projectStatus";
import { AppHeader } from "../../AppHeader";
import { ProjectDetailForm } from "./ProjectDetailForm";
export default async function ProjectPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string; opgeslagen?: string }>;
}) {
const session = await getSession();
if (!session) redirect("/login");
const { id } = await params;
const { error, opgeslagen } = await searchParams;
const db = getDb();
const row = db.prepare("SELECT * FROM projects WHERE id = ?").get(Number(id)) as Project | undefined;
if (!row) notFound();
// node:sqlite levert objecten met null-prototype; spreiden maakt ze doorgeefbaar aan client components.
const project = { ...row };
const expertScores = (
db.prepare("SELECT * FROM project_expert_scores WHERE project_id = ? ORDER BY expert").all(project.id) as ProjectExpertScore[]
).map((s) => ({ ...s }));
const message =
error === "missing"
? { tone: "error", text: "Vul een projectnaam in." }
: error === "invalid"
? { tone: "error", text: "Niet opgeslagen: controleer de getallen (percentage 0–100, expertscores 1–10, geen negatieve waarden)." }
: error === "datum"
? { tone: "error", text: "Niet opgeslagen: controleer de datums (de einddatum mag niet vóór de startdatum liggen)." }
: opgeslagen
? { tone: "ok", text: "Project opgeslagen." }
: null;
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<AppHeader email={session.email} active="projecten" />
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-8 flex flex-col gap-6">
<Link
href="/projecten"
className="text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors"
>
← Terug naar projecten
</Link>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">{project.name}</h1>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${projectStatusBadgeClass(project.status)}`}
>
{projectStatusLabel(project.status)}
</span>
</div>
{message && (
<p
className={
message.tone === "error"
? "text-sm text-red-600 dark:text-red-400"
: "text-sm text-green-700 dark:text-green-400"
}
>
{message.text}
</p>
)}
<ProjectDetailForm project={project} expertScores={expertScores} rondes={listRikxRondes()} />
</main>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use server";
import { redirect } from "next/navigation";
import { getDb } from "../../lib/db";
import { getSession } from "../../lib/auth";
import { DEFAULT_PROJECT_STATUS, isProjectStatus } from "../../lib/projectStatus";
import { readProjectPlan } from "../../lib/projectPlan";
import { BONUS_MALUS_OPTIONS, EXPERT_COUNT, EXPERT_CRITERIA } from "../../lib/rikxCalculatie";
export async function createProject(formData: FormData) {
const session = await getSession();
if (!session) redirect("/login");
const name = ((formData.get("name") as string) ?? "").trim();
const rawStatus = formData.get("status");
const status = isProjectStatus(rawStatus) ? rawStatus : DEFAULT_PROJECT_STATUS;
if (!name) redirect("/projecten/nieuw?error=missing");
const db = getDb();
db.prepare("INSERT INTO projects (name, status, ronde_id) VALUES (?, ?, ?)").run(
name,
status,
readRondeId(db, formData)
);
redirect("/projecten");
}
// Geeft het id van een bestaande Rikx Ronde terug, of null bij "geen ronde"/onbekend id.
function readRondeId(db: ReturnType<typeof getDb>, formData: FormData): number | null {
const id = Number(formData.get("ronde_id"));
if (!id) return null;
return db.prepare("SELECT id FROM rikx_rondes WHERE id = ?").get(id) ? id : null;
}
// Leeg veld → null; accepteert zowel 1234.5 als 1234,5
function parseNumber(value: FormDataEntryValue | null): number | null {
if (typeof value !== "string" || value.trim() === "") return null;
const n = Number(value.replace(",", "."));
return Number.isFinite(n) ? n : NaN;
}
function inRange(n: number | null, min: number, max = Infinity) {
return n === null || (n >= min && n <= max);
}
export async function updateProject(formData: FormData) {
const session = await getSession();
if (!session) redirect("/login");
const id = Number(formData.get("id"));
if (!id) redirect("/projecten");
const name = ((formData.get("name") as string) ?? "").trim();
const rawStatus = formData.get("status");
if (!name) redirect(`/projecten/${id}?error=missing`);
const projectkosten = parseNumber(formData.get("projectkosten"));
const deelnemersWerk = parseNumber(formData.get("deelnemers_werk"));
const deelnemersSkills = parseNumber(formData.get("deelnemers_skills"));
const externBedrag = parseNumber(formData.get("financiering_extern_bedrag"));
const externPct = parseNumber(formData.get("financiering_extern_pct"));
const rawBonusMalus = (formData.get("bonus_malus") as string) ?? "";
const bonusMalus = BONUS_MALUS_OPTIONS.some((o) => o.id === rawBonusMalus) && rawBonusMalus ? rawBonusMalus : null;
const expertScores = Array.from({ length: EXPERT_COUNT }, (_, i) =>
EXPERT_CRITERIA.map((c) => parseNumber(formData.get(`expert_${i + 1}_${c.id}`)))
);
const valid =
inRange(projectkosten, 0) &&
inRange(deelnemersWerk, 0) &&
inRange(deelnemersSkills, 0) &&
inRange(externBedrag, 0) &&
inRange(externPct, 0, 100) &&
expertScores.every((scores) => scores.every((s) => inRange(s, 1, 10)));
if (!valid) redirect(`/projecten/${id}?error=invalid`);
const planResult = readProjectPlan(formData);
if ("error" in planResult) redirect(`/projecten/${id}?error=${planResult.error}`);
const db = getDb();
db.exec("BEGIN");
try {
db.prepare(
`UPDATE projects SET
name = ?, status = COALESCE(?, status),
projectkosten = ?, deelnemers_werk = ?, deelnemers_skills = ?,
financiering_extern_bedrag = ?, financiering_extern_pct = ?, project_plan = ?,
bonus_malus = ?, ronde_id = ?, updated_at = datetime('now')
WHERE id = ?`
).run(
name,
isProjectStatus(rawStatus) ? rawStatus : null,
projectkosten,
deelnemersWerk,
deelnemersSkills,
externBedrag,
externPct,
JSON.stringify(planResult.plan),
bonusMalus,
readRondeId(db, formData),
id
);
const upsert = db.prepare(
`INSERT INTO project_expert_scores (project_id, expert, duur_impact, levensdomeinen, basissituatie)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (project_id, expert) DO UPDATE SET
duur_impact = excluded.duur_impact,
levensdomeinen = excluded.levensdomeinen,
basissituatie = excluded.basissituatie`
);
expertScores.forEach((scores, i) => upsert.run(id, i + 1, ...scores));
db.exec("COMMIT");
} catch (err) {
db.exec("ROLLBACK");
throw err;
}
redirect(`/projecten/${id}?opgeslagen=1`);
}
// Schuif "Rikx calculatie" op de projectpagina; wordt direct opgeslagen.
export async function setRikxCalculatie(id: number, aan: boolean) {
const session = await getSession();
if (!session) redirect("/login");
getDb()
.prepare("UPDATE projects SET rikx_calculatie_aan = ?, updated_at = datetime('now') WHERE id = ?")
.run(aan ? 1 : 0, id);
}
+90
View File
@@ -0,0 +1,90 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getSession } from "../../../lib/auth";
import { listRikxRondes } from "../../../lib/db";
import { DEFAULT_PROJECT_STATUS, PROJECT_STATUSES } from "../../../lib/projectStatus";
import { AppHeader } from "../../AppHeader";
import { createProject } from "../actions";
export default async function NieuwProjectPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const session = await getSession();
if (!session) redirect("/login");
const { error } = await searchParams;
const rondes = listRikxRondes();
const errorMsg = error === "missing" ? "Vul een projectnaam in." : null;
const inputClass =
"rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-rikx-orange";
return (
<div className="min-h-screen bg-white dark:bg-zinc-950">
<AppHeader email={session.email} active="projecten" />
<main className="max-w-6xl mx-auto px-4 sm:px-6 py-10 flex flex-col gap-6">
<Link
href="/projecten"
className="text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors"
>
← Terug naar projecten
</Link>
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Nieuw project</h2>
{errorMsg && <p className="text-sm text-red-600 dark:text-red-400">{errorMsg}</p>}
<form
action={createProject}
className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6 grid grid-cols-1 sm:grid-cols-3 gap-4"
>
<div className="flex flex-col gap-1">
<label htmlFor="name" className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
Projectnaam<span className="text-red-500 ml-1">*</span>
</label>
<input id="name" type="text" name="name" required autoFocus className={inputClass} />
</div>
<div className="flex flex-col gap-1">
<label htmlFor="status" className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
Status
</label>
<select id="status" name="status" defaultValue={DEFAULT_PROJECT_STATUS} className={inputClass}>
{PROJECT_STATUSES.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="ronde_id" className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
Rikx Ronde
</label>
<select id="ronde_id" name="ronde_id" defaultValue="" className={inputClass}>
<option value="">Geen ronde</option>
{rondes.map((r) => (
<option key={r.id} value={r.id}>
{r.label}
</option>
))}
</select>
</div>
<div className="sm:col-span-3 flex gap-3">
<button
type="submit"
className="rounded-lg bg-rikx-maroon hover:bg-rikx-maroon-light px-5 py-2 text-sm font-medium text-white transition-colors"
>
Opslaan
</button>
<Link
href="/projecten"
className="rounded-lg border border-zinc-300 dark:border-zinc-700 px-5 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
>
Annuleren
</Link>
</div>
</form>
</main>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getSession } from "../../lib/auth";
import { getDb, listRikxRondes, type ProjectListItem } from "../../lib/db";
import { PROJECT_STATUSES, isProjectStatus } from "../../lib/projectStatus";
import { AppHeader } from "../AppHeader";
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 getSession();
if (!session) redirect("/login");
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>
<Link
href="/projecten/nieuw"
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
</Link>
</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>
);
}