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>
153 lines
5.5 KiB
TypeScript
153 lines
5.5 KiB
TypeScript
"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>
|
||
);
|
||
}
|