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>
This commit is contained in:
2026-10-08 14:42:22 +02:00
parent 9f0bd87c40
commit 53bf667f54
41 changed files with 2075 additions and 357 deletions
+257
View File
@@ -0,0 +1,257 @@
"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>
);
}
+32
View File
@@ -0,0 +1,32 @@
"use server";
import { getDb } from "../../lib/db";
import { getCurrentUser } from "../../lib/auth";
import { defaultRikxPrijs } from "../../lib/rikxCalculatie";
export type SaveRondeWaardeResult = { ok: true; labels: string[] } | { ok: false; error: string };
// Zet de Rikx waarde (en Rikx prijs = 10%) op de opgegeven Rikx rondes.
// Aangeroepen vanuit het Rikx Waarde-veld op de pagina Waardebepaling projecten.
export async function saveRondeWaarde(rondeIds: number[], rikxWaarde: number): Promise<SaveRondeWaardeResult> {
const user = await getCurrentUser();
if (!user || user.role !== "admin") return { ok: false, error: "Geen toegang" };
if (!Number.isFinite(rikxWaarde) || rikxWaarde < 0) return { ok: false, error: "Ongeldige Rikx waarde" };
const ids = [...new Set(rondeIds.map(Number))].filter((id) => Number.isInteger(id) && id > 0);
if (ids.length === 0) return { ok: false, error: "Geen ronde om bij te werken" };
const db = getDb();
const placeholders = ids.map(() => "?").join(",");
const rondes = db.prepare(`SELECT id, label FROM rikx_rondes WHERE id IN (${placeholders})`).all(...ids) as {
id: number;
label: string;
}[];
if (rondes.length === 0) return { ok: false, error: "Ronde niet gevonden" };
db.prepare(
`UPDATE rikx_rondes SET rikx_waarde = ?, rikx_prijs = ?, updated_at = datetime('now') WHERE id IN (${placeholders})`
).run(rikxWaarde, defaultRikxPrijs(rikxWaarde), ...ids);
return { ok: true, labels: rondes.map((r) => r.label) };
}
+102
View File
@@ -0,0 +1,102 @@
import { redirect } from "next/navigation";
import { requireAdmin } from "../../lib/auth";
import { getDb, listRikxRondes, type Project } from "../../lib/db";
import { listBeoordelingen } from "../../lib/experts";
import { listAccountNames } from "../../lib/platform-db";
import { rikxInputFromProject } from "../../lib/rikxCalculatie";
import { AppHeader } from "../AppHeader";
import { AutoSubmitSelect } from "../projecten/AutoSubmitSelect";
import { WaardebepalingTable, type WaardebepalingRow } from "./WaardebepalingTable";
// Alle projecten met status Waardebepaling, gerangschikt op Kosten/impact van laag naar hoog (laag = efficiënt).
export default async function WaardebepalingPage({
searchParams,
}: {
searchParams: Promise<{ ronde?: string }>;
}) {
const session = await requireAdmin();
const params = await searchParams;
const rondes = listRikxRondes();
// "geen" = projecten zonder ronde; anders het id van een bestaande ronde
const ronde =
params.ronde === "geen" || rondes.some((r) => String(r.id) === params.ronde) ? params.ronde! : "";
const db = getDb();
const where = ["status = 'waardebepaling'"];
const args: number[] = [];
if (ronde === "geen") {
where.push("ronde_id IS NULL");
} else if (ronde) {
where.push("ronde_id = ?");
args.push(Number(ronde));
}
const projects = (db.prepare(`SELECT * FROM projects WHERE ${where.join(" AND ")} ORDER BY id`).all(...args) as Project[]).map(
(p) => ({ ...p })
);
const scores = listBeoordelingen(projects.map((p) => p.id));
const impactmakers = new Map(listAccountNames().map((a) => [a.id, a.name]));
const rows: WaardebepalingRow[] = projects.map((p) => {
const projectRonde = rondes.find((r) => r.id === p.ronde_id) ?? null;
return {
id: p.id,
name: p.name,
impactmaker: p.impactmaker_account_id ? impactmakers.get(p.impactmaker_account_id) ?? null : null,
ronde: projectRonde ? { id: projectRonde.id, label: projectRonde.label, rikxWaarde: projectRonde.rikx_waarde } : null,
input: rikxInputFromProject(
p,
scores.filter((s) => s.project_id === p.id),
projectRonde
),
};
});
const fieldClass =
"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";
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<AppHeader email={session.email} active="projecten" />
<main className="max-w-6xl mx-auto px-4 sm:px-6 py-8 flex flex-col gap-6">
<div>
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">Waardebepaling projecten</h1>
<p className="text-sm text-zinc-500 mt-1">
Projecten met status Waardebepaling, gerangschikt op kosten/impact (laagste eerst).
</p>
</div>
<WaardebepalingTable
rows={rows}
toon75Procent={ronde !== "" && ronde !== "geen"}
filter={
// `contents`: het formulier zelf neemt geen ruimte in, zodat het filter in dezelfde rij
// komt te staan als het Rikx Waarde-veld (zie WaardebepalingTable)
// autoComplete="off": de browser mag de keuze niet herstellen na "Terug", anders toont
// de dropdown een andere ronde dan de lijst
<form action="/waardebepaling" autoComplete="off" className="contents">
<div className="w-full sm:w-64">
<label htmlFor="filter-ronde" className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">
Rikx Ronde
</label>
<AutoSubmitSelect id="filter-ronde" name="ronde" defaultValue={ronde} className={fieldClass}>
<option value="">Alle</option>
{rondes.map((r) => (
<option key={r.id} value={r.id}>
{r.label}
</option>
))}
<option value="geen">Zonder ronde</option>
</AutoSubmitSelect>
</div>
</form>
}
emptyMessage={
ronde ? "Geen projecten in waardebepaling voor deze ronde." : "Er zijn geen projecten in waardebepaling."
}
/>
</main>
</div>
);
}