"use client"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { calculateRikx, defaultRikxPrijs, type RikxInput } from "../../lib/rikxCalculatie"; import { saveRondeWaarde } from "./actions"; export type WaardebepalingRow = { id: number; name: string; impactmaker: string | null; ronde: { id: number; label: string; rikxWaarde: number | null } | null; input: RikxInput; }; type SaveState = | { kind: "idle" } | { kind: "saving" } | { kind: "saved"; labels: string[] } | { kind: "error"; message: string }; // signDisplay "negative": geen "-0,00" als een klein negatief getal op nul wordt afgerond const getal = new Intl.NumberFormat("nl-NL", { minimumFractionDigits: 2, maximumFractionDigits: 2, signDisplay: "negative" }); const euro = new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR", signDisplay: "negative" }); const fmt = (v: number | null, kind: "getal" | "euro" = "getal") => v === null ? "—" : kind === "euro" ? euro.format(v) : getal.format(v); // Wachttijd na de laatste toetsaanslag voordat de waarde in de ronde wordt opgeslagen const SAVE_DELAY_MS = 800; function toNumber(value: string): number | null { if (value.trim() === "") return null; const n = Number(value.replace(",", ".")); return Number.isFinite(n) && n >= 0 ? n : null; } // Positie (0-based) van het 75%-project in de lijst van LAAG naar HOOG op kosten/impact, volgens de // Waarderingssystematiek: het laatste project binnen de 75% meest efficiënte, dus plek ⌈0,75 × n⌉. // Alleen projecten met een berekende kosten/impact tellen mee. -1 als er geen zijn. export function index75Procent(aantalBerekend: number): number { return aantalBerekend > 0 ? Math.ceil(0.75 * aantalBerekend) - 1 : -1; } // Laagste kosten/impact eerst (meest efficiënt); projecten zonder berekening onderaan. Bij gelijke stand op naam en id, // zodat server en browser altijd dezelfde volgorde geven (anders volgt een hydration-fout). function compareRows( a: { id: number; name: string; result: { kosten_impact: number | null } }, b: { id: number; name: string; result: { kosten_impact: number | null } } ): number { const ka = a.result.kosten_impact; const kb = b.result.kosten_impact; if (ka !== kb) { if (ka === null) return 1; if (kb === null) return -1; return ka - kb; } return a.name.localeCompare(b.name, "nl") || a.id - b.id; } // Tabel met een Rikx Waarde-veld erboven. Een ingevulde Rikx Waarde geldt direct voor de getoonde // projecten die bij een ronde horen (Rikx prijs = 10%), de berekening wordt live doorgerekend // en de waarde wordt opgeslagen op de Rikx ronde(s) van die projecten. export function WaardebepalingTable({ rows, filter, emptyMessage, toon75Procent, }: { rows: WaardebepalingRow[]; // Alleen bij een gekozen Rikx ronde; bij "Alle" of "Zonder ronde" geen 75%-lijn toon75Procent: boolean; // Rikx Ronde-filter (server-side formulier); staat in dezelfde rij als het Rikx Waarde-veld filter: ReactNode; emptyMessage: string; }) { const router = useRouter(); const rondes = [...new Map(rows.flatMap((r) => (r.ronde ? [[r.ronde.id, r.ronde]] : []))).values()]; const rondeIds = rondes.map((r) => r.id); // Startwaarde: de Rikx waarde van de getoonde ronde(s), als die voor allemaal gelijk is const gedeeldeWaarde = new Set(rondes.map((r) => r.rikxWaarde)).size === 1 ? rondes[0]?.rikxWaarde ?? null : null; const [waardeTekst, setWaardeTekst] = useState(gedeeldeWaarde === null ? "" : String(gedeeldeWaarde)); const [saveState, setSaveState] = useState({ kind: "idle" }); const opgeslagenRef = useRef(gedeeldeWaarde); const timerRef = useRef | null>(null); const waarde = toNumber(waardeTekst); const prijs = waarde === null ? null : defaultRikxPrijs(waarde); async function save(value: number) { if (timerRef.current) clearTimeout(timerRef.current); if (value === opgeslagenRef.current || rondeIds.length === 0) return; setSaveState({ kind: "saving" }); const result = await saveRondeWaarde(rondeIds, value); if (result.ok) { opgeslagenRef.current = value; setSaveState({ kind: "saved", labels: result.labels }); // Serverdata (rondewaarden) verversen, zodat alles overal klopt router.refresh(); } else { setSaveState({ kind: "error", message: result.error }); } } function onChange(value: string) { setWaardeTekst(value); setSaveState({ kind: "idle" }); if (timerRef.current) clearTimeout(timerRef.current); const n = toNumber(value); if (n !== null) timerRef.current = setTimeout(() => save(n), SAVE_DELAY_MS); } useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []); const results = rows .map((r) => { const aangepast = waarde !== null && r.ronde !== null; const input = aangepast ? { ...r.input, rikx_waarde: waarde, rikx_prijs: prijs } : r.input; return { ...r, input, result: calculateRikx(input), aangepast }; }) .sort(compareRows); // Berekende projecten staan na het sorteren vooraan, dus de index geldt direct voor `results` const berekend = results.filter((r) => r.result.kosten_impact !== null); const grensIndex = toon75Procent ? index75Procent(berekend.length) : -1; return (
{filter}
{rows.length} {rows.length === 1 ? "project" : "projecten"} {rondeIds.length === 0 ? ( Geen projecten met een Rikx ronde op dit scherm. ) : ( <> {prijs === null ? ( "Vul een Rikx Waarde in om de berekening door te rekenen." ) : ( <> Rikx prijs {euro.format(prijs)} (10%) voor{" "} {rondes.length === 1 ? `ronde "${rondes[0].label}"` : `${rondes.length} rondes`}. )} {saveState.kind === "saving" ? "Opslaan in ronde…" : saveState.kind === "saved" ? `Opgeslagen in ${saveState.labels.join(", ")}` : saveState.kind === "error" ? `Niet opgeslagen: ${saveState.message}` : rondes.length > 1 ? "Let op: de waarde wordt op al deze rondes opgeslagen." : ""} )}
{rows.length === 0 ? (

{emptyMessage}

) : (
{results.map((r, i) => { const cell = "px-4 py-3 text-right tabular-nums whitespace-nowrap text-zinc-900 dark:text-zinc-100"; const is75 = i === grensIndex; return ( ); })}
Projectnaam Impactmaker Kosten/impact Rikx prijs Totaal aantal Rikx Totale impactwaarde Meerwaarde Efficiëntie
{r.name} {is75 && ( 75% )} {r.ronde?.label ?? "Geen ronde"} {r.impactmaker ?? "—"} {fmt(r.result.kosten_impact)} {fmt(r.input.rikx_prijs, "euro")} {fmt(r.result.totaal_aantal_rikx)} {fmt(r.result.totale_impactwaarde, "euro")} {fmt(r.result.meerwaarde, "euro")} {fmt(r.result.efficientie_waarde)}
)} {grensIndex >= 0 && (

75%-project: {berekend[grensIndex].name} (kosten/impact {fmt(berekend[grensIndex].result.kosten_impact)}). Tot en met deze lijn staan de 75% meest efficiënte projecten (laagste kosten/impact); eronder de overige 25%.

)}
); }