Voeg Module 1 Projecten toe met project plan, Rikx calculatie en Rikx rondes
- 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>
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user