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:
2026-10-08 10:26:30 +02:00
parent 46eecc31a4
commit 9f0bd87c40
25 changed files with 2161 additions and 55 deletions
+152
View File
@@ -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>
);
}