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>
102 lines
4.1 KiB
TypeScript
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>
|
|
);
|
|
}
|