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

102 lines
4.1 KiB
TypeScript

import Link from "next/link";
import type { ExpertBeoordeling } from "../../../lib/db";
import { BONUS_MALUS_OPTIONS, EXPERT_CRITERIA } from "../../../lib/rikxCalculatie";
export type BeoordelingMetNaam = ExpertBeoordeling & { expert_name: string };
const getal = new Intl.NumberFormat("nl-NL", { maximumFractionDigits: 2 });
const fmt = (v: number | null) => (v === null ? "—" : getal.format(v));
function gemiddelde(b: BeoordelingMetNaam): number | null {
const scores = [b.duur_impact, b.levensdomeinen, b.basissituatie].filter((v): v is number => v !== null);
return scores.length ? scores.reduce((a, c) => a + c, 0) / scores.length : null;
}
function bonusMalusLabel(id: string | null): string {
if (!id) return "Geen";
return BONUS_MALUS_OPTIONS.find((o) => o.id === id)?.label ?? id;
}
// Alleen-lezen overzicht van de beoordelingen die experts via hun eigen pagina hebben ingevuld.
// Impactscore M in de Rikx calculatie is het gemiddelde van de kolom "Gemiddelde".
export function ExpertBeoordelingen({ beoordelingen }: { beoordelingen: BeoordelingMetNaam[] }) {
if (beoordelingen.length === 0) {
return (
<p className="text-sm text-zinc-500 rounded-lg bg-zinc-50 dark:bg-zinc-800/50 px-4 py-3">
Nog geen beoordelingen. Experts vullen hun scores in op hun eigen pagina; beheer de experts bij{" "}
<Link href="/experts" className="underline hover:text-rikx-orange-dark">
CRM → Experts
</Link>
.
</p>
);
}
const cell = "py-2 px-3 text-right tabular-nums";
return (
<div className="flex flex-col gap-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-zinc-500 border-b border-zinc-200 dark:border-zinc-800">
<th className="text-left font-medium py-2 pr-3">Criterium</th>
{beoordelingen.map((b) => (
<th key={b.expert_id} className="text-right font-medium py-2 px-3 whitespace-nowrap">
{b.expert_name}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{EXPERT_CRITERIA.map((c) => (
<tr key={c.id}>
<td className="py-2 pr-3 text-zinc-700 dark:text-zinc-300 whitespace-nowrap">
{c.label}
<span className="block text-xs text-zinc-500">{c.schaal}</span>
</td>
{beoordelingen.map((b) => (
<td key={b.expert_id} className={cell}>
{fmt(b[c.id])}
</td>
))}
</tr>
))}
<tr className="font-semibold text-zinc-900 dark:text-zinc-100">
<td className="py-2 pr-3">Gemiddelde</td>
{beoordelingen.map((b) => (
<td key={b.expert_id} className={cell}>
{fmt(gemiddelde(b))}
</td>
))}
</tr>
<tr>
<td className="py-2 pr-3 text-zinc-700 dark:text-zinc-300">Advies Bonus / Malus</td>
{beoordelingen.map((b) => (
<td key={b.expert_id} className={cell}>
{bonusMalusLabel(b.bonus_malus_advies)}
</td>
))}
</tr>
</tbody>
</table>
</div>
{beoordelingen.some((b) => b.toelichting) && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Toelichting van de experts</h4>
{beoordelingen
.filter((b) => b.toelichting)
.map((b) => (
<blockquote
key={b.expert_id}
className="rounded-lg border-l-2 border-rikx-orange bg-zinc-50 dark:bg-zinc-800/50 px-4 py-2 text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-line"
>
<span className="block text-xs font-medium text-zinc-500 mb-1">{b.expert_name}</span>
{b.toelichting}
</blockquote>
))}
</div>
)}
</div>
);
}