53bf667f54
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>
171 lines
6.2 KiB
TypeScript
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,
|
|
})),
|
|
};
|
|
}
|