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
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { deleteRonde } from "./actions";
export function DeleteRondeButton({
id,
label,
projectCount,
}: {
id: number;
label: string;
projectCount: number;
}) {
return (
<form
action={deleteRonde}
onSubmit={(e) => {
const extra =
projectCount === 1
? " Het gekoppelde project blijft bestaan, maar zonder ronde."
: projectCount > 1
? ` De ${projectCount} gekoppelde projecten blijven bestaan, maar zonder ronde.`
: "";
if (!confirm(`Weet je zeker dat je Rikx Ronde "${label}" wilt verwijderen?${extra}`)) {
e.preventDefault();
}
}}
>
<input type="hidden" name="id" value={id} />
<button type="submit" className="text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors">
Verwijderen
</button>
</form>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { RikxRonde } from "../../lib/db";
const inputClass =
"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";
export const RONDE_ERRORS: Record<string, string> = {
missing: "Vul een ronde label in.",
invalid: "Rikx waarde en Rikx prijs mogen niet negatief zijn.",
label: "Er bestaat al een ronde met dit label.",
};
// Gedeelde velden voor toevoegen en bewerken van een Rikx Ronde.
export function RondeFields({ ronde }: { ronde?: RikxRonde }) {
return (
<>
<label className="flex flex-col gap-1">
<span className={labelClass}>
Ronde label<span className="text-red-500 ml-1">*</span>
</span>
<input name="label" required defaultValue={ronde?.label} placeholder="Rikx Ronde 5 – Rotterdam" className={inputClass} />
</label>
<label className="flex flex-col gap-1">
<span className={labelClass}>Rikx waarde</span>
<input name="rikx_waarde" type="number" min={0} step="any" defaultValue={ronde?.rikx_waarde ?? ""} className={inputClass} />
<span className="text-xs text-zinc-500">Kosten/impact van het 75%-ste project in de ronde.</span>
</label>
<label className="flex flex-col gap-1">
<span className={labelClass}>Rikx prijs (€)</span>
<input name="rikx_prijs" type="number" min={0} step="0.01" defaultValue={ronde?.rikx_prijs ?? ""} className={inputClass} />
<span className="text-xs text-zinc-500">Leeg laten = Rikx waarde / 10.</span>
</label>
</>
);
}
+64
View File
@@ -0,0 +1,64 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getSession } from "../../../lib/auth";
import { getDb, type RikxRonde } from "../../../lib/db";
import { AppHeader } from "../../AppHeader";
import { updateRonde } from "../actions";
import { RONDE_ERRORS, RondeFields } from "../RondeFields";
export default async function EditRondePage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>;
}) {
const session = await getSession();
if (!session) redirect("/login");
const { id } = await params;
const { error } = await searchParams;
const row = getDb().prepare("SELECT * FROM rikx_rondes WHERE id = ?").get(Number(id)) as RikxRonde | undefined;
if (!row) notFound();
const ronde = { ...row };
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-10 flex flex-col gap-6">
<Link href="/rondes" className="text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors">
← Terug naar Rikx rondes
</Link>
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Rikx Ronde bewerken</h1>
{error && RONDE_ERRORS[error] && (
<p className="text-sm text-red-600 dark:text-red-400">{RONDE_ERRORS[error]}</p>
)}
<form
action={updateRonde}
className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6 grid grid-cols-1 sm:grid-cols-3 gap-4"
>
<input type="hidden" name="id" value={ronde.id} />
<RondeFields ronde={ronde} />
<p className="sm:col-span-3 text-xs text-zinc-500">
Een andere Rikx waarde of prijs verandert de Rikx calculatie van alle projecten in deze ronde.
</p>
<div className="sm:col-span-3 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="/rondes"
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>
</main>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use server";
import { redirect } from "next/navigation";
import { getDb } from "../../lib/db";
import { getSession } from "../../lib/auth";
import { defaultRikxPrijs } from "../../lib/rikxCalculatie";
// Leeg veld → null; accepteert zowel 1234.5 als 1234,5
function parseNumber(value: FormDataEntryValue | null): number | null {
if (typeof value !== "string" || value.trim() === "") return null;
const n = Number(value.replace(",", "."));
return Number.isFinite(n) ? n : NaN;
}
// Leest en controleert het rondeformulier; geeft een foutcode terug bij ongeldige invoer.
function readRonde(formData: FormData) {
const label = ((formData.get("label") as string) ?? "").trim();
const rikxWaarde = parseNumber(formData.get("rikx_waarde"));
let rikxPrijs = parseNumber(formData.get("rikx_prijs"));
if (!label) return { error: "missing" } as const;
if ((rikxWaarde !== null && !(rikxWaarde >= 0)) || (rikxPrijs !== null && !(rikxPrijs >= 0))) {
return { error: "invalid" } as const;
}
if (rikxPrijs === null && rikxWaarde !== null) rikxPrijs = defaultRikxPrijs(rikxWaarde);
return { label, rikxWaarde, rikxPrijs } as const;
}
export async function createRonde(formData: FormData) {
const session = await getSession();
if (!session) redirect("/login");
const ronde = readRonde(formData);
if ("error" in ronde) redirect(`/rondes?error=${ronde.error}`);
const db = getDb();
if (db.prepare("SELECT id FROM rikx_rondes WHERE label = ?").get(ronde.label)) {
redirect("/rondes?error=label");
}
db.prepare("INSERT INTO rikx_rondes (label, rikx_waarde, rikx_prijs) VALUES (?, ?, ?)").run(
ronde.label,
ronde.rikxWaarde,
ronde.rikxPrijs
);
redirect("/rondes");
}
export async function updateRonde(formData: FormData) {
const session = await getSession();
if (!session) redirect("/login");
const id = Number(formData.get("id"));
if (!id) redirect("/rondes");
const ronde = readRonde(formData);
if ("error" in ronde) redirect(`/rondes/${id}?error=${ronde.error}`);
const db = getDb();
if (db.prepare("SELECT id FROM rikx_rondes WHERE label = ? AND id != ?").get(ronde.label, id)) {
redirect(`/rondes/${id}?error=label`);
}
db.prepare(
"UPDATE rikx_rondes SET label = ?, rikx_waarde = ?, rikx_prijs = ?, updated_at = datetime('now') WHERE id = ?"
).run(ronde.label, ronde.rikxWaarde, ronde.rikxPrijs, id);
redirect("/rondes");
}
export async function deleteRonde(formData: FormData) {
const session = await getSession();
if (!session) redirect("/login");
const id = Number(formData.get("id"));
// Projecten in deze ronde blijven bestaan; hun ronde_id wordt NULL (ON DELETE SET NULL).
if (id) getDb().prepare("DELETE FROM rikx_rondes WHERE id = ?").run(id);
redirect("/rondes");
}
+112
View File
@@ -0,0 +1,112 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getSession } from "../../lib/auth";
import { getDb, type RikxRonde } from "../../lib/db";
import { AppHeader } from "../AppHeader";
import { createRonde } from "./actions";
import { DeleteRondeButton } from "./DeleteRondeButton";
import { RONDE_ERRORS, RondeFields } from "./RondeFields";
const euro = new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" });
const getal = new Intl.NumberFormat("nl-NL", { maximumFractionDigits: 2 });
export default async function RondesPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const session = await getSession();
if (!session) redirect("/login");
const { error } = await searchParams;
const rondes = (
getDb()
.prepare(
`SELECT r.*, COUNT(p.id) AS project_count
FROM rikx_rondes r LEFT JOIN projects p ON p.ronde_id = r.id
GROUP BY r.id ORDER BY r.created_at DESC, r.id DESC`
)
.all() as (RikxRonde & { project_count: number })[]
).map((r) => ({ ...r }));
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-10 flex flex-col gap-10">
<section>
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100 mb-4">
Rikx rondes ({rondes.length})
</h1>
{rondes.length === 0 ? (
<p className="text-sm text-zinc-500">Nog geen Rikx rondes aangemaakt.</p>
) : (
<div className="rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-zinc-50 dark:bg-zinc-900 text-zinc-500 dark:text-zinc-400">
<tr>
<th className="text-left px-4 py-3 font-medium">Ronde label</th>
<th className="text-right px-4 py-3 font-medium">Rikx waarde</th>
<th className="text-right px-4 py-3 font-medium">Rikx prijs</th>
<th className="text-right px-4 py-3 font-medium">Projecten</th>
<th className="px-4 py-3" colSpan={2}></th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{rondes.map((r) => (
<tr key={r.id} className="bg-white dark:bg-zinc-950">
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{r.label}</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-700 dark:text-zinc-300">
{r.rikx_waarde === null ? "—" : getal.format(r.rikx_waarde)}
</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-700 dark:text-zinc-300">
{r.rikx_prijs === null ? "—" : euro.format(r.rikx_prijs)}
</td>
<td className="px-4 py-3 text-right">
<Link href={`/projecten?ronde=${r.id}`} className="text-rikx-orange-dark hover:underline">
{r.project_count}
</Link>
</td>
<td className="px-4 py-3 text-right">
<Link
href={`/rondes/${r.id}`}
className="text-xs text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 transition-colors"
>
Bewerken
</Link>
</td>
<td className="px-4 py-3 text-right">
<DeleteRondeButton id={r.id} label={r.label} projectCount={r.project_count} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section>
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100 mb-4">Nieuwe Rikx Ronde</h2>
{error && RONDE_ERRORS[error] && (
<p className="text-sm text-red-600 dark:text-red-400 mb-4">{RONDE_ERRORS[error]}</p>
)}
<form
action={createRonde}
className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6 grid grid-cols-1 sm:grid-cols-3 gap-4"
>
<RondeFields />
<div className="sm:col-span-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"
>
Ronde toevoegen
</button>
</div>
</form>
</section>
</main>
</div>
);
}