9f0bd87c40
- Projectenpagina met pipeline (standaard) en tabelweergave, filters op status, Rikx Ronde en zoeken - Projectpagina in stappen: Project plan (velden uit de Rikx-aanvraag) en Rikx calculatie achter een schuif, live berekend volgens de rekenmethode - Rikx rondes beheren (label, Rikx waarde, Rikx prijs) en koppelen aan projecten - Oranje Rikx-menubalk met menu per module, gedeeld door alle pagina's - Oude offerte-tabellen opgeruimd: leeg wordt verwijderd, met data hernoemd naar legacy_* - CLAUDE.md bijgewerkt naar de huidige stand Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
314 lines
13 KiB
TypeScript
314 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useRef, useState, useTransition } from "react";
|
||
import type { Project, ProjectExpertScore, RikxRonde } from "../../../lib/db";
|
||
import { PROJECT_STATUSES } from "../../../lib/projectStatus";
|
||
import {
|
||
BONUS_MALUS_OPTIONS,
|
||
EXPERT_COUNT,
|
||
EXPERT_CRITERIA,
|
||
calculateRikx,
|
||
type ExpertScore,
|
||
} from "../../../lib/rikxCalculatie";
|
||
import { parseProjectPlanJson } from "../../../lib/projectPlan";
|
||
import { setRikxCalculatie, updateProject } from "../actions";
|
||
import { ProjectPlanFields } from "./ProjectPlanFields";
|
||
|
||
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[]) {
|
||
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,
|
||
expert_scores: Array.from({ length: EXPERT_COUNT }, (_, i) =>
|
||
Object.fromEntries(EXPERT_CRITERIA.map((c) => [c.id, toNumber(fd.get(`expert_${i + 1}_${c.id}`))]))
|
||
) as ExpertScore[],
|
||
};
|
||
}
|
||
|
||
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 StepHeading({ step, title, description }: { step: number; title: string; description: string }) {
|
||
return (
|
||
<div className="flex items-start gap-3 mb-5">
|
||
<span className="flex items-center justify-center w-7 h-7 shrink-0 rounded-full bg-rikx-orange text-white text-sm font-semibold">
|
||
{step}
|
||
</span>
|
||
<div>
|
||
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100">{title}</h2>
|
||
<p className="text-sm text-zinc-500">{description}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ProjectDetailForm({
|
||
project,
|
||
expertScores,
|
||
rondes,
|
||
}: {
|
||
project: Project;
|
||
expertScores: ProjectExpertScore[];
|
||
rondes: RikxRonde[];
|
||
}) {
|
||
const formRef = useRef<HTMLFormElement>(null);
|
||
const [calcOn, setCalcOn] = useState(project.rikx_calculatie_aan === 1);
|
||
const [pending, startTransition] = useTransition();
|
||
const initialRonde = rondeById(rondes, project.ronde_id);
|
||
const [input, setInput] = useState(() => ({
|
||
ronde_id: initialRonde?.id ?? null,
|
||
projectkosten: project.projectkosten,
|
||
deelnemers_werk: project.deelnemers_werk,
|
||
deelnemers_skills: project.deelnemers_skills,
|
||
financiering_extern_pct: project.financiering_extern_pct,
|
||
bonus_malus: project.bonus_malus,
|
||
rikx_waarde: initialRonde?.rikx_waarde ?? null,
|
||
rikx_prijs: initialRonde?.rikx_prijs ?? null,
|
||
expert_scores: Array.from({ length: EXPERT_COUNT }, (_, i) => {
|
||
const s = expertScores.find((e) => e.expert === i + 1);
|
||
return {
|
||
duur_impact: s?.duur_impact ?? null,
|
||
levensdomeinen: s?.levensdomeinen ?? null,
|
||
basissituatie: s?.basissituatie ?? null,
|
||
};
|
||
}),
|
||
}));
|
||
const result = calculateRikx(input);
|
||
const ronde = rondeById(rondes, input.ronde_id);
|
||
|
||
function toggleCalc() {
|
||
const next = !calcOn;
|
||
setCalcOn(next);
|
||
startTransition(() => setRikxCalculatie(project.id, next));
|
||
}
|
||
|
||
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
|
||
ref={formRef}
|
||
action={updateProject}
|
||
onInput={() => formRef.current && setInput(readInput(formRef.current, rondes))}
|
||
className="flex flex-col gap-6"
|
||
>
|
||
<input type="hidden" name="id" value={project.id} />
|
||
|
||
<section className={`${cardClass} grid grid-cols-1 sm:grid-cols-3 gap-4`}>
|
||
<Field label="Projectnaam">
|
||
<input name="name" required defaultValue={project.name} className={inputClass} />
|
||
</Field>
|
||
<Field label="Status">
|
||
<select name="status" defaultValue={project.status} className={inputClass}>
|
||
{PROJECT_STATUSES.map((s) => (
|
||
<option key={s.id} value={s.id}>
|
||
{s.label}
|
||
</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>
|
||
|
||
<section className={cardClass}>
|
||
<StepHeading step={1} title="Project plan" description="In te vullen door de sociaal ondernemer." />
|
||
<ProjectPlanFields project={project} plan={parseProjectPlanJson(project.project_plan)} />
|
||
</section>
|
||
|
||
<div className="flex items-center justify-between gap-4 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-6 py-4">
|
||
<div>
|
||
<p className="font-medium text-zinc-900 dark:text-zinc-100">Rikx calculatie</p>
|
||
<p className="text-sm text-zinc-500">Toon de waardebepaling volgens de Rikx-rekenmethode.</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={calcOn}
|
||
aria-label="Rikx calculatie tonen"
|
||
onClick={toggleCalc}
|
||
disabled={pending}
|
||
className={`relative inline-flex h-7 w-12 shrink-0 items-center rounded-full transition-colors disabled:opacity-60 ${
|
||
calcOn ? "bg-rikx-orange" : "bg-zinc-300 dark:bg-zinc-700"
|
||
}`}
|
||
>
|
||
<span
|
||
className={`inline-block h-5 w-5 rounded-full bg-white shadow transition-transform ${
|
||
calcOn ? "translate-x-6" : "translate-x-1"
|
||
}`}
|
||
/>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Verborgen in plaats van weggelaten, zodat de waarden bij opslaan bewaard blijven. */}
|
||
<section className={cardClass} hidden={!calcOn}>
|
||
<StepHeading
|
||
step={2}
|
||
title="Rikx calculatie"
|
||
description="Waardebepaling door het expertteam en Rikx. Uitkomsten worden live berekend."
|
||
/>
|
||
|
||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Expertscores (1–10)</h3>
|
||
<div className="overflow-x-auto mb-6">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="text-zinc-500">
|
||
<th className="text-left font-medium py-2 pr-3">Criterium</th>
|
||
{Array.from({ length: EXPERT_COUNT }, (_, i) => (
|
||
<th key={i} className="text-left font-medium py-2 px-1 min-w-24">
|
||
Expert {i + 1}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{EXPERT_CRITERIA.map((c) => (
|
||
<tr key={c.id}>
|
||
<td className="py-1 pr-3 text-zinc-700 dark:text-zinc-300 whitespace-nowrap">{c.label}</td>
|
||
{Array.from({ length: EXPERT_COUNT }, (_, i) => (
|
||
<td key={i} className="py-1 px-1">
|
||
<input
|
||
name={`expert_${i + 1}_${c.id}`}
|
||
type="number"
|
||
min={1}
|
||
max={10}
|
||
step="0.5"
|
||
aria-label={`${c.label}, expert ${i + 1}`}
|
||
defaultValue={input.expert_scores[i][c.id] ?? ""}
|
||
className={inputClass}
|
||
/>
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
<p className="text-xs text-zinc-500 mt-1">Voorlopig handmatig; met Module 4 vullen de experts dit zelf in.</p>
|
||
</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>
|
||
</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>
|
||
);
|
||
}
|