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
+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>
);
}