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,92 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
// Dropdown waarin meerdere opties aangevinkt kunnen worden. De keuzes gaan als
|
||||
// checkboxes met dezelfde `name` mee in het formulier (FormData.getAll).
|
||||
export function MultiSelectDropdown({
|
||||
id,
|
||||
name,
|
||||
options,
|
||||
defaultValue,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
options: string[];
|
||||
defaultValue: string[];
|
||||
onChange?: (values: string[]) => void;
|
||||
className: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<string[]>(defaultValue);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent | KeyboardEvent) => {
|
||||
if (e instanceof KeyboardEvent ? e.key === "Escape" : !rootRef.current?.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
document.addEventListener("keydown", close);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", close);
|
||||
document.removeEventListener("keydown", close);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function toggle(option: string, checked: boolean) {
|
||||
const next = checked ? options.filter((o) => o === option || selected.includes(o)) : selected.filter((o) => o !== option);
|
||||
setSelected(next);
|
||||
onChange?.(next);
|
||||
}
|
||||
|
||||
const summary =
|
||||
selected.length === 0 ? "Maak een keuze" : selected.length === 1 ? selected[0] : `${selected.length} geselecteerd`;
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className={`${className} flex items-center justify-between gap-2 text-left`}
|
||||
>
|
||||
<span className={`truncate ${selected.length ? "" : "text-zinc-400"}`}>{summary}</span>
|
||||
<svg viewBox="0 0 10 6" aria-hidden className="w-2.5 h-2.5 shrink-0 fill-zinc-500">
|
||||
<path d="M0 0h10L5 6z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Altijd gerenderd zodat de checkboxes meegaan in het formulier, ook als de lijst dicht is. */}
|
||||
<div
|
||||
hidden={!open}
|
||||
className="absolute left-0 right-0 top-full z-30 mt-1 max-h-72 overflow-y-auto rounded-lg bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-black/10 py-1"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option}
|
||||
className="flex items-start gap-2 px-3 py-2 text-sm text-zinc-800 dark:text-zinc-200 hover:bg-orange-50 dark:hover:bg-zinc-800 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={name}
|
||||
value={option}
|
||||
checked={selected.includes(option)}
|
||||
onChange={(e) => toggle(option, e.target.checked)}
|
||||
className="mt-0.5 accent-rikx-orange"
|
||||
/>
|
||||
{option}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{selected.length > 1 && (
|
||||
<p className="mt-1 text-xs text-zinc-500">{selected.join(" · ")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Project } from "../../../lib/db";
|
||||
import {
|
||||
ANDERS,
|
||||
PROJECT_PLAN_GROUPS,
|
||||
andersKey,
|
||||
type PlanField,
|
||||
type ProjectPlan,
|
||||
} from "../../../lib/projectPlan";
|
||||
import { MultiSelectDropdown } from "./MultiSelectDropdown";
|
||||
|
||||
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 textareaClass =
|
||||
"w-full min-h-28 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";
|
||||
|
||||
type ColumnKey = "projectkosten" | "deelnemers_werk" | "deelnemers_skills" | "financiering_extern_bedrag" | "financiering_extern_pct";
|
||||
|
||||
function FieldInput({
|
||||
field,
|
||||
project,
|
||||
plan,
|
||||
onSelectChange,
|
||||
}: {
|
||||
field: PlanField;
|
||||
project: Project;
|
||||
plan: ProjectPlan;
|
||||
onSelectChange: (key: string, value: string) => void;
|
||||
}) {
|
||||
const id = `plan-${field.key}`;
|
||||
const saved = plan[field.key];
|
||||
const [anders, setAnders] = useState(Array.isArray(saved) && saved.includes(ANDERS));
|
||||
|
||||
switch (field.kind) {
|
||||
case "textarea":
|
||||
return <textarea id={id} name={field.key} defaultValue={typeof saved === "string" ? saved : ""} className={textareaClass} />;
|
||||
case "number": {
|
||||
const value = field.column ? project[field.key as ColumnKey] : saved;
|
||||
return (
|
||||
<input
|
||||
id={id}
|
||||
name={field.key}
|
||||
type="number"
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
defaultValue={typeof value === "number" ? value : ""}
|
||||
className={inputClass}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "date":
|
||||
return <input id={id} name={field.key} type="date" defaultValue={typeof saved === "string" ? saved : ""} className={inputClass} />;
|
||||
case "select":
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
name={field.key}
|
||||
defaultValue={typeof saved === "string" ? saved : ""}
|
||||
onChange={(e) => onSelectChange(field.key, e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">Maak een keuze</option>
|
||||
{field.options.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
case "multiselect": {
|
||||
const andersValue = plan[andersKey(field.key)];
|
||||
return (
|
||||
<>
|
||||
<MultiSelectDropdown
|
||||
id={id}
|
||||
name={field.key}
|
||||
options={field.options}
|
||||
defaultValue={Array.isArray(saved) ? saved : []}
|
||||
onChange={(values) => setAnders(values.includes(ANDERS))}
|
||||
className={inputClass}
|
||||
/>
|
||||
{anders && (
|
||||
<input
|
||||
name={andersKey(field.key)}
|
||||
aria-label={`${field.label} – anders, namelijk`}
|
||||
placeholder="Anders, namelijk…"
|
||||
defaultValue={typeof andersValue === "string" ? andersValue : ""}
|
||||
className={`${inputClass} mt-2`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stap 1 "Project plan": alle velden uit lib/projectPlan.ts, gegroepeerd.
|
||||
export function ProjectPlanFields({ project, plan }: { project: Project; plan: ProjectPlan }) {
|
||||
// Huidige waarden van selects, voor velden met showIf (bv. "Zo ja, licht toe")
|
||||
const [selects, setSelects] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(
|
||||
PROJECT_PLAN_GROUPS.flatMap((g) => g.fields)
|
||||
.filter((f) => f.kind === "select")
|
||||
.map((f) => [f.key, typeof plan[f.key] === "string" ? (plan[f.key] as string) : ""])
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{PROJECT_PLAN_GROUPS.map((group) => (
|
||||
<fieldset key={group.title} className="flex flex-col gap-4">
|
||||
<legend className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-3 pb-2 border-b border-zinc-200 dark:border-zinc-800 w-full">
|
||||
{group.title}
|
||||
</legend>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-5">
|
||||
{group.fields
|
||||
.filter((f) => !f.showIf || selects[f.showIf.key] === f.showIf.equals)
|
||||
.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className={[
|
||||
"flex flex-col gap-1",
|
||||
field.wide ? "sm:col-span-2" : "",
|
||||
field.newRow ? "sm:col-start-1" : "",
|
||||
field.followUp ? "-mt-3 ml-2 pl-4 border-l-2 border-rikx-orange" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{/* Minimaal twee regels hoog, zodat velden naast elkaar uitlijnen bij labels van ongelijke lengte */}
|
||||
<label
|
||||
htmlFor={`plan-${field.key}`}
|
||||
className={`text-sm font-medium text-zinc-700 dark:text-zinc-300 ${field.wide || field.newRow ? "" : "sm:min-h-10"}`}
|
||||
>
|
||||
{field.label}
|
||||
</label>
|
||||
<FieldInput
|
||||
field={field}
|
||||
project={project}
|
||||
plan={plan}
|
||||
onSelectChange={(key, value) => setSelects((s) => ({ ...s, [key]: value }))}
|
||||
/>
|
||||
{field.hint && <span className="text-xs text-zinc-500">{field.hint}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getSession } from "../../../lib/auth";
|
||||
import { getDb, listRikxRondes, type Project, type ProjectExpertScore } from "../../../lib/db";
|
||||
import { projectStatusBadgeClass, projectStatusLabel } from "../../../lib/projectStatus";
|
||||
import { AppHeader } from "../../AppHeader";
|
||||
import { ProjectDetailForm } from "./ProjectDetailForm";
|
||||
|
||||
export default async function ProjectPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ error?: string; opgeslagen?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
const { id } = await params;
|
||||
const { error, opgeslagen } = await searchParams;
|
||||
|
||||
const db = getDb();
|
||||
const row = db.prepare("SELECT * FROM projects WHERE id = ?").get(Number(id)) as Project | undefined;
|
||||
if (!row) notFound();
|
||||
// node:sqlite levert objecten met null-prototype; spreiden maakt ze doorgeefbaar aan client components.
|
||||
const project = { ...row };
|
||||
const expertScores = (
|
||||
db.prepare("SELECT * FROM project_expert_scores WHERE project_id = ? ORDER BY expert").all(project.id) as ProjectExpertScore[]
|
||||
).map((s) => ({ ...s }));
|
||||
|
||||
const message =
|
||||
error === "missing"
|
||||
? { tone: "error", text: "Vul een projectnaam in." }
|
||||
: error === "invalid"
|
||||
? { tone: "error", text: "Niet opgeslagen: controleer de getallen (percentage 0–100, expertscores 1–10, geen negatieve waarden)." }
|
||||
: error === "datum"
|
||||
? { tone: "error", text: "Niet opgeslagen: controleer de datums (de einddatum mag niet vóór de startdatum liggen)." }
|
||||
: opgeslagen
|
||||
? { tone: "ok", text: "Project opgeslagen." }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
<AppHeader email={session.email} active="projecten" />
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-8 flex flex-col gap-6">
|
||||
<Link
|
||||
href="/projecten"
|
||||
className="text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors"
|
||||
>
|
||||
← Terug naar projecten
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">{project.name}</h1>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${projectStatusBadgeClass(project.status)}`}
|
||||
>
|
||||
{projectStatusLabel(project.status)}
|
||||
</span>
|
||||
</div>
|
||||
{message && (
|
||||
<p
|
||||
className={
|
||||
message.tone === "error"
|
||||
? "text-sm text-red-600 dark:text-red-400"
|
||||
: "text-sm text-green-700 dark:text-green-400"
|
||||
}
|
||||
>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
<ProjectDetailForm project={project} expertScores={expertScores} rondes={listRikxRondes()} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user