Files
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

258 lines
12 KiB
TypeScript
Raw Permalink 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 { 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<SaveState>({ kind: "idle" });
const opgeslagenRef = useRef<number | null>(gedeeldeWaarde);
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="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-x-4 gap-y-3">
{filter}
<label className="flex flex-col gap-1 w-full sm:w-52">
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Rikx Waarde</span>
<span className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-zinc-500">€</span>
<input
type="number"
inputMode="decimal"
min={0}
step="0.01"
value={waardeTekst}
disabled={rondeIds.length === 0}
onChange={(e) => onChange(e.target.value)}
onBlur={() => waarde !== null && save(waarde)}
placeholder="0,00"
className="w-full h-10 rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 pl-7 pr-3 text-sm tabular-nums text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-rikx-orange disabled:opacity-60"
/>
</span>
</label>
<div className="text-sm flex flex-col gap-0.5 sm:flex-1 sm:min-w-64 min-h-10 justify-center">
<span className="text-zinc-500">
{rows.length} {rows.length === 1 ? "project" : "projecten"}
</span>
{rondeIds.length === 0 ? (
<span className="text-zinc-500">Geen projecten met een Rikx ronde op dit scherm.</span>
) : (
<>
<span className="text-zinc-700 dark:text-zinc-300">
{prijs === null ? (
"Vul een Rikx Waarde in om de berekening door te rekenen."
) : (
<>
Rikx prijs <strong className="tabular-nums">{euro.format(prijs)}</strong> (10%) voor{" "}
{rondes.length === 1 ? `ronde "${rondes[0].label}"` : `${rondes.length} rondes`}.
</>
)}
</span>
<span
aria-live="polite"
className={
saveState.kind === "error"
? "text-red-600 dark:text-red-400"
: saveState.kind === "saved"
? "text-green-700 dark:text-green-400"
: "text-zinc-500"
}
>
{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."
: ""}
</span>
</>
)}
</div>
</div>
{rows.length === 0 ? (
<p className="text-sm text-zinc-500">{emptyMessage}</p>
) : (
<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">Impactmaker</th>
<th className="text-right px-4 py-3 font-medium whitespace-nowrap">Kosten/impact</th>
<th className="text-right px-4 py-3 font-medium whitespace-nowrap">Rikx prijs</th>
<th className="text-right px-4 py-3 font-medium whitespace-nowrap">Totaal aantal Rikx</th>
<th className="text-right px-4 py-3 font-medium whitespace-nowrap">Totale impactwaarde</th>
<th className="text-right px-4 py-3 font-medium whitespace-nowrap">Meerwaarde</th>
<th className="text-right px-4 py-3 font-medium whitespace-nowrap">Efficiëntie</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{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 (
<tr
key={r.id}
className={`bg-white dark:bg-zinc-950 hover:bg-orange-50/60 dark:hover:bg-zinc-900 transition-colors ${
is75 ? "border-b-4 border-b-rikx-orange" : ""
}`}
>
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
<Link href={`/projecten/${r.id}?tab=calculatie`} className="hover:text-rikx-orange-dark hover:underline">
{r.name}
</Link>
{is75 && (
<span className="ml-2 inline-flex items-center rounded-full bg-rikx-orange px-2 py-0.5 text-xs font-semibold text-white align-middle">
75%
</span>
)}
<span className="block text-xs font-normal text-zinc-500">{r.ronde?.label ?? "Geen ronde"}</span>
</td>
<td className="px-4 py-3 text-zinc-700 dark:text-zinc-300">{r.impactmaker ?? "—"}</td>
<td className={cell}>{fmt(r.result.kosten_impact)}</td>
<td className={cell}>{fmt(r.input.rikx_prijs, "euro")}</td>
<td className={cell}>{fmt(r.result.totaal_aantal_rikx)}</td>
<td className={cell}>{fmt(r.result.totale_impactwaarde, "euro")}</td>
<td className={cell}>{fmt(r.result.meerwaarde, "euro")}</td>
<td className={cell}>{fmt(r.result.efficientie_waarde)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{grensIndex >= 0 && (
<p className="-mt-3 text-xs text-zinc-500">
<span className="inline-block w-6 h-1 rounded bg-rikx-orange align-middle mr-2" />
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%.
</p>
)}
</div>
);
}