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>
389 lines
16 KiB
TypeScript
389 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useRef, useState } from "react";
|
||
import type { Project, RikxRonde } from "../../../lib/db";
|
||
import { PROJECT_STATUSES, RIKX_CALCULATIE_VANAF_STATUS, isStatusVanaf } from "../../../lib/projectStatus";
|
||
import {
|
||
BONUS_MALUS_OPTIONS,
|
||
REKENMETHODES,
|
||
calculateRikx,
|
||
rikxInputFromProject,
|
||
type ExpertScore,
|
||
} from "../../../lib/rikxCalculatie";
|
||
import { parseProjectPlanJson } from "../../../lib/projectPlan";
|
||
import { updateProject } from "../actions";
|
||
import { PROJECT_FORM_ID } from "./formId";
|
||
import { ProjectPlanFields } from "./ProjectPlanFields";
|
||
import { ExpertBeoordelingen, type BeoordelingMetNaam } from "./ExpertBeoordelingen";
|
||
|
||
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";
|
||
const labelClass = "text-sm font-medium text-zinc-700 dark:text-zinc-300";
|
||
const cardClass = "bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6";
|
||
|
||
function toNumber(value: FormDataEntryValue | null): number | null {
|
||
if (typeof value !== "string" || value.trim() === "") return null;
|
||
const n = Number(value.replace(",", "."));
|
||
return Number.isFinite(n) ? n : null;
|
||
}
|
||
|
||
function rondeById(rondes: RikxRonde[], id: number | null) {
|
||
return rondes.find((r) => r.id === id) ?? null;
|
||
}
|
||
|
||
function readInput(form: HTMLFormElement, rondes: RikxRonde[], expertScores: ExpertScore[]) {
|
||
const fd = new FormData(form);
|
||
const ronde = rondeById(rondes, Number(fd.get("ronde_id")) || null);
|
||
return {
|
||
ronde_id: ronde?.id ?? null,
|
||
projectkosten: toNumber(fd.get("projectkosten")),
|
||
deelnemers_werk: toNumber(fd.get("deelnemers_werk")),
|
||
deelnemers_skills: toNumber(fd.get("deelnemers_skills")),
|
||
financiering_extern_pct: toNumber(fd.get("financiering_extern_pct")),
|
||
bonus_malus: (fd.get("bonus_malus") as string) || null,
|
||
rikx_waarde: ronde?.rikx_waarde ?? null,
|
||
rikx_prijs: ronde?.rikx_prijs ?? null,
|
||
// Expertscores komen van de experts zelf en zijn hier niet te wijzigen
|
||
expert_scores: expertScores,
|
||
};
|
||
}
|
||
|
||
const nl = (digits: number) => new Intl.NumberFormat("nl-NL", { maximumFractionDigits: digits });
|
||
const euro = new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" });
|
||
const fmt = (v: number | null, kind: "getal" | "euro" | "geheel" = "getal") =>
|
||
v === null ? "—" : kind === "euro" ? euro.format(v) : nl(kind === "geheel" ? 0 : 2).format(v);
|
||
|
||
function Field({
|
||
label,
|
||
hint,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
hint?: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<label className="flex flex-col gap-1">
|
||
<span className={labelClass}>{label}</span>
|
||
{children}
|
||
{hint && <span className="text-xs text-zinc-500">{hint}</span>}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function TabHeading({ title, description }: { title: string; description: string }) {
|
||
return (
|
||
<div className="mb-5">
|
||
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100">{title}</h2>
|
||
<p className="text-sm text-zinc-500">{description}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export type ProjectTab = "plan" | "calculatie";
|
||
|
||
const BEOORDELINGEN = [
|
||
{ id: "goedgekeurd", label: "Goedgekeurd", activeClass: "bg-green-600 text-white" },
|
||
{ id: "afgekeurd", label: "Afgekeurd", activeClass: "bg-red-600 text-white" },
|
||
] as const;
|
||
|
||
// Schakelaar Goedgekeurd | Afgekeurd. Begint leeg (nog niet beoordeeld);
|
||
// nogmaals op de actieve keuze klikken maakt de keuze ongedaan.
|
||
function BeoordelingSwitch({ defaultValue }: { defaultValue: string | null }) {
|
||
const [value, setValue] = useState(defaultValue ?? "");
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<input type="hidden" name="beoordeling" value={value} />
|
||
<div role="radiogroup" aria-label="Beoordeling" className="inline-flex rounded-lg bg-zinc-100 dark:bg-zinc-800 p-1">
|
||
{BEOORDELINGEN.map((b) => {
|
||
const active = value === b.id;
|
||
return (
|
||
<button
|
||
key={b.id}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={active}
|
||
onClick={() => setValue(active ? "" : b.id)}
|
||
className={`rounded-md px-5 py-2 text-sm font-medium transition-colors ${
|
||
active ? b.activeClass : "text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-zinc-100"
|
||
}`}
|
||
>
|
||
{b.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
{!value && <span className="text-xs text-zinc-500">Nog niet beoordeeld</span>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ProjectDetailForm({
|
||
project,
|
||
beoordelingen,
|
||
rondes,
|
||
impactmakers,
|
||
initialTab,
|
||
}: {
|
||
project: Project;
|
||
beoordelingen: BeoordelingMetNaam[];
|
||
rondes: RikxRonde[];
|
||
impactmakers: { id: number; name: string }[];
|
||
initialTab: ProjectTab;
|
||
}) {
|
||
const formRef = useRef<HTMLFormElement>(null);
|
||
// Live status uit de dropdown, zodat de tab Rikx calculatie direct verschijnt of verdwijnt.
|
||
const [status, setStatus] = useState(project.status);
|
||
const calculatieZichtbaar = isStatusVanaf(status, RIKX_CALCULATIE_VANAF_STATUS);
|
||
const [tab, setTab] = useState<ProjectTab>(initialTab);
|
||
const activeTab: ProjectTab = tab === "calculatie" && calculatieZichtbaar ? "calculatie" : "plan";
|
||
const initialRonde = rondeById(rondes, project.ronde_id);
|
||
const [input, setInput] = useState(() => ({
|
||
ronde_id: initialRonde?.id ?? null,
|
||
...rikxInputFromProject(project, beoordelingen, initialRonde),
|
||
}));
|
||
const result = calculateRikx(input);
|
||
const ronde = rondeById(rondes, input.ronde_id);
|
||
|
||
// De browser weigert te versturen bij een ongeldig veld, maar toont geen melding als dat veld op een
|
||
// verborgen tab staat. Daarom: naar die tab gaan en de melding bij het eerste ongeldige veld tonen.
|
||
const invalidHandledRef = useRef(false);
|
||
function handleInvalid(e: React.FormEvent<HTMLFormElement>) {
|
||
if (invalidHandledRef.current) return;
|
||
const field = e.target as HTMLInputElement;
|
||
const panel = field.closest<HTMLElement>("[role=tabpanel]");
|
||
if (!panel?.hidden) return;
|
||
invalidHandledRef.current = true;
|
||
setTab(panel.id === "panel-calculatie" ? "calculatie" : "plan");
|
||
setTimeout(() => {
|
||
invalidHandledRef.current = false;
|
||
field.reportValidity();
|
||
field.focus();
|
||
}, 50);
|
||
}
|
||
|
||
const results: { label: string; value: string; strong?: boolean }[] = [
|
||
{ label: "N-score", value: fmt(result.n_score) },
|
||
{ label: "Scale", value: fmt(result.scale, "geheel") },
|
||
{ label: "Impactscore M", value: fmt(result.impactscore_m) },
|
||
{ label: "IS +/- BM", value: fmt(result.is_bm) },
|
||
{ label: "Impactwaarde", value: fmt(result.impactwaarde) },
|
||
{ label: "Minus cofinanciering", value: fmt(result.minus_cofinanciering) },
|
||
{ label: "I - cofin", value: fmt(result.i_cofin) },
|
||
{ label: "Kosten/impact", value: fmt(result.kosten_impact) },
|
||
{ label: "Totale impactwaarde", value: fmt(result.totale_impactwaarde, "euro"), strong: true },
|
||
{ label: "Totaal aantal Rikx", value: fmt(result.totaal_aantal_rikx), strong: true },
|
||
{ label: "Meerwaarde", value: fmt(result.meerwaarde, "euro"), strong: true },
|
||
{ label: "Efficiëntiewaarde", value: fmt(result.efficientie_waarde), strong: true },
|
||
];
|
||
|
||
return (
|
||
<form
|
||
id={PROJECT_FORM_ID}
|
||
ref={formRef}
|
||
action={updateProject}
|
||
onInput={() => formRef.current && setInput(readInput(formRef.current, rondes, input.expert_scores))}
|
||
onInvalidCapture={handleInvalid}
|
||
className="flex flex-col gap-6"
|
||
>
|
||
<input type="hidden" name="id" value={project.id} />
|
||
<input type="hidden" name="tab" value={activeTab} />
|
||
|
||
<section className={`${cardClass} grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4`}>
|
||
<Field label="Projectnaam">
|
||
<input name="name" required defaultValue={project.name} className={inputClass} />
|
||
</Field>
|
||
<Field label="Status">
|
||
<select name="status" value={status} onChange={(e) => setStatus(e.target.value)} className={inputClass}>
|
||
{PROJECT_STATUSES.map((s) => (
|
||
<option key={s.id} value={s.id}>
|
||
{s.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
<Field label="Impactmaker">
|
||
<select name="impactmaker_account_id" defaultValue={project.impactmaker_account_id ?? ""} className={inputClass}>
|
||
<option value="">Geen impactmaker</option>
|
||
{impactmakers.map((a) => (
|
||
<option key={a.id} value={a.id}>
|
||
{a.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
<Field label="Rikx Ronde">
|
||
<select name="ronde_id" defaultValue={project.ronde_id ?? ""} className={inputClass}>
|
||
<option value="">Geen ronde</option>
|
||
{rondes.map((r) => (
|
||
<option key={r.id} value={r.id}>
|
||
{r.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
</section>
|
||
|
||
<div role="tablist" aria-label="Projectonderdelen" className="flex gap-1 border-b border-zinc-200 dark:border-zinc-800 -mb-6">
|
||
{([
|
||
{ id: "plan", label: "Project plan", zichtbaar: true },
|
||
{ id: "calculatie", label: "Rikx calculatie", zichtbaar: calculatieZichtbaar },
|
||
] as const)
|
||
.filter((t) => t.zichtbaar)
|
||
.map((t) => (
|
||
<button
|
||
key={t.id}
|
||
type="button"
|
||
role="tab"
|
||
id={`tab-${t.id}`}
|
||
aria-selected={activeTab === t.id}
|
||
aria-controls={`panel-${t.id}`}
|
||
onClick={() => setTab(t.id)}
|
||
className={`-mb-px px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||
activeTab === t.id
|
||
? "border-rikx-orange text-zinc-900 dark:text-zinc-100"
|
||
: "border-transparent text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100"
|
||
}`}
|
||
>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Tabs worden verborgen in plaats van weggelaten, zodat alle waarden bij opslaan meegaan. */}
|
||
<section id="panel-plan" role="tabpanel" aria-labelledby="tab-plan" hidden={activeTab !== "plan"} className={`${cardClass} rounded-tl-none`}>
|
||
<TabHeading title="Project plan" description="In te vullen door de sociaal ondernemer." />
|
||
<ProjectPlanFields project={project} plan={parseProjectPlanJson(project.project_plan)} />
|
||
</section>
|
||
|
||
<section
|
||
id="panel-calculatie"
|
||
role="tabpanel"
|
||
aria-labelledby="tab-calculatie"
|
||
hidden={activeTab !== "calculatie"}
|
||
className={`${cardClass} rounded-tl-none`}
|
||
>
|
||
<TabHeading
|
||
title="Rikx calculatie"
|
||
description="Waardebepaling door het expertteam en Rikx. Uitkomsten worden live berekend."
|
||
/>
|
||
|
||
<fieldset className="mb-6">
|
||
<legend className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Rekenmethode</legend>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
{REKENMETHODES.map((m) => (
|
||
<label
|
||
key={m.id}
|
||
className={`flex items-start gap-3 rounded-lg border p-4 ${
|
||
m.beschikbaar
|
||
? "cursor-pointer border-zinc-300 dark:border-zinc-700 has-[:checked]:border-rikx-orange has-[:checked]:bg-orange-50 dark:has-[:checked]:bg-zinc-800"
|
||
: "cursor-not-allowed border-dashed border-zinc-200 dark:border-zinc-800 opacity-60"
|
||
}`}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="rekenmethode"
|
||
value={m.id}
|
||
disabled={!m.beschikbaar}
|
||
defaultChecked={m.beschikbaar && (project.rekenmethode ?? "arbeid") === m.id}
|
||
className="mt-0.5 accent-rikx-orange"
|
||
/>
|
||
<span className="flex flex-col gap-0.5">
|
||
<span className="flex items-center gap-2 text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||
{m.label}
|
||
{!m.beschikbaar && (
|
||
<span className="rounded-full bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 text-xs font-normal text-zinc-500">
|
||
binnenkort
|
||
</span>
|
||
)}
|
||
</span>
|
||
<span className="text-xs text-zinc-500">{m.omschrijving}</span>
|
||
</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</fieldset>
|
||
|
||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Beoordelingen van de experts (1–10)</h3>
|
||
<div className="mb-6">
|
||
<ExpertBeoordelingen beoordelingen={beoordelingen} />
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||
<Field label="Bonus / Malus" hint="Collectief oordeel van de experts: ++ 1,2 · + 1,1 · geen 1,0 · - 0,9 · -- 0,8">
|
||
<select name="bonus_malus" defaultValue={project.bonus_malus ?? ""} className={inputClass}>
|
||
{BONUS_MALUS_OPTIONS.map((o) => (
|
||
<option key={o.label} value={o.id}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
<div className="flex flex-col gap-1">
|
||
<span className={labelClass}>Rikx waarde en Rikx prijs</span>
|
||
{ronde ? (
|
||
<p className="h-10 flex items-center gap-4 rounded-lg bg-zinc-50 dark:bg-zinc-800/50 px-3 text-sm tabular-nums text-zinc-800 dark:text-zinc-200">
|
||
<span>Waarde {fmt(ronde.rikx_waarde)}</span>
|
||
<span>Prijs {fmt(ronde.rikx_prijs, "euro")}</span>
|
||
</p>
|
||
) : (
|
||
<p className="h-10 flex items-center rounded-lg bg-zinc-50 dark:bg-zinc-800/50 px-3 text-sm text-zinc-500">
|
||
Kies eerst een Rikx Ronde
|
||
</p>
|
||
)}
|
||
<span className="text-xs text-zinc-500">
|
||
Uit de Rikx Ronde{ronde ? ` "${ronde.label}"` : ""}; aan te passen bij{" "}
|
||
<Link href="/rondes" className="underline hover:text-rikx-orange-dark">
|
||
Rikx rondes
|
||
</Link>
|
||
.
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Uitkomsten</h3>
|
||
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 rounded-lg bg-zinc-50 dark:bg-zinc-800/50 px-4 py-2">
|
||
{results.map((r) => (
|
||
<div key={r.label} className="flex items-center justify-between gap-4 py-2 border-b border-zinc-200/70 dark:border-zinc-700/70">
|
||
<dt className="text-sm text-zinc-600 dark:text-zinc-400">{r.label}</dt>
|
||
<dd className={`text-sm tabular-nums ${r.strong ? "font-semibold text-zinc-900 dark:text-zinc-100" : "text-zinc-800 dark:text-zinc-200"}`}>
|
||
{r.value}
|
||
</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
|
||
<div className="mt-8 pt-6 border-t border-zinc-200 dark:border-zinc-800 flex flex-col gap-4">
|
||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Beoordeling</h3>
|
||
<BeoordelingSwitch defaultValue={project.beoordeling} />
|
||
<label className="flex flex-col gap-1">
|
||
<span className={labelClass}>Reden</span>
|
||
<textarea
|
||
name="beoordeling_reden"
|
||
defaultValue={project.beoordeling_reden ?? ""}
|
||
placeholder="Onderbouwing van het oordeel, sterke punten en aandachtspunten…"
|
||
className="w-full min-h-40 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>
|
||
</section>
|
||
|
||
<div className="flex gap-3">
|
||
<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"
|
||
>
|
||
Opslaan
|
||
</button>
|
||
<Link
|
||
href="/projecten"
|
||
className="rounded-lg border border-zinc-300 dark:border-zinc-700 px-5 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||
>
|
||
Annuleren
|
||
</Link>
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|