Files
rikx-beheer/app/projecten/[id]/ProjectDetailForm.tsx
T
remco-vermeer 9f0bd87c40 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>
2026-10-08 10:26:30 +02:00

314 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}