Files
rikx-beheer/lib/rikxCalculatie.ts
T
remco-vermeer 53bf667f54 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>
2026-10-08 14:42:22 +02:00

171 lines
6.2 KiB
TypeScript

// 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 = [
{ id: "++", label: "++", factor: 1.2 },
{ id: "+", label: "+", factor: 1.1 },
{ id: "", label: "Geen", factor: 1.0 },
// In de spreadsheet staat "- = 1.1" en "-- = -1.2"; dat zijn vermoedelijk typefouten.
{ id: "-", label: "-", factor: 0.9 },
{ 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", 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;
// 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>;
export type RikxInput = {
projectkosten: number | null;
deelnemers_werk: number | null;
deelnemers_skills: number | null;
financiering_extern_pct: number | null;
bonus_malus: string | null;
// Uit de Rikx Ronde van het project
rikx_waarde: number | null;
rikx_prijs: number | null;
expert_scores: ExpertScore[];
};
export type RikxResult = {
n_score: number | null;
scale: number | null;
impactscore_m: number | null;
is_bm: number | null;
impactwaarde: number | null;
minus_cofinanciering: number | null;
i_cofin: number | null;
kosten_impact: number | null;
totale_impactwaarde: number | null;
totaal_aantal_rikx: number | null;
meerwaarde: number | null;
efficientie_waarde: number | null;
};
function average(values: (number | null)[]): number | null {
const nums = values.filter((v): v is number => v !== null && Number.isFinite(v));
return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
}
// Staffel uit de spreadsheet: 1 = 1, 2-5 = 2, 5-10 = 3, 10-25 = 4, >25 = 5
export function scaleFromNScore(n: number): number {
if (n <= 1) return 1;
if (n <= 5) return 2;
if (n <= 10) return 3;
if (n <= 25) return 4;
return 5;
}
export function bonusMalusFactor(id: string | null): number {
return BONUS_MALUS_OPTIONS.find((o) => o.id === (id ?? ""))?.factor ?? 1;
}
// Vermenigvuldigen/delen die null doorgeven als een invoer ontbreekt of de uitkomst ongeldig is.
function mul(a: number | null, b: number | null) {
return a === null || b === null ? null : a * b;
}
function div(a: number | null, b: number | null) {
return a === null || b === null || b === 0 ? null : a / b;
}
export function calculateRikx(input: RikxInput): RikxResult {
const { projectkosten, deelnemers_werk, deelnemers_skills, rikx_waarde, rikx_prijs } = input;
// De spreadsheet-formule (B4*5)+(B5*1)/6 mist haakjes; bedoeld is ((werk*5)+skills)/6.
const n_score =
deelnemers_werk === null || deelnemers_skills === null
? null
: (deelnemers_werk * 5 + deelnemers_skills) / 6;
const scale = n_score === null ? null : scaleFromNScore(n_score);
const impactscore_m = average(input.expert_scores.map((s) => average(Object.values(s))));
const is_bm = impactscore_m === null ? null : impactscore_m * impactscore_m * bonusMalusFactor(input.bonus_malus);
const impactwaarde = mul(is_bm, scale);
const minus_cofinanciering = (100 - (input.financiering_extern_pct ?? 0)) / 100;
const i_cofin = mul(impactwaarde, minus_cofinanciering);
const kosten_impact = div(projectkosten, i_cofin);
const totale_impactwaarde = mul(i_cofin, rikx_waarde);
const totaal_aantal_rikx = div(projectkosten, rikx_prijs);
const meerwaarde =
totale_impactwaarde === null || projectkosten === null ? null : totale_impactwaarde - projectkosten;
const efficientie_waarde = div(meerwaarde, projectkosten);
return {
n_score,
scale,
impactscore_m,
is_bm,
impactwaarde,
minus_cofinanciering,
i_cofin,
kosten_impact,
totale_impactwaarde,
totaal_aantal_rikx,
meerwaarde,
efficientie_waarde,
};
}
// Rikx prijs volgens de sheet: Rikx waarde / 10 (behapbaarder bedrag voor kleine investeerders)
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,
})),
};
}