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>
198 lines
10 KiB
TypeScript
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'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>
|
|
);
|
|
}
|