Files
rikx-beheer/app/projecten/[id]/ProjectDetailForm.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

389 lines
16 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 } from "react";
import type { Project, RikxRonde } from "../../../lib/db";
import { PROJECT_STATUSES, RIKX_CALCULATIE_VANAF_STATUS, isStatusVanaf } from "../../../lib/projectStatus";
import {
BONUS_MALUS_OPTIONS,
REKENMETHODES,
calculateRikx,
rikxInputFromProject,
type ExpertScore,
} from "../../../lib/rikxCalculatie";
import { parseProjectPlanJson } from "../../../lib/projectPlan";
import { updateProject } from "../actions";
import { PROJECT_FORM_ID } from "./formId";
import { ProjectPlanFields } from "./ProjectPlanFields";
import { ExpertBeoordelingen, type BeoordelingMetNaam } from "./ExpertBeoordelingen";
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[], expertScores: ExpertScore[]) {
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,
// Expertscores komen van de experts zelf en zijn hier niet te wijzigen
expert_scores: expertScores,
};
}
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 TabHeading({ title, description }: { title: string; description: string }) {
return (
<div className="mb-5">
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100">{title}</h2>
<p className="text-sm text-zinc-500">{description}</p>
</div>
);
}
export type ProjectTab = "plan" | "calculatie";
const BEOORDELINGEN = [
{ id: "goedgekeurd", label: "Goedgekeurd", activeClass: "bg-green-600 text-white" },
{ id: "afgekeurd", label: "Afgekeurd", activeClass: "bg-red-600 text-white" },
] as const;
// Schakelaar Goedgekeurd | Afgekeurd. Begint leeg (nog niet beoordeeld);
// nogmaals op de actieve keuze klikken maakt de keuze ongedaan.
function BeoordelingSwitch({ defaultValue }: { defaultValue: string | null }) {
const [value, setValue] = useState(defaultValue ?? "");
return (
<div className="flex flex-wrap items-center gap-3">
<input type="hidden" name="beoordeling" value={value} />
<div role="radiogroup" aria-label="Beoordeling" className="inline-flex rounded-lg bg-zinc-100 dark:bg-zinc-800 p-1">
{BEOORDELINGEN.map((b) => {
const active = value === b.id;
return (
<button
key={b.id}
type="button"
role="radio"
aria-checked={active}
onClick={() => setValue(active ? "" : b.id)}
className={`rounded-md px-5 py-2 text-sm font-medium transition-colors ${
active ? b.activeClass : "text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-zinc-100"
}`}
>
{b.label}
</button>
);
})}
</div>
{!value && <span className="text-xs text-zinc-500">Nog niet beoordeeld</span>}
</div>
);
}
export function ProjectDetailForm({
project,
beoordelingen,
rondes,
impactmakers,
initialTab,
}: {
project: Project;
beoordelingen: BeoordelingMetNaam[];
rondes: RikxRonde[];
impactmakers: { id: number; name: string }[];
initialTab: ProjectTab;
}) {
const formRef = useRef<HTMLFormElement>(null);
// Live status uit de dropdown, zodat de tab Rikx calculatie direct verschijnt of verdwijnt.
const [status, setStatus] = useState(project.status);
const calculatieZichtbaar = isStatusVanaf(status, RIKX_CALCULATIE_VANAF_STATUS);
const [tab, setTab] = useState<ProjectTab>(initialTab);
const activeTab: ProjectTab = tab === "calculatie" && calculatieZichtbaar ? "calculatie" : "plan";
const initialRonde = rondeById(rondes, project.ronde_id);
const [input, setInput] = useState(() => ({
ronde_id: initialRonde?.id ?? null,
...rikxInputFromProject(project, beoordelingen, initialRonde),
}));
const result = calculateRikx(input);
const ronde = rondeById(rondes, input.ronde_id);
// De browser weigert te versturen bij een ongeldig veld, maar toont geen melding als dat veld op een
// verborgen tab staat. Daarom: naar die tab gaan en de melding bij het eerste ongeldige veld tonen.
const invalidHandledRef = useRef(false);
function handleInvalid(e: React.FormEvent<HTMLFormElement>) {
if (invalidHandledRef.current) return;
const field = e.target as HTMLInputElement;
const panel = field.closest<HTMLElement>("[role=tabpanel]");
if (!panel?.hidden) return;
invalidHandledRef.current = true;
setTab(panel.id === "panel-calculatie" ? "calculatie" : "plan");
setTimeout(() => {
invalidHandledRef.current = false;
field.reportValidity();
field.focus();
}, 50);
}
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
id={PROJECT_FORM_ID}
ref={formRef}
action={updateProject}
onInput={() => formRef.current && setInput(readInput(formRef.current, rondes, input.expert_scores))}
onInvalidCapture={handleInvalid}
className="flex flex-col gap-6"
>
<input type="hidden" name="id" value={project.id} />
<input type="hidden" name="tab" value={activeTab} />
<section className={`${cardClass} grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4`}>
<Field label="Projectnaam">
<input name="name" required defaultValue={project.name} className={inputClass} />
</Field>
<Field label="Status">
<select name="status" value={status} onChange={(e) => setStatus(e.target.value)} className={inputClass}>
{PROJECT_STATUSES.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</Field>
<Field label="Impactmaker">
<select name="impactmaker_account_id" defaultValue={project.impactmaker_account_id ?? ""} className={inputClass}>
<option value="">Geen impactmaker</option>
{impactmakers.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</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>
<div role="tablist" aria-label="Projectonderdelen" className="flex gap-1 border-b border-zinc-200 dark:border-zinc-800 -mb-6">
{([
{ id: "plan", label: "Project plan", zichtbaar: true },
{ id: "calculatie", label: "Rikx calculatie", zichtbaar: calculatieZichtbaar },
] as const)
.filter((t) => t.zichtbaar)
.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`tab-${t.id}`}
aria-selected={activeTab === t.id}
aria-controls={`panel-${t.id}`}
onClick={() => setTab(t.id)}
className={`-mb-px px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === t.id
? "border-rikx-orange text-zinc-900 dark:text-zinc-100"
: "border-transparent text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100"
}`}
>
{t.label}
</button>
))}
</div>
{/* Tabs worden verborgen in plaats van weggelaten, zodat alle waarden bij opslaan meegaan. */}
<section id="panel-plan" role="tabpanel" aria-labelledby="tab-plan" hidden={activeTab !== "plan"} className={`${cardClass} rounded-tl-none`}>
<TabHeading title="Project plan" description="In te vullen door de sociaal ondernemer." />
<ProjectPlanFields project={project} plan={parseProjectPlanJson(project.project_plan)} />
</section>
<section
id="panel-calculatie"
role="tabpanel"
aria-labelledby="tab-calculatie"
hidden={activeTab !== "calculatie"}
className={`${cardClass} rounded-tl-none`}
>
<TabHeading
title="Rikx calculatie"
description="Waardebepaling door het expertteam en Rikx. Uitkomsten worden live berekend."
/>
<fieldset className="mb-6">
<legend className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Rekenmethode</legend>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{REKENMETHODES.map((m) => (
<label
key={m.id}
className={`flex items-start gap-3 rounded-lg border p-4 ${
m.beschikbaar
? "cursor-pointer border-zinc-300 dark:border-zinc-700 has-[:checked]:border-rikx-orange has-[:checked]:bg-orange-50 dark:has-[:checked]:bg-zinc-800"
: "cursor-not-allowed border-dashed border-zinc-200 dark:border-zinc-800 opacity-60"
}`}
>
<input
type="radio"
name="rekenmethode"
value={m.id}
disabled={!m.beschikbaar}
defaultChecked={m.beschikbaar && (project.rekenmethode ?? "arbeid") === m.id}
className="mt-0.5 accent-rikx-orange"
/>
<span className="flex flex-col gap-0.5">
<span className="flex items-center gap-2 text-sm font-medium text-zinc-900 dark:text-zinc-100">
{m.label}
{!m.beschikbaar && (
<span className="rounded-full bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 text-xs font-normal text-zinc-500">
binnenkort
</span>
)}
</span>
<span className="text-xs text-zinc-500">{m.omschrijving}</span>
</span>
</label>
))}
</div>
</fieldset>
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Beoordelingen van de experts (1–10)</h3>
<div className="mb-6">
<ExpertBeoordelingen beoordelingen={beoordelingen} />
</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>
<div className="mt-8 pt-6 border-t border-zinc-200 dark:border-zinc-800 flex flex-col gap-4">
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Beoordeling</h3>
<BeoordelingSwitch defaultValue={project.beoordeling} />
<label className="flex flex-col gap-1">
<span className={labelClass}>Reden</span>
<textarea
name="beoordeling_reden"
defaultValue={project.beoordeling_reden ?? ""}
placeholder="Onderbouwing van het oordeel, sterke punten en aandachtspunten…"
className="w-full min-h-40 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"
/>
</label>
</div>
</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>
);
}