Voeg Module 4 experts, waardebepalingspagina en projectverbeteringen toe

Experts (Module 4)
- Pagina Experts (CRM): experts uitnodigen per e-mail (zonder SMTP wordt de
  link getoond), rondes koppelen, opnieuw uitnodigen, verwijderen en
  "Inloggen als" (alleen lezen) om mee te kijken
- Uitnodigingspagina waar een expert zijn wachtwoord instelt (token 7 dagen
  geldig, eenmalig, alleen als hash opgeslagen)
- Expertpagina: projecten in waardebepaling uit de eigen rondes, project plan
  lezen en eigen beoordeling geven (3 scores, advies Bonus/Malus, toelichting)
- Rollen admin/expert: alle beheerpagina's en -acties controleren de rol
- Rikx calculatie gebruikt de beoordelingen van de experts in plaats van de
  handmatige expertkolommen; experts koppelen ook vanaf de rondepagina

Waardebepaling projecten
- Nieuwe pagina met projecten in waardebepaling op kosten/impact (laag naar
  hoog), filter op ronde, Rikx Waarde-veld dat live doorrekent en opslaat in
  de ronde, en een 75%-lijn bij een gekozen ronde

Projecten
- Rikx calculatie als tab, zichtbaar vanaf status Waardebepaling, met keuze
  rekenmethode (Arbeid; Jeugd nog niet beschikbaar) en beoordeling
  goedgekeurd/afgekeurd met reden
- Impactmaker gekoppeld aan een sociaal ondernemer
- Opslaan-knop bovenaan; ongeldige velden op een verborgen tab worden getoond
- Project toevoegen maakt direct een project aan; aparte pagina verwijderd
- Waarderingssystematiek Rikx juni 2023 toegevoegd aan _doc

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:42:22 +02:00
parent 9f0bd87c40
commit 53bf667f54
41 changed files with 2075 additions and 357 deletions
+2 -2
View File
@@ -12,7 +12,7 @@ const NAV_ITEMS: NavItem[] = [
section: "projecten",
items: [
{ label: "Overzicht", href: "/projecten" },
{ label: "Project toevoegen", href: "/projecten/nieuw" },
{ label: "Waardebepaling projecten", href: "/waardebepaling" },
{ label: "Rikx rondes", href: "/rondes" },
],
},
@@ -24,7 +24,7 @@ const NAV_ITEMS: NavItem[] = [
{
label: "CRM",
section: "crm",
items: [{ label: "Sociaal ondernemers", href: "/tenants" }, { label: "Investeerders" }, { label: "Beoordelaars" }],
items: [{ label: "Sociaal ondernemers", href: "/tenants" }, { label: "Investeerders" }, { label: "Experts", href: "/experts" }],
},
{ label: "Publieke Website", section: "website" },
];
+8 -6
View File
@@ -1,26 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { createHmac } from "crypto";
import { getSession } from "../../../lib/auth";
import { getCurrentUser } from "../../../lib/auth";
import { getPlatformDb } from "../../../lib/platform-db";
export async function GET(request: NextRequest) {
const session = await getSession();
if (!session) return NextResponse.redirect(new URL("/login", request.url));
// Alleen admins mogen inloggen bij een sociaal ondernemer
const user = await getCurrentUser();
if (!user) return NextResponse.redirect(new URL("/login", request.url));
if (user.role !== "admin") return NextResponse.redirect(new URL("/expert", request.url));
const accountId = Number(request.nextUrl.searchParams.get("account_id"));
const slug = request.nextUrl.searchParams.get("slug");
if (!accountId || !slug) return NextResponse.redirect(new URL("/tenants", request.url));
const db = getPlatformDb();
const user = db
const platformUser = db
.prepare("SELECT id FROM users WHERE account_id = ? AND role = 'admin' LIMIT 1")
.get(accountId) as { id: number } | undefined;
if (!user) return NextResponse.redirect(new URL("/tenants?error=no-admin", request.url));
if (!platformUser) return NextResponse.redirect(new URL("/tenants?error=no-admin", request.url));
const secret = process.env.AUTO_LOGIN_SECRET!;
const expires = Date.now() + 60_000;
const payload = `${user.id}:${accountId}:${expires}`;
const payload = `${platformUser.id}:${accountId}:${expires}`;
const sig = createHmac("sha256", secret).update(payload).digest("hex");
const token = encodeURIComponent(`${payload}:${sig}`);
+55
View File
@@ -0,0 +1,55 @@
import type { Project } from "../../lib/db";
import { ANDERS, PROJECT_PLAN_GROUPS, andersKey, parseProjectPlanJson, type PlanField } from "../../lib/projectPlan";
const getal = new Intl.NumberFormat("nl-NL", { maximumFractionDigits: 2 });
const euro = new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" });
const datum = new Intl.DateTimeFormat("nl-NL", { day: "numeric", month: "long", year: "numeric" });
type ColumnKey = "projectkosten" | "deelnemers_werk" | "deelnemers_skills" | "financiering_extern_bedrag" | "financiering_extern_pct";
function waarde(field: PlanField, project: Project, plan: Record<string, unknown>): string | null {
const v = field.kind === "number" && field.column ? project[field.key as ColumnKey] : plan[field.key];
if (v === null || v === undefined || v === "" || (Array.isArray(v) && v.length === 0)) return null;
switch (field.kind) {
case "number":
return field.label.includes("(€)") ? euro.format(v as number) : getal.format(v as number);
case "date":
return datum.format(new Date(`${v}T12:00:00`));
case "multiselect": {
const anders = plan[andersKey(field.key)];
return (v as string[]).map((o) => (o === ANDERS && typeof anders === "string" ? `Anders: ${anders}` : o)).join(" · ");
}
default:
return String(v);
}
}
// Alleen-lezen weergave van het project plan voor experts
export function ProjectPlanView({ project }: { project: Project }) {
const plan = parseProjectPlanJson(project.project_plan);
return (
<div className="flex flex-col gap-6">
{PROJECT_PLAN_GROUPS.map((group) => (
<section key={group.title}>
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 pb-2 mb-3 border-b border-zinc-200 dark:border-zinc-800">
{group.title}
</h3>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
{group.fields.map((field) => {
const v = waarde(field, project, plan);
const wide = field.wide || field.kind === "textarea";
return (
<div key={field.key} className={wide ? "sm:col-span-2" : ""}>
<dt className="text-xs font-medium text-zinc-500">{field.label}</dt>
<dd className={`mt-0.5 text-sm whitespace-pre-line ${v ? "text-zinc-900 dark:text-zinc-100" : "text-zinc-400"}`}>
{v ?? "Niet ingevuld"}
</dd>
</div>
);
})}
</dl>
</section>
))}
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
"use server";
import { redirect } from "next/navigation";
import { createSession, getCurrentUser, requireExpert } from "../../lib/auth";
import { getDb } from "../../lib/db";
import { magBeoordelen } from "../../lib/experts";
import { BONUS_MALUS_OPTIONS } from "../../lib/rikxCalculatie";
function readScore(formData: FormData, key: string): number | null | "ongeldig" {
const raw = ((formData.get(key) as string) ?? "").trim();
if (!raw) return null;
const n = Number(raw.replace(",", "."));
return Number.isFinite(n) && n >= 1 && n <= 10 ? n : "ongeldig";
}
export async function saveBeoordeling(formData: FormData) {
const expert = await requireExpert();
const projectId = Number(formData.get("project_id"));
// Een admin die meekijkt mag niet namens de expert beoordelen
if (expert.impersonator) redirect(`/expert?project=${projectId}&error=meekijken`);
if (!projectId || !magBeoordelen(expert.id, projectId)) redirect("/expert");
const duur = readScore(formData, "duur_impact");
const domeinen = readScore(formData, "levensdomeinen");
const basis = readScore(formData, "basissituatie");
if (duur === "ongeldig" || domeinen === "ongeldig" || basis === "ongeldig") {
redirect(`/expert?project=${projectId}&error=score`);
}
const rawAdvies = (formData.get("bonus_malus_advies") as string) ?? "";
const advies = rawAdvies && BONUS_MALUS_OPTIONS.some((o) => o.id === rawAdvies) ? rawAdvies : null;
const toelichting = ((formData.get("toelichting") as string) ?? "").trim().slice(0, 10_000) || null;
getDb()
.prepare(
`INSERT INTO expert_beoordelingen
(project_id, expert_id, duur_impact, levensdomeinen, basissituatie, bonus_malus_advies, toelichting)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (project_id, expert_id) DO UPDATE SET
duur_impact = excluded.duur_impact, levensdomeinen = excluded.levensdomeinen,
basissituatie = excluded.basissituatie, bonus_malus_advies = excluded.bonus_malus_advies,
toelichting = excluded.toelichting, updated_at = datetime('now')`
)
.run(projectId, expert.id, duur, domeinen, basis, advies, toelichting);
redirect(`/expert?project=${projectId}&opgeslagen=1`);
}
// Einde van "Inloggen als": terug naar de eigen admin-sessie
export async function stopImpersonation() {
const user = await getCurrentUser();
if (!user?.impersonator) redirect("/login");
await createSession({ userId: user.impersonator.id, email: user.impersonator.email, name: user.impersonator.name });
redirect("/experts");
}
+197
View File
@@ -0,0 +1,197 @@
import Link from "next/link";
import { requireExpert } from "../../lib/auth";
import { listAccountNames } from "../../lib/platform-db";
import { listBeoordelingen, listProjectenVoorExpert } from "../../lib/experts";
import { BONUS_MALUS_OPTIONS, EXPERT_CRITERIA } from "../../lib/rikxCalculatie";
import { logout } from "../tenants/logout";
import { saveBeoordeling, stopImpersonation } from "./actions";
import { ProjectPlanView } from "./ProjectPlanView";
// De enige pagina voor experts: projecten in waardebepaling uit hun rondes, het project plan om te lezen
// en hun eigen beoordeling. Experts zien niet wat andere experts hebben ingevuld.
export default async function ExpertPage({
searchParams,
}: {
searchParams: Promise<{ project?: string; opgeslagen?: string; error?: string }>;
}) {
const expert = await requireExpert();
const { project: projectParam, opgeslagen, error } = await searchParams;
const projecten = listProjectenVoorExpert(expert.id);
const eigen = listBeoordelingen(projecten.map((p) => p.id)).filter((b) => b.expert_id === expert.id);
const impactmakers = new Map(listAccountNames().map((a) => [a.id, a.name]));
const gekozen = projecten.find((p) => String(p.id) === projectParam) ?? projecten[0] ?? null;
const beoordeling = gekozen ? eigen.find((b) => b.project_id === gekozen.id) ?? null : null;
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";
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<header className="bg-rikx-orange">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-3 flex items-center gap-6">
<span className="text-white text-4xl font-extrabold tracking-tight leading-none">Rikx</span>
<span className="text-white/90 text-lg font-semibold">Expertteam</span>
<div className="ml-auto flex items-center gap-4 text-sm text-white">
<span className="hidden sm:inline">{expert.name}</span>
{!expert.impersonator && (
<form action={logout}>
<button className="hover:text-rikx-maroon transition-colors">Uitloggen</button>
</form>
)}
</div>
</div>
</header>
{expert.impersonator && (
<div className="bg-rikx-maroon text-white">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-2.5 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
<span>
Je bekijkt deze pagina als <strong>{expert.name}</strong>. Alleen lezen: beoordelingen kun je hier niet
opslaan.
</span>
<form action={stopImpersonation} className="ml-auto">
<button className="rounded-md bg-white px-3 py-1 text-sm font-medium text-rikx-maroon hover:bg-orange-50">
Terug naar beheer
</button>
</form>
</div>
</div>
)}
<main className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
{projecten.length === 0 ? (
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-8 text-sm text-zinc-600 dark:text-zinc-400">
Er staan op dit moment geen projecten voor je klaar om te beoordelen.
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-[18rem_1fr] gap-6 items-start">
<nav aria-label="Projecten" className="flex flex-col gap-2 lg:sticky lg:top-6">
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
Projecten in waardebepaling ({projecten.length})
</h2>
{projecten.map((p) => {
const klaar = eigen.some((b) => b.project_id === p.id);
const actief = gekozen?.id === p.id;
return (
<Link
key={p.id}
href={`/expert?project=${p.id}`}
aria-current={actief ? "page" : undefined}
className={`rounded-lg border p-3 transition-colors ${
actief
? "border-rikx-orange bg-orange-50 dark:bg-zinc-800"
: "border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 hover:border-rikx-orange"
}`}
>
<span className="block text-sm font-medium text-zinc-900 dark:text-zinc-100">{p.name}</span>
<span className="block text-xs text-zinc-500">{p.ronde_label}</span>
<span
className={`mt-2 inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
klaar ? "bg-green-100 text-green-800" : "bg-zinc-100 text-zinc-600"
}`}
>
{klaar ? "Beoordeeld" : "Nog te beoordelen"}
</span>
</Link>
);
})}
</nav>
{gekozen && (
<div className="flex flex-col gap-6 min-w-0">
<div>
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">{gekozen.name}</h1>
<p className="text-sm text-zinc-500 mt-1">
{gekozen.ronde_label}
{gekozen.impactmaker_account_id && impactmakers.get(gekozen.impactmaker_account_id)
? ` · Impactmaker: ${impactmakers.get(gekozen.impactmaker_account_id)}`
: ""}
</p>
</div>
<section className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6">
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100 mb-5">Project plan</h2>
<ProjectPlanView project={gekozen} />
</section>
<form
action={saveBeoordeling}
// Nieuwe beginwaarden per project
key={gekozen.id}
className="bg-white dark:bg-zinc-900 rounded-xl border-2 border-rikx-orange p-6 flex flex-col gap-5"
>
<input type="hidden" name="project_id" value={gekozen.id} />
{/* Bij meekijken door een admin: alles zichtbaar maar niet te wijzigen */}
<fieldset disabled={Boolean(expert.impersonator)} className="contents">
<div>
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100">Jouw waardebepaling</h2>
<p className="text-sm text-zinc-500">
Geef per criterium een rapportcijfer van 1 tot 10. Andere experts zien jouw beoordeling niet.
</p>
</div>
{opgeslagen && <p className="text-sm text-green-700 dark:text-green-400">Je beoordeling is opgeslagen.</p>}
{error === "meekijken" && (
<p className="text-sm text-red-600 dark:text-red-400">Niet opgeslagen: je kijkt mee als admin.</p>
)}
{error === "score" && (
<p className="text-sm text-red-600 dark:text-red-400">Niet opgeslagen: cijfers moeten tussen 1 en 10 liggen.</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{EXPERT_CRITERIA.map((c) => (
<label key={c.id} className="flex flex-col gap-1">
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">{c.label}</span>
<input
name={c.id}
type="number"
min={1}
max={10}
step="any"
inputMode="decimal"
defaultValue={beoordeling?.[c.id] ?? ""}
className={inputClass}
/>
<span className="text-xs text-zinc-500">{c.schaal}</span>
</label>
))}
</div>
<label className="flex flex-col gap-1 sm:max-w-xs">
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Advies Bonus / Malus</span>
<select name="bonus_malus_advies" defaultValue={beoordeling?.bonus_malus_advies ?? ""} className={inputClass}>
{BONUS_MALUS_OPTIONS.map((o) => (
<option key={o.label} value={o.id}>
{o.label}
</option>
))}
</select>
<span className="text-xs text-zinc-500">
Extra waardering of aftrek op basis van de aanvullende criteria (evidence, monitoring, innovatie, risico&apos;s).
</span>
</label>
<label className="flex flex-col gap-1">
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Toelichting</span>
<textarea
name="toelichting"
defaultValue={beoordeling?.toelichting ?? ""}
placeholder="Onderbouwing van je cijfers: sterke punten en aandachtspunten…"
className="w-full min-h-36 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"
/>
</label>
<div>
<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 disabled:opacity-50 disabled:cursor-not-allowed"
>
Beoordeling opslaan
</button>
</div>
</fieldset>
</form>
</div>
)}
</div>
)}
</main>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
import { useActionState, useState } from "react";
import type { RikxRonde } from "../../lib/db";
import { createExpert, deleteExpert, resendInvite, type InviteState } from "./actions";
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";
// Melding na het aanmaken of opnieuw uitnodigen; bij een link (geen SMTP) met kopieerknop
export function InviteResult({ state }: { state: InviteState }) {
const [copied, setCopied] = useState(false);
if (state.status === "idle") return null;
if (state.status === "error") return <p className="text-sm text-red-600 dark:text-red-400">{state.message}</p>;
if (state.status === "sent") return <p className="text-sm text-green-700 dark:text-green-400">{state.message}</p>;
return (
<div className="flex flex-col gap-2 rounded-lg bg-orange-50 dark:bg-zinc-800 px-4 py-3 text-sm">
<p className="text-zinc-800 dark:text-zinc-200">{state.message}</p>
<div className="flex flex-wrap items-center gap-2">
<input readOnly value={state.link} onFocus={(e) => e.target.select()} className={`${inputClass} flex-1 min-w-0 font-mono text-xs`} />
<button
type="button"
onClick={async () => {
await navigator.clipboard.writeText(state.link);
setCopied(true);
}}
className="rounded-lg border border-zinc-300 dark:border-zinc-700 px-3 h-10 text-sm hover:bg-white dark:hover:bg-zinc-700"
>
{copied ? "Gekopieerd" : "Kopiëren"}
</button>
</div>
</div>
);
}
export function RondeCheckboxes({ rondes, selected = [] }: { rondes: RikxRonde[]; selected?: number[] }) {
if (rondes.length === 0) {
return <p className="text-sm text-zinc-500">Er zijn nog geen Rikx rondes.</p>;
}
return (
<div className="flex flex-wrap gap-x-5 gap-y-2">
{rondes.map((r) => (
<label key={r.id} className="flex items-center gap-2 text-sm text-zinc-800 dark:text-zinc-200">
<input type="checkbox" name="ronde_ids" value={r.id} defaultChecked={selected.includes(r.id)} className="accent-rikx-orange" />
{r.label}
</label>
))}
</div>
);
}
export function NewExpertForm({ rondes }: { rondes: RikxRonde[] }) {
const [state, action, pending] = useActionState(createExpert, { status: "idle" } as InviteState);
return (
<form
action={action}
// Na succes leeg beginnen voor de volgende expert
key={state.status === "sent" || state.status === "link" ? state.message : "form"}
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-2 gap-4"
>
<label className="flex flex-col gap-1">
<span className={labelClass}>Naam</span>
<input name="name" required className={inputClass} />
</label>
<label className="flex flex-col gap-1">
<span className={labelClass}>E-mailadres</span>
<input name="email" type="email" required className={inputClass} />
</label>
<fieldset className="sm:col-span-2 flex flex-col gap-2">
<legend className={`${labelClass} mb-2`}>Rikx rondes</legend>
<RondeCheckboxes rondes={rondes} />
<span className="text-xs text-zinc-500">De expert ziet alleen projecten in waardebepaling uit deze rondes.</span>
</fieldset>
<div className="sm:col-span-2 flex flex-col gap-3">
<div>
<button
type="submit"
disabled={pending}
className="rounded-lg bg-rikx-maroon hover:bg-rikx-maroon-light px-5 py-2 text-sm font-medium text-white transition-colors disabled:opacity-60"
>
{pending ? "Bezig…" : "Expert uitnodigen"}
</button>
</div>
<InviteResult state={state} />
</div>
</form>
);
}
export function ResendInviteButton({ id, label }: { id: number; label: string }) {
const [state, action, pending] = useActionState(resendInvite, { status: "idle" } as InviteState);
return (
<div className="flex flex-col items-end gap-2">
<form action={action}>
<input type="hidden" name="id" value={id} />
<button type="submit" disabled={pending} className="text-xs text-rikx-orange-dark hover:underline disabled:opacity-60">
{pending ? "Bezig…" : label}
</button>
</form>
{state.status !== "idle" && (
<div className="w-80 max-w-full text-left">
<InviteResult state={state} />
</div>
)}
</div>
);
}
export function DeleteExpertButton({ id, name, aantalBeoordelingen }: { id: number; name: string; aantalBeoordelingen: number }) {
return (
<form
action={deleteExpert}
onSubmit={(e) => {
const extra =
aantalBeoordelingen > 0
? ` Ook ${aantalBeoordelingen === 1 ? "de beoordeling" : `de ${aantalBeoordelingen} beoordelingen`} van deze expert ${aantalBeoordelingen === 1 ? "wordt" : "worden"} verwijderd; dat verandert de Rikx calculatie van die projecten.`
: "";
if (!confirm(`Weet je zeker dat je expert "${name}" 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>
);
}
+73
View File
@@ -0,0 +1,73 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { requireAdmin } from "../../../lib/auth";
import { listRikxRondes } from "../../../lib/db";
import { getExpert } from "../../../lib/experts";
import { AppHeader } from "../../AppHeader";
import { updateExpert } from "../actions";
import { RondeCheckboxes } from "../InviteForms";
export default async function EditExpertPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>;
}) {
const session = await requireAdmin();
const { id } = await params;
const { error } = await searchParams;
const expert = getExpert(Number(id));
if (!expert) notFound();
const errorMsg =
error === "email" ? "Er bestaat al een gebruiker met dit e-mailadres." : error === "missing" ? "Vul een naam en een geldig e-mailadres in." : null;
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";
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<AppHeader email={session.email} active="crm" />
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-10 flex flex-col gap-6">
<Link href="/experts" className="text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors">
← Terug naar experts
</Link>
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Expert bewerken</h1>
{errorMsg && <p className="text-sm text-red-600 dark:text-red-400">{errorMsg}</p>}
<form
action={updateExpert}
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-2 gap-4"
>
<input type="hidden" name="id" value={expert.id} />
<label className="flex flex-col gap-1">
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Naam</span>
<input name="name" required defaultValue={expert.name} className={inputClass} />
</label>
<label className="flex flex-col gap-1">
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">E-mailadres</span>
<input name="email" type="email" required defaultValue={expert.email} className={inputClass} />
</label>
<fieldset className="sm:col-span-2 flex flex-col gap-2">
<legend className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">Rikx rondes</legend>
<RondeCheckboxes rondes={listRikxRondes()} selected={expert.rondeIds} />
</fieldset>
<div className="sm:col-span-2 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="/experts"
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>
);
}
+130
View File
@@ -0,0 +1,130 @@
"use server";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { createSession, requireAdmin } from "../../lib/auth";
import { getDb } from "../../lib/db";
import { UITNODIGING_GELDIG_DAGEN, createInvite, getExpert, setExpertRondes } from "../../lib/experts";
import { isMailConfigured, sendMail } from "../../lib/mail";
export type InviteState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "sent"; message: string }
| { status: "link"; message: string; link: string };
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function readRondeIds(formData: FormData): number[] {
const bestaand = new Set(
(getDb().prepare("SELECT id FROM rikx_rondes").all() as { id: number }[]).map((r) => r.id)
);
return formData
.getAll("ronde_ids")
.map(Number)
.filter((id) => bestaand.has(id));
}
async function baseUrl(): Promise<string> {
const h = await headers();
const host = h.get("x-forwarded-host") ?? h.get("host") ?? "localhost:3000";
const proto = h.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
return `${proto}://${host}`;
}
// Maakt een uitnodiging en mailt die; zonder SMTP komt de link terug om zelf door te geven.
async function invite(userId: number, name: string, email: string): Promise<InviteState> {
const link = `${await baseUrl()}/uitnodiging/${createInvite(userId)}`;
if (!isMailConfigured()) {
return {
status: "link",
message: `E-mail is nog niet ingesteld (SMTP). Geef deze link zelf door aan ${name}; hij is ${UITNODIGING_GELDIG_DAGEN} dagen geldig.`,
link,
};
}
try {
await sendMail({
to: email,
subject: "Uitnodiging Rikx expertteam",
text: `Beste ${name},\n\nJe bent uitgenodigd voor het Rikx expertteam. Via onderstaande link stel je je wachtwoord in en kun je de projecten in waardebepaling beoordelen.\n\n${link}\n\nDe link is ${UITNODIGING_GELDIG_DAGEN} dagen geldig.\n\nMet vriendelijke groet,\nRikx`,
});
return { status: "sent", message: `Uitnodiging verstuurd naar ${email}.` };
} catch (err) {
console.error("Uitnodiging versturen mislukt:", err);
return {
status: "link",
message: `Versturen van de e-mail is mislukt. Geef deze link zelf door aan ${name}; hij is ${UITNODIGING_GELDIG_DAGEN} dagen geldig.`,
link,
};
}
}
export async function createExpert(_prev: InviteState, formData: FormData): Promise<InviteState> {
await requireAdmin();
const name = ((formData.get("name") as string) ?? "").trim();
const email = ((formData.get("email") as string) ?? "").trim().toLowerCase();
if (!name || !EMAIL_RE.test(email)) return { status: "error", message: "Vul een naam en een geldig e-mailadres in." };
const db = getDb();
if (db.prepare("SELECT id FROM users WHERE lower(email) = ?").get(email)) {
return { status: "error", message: "Er bestaat al een gebruiker met dit e-mailadres." };
}
// Lege password_hash: inloggen kan pas na het instellen van een wachtwoord via de uitnodiging
const result = db
.prepare("INSERT INTO users (email, password_hash, name, role) VALUES (?, '', ?, 'expert')")
.run(email, name);
const id = Number(result.lastInsertRowid);
setExpertRondes(id, readRondeIds(formData));
// Lijst bovenaan de pagina bijwerken (het formulier blijft staan om de link te tonen)
revalidatePath("/experts");
return invite(id, name, email);
}
export async function resendInvite(_prev: InviteState, formData: FormData): Promise<InviteState> {
await requireAdmin();
const expert = getExpert(Number(formData.get("id")));
if (!expert) return { status: "error", message: "Expert niet gevonden." };
return invite(expert.id, expert.name, expert.email);
}
export async function updateExpert(formData: FormData) {
await requireAdmin();
const id = Number(formData.get("id"));
const expert = getExpert(id);
if (!expert) redirect("/experts");
const name = ((formData.get("name") as string) ?? "").trim();
const email = ((formData.get("email") as string) ?? "").trim().toLowerCase();
if (!name || !EMAIL_RE.test(email)) redirect(`/experts/${id}?error=missing`);
const db = getDb();
if (db.prepare("SELECT id FROM users WHERE lower(email) = ? AND id != ?").get(email, id)) {
redirect(`/experts/${id}?error=email`);
}
db.prepare("UPDATE users SET name = ?, email = ? WHERE id = ? AND role = 'expert'").run(name, email, id);
setExpertRondes(id, readRondeIds(formData));
redirect("/experts");
}
export async function deleteExpert(formData: FormData) {
await requireAdmin();
const id = Number(formData.get("id"));
// Beoordelingen, rondes en uitnodigingen van de expert verdwijnen mee (ON DELETE CASCADE)
if (id) getDb().prepare("DELETE FROM users WHERE id = ? AND role = 'expert'").run(id);
redirect("/experts");
}
// "Inloggen als": de admin bekijkt de expertpagina zoals deze expert hem ziet (alleen lezen).
// Terug via "Terug naar beheer" op de expertpagina (app/expert/actions.ts).
export async function impersonateExpert(formData: FormData) {
const admin = await requireAdmin();
const expert = getExpert(Number(formData.get("id")));
if (!expert) redirect("/experts");
await createSession({ userId: expert.id, email: expert.email, name: expert.name, impersonatorId: admin.id });
redirect("/expert");
}
+94
View File
@@ -0,0 +1,94 @@
import Link from "next/link";
import { requireAdmin } from "../../lib/auth";
import { listRikxRondes } from "../../lib/db";
import { listExperts } from "../../lib/experts";
import { AppHeader } from "../AppHeader";
import { impersonateExpert } from "./actions";
import { DeleteExpertButton, NewExpertForm, ResendInviteButton } from "./InviteForms";
export default async function ExpertsPage() {
const session = await requireAdmin();
const experts = listExperts();
const rondes = listRikxRondes();
const rondeLabel = new Map(rondes.map((r) => [r.id, r.label]));
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<AppHeader email={session.email} active="crm" />
<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-1">Experts ({experts.length})</h1>
<p className="text-sm text-zinc-500 mb-4">
Experts loggen in op hun eigen pagina en beoordelen daar de projecten in waardebepaling van hun rondes.
</p>
{experts.length === 0 ? (
<p className="text-sm text-zinc-500">Nog geen experts uitgenodigd.</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">Naam</th>
<th className="text-left px-4 py-3 font-medium">E-mailadres</th>
<th className="text-left px-4 py-3 font-medium">Rikx rondes</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th className="text-right px-4 py-3 font-medium">Beoordelingen</th>
<th className="px-4 py-3" colSpan={4}></th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{experts.map((e) => (
<tr key={e.id} className="bg-white dark:bg-zinc-950 align-top">
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{e.name}</td>
<td className="px-4 py-3 text-zinc-600 dark:text-zinc-400">{e.email}</td>
<td className="px-4 py-3 text-zinc-700 dark:text-zinc-300">
{e.rondeIds.length ? e.rondeIds.map((id) => rondeLabel.get(id)).join(", ") : "—"}
</td>
<td className="px-4 py-3">
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${
e.actief ? "bg-green-100 text-green-800" : "bg-zinc-100 text-zinc-600"
}`}
>
{e.actief ? "Actief" : "Uitgenodigd"}
</span>
</td>
<td className="px-4 py-3 text-right tabular-nums">{e.aantalBeoordelingen}</td>
<td className="px-4 py-3 text-right">
{/* Opnieuw uitnodigen is ook de manier om een vergeten wachtwoord opnieuw in te stellen */}
<ResendInviteButton id={e.id} label={e.actief ? "Nieuwe inloglink" : "Opnieuw uitnodigen"} />
</td>
<td className="px-4 py-3 text-right">
{/* Bekijk de expertpagina zoals deze expert hem ziet (alleen lezen) */}
<form action={impersonateExpert}>
<input type="hidden" name="id" value={e.id} />
<button type="submit" className="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 whitespace-nowrap">
Inloggen als ↗
</button>
</form>
</td>
<td className="px-4 py-3 text-right">
<Link href={`/experts/${e.id}`} className="text-xs text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100">
Bewerken
</Link>
</td>
<td className="px-4 py-3 text-right">
<DeleteExpertButton id={e.id} name={e.name} aantalBeoordelingen={e.aantalBeoordelingen} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section>
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100 mb-4">Nieuwe expert uitnodigen</h2>
<NewExpertForm rondes={rondes} />
</section>
</main>
</div>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
"use server";
import { redirect } from "next/navigation";
import { getUserByEmail, verifyPassword, createSession } from "../../lib/auth";
import { getUserByEmail, verifyPassword, createSession, homeForRole } from "../../lib/auth";
export async function login(formData: FormData) {
const email = formData.get("email") as string;
@@ -11,5 +11,5 @@ export async function login(formData: FormData) {
redirect("/login?error=1");
}
await createSession({ userId: user.id, email: user.email, name: user.name });
redirect("/tenants");
redirect(homeForRole(user.role === "expert" ? "expert" : "admin"));
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
import { getSession } from "../../lib/auth";
import { getCurrentUser, homeForRole } from "../../lib/auth";
import { login } from "./actions";
export default async function LoginPage({
@@ -7,8 +7,8 @@ export default async function LoginPage({
}: {
searchParams: Promise<{ error?: string }>;
}) {
const session = await getSession();
if (session) redirect("/tenants");
const user = await getCurrentUser();
if (user) redirect(homeForRole(user.role));
const { error } = await searchParams;
+3 -3
View File
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { getSession } from "../lib/auth";
import { getCurrentUser, homeForRole } from "../lib/auth";
export default async function Home() {
const session = await getSession();
redirect(session ? "/tenants" : "/login");
const user = await getCurrentUser();
redirect(user ? homeForRole(user.role) : "/login");
}
+101
View File
@@ -0,0 +1,101 @@
import Link from "next/link";
import type { ExpertBeoordeling } from "../../../lib/db";
import { BONUS_MALUS_OPTIONS, EXPERT_CRITERIA } from "../../../lib/rikxCalculatie";
export type BeoordelingMetNaam = ExpertBeoordeling & { expert_name: string };
const getal = new Intl.NumberFormat("nl-NL", { maximumFractionDigits: 2 });
const fmt = (v: number | null) => (v === null ? "—" : getal.format(v));
function gemiddelde(b: BeoordelingMetNaam): number | null {
const scores = [b.duur_impact, b.levensdomeinen, b.basissituatie].filter((v): v is number => v !== null);
return scores.length ? scores.reduce((a, c) => a + c, 0) / scores.length : null;
}
function bonusMalusLabel(id: string | null): string {
if (!id) return "Geen";
return BONUS_MALUS_OPTIONS.find((o) => o.id === id)?.label ?? id;
}
// Alleen-lezen overzicht van de beoordelingen die experts via hun eigen pagina hebben ingevuld.
// Impactscore M in de Rikx calculatie is het gemiddelde van de kolom "Gemiddelde".
export function ExpertBeoordelingen({ beoordelingen }: { beoordelingen: BeoordelingMetNaam[] }) {
if (beoordelingen.length === 0) {
return (
<p className="text-sm text-zinc-500 rounded-lg bg-zinc-50 dark:bg-zinc-800/50 px-4 py-3">
Nog geen beoordelingen. Experts vullen hun scores in op hun eigen pagina; beheer de experts bij{" "}
<Link href="/experts" className="underline hover:text-rikx-orange-dark">
CRM → Experts
</Link>
.
</p>
);
}
const cell = "py-2 px-3 text-right tabular-nums";
return (
<div className="flex flex-col gap-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-zinc-500 border-b border-zinc-200 dark:border-zinc-800">
<th className="text-left font-medium py-2 pr-3">Criterium</th>
{beoordelingen.map((b) => (
<th key={b.expert_id} className="text-right font-medium py-2 px-3 whitespace-nowrap">
{b.expert_name}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{EXPERT_CRITERIA.map((c) => (
<tr key={c.id}>
<td className="py-2 pr-3 text-zinc-700 dark:text-zinc-300 whitespace-nowrap">
{c.label}
<span className="block text-xs text-zinc-500">{c.schaal}</span>
</td>
{beoordelingen.map((b) => (
<td key={b.expert_id} className={cell}>
{fmt(b[c.id])}
</td>
))}
</tr>
))}
<tr className="font-semibold text-zinc-900 dark:text-zinc-100">
<td className="py-2 pr-3">Gemiddelde</td>
{beoordelingen.map((b) => (
<td key={b.expert_id} className={cell}>
{fmt(gemiddelde(b))}
</td>
))}
</tr>
<tr>
<td className="py-2 pr-3 text-zinc-700 dark:text-zinc-300">Advies Bonus / Malus</td>
{beoordelingen.map((b) => (
<td key={b.expert_id} className={cell}>
{bonusMalusLabel(b.bonus_malus_advies)}
</td>
))}
</tr>
</tbody>
</table>
</div>
{beoordelingen.some((b) => b.toelichting) && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Toelichting van de experts</h4>
{beoordelingen
.filter((b) => b.toelichting)
.map((b) => (
<blockquote
key={b.expert_id}
className="rounded-lg border-l-2 border-rikx-orange bg-zinc-50 dark:bg-zinc-800/50 px-4 py-2 text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-line"
>
<span className="block text-xs font-medium text-zinc-500 mb-1">{b.expert_name}</span>
{b.toelichting}
</blockquote>
))}
</div>
)}
</div>
);
}
+185 -110
View File
@@ -1,19 +1,21 @@
"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 { useRef, useState } from "react";
import type { Project, RikxRonde } from "../../../lib/db";
import { PROJECT_STATUSES, RIKX_CALCULATIE_VANAF_STATUS, isStatusVanaf } from "../../../lib/projectStatus";
import {
BONUS_MALUS_OPTIONS,
EXPERT_COUNT,
EXPERT_CRITERIA,
REKENMETHODES,
calculateRikx,
rikxInputFromProject,
type ExpertScore,
} from "../../../lib/rikxCalculatie";
import { parseProjectPlanJson } from "../../../lib/projectPlan";
import { setRikxCalculatie, updateProject } from "../actions";
import { updateProject } from "../actions";
import { PROJECT_FORM_ID } from "./formId";
import { ProjectPlanFields } from "./ProjectPlanFields";
import { ExpertBeoordelingen, type BeoordelingMetNaam } from "./ExpertBeoordelingen";
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";
@@ -30,7 +32,7 @@ function rondeById(rondes: RikxRonde[], id: number | null) {
return rondes.find((r) => r.id === id) ?? null;
}
function readInput(form: HTMLFormElement, rondes: RikxRonde[]) {
function readInput(form: HTMLFormElement, rondes: RikxRonde[], expertScores: ExpertScore[]) {
const fd = new FormData(form);
const ronde = rondeById(rondes, Number(fd.get("ronde_id")) || null);
return {
@@ -42,9 +44,8 @@ function readInput(form: HTMLFormElement, rondes: RikxRonde[]) {
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[],
// Expertscores komen van de experts zelf en zijn hier niet te wijzigen
expert_scores: expertScores,
};
}
@@ -71,58 +72,95 @@ function Field({
);
}
function StepHeading({ step, title, description }: { step: number; title: string; description: string }) {
function TabHeading({ title, description }: { 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 className="mb-5">
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100">{title}</h2>
<p className="text-sm text-zinc-500">{description}</p>
</div>
);
}
export type ProjectTab = "plan" | "calculatie";
const BEOORDELINGEN = [
{ id: "goedgekeurd", label: "Goedgekeurd", activeClass: "bg-green-600 text-white" },
{ id: "afgekeurd", label: "Afgekeurd", activeClass: "bg-red-600 text-white" },
] as const;
// Schakelaar Goedgekeurd | Afgekeurd. Begint leeg (nog niet beoordeeld);
// nogmaals op de actieve keuze klikken maakt de keuze ongedaan.
function BeoordelingSwitch({ defaultValue }: { defaultValue: string | null }) {
const [value, setValue] = useState(defaultValue ?? "");
return (
<div className="flex flex-wrap items-center gap-3">
<input type="hidden" name="beoordeling" value={value} />
<div role="radiogroup" aria-label="Beoordeling" className="inline-flex rounded-lg bg-zinc-100 dark:bg-zinc-800 p-1">
{BEOORDELINGEN.map((b) => {
const active = value === b.id;
return (
<button
key={b.id}
type="button"
role="radio"
aria-checked={active}
onClick={() => setValue(active ? "" : b.id)}
className={`rounded-md px-5 py-2 text-sm font-medium transition-colors ${
active ? b.activeClass : "text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-zinc-100"
}`}
>
{b.label}
</button>
);
})}
</div>
{!value && <span className="text-xs text-zinc-500">Nog niet beoordeeld</span>}
</div>
);
}
export function ProjectDetailForm({
project,
expertScores,
beoordelingen,
rondes,
impactmakers,
initialTab,
}: {
project: Project;
expertScores: ProjectExpertScore[];
beoordelingen: BeoordelingMetNaam[];
rondes: RikxRonde[];
impactmakers: { id: number; name: string }[];
initialTab: ProjectTab;
}) {
const formRef = useRef<HTMLFormElement>(null);
const [calcOn, setCalcOn] = useState(project.rikx_calculatie_aan === 1);
const [pending, startTransition] = useTransition();
// Live status uit de dropdown, zodat de tab Rikx calculatie direct verschijnt of verdwijnt.
const [status, setStatus] = useState(project.status);
const calculatieZichtbaar = isStatusVanaf(status, RIKX_CALCULATIE_VANAF_STATUS);
const [tab, setTab] = useState<ProjectTab>(initialTab);
const activeTab: ProjectTab = tab === "calculatie" && calculatieZichtbaar ? "calculatie" : "plan";
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,
};
}),
...rikxInputFromProject(project, beoordelingen, initialRonde),
}));
const result = calculateRikx(input);
const ronde = rondeById(rondes, input.ronde_id);
function toggleCalc() {
const next = !calcOn;
setCalcOn(next);
startTransition(() => setRikxCalculatie(project.id, next));
// De browser weigert te versturen bij een ongeldig veld, maar toont geen melding als dat veld op een
// verborgen tab staat. Daarom: naar die tab gaan en de melding bij het eerste ongeldige veld tonen.
const invalidHandledRef = useRef(false);
function handleInvalid(e: React.FormEvent<HTMLFormElement>) {
if (invalidHandledRef.current) return;
const field = e.target as HTMLInputElement;
const panel = field.closest<HTMLElement>("[role=tabpanel]");
if (!panel?.hidden) return;
invalidHandledRef.current = true;
setTab(panel.id === "panel-calculatie" ? "calculatie" : "plan");
setTimeout(() => {
invalidHandledRef.current = false;
field.reportValidity();
field.focus();
}, 50);
}
const results: { label: string; value: string; strong?: boolean }[] = [
@@ -142,19 +180,22 @@ export function ProjectDetailForm({
return (
<form
id={PROJECT_FORM_ID}
ref={formRef}
action={updateProject}
onInput={() => formRef.current && setInput(readInput(formRef.current, rondes))}
onInput={() => formRef.current && setInput(readInput(formRef.current, rondes, input.expert_scores))}
onInvalidCapture={handleInvalid}
className="flex flex-col gap-6"
>
<input type="hidden" name="id" value={project.id} />
<input type="hidden" name="tab" value={activeTab} />
<section className={`${cardClass} grid grid-cols-1 sm:grid-cols-3 gap-4`}>
<section className={`${cardClass} grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 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}>
<select name="status" value={status} onChange={(e) => setStatus(e.target.value)} className={inputClass}>
{PROJECT_STATUSES.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
@@ -162,6 +203,16 @@ export function ProjectDetailForm({
))}
</select>
</Field>
<Field label="Impactmaker">
<select name="impactmaker_account_id" defaultValue={project.impactmaker_account_id ?? ""} className={inputClass}>
<option value="">Geen impactmaker</option>
{impactmakers.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</Field>
<Field label="Rikx Ronde">
<select name="ronde_id" defaultValue={project.ronde_id ?? ""} className={inputClass}>
<option value="">Geen ronde</option>
@@ -174,79 +225,89 @@ export function ProjectDetailForm({
</Field>
</section>
<section className={cardClass}>
<StepHeading step={1} title="Project plan" description="In te vullen door de sociaal ondernemer." />
<div role="tablist" aria-label="Projectonderdelen" className="flex gap-1 border-b border-zinc-200 dark:border-zinc-800 -mb-6">
{([
{ id: "plan", label: "Project plan", zichtbaar: true },
{ id: "calculatie", label: "Rikx calculatie", zichtbaar: calculatieZichtbaar },
] as const)
.filter((t) => t.zichtbaar)
.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`tab-${t.id}`}
aria-selected={activeTab === t.id}
aria-controls={`panel-${t.id}`}
onClick={() => setTab(t.id)}
className={`-mb-px px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === t.id
? "border-rikx-orange text-zinc-900 dark:text-zinc-100"
: "border-transparent text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100"
}`}
>
{t.label}
</button>
))}
</div>
{/* Tabs worden verborgen in plaats van weggelaten, zodat alle waarden bij opslaan meegaan. */}
<section id="panel-plan" role="tabpanel" aria-labelledby="tab-plan" hidden={activeTab !== "plan"} className={`${cardClass} rounded-tl-none`}>
<TabHeading 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}
<section
id="panel-calculatie"
role="tabpanel"
aria-labelledby="tab-calculatie"
hidden={activeTab !== "calculatie"}
className={`${cardClass} rounded-tl-none`}
>
<TabHeading
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>
<fieldset className="mb-6">
<legend className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Rekenmethode</legend>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{REKENMETHODES.map((m) => (
<label
key={m.id}
className={`flex items-start gap-3 rounded-lg border p-4 ${
m.beschikbaar
? "cursor-pointer border-zinc-300 dark:border-zinc-700 has-[:checked]:border-rikx-orange has-[:checked]:bg-orange-50 dark:has-[:checked]:bg-zinc-800"
: "cursor-not-allowed border-dashed border-zinc-200 dark:border-zinc-800 opacity-60"
}`}
>
<input
type="radio"
name="rekenmethode"
value={m.id}
disabled={!m.beschikbaar}
defaultChecked={m.beschikbaar && (project.rekenmethode ?? "arbeid") === m.id}
className="mt-0.5 accent-rikx-orange"
/>
<span className="flex flex-col gap-0.5">
<span className="flex items-center gap-2 text-sm font-medium text-zinc-900 dark:text-zinc-100">
{m.label}
{!m.beschikbaar && (
<span className="rounded-full bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 text-xs font-normal text-zinc-500">
binnenkort
</span>
)}
</span>
<span className="text-xs text-zinc-500">{m.omschrijving}</span>
</span>
</label>
))}
</div>
</fieldset>
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Beoordelingen van de experts (1–10)</h3>
<div className="mb-6">
<ExpertBeoordelingen beoordelingen={beoordelingen} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
@@ -292,6 +353,20 @@ export function ProjectDetailForm({
</div>
))}
</dl>
<div className="mt-8 pt-6 border-t border-zinc-200 dark:border-zinc-800 flex flex-col gap-4">
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Beoordeling</h3>
<BeoordelingSwitch defaultValue={project.beoordeling} />
<label className="flex flex-col gap-1">
<span className={labelClass}>Reden</span>
<textarea
name="beoordeling_reden"
defaultValue={project.beoordeling_reden ?? ""}
placeholder="Onderbouwing van het oordeel, sterke punten en aandachtspunten…"
className="w-full min-h-40 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"
/>
</label>
</div>
</section>
<div className="flex gap-3">
+3
View File
@@ -0,0 +1,3 @@
// Id van het projectformulier, zodat de Opslaan-knop bovenaan de pagina (buiten het formulier) het kan versturen.
// Apart bestand: constanten uit een "use client"-module zijn op de server niet als gewone waarde bruikbaar.
export const PROJECT_FORM_ID = "project-form";
+25 -11
View File
@@ -1,9 +1,12 @@
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 { requireAdmin } from "../../../lib/auth";
import { getDb, listRikxRondes, type Project } from "../../../lib/db";
import { listBeoordelingen } from "../../../lib/experts";
import { projectStatusBadgeClass, projectStatusLabel } from "../../../lib/projectStatus";
import { listAccountNames } from "../../../lib/platform-db";
import { AppHeader } from "../../AppHeader";
import { PROJECT_FORM_ID } from "./formId";
import { ProjectDetailForm } from "./ProjectDetailForm";
export default async function ProjectPage({
@@ -11,28 +14,25 @@ export default async function ProjectPage({
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string; opgeslagen?: string }>;
searchParams: Promise<{ error?: string; opgeslagen?: string; tab?: string }>;
}) {
const session = await getSession();
if (!session) redirect("/login");
const session = await requireAdmin();
const { id } = await params;
const { error, opgeslagen } = await searchParams;
const { error, opgeslagen, tab } = 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 beoordelingen = listBeoordelingen([project.id]);
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)." }
? { tone: "error", text: "Niet opgeslagen: controleer de getallen (percentage 0–100, geen negatieve waarden)." }
: error === "datum"
? { tone: "error", text: "Niet opgeslagen: controleer de datums (de einddatum mag niet vóór de startdatum liggen)." }
: opgeslagen
@@ -57,6 +57,14 @@ export default async function ProjectPage({
>
{projectStatusLabel(project.status)}
</span>
{/* Verstuurt het projectformulier hieronder (form-attribuut) */}
<button
type="submit"
form={PROJECT_FORM_ID}
className="ml-auto rounded-lg bg-rikx-maroon hover:bg-rikx-maroon-light px-5 py-2 text-sm font-medium text-white transition-colors"
>
Opslaan
</button>
</div>
{message && (
<p
@@ -69,7 +77,13 @@ export default async function ProjectPage({
{message.text}
</p>
)}
<ProjectDetailForm project={project} expertScores={expertScores} rondes={listRikxRondes()} />
<ProjectDetailForm
project={project}
beoordelingen={beoordelingen}
rondes={listRikxRondes()}
impactmakers={listAccountNames()}
initialTab={tab === "calculatie" ? "calculatie" : "plan"}
/>
</main>
</div>
);
+61 -76
View File
@@ -2,29 +2,26 @@
import { redirect } from "next/navigation";
import { getDb } from "../../lib/db";
import { getSession } from "../../lib/auth";
import { requireAdmin } from "../../lib/auth";
import { DEFAULT_PROJECT_STATUS, isProjectStatus } from "../../lib/projectStatus";
import { listAccountNames } from "../../lib/platform-db";
import { readProjectPlan } from "../../lib/projectPlan";
import { BONUS_MALUS_OPTIONS, EXPERT_COUNT, EXPERT_CRITERIA } from "../../lib/rikxCalculatie";
import {
BONUS_MALUS_OPTIONS,
DEFAULT_REKENMETHODE,
isBeschikbareRekenmethode,
} from "../../lib/rikxCalculatie";
export async function createProject(formData: FormData) {
const session = await getSession();
if (!session) redirect("/login");
// "+ Project toevoegen": maakt direct een nieuw project aan (status Aanvraag) en opent de projectpagina,
// waar naam, ronde, impactmaker en het project plan worden ingevuld.
export async function createProject() {
const session = await requireAdmin();
const name = ((formData.get("name") as string) ?? "").trim();
const rawStatus = formData.get("status");
const status = isProjectStatus(rawStatus) ? rawStatus : DEFAULT_PROJECT_STATUS;
const result = getDb()
.prepare("INSERT INTO projects (name, status) VALUES (?, ?)")
.run("Nieuw project", DEFAULT_PROJECT_STATUS);
if (!name) redirect("/projecten/nieuw?error=missing");
const db = getDb();
db.prepare("INSERT INTO projects (name, status, ronde_id) VALUES (?, ?, ?)").run(
name,
status,
readRondeId(db, formData)
);
redirect("/projecten");
redirect(`/projecten/${Number(result.lastInsertRowid)}`);
}
// Geeft het id van een bestaande Rikx Ronde terug, of null bij "geen ronde"/onbekend id.
@@ -34,6 +31,13 @@ function readRondeId(db: ReturnType<typeof getDb>, formData: FormData): number |
return db.prepare("SELECT id FROM rikx_rondes WHERE id = ?").get(id) ? id : null;
}
// Geeft het id van een bestaande sociaal ondernemer (platform-account) terug, of null.
function readImpactmakerId(formData: FormData): number | null {
const id = Number(formData.get("impactmaker_account_id"));
if (!id) return null;
return listAccountNames().some((a) => a.id === id) ? id : null;
}
// 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;
@@ -46,87 +50,68 @@ function inRange(n: number | null, min: number, max = Infinity) {
}
export async function updateProject(formData: FormData) {
const session = await getSession();
if (!session) redirect("/login");
const session = await requireAdmin();
const id = Number(formData.get("id"));
if (!id) redirect("/projecten");
// Terug naar de tab waar de gebruiker op stond, ook bij een fout
const tab = formData.get("tab") === "calculatie" ? "&tab=calculatie" : "";
const name = ((formData.get("name") as string) ?? "").trim();
const rawStatus = formData.get("status");
if (!name) redirect(`/projecten/${id}?error=missing`);
if (!name) redirect(`/projecten/${id}?error=missing${tab}`);
const projectkosten = parseNumber(formData.get("projectkosten"));
const deelnemersWerk = parseNumber(formData.get("deelnemers_werk"));
const deelnemersSkills = parseNumber(formData.get("deelnemers_skills"));
const externBedrag = parseNumber(formData.get("financiering_extern_bedrag"));
const externPct = parseNumber(formData.get("financiering_extern_pct"));
const rawRekenmethode = formData.get("rekenmethode");
const rekenmethode = isBeschikbareRekenmethode(rawRekenmethode) ? rawRekenmethode : DEFAULT_REKENMETHODE;
const rawBeoordeling = formData.get("beoordeling");
const beoordeling = rawBeoordeling === "goedgekeurd" || rawBeoordeling === "afgekeurd" ? rawBeoordeling : null;
const beoordelingReden = ((formData.get("beoordeling_reden") as string) ?? "").trim().slice(0, 10_000) || null;
const rawBonusMalus = (formData.get("bonus_malus") as string) ?? "";
const bonusMalus = BONUS_MALUS_OPTIONS.some((o) => o.id === rawBonusMalus) && rawBonusMalus ? rawBonusMalus : null;
const expertScores = Array.from({ length: EXPERT_COUNT }, (_, i) =>
EXPERT_CRITERIA.map((c) => parseNumber(formData.get(`expert_${i + 1}_${c.id}`)))
);
const valid =
inRange(projectkosten, 0) &&
inRange(deelnemersWerk, 0) &&
inRange(deelnemersSkills, 0) &&
inRange(externBedrag, 0) &&
inRange(externPct, 0, 100) &&
expertScores.every((scores) => scores.every((s) => inRange(s, 1, 10)));
if (!valid) redirect(`/projecten/${id}?error=invalid`);
inRange(externPct, 0, 100);
if (!valid) redirect(`/projecten/${id}?error=invalid${tab}`);
const planResult = readProjectPlan(formData);
if ("error" in planResult) redirect(`/projecten/${id}?error=${planResult.error}`);
if ("error" in planResult) redirect(`/projecten/${id}?error=${planResult.error}${tab}`);
// Expertscores worden niet hier opgeslagen maar door de experts zelf (app/expert)
const db = getDb();
db.exec("BEGIN");
try {
db.prepare(
`UPDATE projects SET
name = ?, status = COALESCE(?, status),
projectkosten = ?, deelnemers_werk = ?, deelnemers_skills = ?,
financiering_extern_bedrag = ?, financiering_extern_pct = ?, project_plan = ?,
bonus_malus = ?, ronde_id = ?, updated_at = datetime('now')
WHERE id = ?`
).run(
name,
isProjectStatus(rawStatus) ? rawStatus : null,
projectkosten,
deelnemersWerk,
deelnemersSkills,
externBedrag,
externPct,
JSON.stringify(planResult.plan),
bonusMalus,
readRondeId(db, formData),
id
);
const upsert = db.prepare(
`INSERT INTO project_expert_scores (project_id, expert, duur_impact, levensdomeinen, basissituatie)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (project_id, expert) DO UPDATE SET
duur_impact = excluded.duur_impact,
levensdomeinen = excluded.levensdomeinen,
basissituatie = excluded.basissituatie`
);
expertScores.forEach((scores, i) => upsert.run(id, i + 1, ...scores));
db.exec("COMMIT");
} catch (err) {
db.exec("ROLLBACK");
throw err;
}
db.prepare(
`UPDATE projects SET
name = ?, status = COALESCE(?, status),
projectkosten = ?, deelnemers_werk = ?, deelnemers_skills = ?,
financiering_extern_bedrag = ?, financiering_extern_pct = ?, project_plan = ?,
rekenmethode = ?, bonus_malus = ?, beoordeling = ?, beoordeling_reden = ?, ronde_id = ?,
impactmaker_account_id = ?, updated_at = datetime('now')
WHERE id = ?`
).run(
name,
isProjectStatus(rawStatus) ? rawStatus : null,
projectkosten,
deelnemersWerk,
deelnemersSkills,
externBedrag,
externPct,
JSON.stringify(planResult.plan),
rekenmethode,
bonusMalus,
beoordeling,
beoordelingReden,
readRondeId(db, formData),
readImpactmakerId(formData),
id
);
redirect(`/projecten/${id}?opgeslagen=1`);
}
// Schuif "Rikx calculatie" op de projectpagina; wordt direct opgeslagen.
export async function setRikxCalculatie(id: number, aan: boolean) {
const session = await getSession();
if (!session) redirect("/login");
getDb()
.prepare("UPDATE projects SET rikx_calculatie_aan = ?, updated_at = datetime('now') WHERE id = ?")
.run(aan ? 1 : 0, id);
redirect(`/projecten/${id}?opgeslagen=1${tab}`);
}
-90
View File
@@ -1,90 +0,0 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getSession } from "../../../lib/auth";
import { listRikxRondes } from "../../../lib/db";
import { DEFAULT_PROJECT_STATUS, PROJECT_STATUSES } from "../../../lib/projectStatus";
import { AppHeader } from "../../AppHeader";
import { createProject } from "../actions";
export default async function NieuwProjectPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const session = await getSession();
if (!session) redirect("/login");
const { error } = await searchParams;
const rondes = listRikxRondes();
const errorMsg = error === "missing" ? "Vul een projectnaam in." : null;
const inputClass =
"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";
return (
<div className="min-h-screen bg-white dark:bg-zinc-950">
<AppHeader email={session.email} active="projecten" />
<main className="max-w-6xl mx-auto px-4 sm:px-6 py-10 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>
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Nieuw project</h2>
{errorMsg && <p className="text-sm text-red-600 dark:text-red-400">{errorMsg}</p>}
<form
action={createProject}
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"
>
<div className="flex flex-col gap-1">
<label htmlFor="name" className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
Projectnaam<span className="text-red-500 ml-1">*</span>
</label>
<input id="name" type="text" name="name" required autoFocus className={inputClass} />
</div>
<div className="flex flex-col gap-1">
<label htmlFor="status" className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
Status
</label>
<select id="status" name="status" defaultValue={DEFAULT_PROJECT_STATUS} className={inputClass}>
{PROJECT_STATUSES.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="ronde_id" className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
Rikx Ronde
</label>
<select id="ronde_id" name="ronde_id" defaultValue="" className={inputClass}>
<option value="">Geen ronde</option>
{rondes.map((r) => (
<option key={r.id} value={r.id}>
{r.label}
</option>
))}
</select>
</div>
<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="/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>
</main>
</div>
);
}
+11 -9
View File
@@ -1,9 +1,10 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getSession } from "../../lib/auth";
import { requireAdmin } from "../../lib/auth";
import { getDb, listRikxRondes, type ProjectListItem } from "../../lib/db";
import { PROJECT_STATUSES, isProjectStatus } from "../../lib/projectStatus";
import { AppHeader } from "../AppHeader";
import { createProject } from "./actions";
import { AutoSubmitSelect } from "./AutoSubmitSelect";
import { ProjectPipeline } from "./ProjectPipeline";
import { ProjectTable } from "./ProjectTable";
@@ -17,8 +18,7 @@ export default async function ProjectenPage({
}: {
searchParams: Promise<{ q?: string; status?: string; ronde?: string; weergave?: string }>;
}) {
const session = await getSession();
if (!session) redirect("/login");
const session = await requireAdmin();
const params = await searchParams;
const q = (params.q ?? "").trim();
@@ -130,12 +130,14 @@ export default async function ProjectenPage({
</div>
</form>
<Link
href="/projecten/nieuw"
className="rounded-lg bg-rikx-maroon hover:bg-rikx-maroon-light px-5 py-2.5 text-sm font-medium text-white transition-colors whitespace-nowrap"
>
+ Project toevoegen
</Link>
<form action={createProject}>
<button
type="submit"
className="rounded-lg bg-rikx-maroon hover:bg-rikx-maroon-light px-5 py-2.5 text-sm font-medium text-white transition-colors whitespace-nowrap"
>
+ Project toevoegen
</button>
</form>
</div>
{isFiltered && (
+38
View File
@@ -0,0 +1,38 @@
import Link from "next/link";
import type { Expert } from "../../lib/experts";
// Experts koppelen aan een ronde; dezelfde koppeling als op de pagina Experts
export function ExpertCheckboxes({ experts, selected = [] }: { experts: Expert[]; selected?: number[] }) {
return (
<fieldset className="sm:col-span-3 flex flex-col gap-2">
<legend className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">Experts</legend>
{experts.length === 0 ? (
<p className="text-sm text-zinc-500">
Er zijn nog geen experts.{" "}
<Link href="/experts" className="underline hover:text-rikx-orange-dark">
Expert uitnodigen
</Link>
</p>
) : (
<div className="flex flex-wrap gap-x-5 gap-y-2">
{experts.map((e) => (
<label key={e.id} className="flex items-center gap-2 text-sm text-zinc-800 dark:text-zinc-200">
<input
type="checkbox"
name="expert_ids"
value={e.id}
defaultChecked={selected.includes(e.id)}
className="accent-rikx-orange"
/>
{e.name}
{!e.actief && <span className="text-xs text-zinc-500">(uitgenodigd)</span>}
</label>
))}
</div>
)}
<span className="text-xs text-zinc-500">
Gekoppelde experts zien de projecten van deze ronde zodra die in waardebepaling staan.
</span>
</fieldset>
);
}
+6 -3
View File
@@ -1,9 +1,11 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getSession } from "../../../lib/auth";
import { requireAdmin } from "../../../lib/auth";
import { getDb, type RikxRonde } from "../../../lib/db";
import { AppHeader } from "../../AppHeader";
import { updateRonde } from "../actions";
import { listExperts } from "../../../lib/experts";
import { ExpertCheckboxes } from "../ExpertCheckboxes";
import { RONDE_ERRORS, RondeFields } from "../RondeFields";
export default async function EditRondePage({
@@ -13,14 +15,14 @@ export default async function EditRondePage({
params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>;
}) {
const session = await getSession();
if (!session) redirect("/login");
const session = await requireAdmin();
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 };
const experts = listExperts();
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
@@ -40,6 +42,7 @@ export default async function EditRondePage({
>
<input type="hidden" name="id" value={ronde.id} />
<RondeFields ronde={ronde} />
<ExpertCheckboxes experts={experts} selected={experts.filter((e) => e.rondeIds.includes(ronde.id)).map((e) => e.id)} />
<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>
+12 -8
View File
@@ -2,7 +2,8 @@
import { redirect } from "next/navigation";
import { getDb } from "../../lib/db";
import { getSession } from "../../lib/auth";
import { requireAdmin } from "../../lib/auth";
import { setRondeExperts } from "../../lib/experts";
import { defaultRikxPrijs } from "../../lib/rikxCalculatie";
// Leeg veld → null; accepteert zowel 1234.5 als 1234,5
@@ -25,9 +26,12 @@ function readRonde(formData: FormData) {
return { label, rikxWaarde, rikxPrijs } as const;
}
function readExpertIds(formData: FormData): number[] {
return formData.getAll("expert_ids").map(Number).filter((id) => Number.isInteger(id) && id > 0);
}
export async function createRonde(formData: FormData) {
const session = await getSession();
if (!session) redirect("/login");
const session = await requireAdmin();
const ronde = readRonde(formData);
if ("error" in ronde) redirect(`/rondes?error=${ronde.error}`);
@@ -36,18 +40,18 @@ export async function createRonde(formData: FormData) {
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(
const result = db.prepare("INSERT INTO rikx_rondes (label, rikx_waarde, rikx_prijs) VALUES (?, ?, ?)").run(
ronde.label,
ronde.rikxWaarde,
ronde.rikxPrijs
);
setRondeExperts(Number(result.lastInsertRowid), readExpertIds(formData));
redirect("/rondes");
}
export async function updateRonde(formData: FormData) {
const session = await getSession();
if (!session) redirect("/login");
const session = await requireAdmin();
const id = Number(formData.get("id"));
if (!id) redirect("/rondes");
@@ -62,13 +66,13 @@ export async function updateRonde(formData: FormData) {
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);
setRondeExperts(id, readExpertIds(formData));
redirect("/rondes");
}
export async function deleteRonde(formData: FormData) {
const session = await getSession();
if (!session) redirect("/login");
const session = await requireAdmin();
const id = Number(formData.get("id"));
// Projecten in deze ronde blijven bestaan; hun ronde_id wordt NULL (ON DELETE SET NULL).
+13 -3
View File
@@ -1,10 +1,12 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getSession } from "../../lib/auth";
import { requireAdmin } from "../../lib/auth";
import { getDb, type RikxRonde } from "../../lib/db";
import { AppHeader } from "../AppHeader";
import { createRonde } from "./actions";
import { DeleteRondeButton } from "./DeleteRondeButton";
import { listExperts } from "../../lib/experts";
import { ExpertCheckboxes } from "./ExpertCheckboxes";
import { RONDE_ERRORS, RondeFields } from "./RondeFields";
const euro = new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" });
@@ -15,10 +17,10 @@ export default async function RondesPage({
}: {
searchParams: Promise<{ error?: string }>;
}) {
const session = await getSession();
if (!session) redirect("/login");
const session = await requireAdmin();
const { error } = await searchParams;
const experts = listExperts();
const rondes = (
getDb()
.prepare(
@@ -49,6 +51,7 @@ export default async function RondesPage({
<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="text-left px-4 py-3 font-medium">Experts</th>
<th className="px-4 py-3" colSpan={2}></th>
</tr>
</thead>
@@ -67,6 +70,12 @@ export default async function RondesPage({
{r.project_count}
</Link>
</td>
<td className="px-4 py-3 text-zinc-700 dark:text-zinc-300">
{experts
.filter((e) => e.rondeIds.includes(r.id))
.map((e) => e.name)
.join(", ") || "—"}
</td>
<td className="px-4 py-3 text-right">
<Link
href={`/rondes/${r.id}`}
@@ -96,6 +105,7 @@ export default async function RondesPage({
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 />
<ExpertCheckboxes experts={experts} />
<div className="sm:col-span-3">
<button
type="submit"
+2 -3
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getSession } from "../../../lib/auth";
import { requireAdmin } from "../../../lib/auth";
import { getAccount } from "../../../lib/platform-db";
import { updateTenant } from "../actions";
import { AppHeader } from "../../AppHeader";
@@ -12,8 +12,7 @@ export default async function EditTenantPage({
params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>;
}) {
const session = await getSession();
if (!session) redirect("/login");
const session = await requireAdmin();
const { id } = await params;
const { error } = await searchParams;
+4 -7
View File
@@ -3,7 +3,7 @@
import { redirect } from "next/navigation";
import { randomBytes, scryptSync } from "crypto";
import { getPlatformDb } from "../../lib/platform-db";
import { getSession } from "../../lib/auth";
import { requireAdmin } from "../../lib/auth";
function requireAuth() {
// Called inside server actions — session check is synchronous-safe via cache
@@ -16,8 +16,7 @@ function hashPassword(password: string): string {
}
export async function createTenant(formData: FormData) {
const session = await getSession();
if (!session) redirect("/login");
const session = await requireAdmin();
const name = (formData.get("name") as string).trim();
const slug = (formData.get("slug") as string).trim().toLowerCase().replace(/[^a-z0-9-]/g, "-");
@@ -45,8 +44,7 @@ export async function createTenant(formData: FormData) {
}
export async function deleteTenant(formData: FormData) {
const session = await getSession();
if (!session) redirect("/login");
const session = await requireAdmin();
const accountId = Number(formData.get("account_id"));
if (!accountId) redirect("/tenants");
@@ -59,8 +57,7 @@ export async function deleteTenant(formData: FormData) {
}
export async function updateTenant(formData: FormData) {
const session = await getSession();
if (!session) redirect("/login");
const session = await requireAdmin();
const accountId = Number(formData.get("account_id"));
const name = ((formData.get("name") as string) ?? "").trim();
+2 -3
View File
@@ -2,12 +2,11 @@
import { createHmac } from "crypto";
import { redirect } from "next/navigation";
import { getSession } from "../../lib/auth";
import { requireAdmin } from "../../lib/auth";
import { getPlatformDb } from "../../lib/platform-db";
export async function autoLogin(formData: FormData) {
const session = await getSession();
if (!session) redirect("/login");
const session = await requireAdmin();
const accountId = Number(formData.get("account_id"));
const slug = formData.get("slug") as string;
+2 -3
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
import { getSession } from "../../lib/auth";
import { requireAdmin } from "../../lib/auth";
import { listAccounts } from "../../lib/platform-db";
import { createTenant } from "./actions";
import { DeleteTenantButton } from "./DeleteTenantButton";
@@ -10,8 +10,7 @@ export default async function TenantsPage({
}: {
searchParams: Promise<{ error?: string }>;
}) {
const session = await getSession();
if (!session) redirect("/login");
const session = await requireAdmin();
const { error } = await searchParams;
const accounts = listAccounts();
+23
View File
@@ -0,0 +1,23 @@
"use server";
import { redirect } from "next/navigation";
import { createSession, hashPassword } from "../../../lib/auth";
import { getDb } from "../../../lib/db";
import { MIN_WACHTWOORD_LENGTE, findInvite, markInviteUsed } from "../../../lib/experts";
export async function setPassword(formData: FormData) {
const token = (formData.get("token") as string) ?? "";
const password = (formData.get("password") as string) ?? "";
const herhaal = (formData.get("password_herhaal") as string) ?? "";
const invite = findInvite(token);
if (!invite) redirect(`/uitnodiging/${encodeURIComponent(token)}`);
if (password.length < MIN_WACHTWOORD_LENGTE) redirect(`/uitnodiging/${encodeURIComponent(token)}?error=kort`);
if (password !== herhaal) redirect(`/uitnodiging/${encodeURIComponent(token)}?error=verschil`);
getDb().prepare("UPDATE users SET password_hash = ? WHERE id = ? AND role = 'expert'").run(hashPassword(password), invite.user_id);
markInviteUsed(invite.id);
await createSession({ userId: invite.user_id, email: invite.email, name: invite.name });
redirect("/expert");
}
+79
View File
@@ -0,0 +1,79 @@
import Link from "next/link";
import { MIN_WACHTWOORD_LENGTE, findInvite } from "../../../lib/experts";
import { setPassword } from "./actions";
// Openbare pagina (zonder login): een uitgenodigde expert stelt hier zijn wachtwoord in.
export default async function UitnodigingPage({
params,
searchParams,
}: {
params: Promise<{ token: string }>;
searchParams: Promise<{ error?: string }>;
}) {
const { token } = await params;
const { error } = await searchParams;
const invite = findInvite(token);
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 errorMsg =
error === "kort"
? `Kies een wachtwoord van minimaal ${MIN_WACHTWOORD_LENGTE} tekens.`
: error === "verschil"
? "De wachtwoorden zijn niet gelijk."
: null;
return (
<div className="min-h-screen flex flex-col bg-zinc-50 dark:bg-zinc-950">
<header className="bg-rikx-orange">
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-3">
<span className="text-white text-4xl font-extrabold tracking-tight leading-none">Rikx</span>
</div>
</header>
<main className="flex-1 flex items-start justify-center px-4 py-16">
<div className="w-full max-w-sm">
{!invite ? (
<div className="bg-white dark:bg-zinc-900 p-8 rounded-xl border border-zinc-200 dark:border-zinc-800 flex flex-col gap-3">
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Uitnodiging niet geldig</h1>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Deze link is verlopen of al gebruikt. Vraag een nieuwe uitnodiging aan bij Rikx, of log in als je al een
wachtwoord hebt ingesteld.
</p>
<Link href="/login" className="text-sm text-rikx-orange-dark hover:underline">
Naar inloggen
</Link>
</div>
) : (
<form
action={setPassword}
className="flex flex-col gap-4 bg-white dark:bg-zinc-900 p-8 rounded-xl border border-zinc-200 dark:border-zinc-800"
>
<div>
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Welkom, {invite.name}</h1>
<p className="text-sm text-zinc-500 mt-1">
Stel een wachtwoord in voor het Rikx expertteam. Je logt daarna in met {invite.email}.
</p>
</div>
{errorMsg && <p className="text-sm text-red-600 dark:text-red-400">{errorMsg}</p>}
<input type="hidden" name="token" value={token} />
<label className="flex flex-col gap-1">
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Wachtwoord</span>
<input name="password" type="password" required minLength={MIN_WACHTWOORD_LENGTE} autoComplete="new-password" className={inputClass} />
</label>
<label className="flex flex-col gap-1">
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Herhaal wachtwoord</span>
<input name="password_herhaal" type="password" required minLength={MIN_WACHTWOORD_LENGTE} autoComplete="new-password" className={inputClass} />
</label>
<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"
>
Wachtwoord instellen
</button>
</form>
)}
</div>
</main>
</div>
);
}
+257
View File
@@ -0,0 +1,257 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { calculateRikx, defaultRikxPrijs, type RikxInput } from "../../lib/rikxCalculatie";
import { saveRondeWaarde } from "./actions";
export type WaardebepalingRow = {
id: number;
name: string;
impactmaker: string | null;
ronde: { id: number; label: string; rikxWaarde: number | null } | null;
input: RikxInput;
};
type SaveState =
| { kind: "idle" }
| { kind: "saving" }
| { kind: "saved"; labels: string[] }
| { kind: "error"; message: string };
// signDisplay "negative": geen "-0,00" als een klein negatief getal op nul wordt afgerond
const getal = new Intl.NumberFormat("nl-NL", { minimumFractionDigits: 2, maximumFractionDigits: 2, signDisplay: "negative" });
const euro = new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR", signDisplay: "negative" });
const fmt = (v: number | null, kind: "getal" | "euro" = "getal") =>
v === null ? "—" : kind === "euro" ? euro.format(v) : getal.format(v);
// Wachttijd na de laatste toetsaanslag voordat de waarde in de ronde wordt opgeslagen
const SAVE_DELAY_MS = 800;
function toNumber(value: string): number | null {
if (value.trim() === "") return null;
const n = Number(value.replace(",", "."));
return Number.isFinite(n) && n >= 0 ? n : null;
}
// Positie (0-based) van het 75%-project in de lijst van LAAG naar HOOG op kosten/impact, volgens de
// Waarderingssystematiek: het laatste project binnen de 75% meest efficiënte, dus plek ⌈0,75 × n⌉.
// Alleen projecten met een berekende kosten/impact tellen mee. -1 als er geen zijn.
export function index75Procent(aantalBerekend: number): number {
return aantalBerekend > 0 ? Math.ceil(0.75 * aantalBerekend) - 1 : -1;
}
// Laagste kosten/impact eerst (meest efficiënt); projecten zonder berekening onderaan. Bij gelijke stand op naam en id,
// zodat server en browser altijd dezelfde volgorde geven (anders volgt een hydration-fout).
function compareRows(
a: { id: number; name: string; result: { kosten_impact: number | null } },
b: { id: number; name: string; result: { kosten_impact: number | null } }
): number {
const ka = a.result.kosten_impact;
const kb = b.result.kosten_impact;
if (ka !== kb) {
if (ka === null) return 1;
if (kb === null) return -1;
return ka - kb;
}
return a.name.localeCompare(b.name, "nl") || a.id - b.id;
}
// Tabel met een Rikx Waarde-veld erboven. Een ingevulde Rikx Waarde geldt direct voor de getoonde
// projecten die bij een ronde horen (Rikx prijs = 10%), de berekening wordt live doorgerekend
// en de waarde wordt opgeslagen op de Rikx ronde(s) van die projecten.
export function WaardebepalingTable({
rows,
filter,
emptyMessage,
toon75Procent,
}: {
rows: WaardebepalingRow[];
// Alleen bij een gekozen Rikx ronde; bij "Alle" of "Zonder ronde" geen 75%-lijn
toon75Procent: boolean;
// Rikx Ronde-filter (server-side formulier); staat in dezelfde rij als het Rikx Waarde-veld
filter: ReactNode;
emptyMessage: string;
}) {
const router = useRouter();
const rondes = [...new Map(rows.flatMap((r) => (r.ronde ? [[r.ronde.id, r.ronde]] : []))).values()];
const rondeIds = rondes.map((r) => r.id);
// Startwaarde: de Rikx waarde van de getoonde ronde(s), als die voor allemaal gelijk is
const gedeeldeWaarde = new Set(rondes.map((r) => r.rikxWaarde)).size === 1 ? rondes[0]?.rikxWaarde ?? null : null;
const [waardeTekst, setWaardeTekst] = useState(gedeeldeWaarde === null ? "" : String(gedeeldeWaarde));
const [saveState, setSaveState] = useState<SaveState>({ kind: "idle" });
const opgeslagenRef = useRef<number | null>(gedeeldeWaarde);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const waarde = toNumber(waardeTekst);
const prijs = waarde === null ? null : defaultRikxPrijs(waarde);
async function save(value: number) {
if (timerRef.current) clearTimeout(timerRef.current);
if (value === opgeslagenRef.current || rondeIds.length === 0) return;
setSaveState({ kind: "saving" });
const result = await saveRondeWaarde(rondeIds, value);
if (result.ok) {
opgeslagenRef.current = value;
setSaveState({ kind: "saved", labels: result.labels });
// Serverdata (rondewaarden) verversen, zodat alles overal klopt
router.refresh();
} else {
setSaveState({ kind: "error", message: result.error });
}
}
function onChange(value: string) {
setWaardeTekst(value);
setSaveState({ kind: "idle" });
if (timerRef.current) clearTimeout(timerRef.current);
const n = toNumber(value);
if (n !== null) timerRef.current = setTimeout(() => save(n), SAVE_DELAY_MS);
}
useEffect(() => () => {
if (timerRef.current) clearTimeout(timerRef.current);
}, []);
const results = rows
.map((r) => {
const aangepast = waarde !== null && r.ronde !== null;
const input = aangepast ? { ...r.input, rikx_waarde: waarde, rikx_prijs: prijs } : r.input;
return { ...r, input, result: calculateRikx(input), aangepast };
})
.sort(compareRows);
// Berekende projecten staan na het sorteren vooraan, dus de index geldt direct voor `results`
const berekend = results.filter((r) => r.result.kosten_impact !== null);
const grensIndex = toon75Procent ? index75Procent(berekend.length) : -1;
return (
<div className="flex flex-col gap-6">
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-4 sm:p-5 flex flex-wrap items-end gap-x-4 gap-y-3">
{filter}
<label className="flex flex-col gap-1 w-full sm:w-52">
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Rikx Waarde</span>
<span className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-zinc-500">€</span>
<input
type="number"
inputMode="decimal"
min={0}
step="0.01"
value={waardeTekst}
disabled={rondeIds.length === 0}
onChange={(e) => onChange(e.target.value)}
onBlur={() => waarde !== null && save(waarde)}
placeholder="0,00"
className="w-full h-10 rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 pl-7 pr-3 text-sm tabular-nums text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-rikx-orange disabled:opacity-60"
/>
</span>
</label>
<div className="text-sm flex flex-col gap-0.5 sm:flex-1 sm:min-w-64 min-h-10 justify-center">
<span className="text-zinc-500">
{rows.length} {rows.length === 1 ? "project" : "projecten"}
</span>
{rondeIds.length === 0 ? (
<span className="text-zinc-500">Geen projecten met een Rikx ronde op dit scherm.</span>
) : (
<>
<span className="text-zinc-700 dark:text-zinc-300">
{prijs === null ? (
"Vul een Rikx Waarde in om de berekening door te rekenen."
) : (
<>
Rikx prijs <strong className="tabular-nums">{euro.format(prijs)}</strong> (10%) voor{" "}
{rondes.length === 1 ? `ronde "${rondes[0].label}"` : `${rondes.length} rondes`}.
</>
)}
</span>
<span
aria-live="polite"
className={
saveState.kind === "error"
? "text-red-600 dark:text-red-400"
: saveState.kind === "saved"
? "text-green-700 dark:text-green-400"
: "text-zinc-500"
}
>
{saveState.kind === "saving"
? "Opslaan in ronde…"
: saveState.kind === "saved"
? `Opgeslagen in ${saveState.labels.join(", ")}`
: saveState.kind === "error"
? `Niet opgeslagen: ${saveState.message}`
: rondes.length > 1
? "Let op: de waarde wordt op al deze rondes opgeslagen."
: ""}
</span>
</>
)}
</div>
</div>
{rows.length === 0 ? (
<p className="text-sm text-zinc-500">{emptyMessage}</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">Projectnaam</th>
<th className="text-left px-4 py-3 font-medium">Impactmaker</th>
<th className="text-right px-4 py-3 font-medium whitespace-nowrap">Kosten/impact</th>
<th className="text-right px-4 py-3 font-medium whitespace-nowrap">Rikx prijs</th>
<th className="text-right px-4 py-3 font-medium whitespace-nowrap">Totaal aantal Rikx</th>
<th className="text-right px-4 py-3 font-medium whitespace-nowrap">Totale impactwaarde</th>
<th className="text-right px-4 py-3 font-medium whitespace-nowrap">Meerwaarde</th>
<th className="text-right px-4 py-3 font-medium whitespace-nowrap">Efficiëntie</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{results.map((r, i) => {
const cell = "px-4 py-3 text-right tabular-nums whitespace-nowrap text-zinc-900 dark:text-zinc-100";
const is75 = i === grensIndex;
return (
<tr
key={r.id}
className={`bg-white dark:bg-zinc-950 hover:bg-orange-50/60 dark:hover:bg-zinc-900 transition-colors ${
is75 ? "border-b-4 border-b-rikx-orange" : ""
}`}
>
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
<Link href={`/projecten/${r.id}?tab=calculatie`} className="hover:text-rikx-orange-dark hover:underline">
{r.name}
</Link>
{is75 && (
<span className="ml-2 inline-flex items-center rounded-full bg-rikx-orange px-2 py-0.5 text-xs font-semibold text-white align-middle">
75%
</span>
)}
<span className="block text-xs font-normal text-zinc-500">{r.ronde?.label ?? "Geen ronde"}</span>
</td>
<td className="px-4 py-3 text-zinc-700 dark:text-zinc-300">{r.impactmaker ?? "—"}</td>
<td className={cell}>{fmt(r.result.kosten_impact)}</td>
<td className={cell}>{fmt(r.input.rikx_prijs, "euro")}</td>
<td className={cell}>{fmt(r.result.totaal_aantal_rikx)}</td>
<td className={cell}>{fmt(r.result.totale_impactwaarde, "euro")}</td>
<td className={cell}>{fmt(r.result.meerwaarde, "euro")}</td>
<td className={cell}>{fmt(r.result.efficientie_waarde)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{grensIndex >= 0 && (
<p className="-mt-3 text-xs text-zinc-500">
<span className="inline-block w-6 h-1 rounded bg-rikx-orange align-middle mr-2" />
75%-project: {berekend[grensIndex].name} (kosten/impact {fmt(berekend[grensIndex].result.kosten_impact)}).
Tot en met deze lijn staan de 75% meest efficiënte projecten (laagste kosten/impact); eronder de overige 25%.
</p>
)}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
"use server";
import { getDb } from "../../lib/db";
import { getCurrentUser } from "../../lib/auth";
import { defaultRikxPrijs } from "../../lib/rikxCalculatie";
export type SaveRondeWaardeResult = { ok: true; labels: string[] } | { ok: false; error: string };
// Zet de Rikx waarde (en Rikx prijs = 10%) op de opgegeven Rikx rondes.
// Aangeroepen vanuit het Rikx Waarde-veld op de pagina Waardebepaling projecten.
export async function saveRondeWaarde(rondeIds: number[], rikxWaarde: number): Promise<SaveRondeWaardeResult> {
const user = await getCurrentUser();
if (!user || user.role !== "admin") return { ok: false, error: "Geen toegang" };
if (!Number.isFinite(rikxWaarde) || rikxWaarde < 0) return { ok: false, error: "Ongeldige Rikx waarde" };
const ids = [...new Set(rondeIds.map(Number))].filter((id) => Number.isInteger(id) && id > 0);
if (ids.length === 0) return { ok: false, error: "Geen ronde om bij te werken" };
const db = getDb();
const placeholders = ids.map(() => "?").join(",");
const rondes = db.prepare(`SELECT id, label FROM rikx_rondes WHERE id IN (${placeholders})`).all(...ids) as {
id: number;
label: string;
}[];
if (rondes.length === 0) return { ok: false, error: "Ronde niet gevonden" };
db.prepare(
`UPDATE rikx_rondes SET rikx_waarde = ?, rikx_prijs = ?, updated_at = datetime('now') WHERE id IN (${placeholders})`
).run(rikxWaarde, defaultRikxPrijs(rikxWaarde), ...ids);
return { ok: true, labels: rondes.map((r) => r.label) };
}
+102
View File
@@ -0,0 +1,102 @@
import { redirect } from "next/navigation";
import { requireAdmin } from "../../lib/auth";
import { getDb, listRikxRondes, type Project } from "../../lib/db";
import { listBeoordelingen } from "../../lib/experts";
import { listAccountNames } from "../../lib/platform-db";
import { rikxInputFromProject } from "../../lib/rikxCalculatie";
import { AppHeader } from "../AppHeader";
import { AutoSubmitSelect } from "../projecten/AutoSubmitSelect";
import { WaardebepalingTable, type WaardebepalingRow } from "./WaardebepalingTable";
// Alle projecten met status Waardebepaling, gerangschikt op Kosten/impact van laag naar hoog (laag = efficiënt).
export default async function WaardebepalingPage({
searchParams,
}: {
searchParams: Promise<{ ronde?: string }>;
}) {
const session = await requireAdmin();
const params = await searchParams;
const rondes = listRikxRondes();
// "geen" = projecten zonder ronde; anders het id van een bestaande ronde
const ronde =
params.ronde === "geen" || rondes.some((r) => String(r.id) === params.ronde) ? params.ronde! : "";
const db = getDb();
const where = ["status = 'waardebepaling'"];
const args: number[] = [];
if (ronde === "geen") {
where.push("ronde_id IS NULL");
} else if (ronde) {
where.push("ronde_id = ?");
args.push(Number(ronde));
}
const projects = (db.prepare(`SELECT * FROM projects WHERE ${where.join(" AND ")} ORDER BY id`).all(...args) as Project[]).map(
(p) => ({ ...p })
);
const scores = listBeoordelingen(projects.map((p) => p.id));
const impactmakers = new Map(listAccountNames().map((a) => [a.id, a.name]));
const rows: WaardebepalingRow[] = projects.map((p) => {
const projectRonde = rondes.find((r) => r.id === p.ronde_id) ?? null;
return {
id: p.id,
name: p.name,
impactmaker: p.impactmaker_account_id ? impactmakers.get(p.impactmaker_account_id) ?? null : null,
ronde: projectRonde ? { id: projectRonde.id, label: projectRonde.label, rikxWaarde: projectRonde.rikx_waarde } : null,
input: rikxInputFromProject(
p,
scores.filter((s) => s.project_id === p.id),
projectRonde
),
};
});
const fieldClass =
"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";
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<AppHeader email={session.email} active="projecten" />
<main className="max-w-6xl mx-auto px-4 sm:px-6 py-8 flex flex-col gap-6">
<div>
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">Waardebepaling projecten</h1>
<p className="text-sm text-zinc-500 mt-1">
Projecten met status Waardebepaling, gerangschikt op kosten/impact (laagste eerst).
</p>
</div>
<WaardebepalingTable
rows={rows}
toon75Procent={ronde !== "" && ronde !== "geen"}
filter={
// `contents`: het formulier zelf neemt geen ruimte in, zodat het filter in dezelfde rij
// komt te staan als het Rikx Waarde-veld (zie WaardebepalingTable)
// autoComplete="off": de browser mag de keuze niet herstellen na "Terug", anders toont
// de dropdown een andere ronde dan de lijst
<form action="/waardebepaling" autoComplete="off" className="contents">
<div className="w-full sm:w-64">
<label htmlFor="filter-ronde" className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">
Rikx Ronde
</label>
<AutoSubmitSelect id="filter-ronde" name="ronde" defaultValue={ronde} className={fieldClass}>
<option value="">Alle</option>
{rondes.map((r) => (
<option key={r.id} value={r.id}>
{r.label}
</option>
))}
<option value="geen">Zonder ronde</option>
</AutoSubmitSelect>
</div>
</form>
}
emptyMessage={
ronde ? "Geen projecten in waardebepaling voor deze ronde." : "Er zijn geen projecten in waardebepaling."
}
/>
</main>
</div>
);
}