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
+62 -1
View File
@@ -1,4 +1,5 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { createHmac, timingSafeEqual, randomBytes, scryptSync } from "crypto";
import { getDb } from "./db";
@@ -6,7 +7,8 @@ const SESSION_SECRET = process.env.SESSION_SECRET!;
const COOKIE_NAME = "session";
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 {
const data = JSON.stringify(payload);
@@ -57,6 +59,8 @@ export function hashPassword(password: string): string {
}
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 attempt = scryptSync(password, salt, 64).toString("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 }
| 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'))
);
-- Expertscores per project voor de Rikx calculatie (Impactscore M).
-- Voorlopig handmatig ingevuld per expertnummer; Module 4 koppelt dit later aan expert-logins.
-- Module 4: experts zijn gebruikers met role = 'expert'.
-- 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 (
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
expert INTEGER NOT NULL,
@@ -73,9 +105,16 @@ function migrate(db: DatabaseSync) {
addColumnIfMissing(db, "projects", "financiering_extern_pct", "REAL");
// Overige project plan-velden als JSON; definities in lib/projectPlan.ts
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", "rekenmethode", "TEXT NOT NULL DEFAULT 'arbeid'");
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");
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;
project_plan: string | null;
rikx_calculatie_aan: number;
rekenmethode: string;
bonus_malus: string | null;
beoordeling: string | null;
beoordeling_reden: string | null;
impactmaker_account_id: number | null;
ronde_id: number | null;
created_at: string;
updated_at: string;
@@ -140,6 +183,19 @@ export type Project = {
// Project met het label van de gekoppelde Rikx Ronde (voor overzichten)
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 = {
project_id: 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 = ?")
.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 {
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.
export const BONUS_MALUS_OPTIONS = [
@@ -10,13 +11,35 @@ export const BONUS_MALUS_OPTIONS = [
{ id: "--", label: "--", factor: 0.8 },
] as const;
// Schalen volgens _doc/Waarderingssystematiek Rikx juni 2023.pdf ("Inhoudelijke focus")
export const EXPERT_CRITERIA = [
{ id: "duur_impact", label: "Duur van impact" },
{ id: "levensdomeinen", label: "Aantal levensdomeinen" },
{ id: "basissituatie", label: "Basissituatie doelgroep" },
{ id: "duur_impact", label: "Duur van impact", schaal: "kortstondig → levenslang" },
{ id: "levensdomeinen", label: "Aantal levensdomeinen", schaal: "één levensdomein → alle levensdomeinen" },
{ id: "basissituatie", label: "Basissituatie doelgroep", schaal: "relatief eenvoudig → relatief zwaar" },
] 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 ExpertScore = Record<ExpertCriterium, number | null>;
@@ -116,3 +139,32 @@ export function calculateRikx(input: RikxInput): RikxResult {
export function defaultRikxPrijs(rikxWaarde: number): number {
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,
})),
};
}