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
+12 -6
View File
@@ -4,9 +4,15 @@ Beheerportaal voor de Rikx-methodiek. Beheert de sociaal ondernemers (accounts i
## Pagina's ## Pagina's
- `/tenants` — sociaal ondernemers aanmaken, bewerken, verwijderen, auto-inlog (leest/schrijft de platformdatabase via `lib/platform-db.ts`) - `/tenants` — sociaal ondernemers aanmaken, bewerken, verwijderen, auto-inlog (leest/schrijft de platformdatabase via `lib/platform-db.ts`)
- `/projecten` — projecten als pipeline (standaard, kolom per status) of tabel (`?weergave=tabel`), met filter op status, Rikx Ronde en zoeken; `/projecten/nieuw` — project toevoegen (naam + status) - `/projecten` — projecten als pipeline (standaard, kolom per status) of tabel (`?weergave=tabel`), met filter op status, Rikx Ronde en zoeken. De knop "+ Project toevoegen" maakt direct een project aan ("Nieuw project", status Aanvraag) en opent de projectpagina
- `/rondes` — Rikx rondes beheren (label, Rikx waarde, Rikx prijs); een project hoort optioneel bij één ronde (`projects.ronde_id`) - `/waardebepaling` — Waardebepaling projecten: tabel met projecten in status Waardebepaling (projectnaam, impactmaker, kosten/impact), gerangschikt op kosten/impact van laag naar hoog, filter op Rikx Ronde. Alleen bij een gekozen Rikx ronde (niet bij Alle/Zonder ronde) een dikke oranje lijn onder het 75%-project: plek ⌈0,75 × n⌉ van de projecten met een berekende kosten/impact (zie `index75Procent`), volgens de Waarderingssystematiek; erboven de 75% meest efficiënte. Bovenaan een Rikx Waarde-veld: rekent live door met Rikx prijs = 10% voor de getoonde projecten met een ronde, en slaat waarde en prijs (na 0,8 s of bij verlaten van het veld) op in de Rikx ronde(s) van die projecten (`app/waardebepaling/actions.ts`)
- `/projecten/[id]` — projectpagina in stappen: 1 Project plan (velden en keuzelijsten gedefinieerd in `lib/projectPlan.ts`), 2 Rikx calculatie (alleen zichtbaar als de schuif aan staat, `rikx_calculatie_aan`). Uitkomsten worden live berekend met `lib/rikxCalculatie.ts` - Impactmaker van een project = sociaal ondernemer: `projects.impactmaker_account_id` verwijst naar `accounts.id` in de platformdatabase (geen foreign key; namen via `listAccountNames()` in `lib/platform-db.ts`)
- `/rondes` — Rikx rondes beheren (label, Rikx waarde, Rikx prijs, gekoppelde experts; dezelfde koppeling `expert_rondes` als op `/experts`); een project hoort optioneel bij één ronde (`projects.ronde_id`)
- `/projecten/[id]` — projectpagina met tabs: Project plan (velden en keuzelijsten gedefinieerd in `lib/projectPlan.ts`) en Rikx calculatie (alleen zichtbaar vanaf status Waardebepaling, zie `RIKX_CALCULATIE_VANAF_STATUS` in `lib/projectStatus.ts`; rekenmethode `arbeid`, `jeugd` is zichtbaar maar nog niet beschikbaar). Onderaan de tab: Beoordeling (`beoordeling`: goedgekeurd/afgekeurd/leeg) met `beoordeling_reden`. Kolom `rikx_calculatie_aan` is van de oude schuif en wordt niet meer gebruikt. Uitkomsten worden live berekend met `lib/rikxCalculatie.ts`
- `/experts` (CRM → Experts) — experts uitnodigen, rondes koppelen, opnieuw uitnodigen (ook voor een vergeten wachtwoord), verwijderen, en "Inloggen als": een admin bekijkt `/expert` als die expert (alleen lezen; sessie met `impersonatorId`, terug via "Terug naar beheer"). Uitnodiging per e-mail via SMTP; zonder SMTP_HOST toont de pagina de link om zelf door te geven
- `/uitnodiging/[token]` — openbaar: expert stelt wachtwoord in (token 7 dagen geldig, eenmalig; alleen de SHA-256-hash staat in de database)
- `/expert` — de enige pagina voor experts: projecten in Waardebepaling uit hun rondes, project plan (alleen lezen) en hun eigen beoordeling (3 scores 1–10, advies Bonus/Malus, toelichting). Experts zien elkaars beoordeling niet
- Rollen: `users.role` = `admin` of `expert`. Elke beheerpagina en -actie begint met `requireAdmin()`, de expertpagina met `requireExpert()` (`lib/auth.ts`); de rol komt uit de database, niet uit de cookie
- Gedeelde kop met navigatie: `app/AppHeader.tsx` (oranje Rikx-balk, menu per module; items zonder pagina tonen "binnenkort") - Gedeelde kop met navigatie: `app/AppHeader.tsx` (oranje Rikx-balk, menu per module; items zonder pagina tonen "binnenkort")
- Huisstijlkleuren als Tailwind-tokens in `app/globals.css`: `rikx-orange`, `rikx-purple` (actief menu-item), `rikx-maroon` (primaire knop). Statuskleuren in `lib/projectStatus.ts` - Huisstijlkleuren als Tailwind-tokens in `app/globals.css`: `rikx-orange`, `rikx-purple` (actief menu-item), `rikx-maroon` (primaire knop). Statuskleuren in `lib/projectStatus.ts`
- Conventie: server actions in `actions.ts` per map, geen API-routes (behalve `app/api/auto-login`) - Conventie: server actions in `actions.ts` per map, geen API-routes (behalve `app/api/auto-login`)
@@ -36,8 +42,8 @@ npm run create-admin maarten@rikx.nl wachtwoord "Maarten"
- Locatie lokaal: `./data/app.db` - Locatie lokaal: `./data/app.db`
- Locatie productie: `/data/app.db` (absoluut pad — Next.js standalone doet process.chdir bij start) - Locatie productie: `/data/app.db` (absoluut pad — Next.js standalone doet process.chdir bij start)
- Schema: `lib/db.ts` — migraties draaien automatisch bij eerste verbinding - Schema: `lib/db.ts` — migraties draaien automatisch bij eerste verbinding
- Tabellen: `users`, `projects` (`name`, `status`; statussen in `lib/projectStatus.ts`, standaard `aanvraag`; plus de velden uit de rekenmethode als kolommen; overige project plan-velden als JSON in `project_plan`), `project_expert_scores` (3 experts × 3 criteria, voorlopig handmatig), `rikx_rondes` (Rikx waarde en prijs per ronde; de calculatie haalt ze uit de ronde van het project, prijs standaard waarde / 10) - Tabellen: `users`, `projects` (`name`, `status`; statussen in `lib/projectStatus.ts`, standaard `aanvraag`; plus de velden uit de rekenmethode als kolommen; overige project plan-velden als JSON in `project_plan`), `expert_beoordelingen` (per project en expert; Impactscore M = gemiddelde over de experts; de Rikx calculatie-tab toont ze alleen-lezen), `expert_rondes`, `expert_uitnodigingen`. `project_expert_scores` (oude handmatige scores) wordt niet meer gebruikt maar blijft staan, `rikx_rondes` (Rikx waarde en prijs per ronde; de calculatie haalt ze uit de ronde van het project, prijs standaard waarde / 10)
- Rekenmethode: `_doc/Rikx rekenmethode uitleg.xlsx`, tabblad Uitleg. Afwijkingen van de sheet: N-score = ((werk×5)+skills)/6 (sheet mist haakjes), Bonus/Malus `-` = 0,9 en `--` = 0,8 (sheet heeft typefouten) - Rekenmethode (Rikx Waardeberekening Arbeid): `_doc/Rikx rekenmethode uitleg.xlsx`, tabblad Uitleg; achtergrond in `_doc/Waarderingssystematiek Rikx juni 2023.pdf`. Afwijkingen van de sheet: N-score = ((werk×5)+skills)/6 (sheet mist haakjes), Bonus/Malus `-` = 0,9 en `--` = 0,8 (sheet heeft typefouten)
- De oude offerte-tabellen (`clients`, `projects` met `client_id`, `quotes`, `quote_items`) worden bij de migratie verwijderd als ze leeg zijn; met gegevens worden ze hernoemd naar `legacy_*` - De oude offerte-tabellen (`clients`, `projects` met `client_id`, `quotes`, `quote_items`) worden bij de migratie verwijderd als ze leeg zijn; met gegevens worden ze hernoemd naar `legacy_*`
- Tweede database (alleen-platform): `RIKX_PLATFORM_DB_PATH` (standaard `/rikx-platform-data/app.db`) met `accounts` en `users` van het ZRM-platform - Tweede database (alleen-platform): `RIKX_PLATFORM_DB_PATH` (standaard `/rikx-platform-data/app.db`) met `accounts` en `users` van het ZRM-platform
@@ -55,7 +61,7 @@ npm run create-admin maarten@rikx.nl wachtwoord "Maarten"
| `DB_PATH` | `./data/app.db` | **In productie: `/data/app.db`** (absoluut pad) | | `DB_PATH` | `./data/app.db` | **In productie: `/data/app.db`** (absoluut pad) |
| `RIKX_PLATFORM_DB_PATH` | `/rikx-platform-data/app.db` | Database van het ZRM-platform (sociaal ondernemers) | | `RIKX_PLATFORM_DB_PATH` | `/rikx-platform-data/app.db` | Database van het ZRM-platform (sociaal ondernemers) |
| `NODE_ENV` | `development` | In productie: `production` | | `NODE_ENV` | `development` | In productie: `production` |
| `SMTP_HOST` | — | SMTP server voor email-notificaties | | `SMTP_HOST` | — | SMTP server voor e-mail (o.a. uitnodigingen van experts); leeg = geen e-mail, link wordt in beeld getoond |
| `SMTP_PORT` | `587` | SMTP poort | | `SMTP_PORT` | `587` | SMTP poort |
| `SMTP_USER` | — | SMTP gebruikersnaam | | `SMTP_USER` | — | SMTP gebruikersnaam |
| `SMTP_PASS` | — | SMTP wachtwoord | | `SMTP_PASS` | — | SMTP wachtwoord |
Binary file not shown.
+2 -2
View File
@@ -12,7 +12,7 @@ const NAV_ITEMS: NavItem[] = [
section: "projecten", section: "projecten",
items: [ items: [
{ label: "Overzicht", href: "/projecten" }, { label: "Overzicht", href: "/projecten" },
{ label: "Project toevoegen", href: "/projecten/nieuw" }, { label: "Waardebepaling projecten", href: "/waardebepaling" },
{ label: "Rikx rondes", href: "/rondes" }, { label: "Rikx rondes", href: "/rondes" },
], ],
}, },
@@ -24,7 +24,7 @@ const NAV_ITEMS: NavItem[] = [
{ {
label: "CRM", label: "CRM",
section: "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" }, { label: "Publieke Website", section: "website" },
]; ];
+8 -6
View File
@@ -1,26 +1,28 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { createHmac } from "crypto"; import { createHmac } from "crypto";
import { getSession } from "../../../lib/auth"; import { getCurrentUser } from "../../../lib/auth";
import { getPlatformDb } from "../../../lib/platform-db"; import { getPlatformDb } from "../../../lib/platform-db";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const session = await getSession(); // Alleen admins mogen inloggen bij een sociaal ondernemer
if (!session) return NextResponse.redirect(new URL("/login", request.url)); 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 accountId = Number(request.nextUrl.searchParams.get("account_id"));
const slug = request.nextUrl.searchParams.get("slug"); const slug = request.nextUrl.searchParams.get("slug");
if (!accountId || !slug) return NextResponse.redirect(new URL("/tenants", request.url)); if (!accountId || !slug) return NextResponse.redirect(new URL("/tenants", request.url));
const db = getPlatformDb(); const db = getPlatformDb();
const user = db const platformUser = db
.prepare("SELECT id FROM users WHERE account_id = ? AND role = 'admin' LIMIT 1") .prepare("SELECT id FROM users WHERE account_id = ? AND role = 'admin' LIMIT 1")
.get(accountId) as { id: number } | undefined; .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 secret = process.env.AUTO_LOGIN_SECRET!;
const expires = Date.now() + 60_000; 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 sig = createHmac("sha256", secret).update(payload).digest("hex");
const token = encodeURIComponent(`${payload}:${sig}`); 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"; "use server";
import { redirect } from "next/navigation"; 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) { export async function login(formData: FormData) {
const email = formData.get("email") as string; const email = formData.get("email") as string;
@@ -11,5 +11,5 @@ export async function login(formData: FormData) {
redirect("/login?error=1"); redirect("/login?error=1");
} }
await createSession({ userId: user.id, email: user.email, name: user.name }); 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 { redirect } from "next/navigation";
import { getSession } from "../../lib/auth"; import { getCurrentUser, homeForRole } from "../../lib/auth";
import { login } from "./actions"; import { login } from "./actions";
export default async function LoginPage({ export default async function LoginPage({
@@ -7,8 +7,8 @@ export default async function LoginPage({
}: { }: {
searchParams: Promise<{ error?: string }>; searchParams: Promise<{ error?: string }>;
}) { }) {
const session = await getSession(); const user = await getCurrentUser();
if (session) redirect("/tenants"); if (user) redirect(homeForRole(user.role));
const { error } = await searchParams; const { error } = await searchParams;
+3 -3
View File
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getSession } from "../lib/auth"; import { getCurrentUser, homeForRole } from "../lib/auth";
export default async function Home() { export default async function Home() {
const session = await getSession(); const user = await getCurrentUser();
redirect(session ? "/tenants" : "/login"); 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"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useRef, useState, useTransition } from "react"; import { useRef, useState } from "react";
import type { Project, ProjectExpertScore, RikxRonde } from "../../../lib/db"; import type { Project, RikxRonde } from "../../../lib/db";
import { PROJECT_STATUSES } from "../../../lib/projectStatus"; import { PROJECT_STATUSES, RIKX_CALCULATIE_VANAF_STATUS, isStatusVanaf } from "../../../lib/projectStatus";
import { import {
BONUS_MALUS_OPTIONS, BONUS_MALUS_OPTIONS,
EXPERT_COUNT, REKENMETHODES,
EXPERT_CRITERIA,
calculateRikx, calculateRikx,
rikxInputFromProject,
type ExpertScore, type ExpertScore,
} from "../../../lib/rikxCalculatie"; } from "../../../lib/rikxCalculatie";
import { parseProjectPlanJson } from "../../../lib/projectPlan"; 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 { ProjectPlanFields } from "./ProjectPlanFields";
import { ExpertBeoordelingen, type BeoordelingMetNaam } from "./ExpertBeoordelingen";
const inputClass = 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"; "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; 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 fd = new FormData(form);
const ronde = rondeById(rondes, Number(fd.get("ronde_id")) || null); const ronde = rondeById(rondes, Number(fd.get("ronde_id")) || null);
return { return {
@@ -42,9 +44,8 @@ function readInput(form: HTMLFormElement, rondes: RikxRonde[]) {
bonus_malus: (fd.get("bonus_malus") as string) || null, bonus_malus: (fd.get("bonus_malus") as string) || null,
rikx_waarde: ronde?.rikx_waarde ?? null, rikx_waarde: ronde?.rikx_waarde ?? null,
rikx_prijs: ronde?.rikx_prijs ?? null, rikx_prijs: ronde?.rikx_prijs ?? null,
expert_scores: Array.from({ length: EXPERT_COUNT }, (_, i) => // Expertscores komen van de experts zelf en zijn hier niet te wijzigen
Object.fromEntries(EXPERT_CRITERIA.map((c) => [c.id, toNumber(fd.get(`expert_${i + 1}_${c.id}`))])) expert_scores: expertScores,
) as ExpertScore[],
}; };
} }
@@ -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 ( return (
<div className="flex items-start gap-3 mb-5"> <div className="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"> <h2 className="font-semibold text-zinc-900 dark:text-zinc-100">{title}</h2>
{step} <p className="text-sm text-zinc-500">{description}</p>
</span> </div>
<div> );
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100">{title}</h2> }
<p className="text-sm text-zinc-500">{description}</p>
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> </div>
{!value && <span className="text-xs text-zinc-500">Nog niet beoordeeld</span>}
</div> </div>
); );
} }
export function ProjectDetailForm({ export function ProjectDetailForm({
project, project,
expertScores, beoordelingen,
rondes, rondes,
impactmakers,
initialTab,
}: { }: {
project: Project; project: Project;
expertScores: ProjectExpertScore[]; beoordelingen: BeoordelingMetNaam[];
rondes: RikxRonde[]; rondes: RikxRonde[];
impactmakers: { id: number; name: string }[];
initialTab: ProjectTab;
}) { }) {
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
const [calcOn, setCalcOn] = useState(project.rikx_calculatie_aan === 1); // Live status uit de dropdown, zodat de tab Rikx calculatie direct verschijnt of verdwijnt.
const [pending, startTransition] = useTransition(); 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 initialRonde = rondeById(rondes, project.ronde_id);
const [input, setInput] = useState(() => ({ const [input, setInput] = useState(() => ({
ronde_id: initialRonde?.id ?? null, ronde_id: initialRonde?.id ?? null,
projectkosten: project.projectkosten, ...rikxInputFromProject(project, beoordelingen, initialRonde),
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,
};
}),
})); }));
const result = calculateRikx(input); const result = calculateRikx(input);
const ronde = rondeById(rondes, input.ronde_id); const ronde = rondeById(rondes, input.ronde_id);
function toggleCalc() { // De browser weigert te versturen bij een ongeldig veld, maar toont geen melding als dat veld op een
const next = !calcOn; // verborgen tab staat. Daarom: naar die tab gaan en de melding bij het eerste ongeldige veld tonen.
setCalcOn(next); const invalidHandledRef = useRef(false);
startTransition(() => setRikxCalculatie(project.id, next)); 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 }[] = [ const results: { label: string; value: string; strong?: boolean }[] = [
@@ -142,19 +180,22 @@ export function ProjectDetailForm({
return ( return (
<form <form
id={PROJECT_FORM_ID}
ref={formRef} ref={formRef}
action={updateProject} 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" className="flex flex-col gap-6"
> >
<input type="hidden" name="id" value={project.id} /> <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"> <Field label="Projectnaam">
<input name="name" required defaultValue={project.name} className={inputClass} /> <input name="name" required defaultValue={project.name} className={inputClass} />
</Field> </Field>
<Field label="Status"> <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) => ( {PROJECT_STATUSES.map((s) => (
<option key={s.id} value={s.id}> <option key={s.id} value={s.id}>
{s.label} {s.label}
@@ -162,6 +203,16 @@ export function ProjectDetailForm({
))} ))}
</select> </select>
</Field> </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"> <Field label="Rikx Ronde">
<select name="ronde_id" defaultValue={project.ronde_id ?? ""} className={inputClass}> <select name="ronde_id" defaultValue={project.ronde_id ?? ""} className={inputClass}>
<option value="">Geen ronde</option> <option value="">Geen ronde</option>
@@ -174,79 +225,89 @@ export function ProjectDetailForm({
</Field> </Field>
</section> </section>
<section className={cardClass}> <div role="tablist" aria-label="Projectonderdelen" className="flex gap-1 border-b border-zinc-200 dark:border-zinc-800 -mb-6">
<StepHeading step={1} title="Project plan" description="In te vullen door de sociaal ondernemer." /> {([
{ 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)} /> <ProjectPlanFields project={project} plan={parseProjectPlanJson(project.project_plan)} />
</section> </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"> <section
<div> id="panel-calculatie"
<p className="font-medium text-zinc-900 dark:text-zinc-100">Rikx calculatie</p> role="tabpanel"
<p className="text-sm text-zinc-500">Toon de waardebepaling volgens de Rikx-rekenmethode.</p> aria-labelledby="tab-calculatie"
</div> hidden={activeTab !== "calculatie"}
<button className={`${cardClass} rounded-tl-none`}
type="button" >
role="switch" <TabHeading
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}
title="Rikx calculatie" title="Rikx calculatie"
description="Waardebepaling door het expertteam en Rikx. Uitkomsten worden live berekend." 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> <fieldset className="mb-6">
<div className="overflow-x-auto mb-6"> <legend className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Rekenmethode</legend>
<table className="w-full text-sm"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<thead> {REKENMETHODES.map((m) => (
<tr className="text-zinc-500"> <label
<th className="text-left font-medium py-2 pr-3">Criterium</th> key={m.id}
{Array.from({ length: EXPERT_COUNT }, (_, i) => ( className={`flex items-start gap-3 rounded-lg border p-4 ${
<th key={i} className="text-left font-medium py-2 px-1 min-w-24"> m.beschikbaar
Expert {i + 1} ? "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"
</th> : "cursor-not-allowed border-dashed border-zinc-200 dark:border-zinc-800 opacity-60"
))} }`}
</tr> >
</thead> <input
<tbody> type="radio"
{EXPERT_CRITERIA.map((c) => ( name="rekenmethode"
<tr key={c.id}> value={m.id}
<td className="py-1 pr-3 text-zinc-700 dark:text-zinc-300 whitespace-nowrap">{c.label}</td> disabled={!m.beschikbaar}
{Array.from({ length: EXPERT_COUNT }, (_, i) => ( defaultChecked={m.beschikbaar && (project.rekenmethode ?? "arbeid") === m.id}
<td key={i} className="py-1 px-1"> className="mt-0.5 accent-rikx-orange"
<input />
name={`expert_${i + 1}_${c.id}`} <span className="flex flex-col gap-0.5">
type="number" <span className="flex items-center gap-2 text-sm font-medium text-zinc-900 dark:text-zinc-100">
min={1} {m.label}
max={10} {!m.beschikbaar && (
step="0.5" <span className="rounded-full bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 text-xs font-normal text-zinc-500">
aria-label={`${c.label}, expert ${i + 1}`} binnenkort
defaultValue={input.expert_scores[i][c.id] ?? ""} </span>
className={inputClass} )}
/> </span>
</td> <span className="text-xs text-zinc-500">{m.omschrijving}</span>
))} </span>
</tr> </label>
))} ))}
</tbody> </div>
</table> </fieldset>
<p className="text-xs text-zinc-500 mt-1">Voorlopig handmatig; met Module 4 vullen de experts dit zelf in.</p>
<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>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
@@ -292,6 +353,20 @@ export function ProjectDetailForm({
</div> </div>
))} ))}
</dl> </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> </section>
<div className="flex gap-3"> <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 Link from "next/link";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getSession } from "../../../lib/auth"; import { requireAdmin } from "../../../lib/auth";
import { getDb, listRikxRondes, type Project, type ProjectExpertScore } from "../../../lib/db"; import { getDb, listRikxRondes, type Project } from "../../../lib/db";
import { listBeoordelingen } from "../../../lib/experts";
import { projectStatusBadgeClass, projectStatusLabel } from "../../../lib/projectStatus"; import { projectStatusBadgeClass, projectStatusLabel } from "../../../lib/projectStatus";
import { listAccountNames } from "../../../lib/platform-db";
import { AppHeader } from "../../AppHeader"; import { AppHeader } from "../../AppHeader";
import { PROJECT_FORM_ID } from "./formId";
import { ProjectDetailForm } from "./ProjectDetailForm"; import { ProjectDetailForm } from "./ProjectDetailForm";
export default async function ProjectPage({ export default async function ProjectPage({
@@ -11,28 +14,25 @@ export default async function ProjectPage({
searchParams, searchParams,
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string; opgeslagen?: string }>; searchParams: Promise<{ error?: string; opgeslagen?: string; tab?: string }>;
}) { }) {
const session = await getSession(); const session = await requireAdmin();
if (!session) redirect("/login");
const { id } = await params; const { id } = await params;
const { error, opgeslagen } = await searchParams; const { error, opgeslagen, tab } = await searchParams;
const db = getDb(); const db = getDb();
const row = db.prepare("SELECT * FROM projects WHERE id = ?").get(Number(id)) as Project | undefined; const row = db.prepare("SELECT * FROM projects WHERE id = ?").get(Number(id)) as Project | undefined;
if (!row) notFound(); if (!row) notFound();
// node:sqlite levert objecten met null-prototype; spreiden maakt ze doorgeefbaar aan client components. // node:sqlite levert objecten met null-prototype; spreiden maakt ze doorgeefbaar aan client components.
const project = { ...row }; const project = { ...row };
const expertScores = ( const beoordelingen = listBeoordelingen([project.id]);
db.prepare("SELECT * FROM project_expert_scores WHERE project_id = ? ORDER BY expert").all(project.id) as ProjectExpertScore[]
).map((s) => ({ ...s }));
const message = const message =
error === "missing" error === "missing"
? { tone: "error", text: "Vul een projectnaam in." } ? { tone: "error", text: "Vul een projectnaam in." }
: error === "invalid" : 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" : error === "datum"
? { tone: "error", text: "Niet opgeslagen: controleer de datums (de einddatum mag niet vóór de startdatum liggen)." } ? { tone: "error", text: "Niet opgeslagen: controleer de datums (de einddatum mag niet vóór de startdatum liggen)." }
: opgeslagen : opgeslagen
@@ -57,6 +57,14 @@ export default async function ProjectPage({
> >
{projectStatusLabel(project.status)} {projectStatusLabel(project.status)}
</span> </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> </div>
{message && ( {message && (
<p <p
@@ -69,7 +77,13 @@ export default async function ProjectPage({
{message.text} {message.text}
</p> </p>
)} )}
<ProjectDetailForm project={project} expertScores={expertScores} rondes={listRikxRondes()} /> <ProjectDetailForm
project={project}
beoordelingen={beoordelingen}
rondes={listRikxRondes()}
impactmakers={listAccountNames()}
initialTab={tab === "calculatie" ? "calculatie" : "plan"}
/>
</main> </main>
</div> </div>
); );
+61 -76
View File
@@ -2,29 +2,26 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getDb } from "../../lib/db"; import { getDb } from "../../lib/db";
import { getSession } from "../../lib/auth"; import { requireAdmin } from "../../lib/auth";
import { DEFAULT_PROJECT_STATUS, isProjectStatus } from "../../lib/projectStatus"; import { DEFAULT_PROJECT_STATUS, isProjectStatus } from "../../lib/projectStatus";
import { listAccountNames } from "../../lib/platform-db";
import { readProjectPlan } from "../../lib/projectPlan"; 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) { // "+ Project toevoegen": maakt direct een nieuw project aan (status Aanvraag) en opent de projectpagina,
const session = await getSession(); // waar naam, ronde, impactmaker en het project plan worden ingevuld.
if (!session) redirect("/login"); export async function createProject() {
const session = await requireAdmin();
const name = ((formData.get("name") as string) ?? "").trim(); const result = getDb()
const rawStatus = formData.get("status"); .prepare("INSERT INTO projects (name, status) VALUES (?, ?)")
const status = isProjectStatus(rawStatus) ? rawStatus : DEFAULT_PROJECT_STATUS; .run("Nieuw project", DEFAULT_PROJECT_STATUS);
if (!name) redirect("/projecten/nieuw?error=missing"); redirect(`/projecten/${Number(result.lastInsertRowid)}`);
const db = getDb();
db.prepare("INSERT INTO projects (name, status, ronde_id) VALUES (?, ?, ?)").run(
name,
status,
readRondeId(db, formData)
);
redirect("/projecten");
} }
// Geeft het id van een bestaande Rikx Ronde terug, of null bij "geen ronde"/onbekend id. // 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; 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 // Leeg veld → null; accepteert zowel 1234.5 als 1234,5
function parseNumber(value: FormDataEntryValue | null): number | null { function parseNumber(value: FormDataEntryValue | null): number | null {
if (typeof value !== "string" || value.trim() === "") return 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) { export async function updateProject(formData: FormData) {
const session = await getSession(); const session = await requireAdmin();
if (!session) redirect("/login");
const id = Number(formData.get("id")); const id = Number(formData.get("id"));
if (!id) redirect("/projecten"); 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 name = ((formData.get("name") as string) ?? "").trim();
const rawStatus = formData.get("status"); 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 projectkosten = parseNumber(formData.get("projectkosten"));
const deelnemersWerk = parseNumber(formData.get("deelnemers_werk")); const deelnemersWerk = parseNumber(formData.get("deelnemers_werk"));
const deelnemersSkills = parseNumber(formData.get("deelnemers_skills")); const deelnemersSkills = parseNumber(formData.get("deelnemers_skills"));
const externBedrag = parseNumber(formData.get("financiering_extern_bedrag")); const externBedrag = parseNumber(formData.get("financiering_extern_bedrag"));
const externPct = parseNumber(formData.get("financiering_extern_pct")); 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 rawBonusMalus = (formData.get("bonus_malus") as string) ?? "";
const bonusMalus = BONUS_MALUS_OPTIONS.some((o) => o.id === rawBonusMalus) && rawBonusMalus ? rawBonusMalus : null; 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 = const valid =
inRange(projectkosten, 0) && inRange(projectkosten, 0) &&
inRange(deelnemersWerk, 0) && inRange(deelnemersWerk, 0) &&
inRange(deelnemersSkills, 0) && inRange(deelnemersSkills, 0) &&
inRange(externBedrag, 0) && inRange(externBedrag, 0) &&
inRange(externPct, 0, 100) && inRange(externPct, 0, 100);
expertScores.every((scores) => scores.every((s) => inRange(s, 1, 10))); if (!valid) redirect(`/projecten/${id}?error=invalid${tab}`);
if (!valid) redirect(`/projecten/${id}?error=invalid`);
const planResult = readProjectPlan(formData); 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(); const db = getDb();
db.exec("BEGIN"); db.prepare(
try { `UPDATE projects SET
db.prepare( name = ?, status = COALESCE(?, status),
`UPDATE projects SET projectkosten = ?, deelnemers_werk = ?, deelnemers_skills = ?,
name = ?, status = COALESCE(?, status), financiering_extern_bedrag = ?, financiering_extern_pct = ?, project_plan = ?,
projectkosten = ?, deelnemers_werk = ?, deelnemers_skills = ?, rekenmethode = ?, bonus_malus = ?, beoordeling = ?, beoordeling_reden = ?, ronde_id = ?,
financiering_extern_bedrag = ?, financiering_extern_pct = ?, project_plan = ?, impactmaker_account_id = ?, updated_at = datetime('now')
bonus_malus = ?, ronde_id = ?, updated_at = datetime('now') WHERE id = ?`
WHERE id = ?` ).run(
).run( name,
name, isProjectStatus(rawStatus) ? rawStatus : null,
isProjectStatus(rawStatus) ? rawStatus : null, projectkosten,
projectkosten, deelnemersWerk,
deelnemersWerk, deelnemersSkills,
deelnemersSkills, externBedrag,
externBedrag, externPct,
externPct, JSON.stringify(planResult.plan),
JSON.stringify(planResult.plan), rekenmethode,
bonusMalus, bonusMalus,
readRondeId(db, formData), beoordeling,
id beoordelingReden,
); readRondeId(db, formData),
const upsert = db.prepare( readImpactmakerId(formData),
`INSERT INTO project_expert_scores (project_id, expert, duur_impact, levensdomeinen, basissituatie) id
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;
}
redirect(`/projecten/${id}?opgeslagen=1`); redirect(`/projecten/${id}?opgeslagen=1${tab}`);
}
// 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);
} }
-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 Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getSession } from "../../lib/auth"; import { requireAdmin } from "../../lib/auth";
import { getDb, listRikxRondes, type ProjectListItem } from "../../lib/db"; import { getDb, listRikxRondes, type ProjectListItem } from "../../lib/db";
import { PROJECT_STATUSES, isProjectStatus } from "../../lib/projectStatus"; import { PROJECT_STATUSES, isProjectStatus } from "../../lib/projectStatus";
import { AppHeader } from "../AppHeader"; import { AppHeader } from "../AppHeader";
import { createProject } from "./actions";
import { AutoSubmitSelect } from "./AutoSubmitSelect"; import { AutoSubmitSelect } from "./AutoSubmitSelect";
import { ProjectPipeline } from "./ProjectPipeline"; import { ProjectPipeline } from "./ProjectPipeline";
import { ProjectTable } from "./ProjectTable"; import { ProjectTable } from "./ProjectTable";
@@ -17,8 +18,7 @@ export default async function ProjectenPage({
}: { }: {
searchParams: Promise<{ q?: string; status?: string; ronde?: string; weergave?: string }>; searchParams: Promise<{ q?: string; status?: string; ronde?: string; weergave?: string }>;
}) { }) {
const session = await getSession(); const session = await requireAdmin();
if (!session) redirect("/login");
const params = await searchParams; const params = await searchParams;
const q = (params.q ?? "").trim(); const q = (params.q ?? "").trim();
@@ -130,12 +130,14 @@ export default async function ProjectenPage({
</div> </div>
</form> </form>
<Link <form action={createProject}>
href="/projecten/nieuw" <button
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" 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 >
</Link> + Project toevoegen
</button>
</form>
</div> </div>
{isFiltered && ( {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 Link from "next/link";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getSession } from "../../../lib/auth"; import { requireAdmin } from "../../../lib/auth";
import { getDb, type RikxRonde } from "../../../lib/db"; import { getDb, type RikxRonde } from "../../../lib/db";
import { AppHeader } from "../../AppHeader"; import { AppHeader } from "../../AppHeader";
import { updateRonde } from "../actions"; import { updateRonde } from "../actions";
import { listExperts } from "../../../lib/experts";
import { ExpertCheckboxes } from "../ExpertCheckboxes";
import { RONDE_ERRORS, RondeFields } from "../RondeFields"; import { RONDE_ERRORS, RondeFields } from "../RondeFields";
export default async function EditRondePage({ export default async function EditRondePage({
@@ -13,14 +15,14 @@ export default async function EditRondePage({
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>; searchParams: Promise<{ error?: string }>;
}) { }) {
const session = await getSession(); const session = await requireAdmin();
if (!session) redirect("/login");
const { id } = await params; const { id } = await params;
const { error } = await searchParams; const { error } = await searchParams;
const row = getDb().prepare("SELECT * FROM rikx_rondes WHERE id = ?").get(Number(id)) as RikxRonde | undefined; const row = getDb().prepare("SELECT * FROM rikx_rondes WHERE id = ?").get(Number(id)) as RikxRonde | undefined;
if (!row) notFound(); if (!row) notFound();
const ronde = { ...row }; const ronde = { ...row };
const experts = listExperts();
return ( return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950"> <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} /> <input type="hidden" name="id" value={ronde.id} />
<RondeFields ronde={ronde} /> <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"> <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. Een andere Rikx waarde of prijs verandert de Rikx calculatie van alle projecten in deze ronde.
</p> </p>
+12 -8
View File
@@ -2,7 +2,8 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getDb } from "../../lib/db"; 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"; import { defaultRikxPrijs } from "../../lib/rikxCalculatie";
// Leeg veld → null; accepteert zowel 1234.5 als 1234,5 // Leeg veld → null; accepteert zowel 1234.5 als 1234,5
@@ -25,9 +26,12 @@ function readRonde(formData: FormData) {
return { label, rikxWaarde, rikxPrijs } as const; 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) { export async function createRonde(formData: FormData) {
const session = await getSession(); const session = await requireAdmin();
if (!session) redirect("/login");
const ronde = readRonde(formData); const ronde = readRonde(formData);
if ("error" in ronde) redirect(`/rondes?error=${ronde.error}`); 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)) { if (db.prepare("SELECT id FROM rikx_rondes WHERE label = ?").get(ronde.label)) {
redirect("/rondes?error=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.label,
ronde.rikxWaarde, ronde.rikxWaarde,
ronde.rikxPrijs ronde.rikxPrijs
); );
setRondeExperts(Number(result.lastInsertRowid), readExpertIds(formData));
redirect("/rondes"); redirect("/rondes");
} }
export async function updateRonde(formData: FormData) { export async function updateRonde(formData: FormData) {
const session = await getSession(); const session = await requireAdmin();
if (!session) redirect("/login");
const id = Number(formData.get("id")); const id = Number(formData.get("id"));
if (!id) redirect("/rondes"); if (!id) redirect("/rondes");
@@ -62,13 +66,13 @@ export async function updateRonde(formData: FormData) {
db.prepare( db.prepare(
"UPDATE rikx_rondes SET label = ?, rikx_waarde = ?, rikx_prijs = ?, updated_at = datetime('now') WHERE id = ?" "UPDATE rikx_rondes SET label = ?, rikx_waarde = ?, rikx_prijs = ?, updated_at = datetime('now') WHERE id = ?"
).run(ronde.label, ronde.rikxWaarde, ronde.rikxPrijs, id); ).run(ronde.label, ronde.rikxWaarde, ronde.rikxPrijs, id);
setRondeExperts(id, readExpertIds(formData));
redirect("/rondes"); redirect("/rondes");
} }
export async function deleteRonde(formData: FormData) { export async function deleteRonde(formData: FormData) {
const session = await getSession(); const session = await requireAdmin();
if (!session) redirect("/login");
const id = Number(formData.get("id")); const id = Number(formData.get("id"));
// Projecten in deze ronde blijven bestaan; hun ronde_id wordt NULL (ON DELETE SET NULL). // 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 Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getSession } from "../../lib/auth"; import { requireAdmin } from "../../lib/auth";
import { getDb, type RikxRonde } from "../../lib/db"; import { getDb, type RikxRonde } from "../../lib/db";
import { AppHeader } from "../AppHeader"; import { AppHeader } from "../AppHeader";
import { createRonde } from "./actions"; import { createRonde } from "./actions";
import { DeleteRondeButton } from "./DeleteRondeButton"; import { DeleteRondeButton } from "./DeleteRondeButton";
import { listExperts } from "../../lib/experts";
import { ExpertCheckboxes } from "./ExpertCheckboxes";
import { RONDE_ERRORS, RondeFields } from "./RondeFields"; import { RONDE_ERRORS, RondeFields } from "./RondeFields";
const euro = new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" }); const euro = new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" });
@@ -15,10 +17,10 @@ export default async function RondesPage({
}: { }: {
searchParams: Promise<{ error?: string }>; searchParams: Promise<{ error?: string }>;
}) { }) {
const session = await getSession(); const session = await requireAdmin();
if (!session) redirect("/login");
const { error } = await searchParams; const { error } = await searchParams;
const experts = listExperts();
const rondes = ( const rondes = (
getDb() getDb()
.prepare( .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 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">Rikx prijs</th>
<th className="text-right px-4 py-3 font-medium">Projecten</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> <th className="px-4 py-3" colSpan={2}></th>
</tr> </tr>
</thead> </thead>
@@ -67,6 +70,12 @@ export default async function RondesPage({
{r.project_count} {r.project_count}
</Link> </Link>
</td> </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"> <td className="px-4 py-3 text-right">
<Link <Link
href={`/rondes/${r.id}`} 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" 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 /> <RondeFields />
<ExpertCheckboxes experts={experts} />
<div className="sm:col-span-3"> <div className="sm:col-span-3">
<button <button
type="submit" type="submit"
+2 -3
View File
@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getSession } from "../../../lib/auth"; import { requireAdmin } from "../../../lib/auth";
import { getAccount } from "../../../lib/platform-db"; import { getAccount } from "../../../lib/platform-db";
import { updateTenant } from "../actions"; import { updateTenant } from "../actions";
import { AppHeader } from "../../AppHeader"; import { AppHeader } from "../../AppHeader";
@@ -12,8 +12,7 @@ export default async function EditTenantPage({
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>; searchParams: Promise<{ error?: string }>;
}) { }) {
const session = await getSession(); const session = await requireAdmin();
if (!session) redirect("/login");
const { id } = await params; const { id } = await params;
const { error } = await searchParams; const { error } = await searchParams;
+4 -7
View File
@@ -3,7 +3,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { randomBytes, scryptSync } from "crypto"; import { randomBytes, scryptSync } from "crypto";
import { getPlatformDb } from "../../lib/platform-db"; import { getPlatformDb } from "../../lib/platform-db";
import { getSession } from "../../lib/auth"; import { requireAdmin } from "../../lib/auth";
function requireAuth() { function requireAuth() {
// Called inside server actions — session check is synchronous-safe via cache // 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) { export async function createTenant(formData: FormData) {
const session = await getSession(); const session = await requireAdmin();
if (!session) redirect("/login");
const name = (formData.get("name") as string).trim(); const name = (formData.get("name") as string).trim();
const slug = (formData.get("slug") as string).trim().toLowerCase().replace(/[^a-z0-9-]/g, "-"); 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) { export async function deleteTenant(formData: FormData) {
const session = await getSession(); const session = await requireAdmin();
if (!session) redirect("/login");
const accountId = Number(formData.get("account_id")); const accountId = Number(formData.get("account_id"));
if (!accountId) redirect("/tenants"); if (!accountId) redirect("/tenants");
@@ -59,8 +57,7 @@ export async function deleteTenant(formData: FormData) {
} }
export async function updateTenant(formData: FormData) { export async function updateTenant(formData: FormData) {
const session = await getSession(); const session = await requireAdmin();
if (!session) redirect("/login");
const accountId = Number(formData.get("account_id")); const accountId = Number(formData.get("account_id"));
const name = ((formData.get("name") as string) ?? "").trim(); const name = ((formData.get("name") as string) ?? "").trim();
+2 -3
View File
@@ -2,12 +2,11 @@
import { createHmac } from "crypto"; import { createHmac } from "crypto";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getSession } from "../../lib/auth"; import { requireAdmin } from "../../lib/auth";
import { getPlatformDb } from "../../lib/platform-db"; import { getPlatformDb } from "../../lib/platform-db";
export async function autoLogin(formData: FormData) { export async function autoLogin(formData: FormData) {
const session = await getSession(); const session = await requireAdmin();
if (!session) redirect("/login");
const accountId = Number(formData.get("account_id")); const accountId = Number(formData.get("account_id"));
const slug = formData.get("slug") as string; const slug = formData.get("slug") as string;
+2 -3
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getSession } from "../../lib/auth"; import { requireAdmin } from "../../lib/auth";
import { listAccounts } from "../../lib/platform-db"; import { listAccounts } from "../../lib/platform-db";
import { createTenant } from "./actions"; import { createTenant } from "./actions";
import { DeleteTenantButton } from "./DeleteTenantButton"; import { DeleteTenantButton } from "./DeleteTenantButton";
@@ -10,8 +10,7 @@ export default async function TenantsPage({
}: { }: {
searchParams: Promise<{ error?: string }>; searchParams: Promise<{ error?: string }>;
}) { }) {
const session = await getSession(); const session = await requireAdmin();
if (!session) redirect("/login");
const { error } = await searchParams; const { error } = await searchParams;
const accounts = listAccounts(); 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>
);
}
+62 -1
View File
@@ -1,4 +1,5 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { createHmac, timingSafeEqual, randomBytes, scryptSync } from "crypto"; import { createHmac, timingSafeEqual, randomBytes, scryptSync } from "crypto";
import { getDb } from "./db"; import { getDb } from "./db";
@@ -6,7 +7,8 @@ const SESSION_SECRET = process.env.SESSION_SECRET!;
const COOKIE_NAME = "session"; const COOKIE_NAME = "session";
const MAX_AGE = 60 * 60 * 24 * 7; // 7 dagen const MAX_AGE = 60 * 60 * 24 * 7; // 7 dagen
type SessionPayload = { userId: number; email: string; name: string }; // impersonatorId: admin die via "Inloggen als" de pagina van een expert bekijkt (alleen lezen)
type SessionPayload = { userId: number; email: string; name: string; impersonatorId?: number };
function sign(payload: SessionPayload): string { function sign(payload: SessionPayload): string {
const data = JSON.stringify(payload); const data = JSON.stringify(payload);
@@ -57,6 +59,8 @@ export function hashPassword(password: string): string {
} }
export function verifyPassword(password: string, stored: string): boolean { export function verifyPassword(password: string, stored: string): boolean {
// Uitgenodigde experts hebben nog geen wachtwoord (lege hash) en kunnen niet inloggen
if (!stored || !stored.includes(":")) return false;
const [salt, hash] = stored.split(":"); const [salt, hash] = stored.split(":");
const attempt = scryptSync(password, salt, 64).toString("hex"); const attempt = scryptSync(password, salt, 64).toString("hex");
return timingSafeEqual(Buffer.from(hash, "hex"), Buffer.from(attempt, "hex")); return timingSafeEqual(Buffer.from(hash, "hex"), Buffer.from(attempt, "hex"));
@@ -68,3 +72,60 @@ export function getUserByEmail(email: string) {
| { id: number; email: string; name: string; password_hash: string; role: string } | { id: number; email: string; name: string; password_hash: string; role: string }
| undefined; | undefined;
} }
export type Role = "admin" | "expert";
export type CurrentUser = {
id: number;
email: string;
name: string;
role: Role;
// Gezet als een admin via "Inloggen als" meekijkt; alleen geldig als die gebruiker nog admin is
impersonator: { id: number; email: string; name: string } | null;
};
// Ingelogde gebruiker met zijn rol uit de database (niet uit de cookie), zodat een
// gewijzigde of verwijderde gebruiker direct geen toegang meer heeft.
export async function getCurrentUser(): Promise<CurrentUser | null> {
const session = await getSession();
if (!session) return null;
const user = getDb().prepare("SELECT id, email, name, role FROM users WHERE id = ?").get(session.userId) as
| { id: number; email: string; name: string; role: string }
| undefined;
if (!user) return null;
let impersonator: CurrentUser["impersonator"] = null;
if (session.impersonatorId) {
const admin = getDb()
.prepare("SELECT id, email, name FROM users WHERE id = ? AND role = 'admin'")
.get(session.impersonatorId) as { id: number; email: string; name: string } | undefined;
// Een meekijk-sessie van een gebruiker die geen admin (meer) is, is ongeldig
if (!admin) return null;
impersonator = { id: admin.id, email: admin.email, name: admin.name };
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role === "expert" ? "expert" : "admin",
impersonator,
};
}
export function homeForRole(role: Role): string {
return role === "expert" ? "/expert" : "/tenants";
}
// Voor alle beheerpagina's en -acties: alleen admins. Experts gaan naar hun eigen pagina.
export async function requireAdmin(): Promise<CurrentUser> {
const user = await getCurrentUser();
if (!user) redirect("/login");
if (user.role !== "admin") redirect(homeForRole(user.role));
return user;
}
// Voor de expertpagina: alleen experts.
export async function requireExpert(): Promise<CurrentUser> {
const user = await getCurrentUser();
if (!user) redirect("/login");
if (user.role !== "expert") redirect(homeForRole(user.role));
return user;
}
+59 -3
View File
@@ -52,8 +52,40 @@ function migrate(db: DatabaseSync) {
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- Expertscores per project voor de Rikx calculatie (Impactscore M). -- Module 4: experts zijn gebruikers met role = 'expert'.
-- Voorlopig handmatig ingevuld per expertnummer; Module 4 koppelt dit later aan expert-logins. -- Rondes waarvan een expert de projecten in waardebepaling ziet
CREATE TABLE IF NOT EXISTS expert_rondes (
expert_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ronde_id INTEGER NOT NULL REFERENCES rikx_rondes(id) ON DELETE CASCADE,
PRIMARY KEY (expert_id, ronde_id)
);
-- Uitnodigingen: alleen de SHA-256-hash van het token wordt bewaard
CREATE TABLE IF NOT EXISTS expert_uitnodigingen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Beoordeling van een project door een expert; Impactscore M = gemiddelde over de experts
CREATE TABLE IF NOT EXISTS expert_beoordelingen (
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
expert_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
duur_impact REAL,
levensdomeinen REAL,
basissituatie REAL,
bonus_malus_advies TEXT,
toelichting TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (project_id, expert_id)
);
-- Oude, handmatig ingevulde expertscores (expert 1–3). Niet meer gebruikt sinds de expertbeoordelingen;
-- de tabel blijft bestaan omdat hij in productie al gegevens kan bevatten.
CREATE TABLE IF NOT EXISTS project_expert_scores ( CREATE TABLE IF NOT EXISTS project_expert_scores (
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
expert INTEGER NOT NULL, expert INTEGER NOT NULL,
@@ -73,9 +105,16 @@ function migrate(db: DatabaseSync) {
addColumnIfMissing(db, "projects", "financiering_extern_pct", "REAL"); addColumnIfMissing(db, "projects", "financiering_extern_pct", "REAL");
// Overige project plan-velden als JSON; definities in lib/projectPlan.ts // Overige project plan-velden als JSON; definities in lib/projectPlan.ts
addColumnIfMissing(db, "projects", "project_plan", "TEXT"); addColumnIfMissing(db, "projects", "project_plan", "TEXT");
// Stap "Rikx calculatie" (alleen zichtbaar als rikx_calculatie_aan = 1) // Tab "Rikx calculatie". rikx_calculatie_aan is de oude schuif (niet meer gebruikt;
// de tab is nu zichtbaar vanaf status waardebepaling) en blijft staan omdat hij al in productie bestaat.
addColumnIfMissing(db, "projects", "rikx_calculatie_aan", "INTEGER NOT NULL DEFAULT 0"); addColumnIfMissing(db, "projects", "rikx_calculatie_aan", "INTEGER NOT NULL DEFAULT 0");
addColumnIfMissing(db, "projects", "rekenmethode", "TEXT NOT NULL DEFAULT 'arbeid'");
addColumnIfMissing(db, "projects", "bonus_malus", "TEXT"); addColumnIfMissing(db, "projects", "bonus_malus", "TEXT");
// Beoordeling onderaan de tab Rikx calculatie: 'goedgekeurd', 'afgekeurd' of NULL (nog niet beoordeeld)
addColumnIfMissing(db, "projects", "beoordeling", "TEXT");
addColumnIfMissing(db, "projects", "beoordeling_reden", "TEXT");
// Impactmaker: de sociaal ondernemer (accounts.id in de platformdatabase; geen foreign key, andere database)
addColumnIfMissing(db, "projects", "impactmaker_account_id", "INTEGER");
addColumnIfMissing(db, "projects", "ronde_id", "INTEGER REFERENCES rikx_rondes(id) ON DELETE SET NULL"); addColumnIfMissing(db, "projects", "ronde_id", "INTEGER REFERENCES rikx_rondes(id) ON DELETE SET NULL");
db.exec("CREATE INDEX IF NOT EXISTS idx_projects_ronde ON projects(ronde_id)"); db.exec("CREATE INDEX IF NOT EXISTS idx_projects_ronde ON projects(ronde_id)");
} }
@@ -131,7 +170,11 @@ export type Project = {
financiering_extern_pct: number | null; financiering_extern_pct: number | null;
project_plan: string | null; project_plan: string | null;
rikx_calculatie_aan: number; rikx_calculatie_aan: number;
rekenmethode: string;
bonus_malus: string | null; bonus_malus: string | null;
beoordeling: string | null;
beoordeling_reden: string | null;
impactmaker_account_id: number | null;
ronde_id: number | null; ronde_id: number | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -140,6 +183,19 @@ export type Project = {
// Project met het label van de gekoppelde Rikx Ronde (voor overzichten) // Project met het label van de gekoppelde Rikx Ronde (voor overzichten)
export type ProjectListItem = Project & { ronde_label: string | null }; export type ProjectListItem = Project & { ronde_label: string | null };
export type ExpertBeoordeling = {
project_id: number;
expert_id: number;
duur_impact: number | null;
levensdomeinen: number | null;
basissituatie: number | null;
bonus_malus_advies: string | null;
toelichting: string | null;
created_at: string;
updated_at: string;
};
// Oude handmatige scores (niet meer gebruikt)
export type ProjectExpertScore = { export type ProjectExpertScore = {
project_id: number; project_id: number;
expert: number; expert: number;
+134
View File
@@ -0,0 +1,134 @@
import { createHash, randomBytes } from "crypto";
import { getDb, type ExpertBeoordeling, type Project } from "./db";
// Module 4: experts zijn gebruikers (users.role = 'expert') die per Rikx ronde projecten beoordelen.
export type Expert = {
id: number;
name: string;
email: string;
actief: boolean; // heeft een wachtwoord ingesteld via de uitnodiging
rondeIds: number[];
aantalBeoordelingen: number;
created_at: string;
};
const INVITE_DAGEN = 7;
export function listExperts(): Expert[] {
const db = getDb();
const rows = db
.prepare(
`SELECT u.id, u.name, u.email, u.password_hash, u.created_at,
(SELECT COUNT(*) FROM expert_beoordelingen b WHERE b.expert_id = u.id) AS aantal
FROM users u WHERE u.role = 'expert' ORDER BY u.name COLLATE NOCASE`
)
.all() as { id: number; name: string; email: string; password_hash: string; created_at: string; aantal: number }[];
const rondes = db.prepare("SELECT expert_id, ronde_id FROM expert_rondes").all() as {
expert_id: number;
ronde_id: number;
}[];
return rows.map((r) => ({
id: r.id,
name: r.name,
email: r.email,
actief: r.password_hash.includes(":"),
rondeIds: rondes.filter((x) => x.expert_id === r.id).map((x) => x.ronde_id),
aantalBeoordelingen: r.aantal,
created_at: r.created_at,
}));
}
export function getExpert(id: number): Expert | undefined {
return listExperts().find((e) => e.id === id);
}
export function getExpertRondeIds(expertId: number): number[] {
return (
getDb().prepare("SELECT ronde_id FROM expert_rondes WHERE expert_id = ?").all(expertId) as { ronde_id: number }[]
).map((r) => r.ronde_id);
}
export function setExpertRondes(expertId: number, rondeIds: number[]) {
const db = getDb();
db.prepare("DELETE FROM expert_rondes WHERE expert_id = ?").run(expertId);
const insert = db.prepare("INSERT OR IGNORE INTO expert_rondes (expert_id, ronde_id) VALUES (?, ?)");
for (const id of rondeIds) insert.run(expertId, id);
}
// Koppeling vanaf de kant van de ronde (pagina Rikx rondes); alleen bestaande experts tellen mee
export function setRondeExperts(rondeId: number, expertIds: number[]) {
const db = getDb();
const experts = new Set(
(db.prepare("SELECT id FROM users WHERE role = 'expert'").all() as { id: number }[]).map((u) => u.id)
);
db.prepare("DELETE FROM expert_rondes WHERE ronde_id = ?").run(rondeId);
const insert = db.prepare("INSERT OR IGNORE INTO expert_rondes (expert_id, ronde_id) VALUES (?, ?)");
for (const id of expertIds) if (experts.has(id)) insert.run(id, rondeId);
}
// Projecten die een expert ziet: status Waardebepaling, in een van zijn rondes
export function listProjectenVoorExpert(expertId: number): (Project & { ronde_label: string })[] {
const rondeIds = getExpertRondeIds(expertId);
if (rondeIds.length === 0) return [];
return (
getDb()
.prepare(
`SELECT p.*, r.label AS ronde_label FROM projects p JOIN rikx_rondes r ON r.id = p.ronde_id
WHERE p.status = 'waardebepaling' AND p.ronde_id IN (${rondeIds.map(() => "?").join(",")})
ORDER BY r.label COLLATE NOCASE, p.name COLLATE NOCASE`
)
.all(...rondeIds) as (Project & { ronde_label: string })[]
).map((p) => ({ ...p }));
}
export function magBeoordelen(expertId: number, projectId: number): boolean {
return listProjectenVoorExpert(expertId).some((p) => p.id === projectId);
}
// Beoordelingen van alle experts voor de gegeven projecten, met de naam van de expert
export function listBeoordelingen(projectIds: number[]): (ExpertBeoordeling & { expert_name: string })[] {
if (projectIds.length === 0) return [];
return (
getDb()
.prepare(
`SELECT b.*, u.name AS expert_name FROM expert_beoordelingen b
JOIN users u ON u.id = b.expert_id
WHERE b.project_id IN (${projectIds.map(() => "?").join(",")})
ORDER BY u.name COLLATE NOCASE`
)
.all(...projectIds) as (ExpertBeoordeling & { expert_name: string })[]
).map((b) => ({ ...b }));
}
const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
// Maakt een nieuwe uitnodiging; eerdere, ongebruikte uitnodigingen van deze gebruiker vervallen.
export function createInvite(userId: number): string {
const db = getDb();
db.prepare("DELETE FROM expert_uitnodigingen WHERE user_id = ? AND used_at IS NULL").run(userId);
const token = randomBytes(32).toString("base64url");
db.prepare(
`INSERT INTO expert_uitnodigingen (user_id, token_hash, expires_at)
VALUES (?, ?, datetime('now', '+${INVITE_DAGEN} days'))`
).run(userId, hashToken(token));
return token;
}
// Geldige (niet verlopen, niet gebruikte) uitnodiging bij een token, met de bijbehorende expert
export function findInvite(token: string) {
return getDb()
.prepare(
`SELECT i.id, i.user_id, u.name, u.email FROM expert_uitnodigingen i
JOIN users u ON u.id = i.user_id
WHERE i.token_hash = ? AND i.used_at IS NULL AND i.expires_at > datetime('now') AND u.role = 'expert'`
)
.get(hashToken(token)) as { id: number; user_id: number; name: string; email: string } | undefined;
}
export function markInviteUsed(inviteId: number) {
getDb().prepare("UPDATE expert_uitnodigingen SET used_at = datetime('now') WHERE id = ?").run(inviteId);
}
export const UITNODIGING_GELDIG_DAGEN = INVITE_DAGEN;
export const MIN_WACHTWOORD_LENGTE = 8;
+18
View File
@@ -0,0 +1,18 @@
import nodemailer from "nodemailer";
// E-mail via SMTP (zie SMTP_* in CLAUDE.md). Zonder SMTP_HOST wordt er niets verstuurd;
// de aanroeper toont dan bijvoorbeeld de uitnodigingslink in beeld.
export function isMailConfigured(): boolean {
return Boolean(process.env.SMTP_HOST);
}
export async function sendMail(message: { to: string; subject: string; text: string; html?: string }): Promise<void> {
const port = Number(process.env.SMTP_PORT || 587);
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port,
secure: port === 465,
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } : undefined,
});
await transport.sendMail({ from: process.env.SMTP_FROM || "noreply@rikx.nl", ...message });
}
+16
View File
@@ -46,3 +46,19 @@ export function getAccount(id: number): PlatformAccount | undefined {
.prepare("SELECT * FROM accounts WHERE id = ?") .prepare("SELECT * FROM accounts WHERE id = ?")
.get(id) as PlatformAccount | undefined; .get(id) as PlatformAccount | undefined;
} }
// Namen van alle sociaal ondernemers, voor keuzelijsten en overzichten in Rikx Beheer.
// Geeft een lege lijst als de platformdatabase niet bereikbaar is, zodat projectpagina's blijven werken.
export function listAccountNames(): { id: number; name: string }[] {
try {
return (
getPlatformDb().prepare("SELECT id, name FROM accounts ORDER BY name COLLATE NOCASE").all() as {
id: number;
name: string;
}[]
).map((a) => ({ id: a.id, name: a.name }));
} catch (err) {
console.error("Platformdatabase niet bereikbaar:", err);
return [];
}
}
+10
View File
@@ -23,3 +23,13 @@ export function projectStatusLabel(id: string): string {
export function projectStatusBadgeClass(id: string): string { export function projectStatusBadgeClass(id: string): string {
return PROJECT_STATUSES.find((s) => s.id === id)?.badgeClass ?? "bg-zinc-100 text-zinc-700"; return PROJECT_STATUSES.find((s) => s.id === id)?.badgeClass ?? "bg-zinc-100 text-zinc-700";
} }
// Statusvolgorde zoals in PROJECT_STATUSES; true als `status` gelijk aan of later dan `vanaf` is.
export function isStatusVanaf(status: string, vanaf: string): boolean {
const index = PROJECT_STATUSES.findIndex((s) => s.id === status);
const vanafIndex = PROJECT_STATUSES.findIndex((s) => s.id === vanaf);
return index !== -1 && vanafIndex !== -1 && index >= vanafIndex;
}
// De tab "Rikx calculatie" op de projectpagina is zichtbaar vanaf deze status.
export const RIKX_CALCULATIE_VANAF_STATUS = "waardebepaling";
+57 -5
View File
@@ -1,4 +1,5 @@
// Rikx-rekenmethode, overgenomen uit _doc/Rikx rekenmethode uitleg.xlsx (tabblad "Uitleg"). // Rikx Waardeberekening Arbeid, overgenomen uit _doc/Rikx rekenmethode uitleg.xlsx (tabblad "Uitleg")
// en _doc/Waarderingssystematiek Rikx juni 2023.pdf.
// Pure functies zonder database-toegang, zodat het formulier live kan rekenen. // Pure functies zonder database-toegang, zodat het formulier live kan rekenen.
export const BONUS_MALUS_OPTIONS = [ export const BONUS_MALUS_OPTIONS = [
@@ -10,13 +11,35 @@ export const BONUS_MALUS_OPTIONS = [
{ id: "--", label: "--", factor: 0.8 }, { id: "--", label: "--", factor: 0.8 },
] as const; ] as const;
// Schalen volgens _doc/Waarderingssystematiek Rikx juni 2023.pdf ("Inhoudelijke focus")
export const EXPERT_CRITERIA = [ export const EXPERT_CRITERIA = [
{ id: "duur_impact", label: "Duur van impact" }, { id: "duur_impact", label: "Duur van impact", schaal: "kortstondig → levenslang" },
{ id: "levensdomeinen", label: "Aantal levensdomeinen" }, { id: "levensdomeinen", label: "Aantal levensdomeinen", schaal: "één levensdomein → alle levensdomeinen" },
{ id: "basissituatie", label: "Basissituatie doelgroep" }, { id: "basissituatie", label: "Basissituatie doelgroep", schaal: "relatief eenvoudig → relatief zwaar" },
] as const; ] as const;
export const EXPERT_COUNT = 3; // Rikx kent twee rekenmethodes. Alleen "Arbeid" is uitgewerkt (de formule hieronder);
// "Jeugd" is zichtbaar maar nog niet te kiezen.
export const REKENMETHODES = [
{
id: "arbeid",
label: "Rikx Waardeberekening Arbeid",
omschrijving: "Impact op de arbeidsmarkt: uitstroom naar werk en het opdoen van skills.",
beschikbaar: true,
},
{
id: "jeugd",
label: "Rikx Waardeberekening Jeugd",
omschrijving: "Nog niet beschikbaar.",
beschikbaar: false,
},
] as const;
export const DEFAULT_REKENMETHODE = "arbeid";
export function isBeschikbareRekenmethode(value: unknown): value is string {
return REKENMETHODES.some((m) => m.id === value && m.beschikbaar);
}
export type ExpertCriterium = (typeof EXPERT_CRITERIA)[number]["id"]; export type ExpertCriterium = (typeof EXPERT_CRITERIA)[number]["id"];
export type ExpertScore = Record<ExpertCriterium, number | null>; export type ExpertScore = Record<ExpertCriterium, number | null>;
@@ -116,3 +139,32 @@ export function calculateRikx(input: RikxInput): RikxResult {
export function defaultRikxPrijs(rikxWaarde: number): number { export function defaultRikxPrijs(rikxWaarde: number): number {
return rikxWaarde / 10; return rikxWaarde / 10;
} }
// Bouwt de invoer voor calculateRikx uit een opgeslagen project, de beoordelingen van de experts
// (elk aantal; Impactscore M = gemiddelde over de experts) en zijn Rikx Ronde.
export function rikxInputFromProject(
project: {
projectkosten: number | null;
deelnemers_werk: number | null;
deelnemers_skills: number | null;
financiering_extern_pct: number | null;
bonus_malus: string | null;
},
beoordelingen: { duur_impact: number | null; levensdomeinen: number | null; basissituatie: number | null }[],
ronde: { rikx_waarde: number | null; rikx_prijs: number | null } | null
): RikxInput {
return {
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: ronde?.rikx_waarde ?? null,
rikx_prijs: ronde?.rikx_prijs ?? null,
expert_scores: beoordelingen.map((b) => ({
duur_impact: b.duur_impact,
levensdomeinen: b.levensdomeinen,
basissituatie: b.basissituatie,
})),
};
}