Files
rikx-beheer/app/expert/page.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

198 lines
10 KiB
TypeScript

import Link from "next/link";
import { requireExpert } from "../../lib/auth";
import { listAccountNames } from "../../lib/platform-db";
import { listBeoordelingen, listProjectenVoorExpert } from "../../lib/experts";
import { BONUS_MALUS_OPTIONS, EXPERT_CRITERIA } from "../../lib/rikxCalculatie";
import { logout } from "../tenants/logout";
import { saveBeoordeling, stopImpersonation } from "./actions";
import { ProjectPlanView } from "./ProjectPlanView";
// De enige pagina voor experts: projecten in waardebepaling uit hun rondes, het project plan om te lezen
// en hun eigen beoordeling. Experts zien niet wat andere experts hebben ingevuld.
export default async function ExpertPage({
searchParams,
}: {
searchParams: Promise<{ project?: string; opgeslagen?: string; error?: string }>;
}) {
const expert = await requireExpert();
const { project: projectParam, opgeslagen, error } = await searchParams;
const projecten = listProjectenVoorExpert(expert.id);
const eigen = listBeoordelingen(projecten.map((p) => p.id)).filter((b) => b.expert_id === expert.id);
const impactmakers = new Map(listAccountNames().map((a) => [a.id, a.name]));
const gekozen = projecten.find((p) => String(p.id) === projectParam) ?? projecten[0] ?? null;
const beoordeling = gekozen ? eigen.find((b) => b.project_id === gekozen.id) ?? null : null;
const inputClass =
"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">
<header className="bg-rikx-orange">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-3 flex items-center gap-6">
<span className="text-white text-4xl font-extrabold tracking-tight leading-none">Rikx</span>
<span className="text-white/90 text-lg font-semibold">Expertteam</span>
<div className="ml-auto flex items-center gap-4 text-sm text-white">
<span className="hidden sm:inline">{expert.name}</span>
{!expert.impersonator && (
<form action={logout}>
<button className="hover:text-rikx-maroon transition-colors">Uitloggen</button>
</form>
)}
</div>
</div>
</header>
{expert.impersonator && (
<div className="bg-rikx-maroon text-white">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-2.5 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
<span>
Je bekijkt deze pagina als <strong>{expert.name}</strong>. Alleen lezen: beoordelingen kun je hier niet
opslaan.
</span>
<form action={stopImpersonation} className="ml-auto">
<button className="rounded-md bg-white px-3 py-1 text-sm font-medium text-rikx-maroon hover:bg-orange-50">
Terug naar beheer
</button>
</form>
</div>
</div>
)}
<main className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
{projecten.length === 0 ? (
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-8 text-sm text-zinc-600 dark:text-zinc-400">
Er staan op dit moment geen projecten voor je klaar om te beoordelen.
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-[18rem_1fr] gap-6 items-start">
<nav aria-label="Projecten" className="flex flex-col gap-2 lg:sticky lg:top-6">
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
Projecten in waardebepaling ({projecten.length})
</h2>
{projecten.map((p) => {
const klaar = eigen.some((b) => b.project_id === p.id);
const actief = gekozen?.id === p.id;
return (
<Link
key={p.id}
href={`/expert?project=${p.id}`}
aria-current={actief ? "page" : undefined}
className={`rounded-lg border p-3 transition-colors ${
actief
? "border-rikx-orange bg-orange-50 dark:bg-zinc-800"
: "border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 hover:border-rikx-orange"
}`}
>
<span className="block text-sm font-medium text-zinc-900 dark:text-zinc-100">{p.name}</span>
<span className="block text-xs text-zinc-500">{p.ronde_label}</span>
<span
className={`mt-2 inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
klaar ? "bg-green-100 text-green-800" : "bg-zinc-100 text-zinc-600"
}`}
>
{klaar ? "Beoordeeld" : "Nog te beoordelen"}
</span>
</Link>
);
})}
</nav>
{gekozen && (
<div className="flex flex-col gap-6 min-w-0">
<div>
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">{gekozen.name}</h1>
<p className="text-sm text-zinc-500 mt-1">
{gekozen.ronde_label}
{gekozen.impactmaker_account_id && impactmakers.get(gekozen.impactmaker_account_id)
? ` · Impactmaker: ${impactmakers.get(gekozen.impactmaker_account_id)}`
: ""}
</p>
</div>
<section className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6">
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100 mb-5">Project plan</h2>
<ProjectPlanView project={gekozen} />
</section>
<form
action={saveBeoordeling}
// Nieuwe beginwaarden per project
key={gekozen.id}
className="bg-white dark:bg-zinc-900 rounded-xl border-2 border-rikx-orange p-6 flex flex-col gap-5"
>
<input type="hidden" name="project_id" value={gekozen.id} />
{/* Bij meekijken door een admin: alles zichtbaar maar niet te wijzigen */}
<fieldset disabled={Boolean(expert.impersonator)} className="contents">
<div>
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100">Jouw waardebepaling</h2>
<p className="text-sm text-zinc-500">
Geef per criterium een rapportcijfer van 1 tot 10. Andere experts zien jouw beoordeling niet.
</p>
</div>
{opgeslagen && <p className="text-sm text-green-700 dark:text-green-400">Je beoordeling is opgeslagen.</p>}
{error === "meekijken" && (
<p className="text-sm text-red-600 dark:text-red-400">Niet opgeslagen: je kijkt mee als admin.</p>
)}
{error === "score" && (
<p className="text-sm text-red-600 dark:text-red-400">Niet opgeslagen: cijfers moeten tussen 1 en 10 liggen.</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{EXPERT_CRITERIA.map((c) => (
<label key={c.id} className="flex flex-col gap-1">
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">{c.label}</span>
<input
name={c.id}
type="number"
min={1}
max={10}
step="any"
inputMode="decimal"
defaultValue={beoordeling?.[c.id] ?? ""}
className={inputClass}
/>
<span className="text-xs text-zinc-500">{c.schaal}</span>
</label>
))}
</div>
<label className="flex flex-col gap-1 sm:max-w-xs">
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Advies Bonus / Malus</span>
<select name="bonus_malus_advies" defaultValue={beoordeling?.bonus_malus_advies ?? ""} className={inputClass}>
{BONUS_MALUS_OPTIONS.map((o) => (
<option key={o.label} value={o.id}>
{o.label}
</option>
))}
</select>
<span className="text-xs text-zinc-500">
Extra waardering of aftrek op basis van de aanvullende criteria (evidence, monitoring, innovatie, risico&apos;s).
</span>
</label>
<label className="flex flex-col gap-1">
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Toelichting</span>
<textarea
name="toelichting"
defaultValue={beoordeling?.toelichting ?? ""}
placeholder="Onderbouwing van je cijfers: sterke punten en aandachtspunten…"
className="w-full min-h-36 rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-rikx-orange"
/>
</label>
<div>
<button
type="submit"
className="rounded-lg bg-rikx-maroon hover:bg-rikx-maroon-light px-5 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Beoordeling opslaan
</button>
</div>
</fieldset>
</form>
</div>
)}
</div>
)}
</main>
</div>
);
}