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:
+62
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user