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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user