53bf667f54
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>
258 lines
12 KiB
TypeScript
258 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
}
|