Voeg Module 1 Projecten toe met project plan, Rikx calculatie en Rikx rondes
- Projectenpagina met pipeline (standaard) en tabelweergave, filters op status, Rikx Ronde en zoeken - Projectpagina in stappen: Project plan (velden uit de Rikx-aanvraag) en Rikx calculatie achter een schuif, live berekend volgens de rekenmethode - Rikx rondes beheren (label, Rikx waarde, Rikx prijs) en koppelen aan projecten - Oranje Rikx-menubalk met menu per module, gedeeld door alle pagina's - Oude offerte-tabellen opgeruimd: leeg wordt verwijderd, met data hernoemd naar legacy_* - CLAUDE.md bijgewerkt naar de huidige stand Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,49 +26,139 @@ function migrate(db: DatabaseSync) {
|
||||
role TEXT NOT NULL DEFAULT 'admin',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
address TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
dropLegacyQuoteTables(db);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id INTEGER NOT NULL REFERENCES clients(id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'aanvraag',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quotes (
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
|
||||
|
||||
-- Rikx Ronde: investeringsronde (bv. van een gemeente) waarin projecten meedoen.
|
||||
-- Rikx waarde en Rikx prijs gelden voor alle projecten in de ronde.
|
||||
CREATE TABLE IF NOT EXISTS rikx_rondes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
notes TEXT,
|
||||
sent_at TEXT,
|
||||
accepted_at TEXT,
|
||||
label TEXT NOT NULL UNIQUE,
|
||||
rikx_waarde REAL,
|
||||
rikx_prijs REAL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quote_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
quote_id INTEGER NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||||
description TEXT NOT NULL,
|
||||
quantity REAL NOT NULL DEFAULT 1,
|
||||
unit TEXT,
|
||||
unit_price REAL NOT NULL DEFAULT 0,
|
||||
total REAL GENERATED ALWAYS AS (quantity * unit_price) VIRTUAL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
-- Expertscores per project voor de Rikx calculatie (Impactscore M).
|
||||
-- Voorlopig handmatig ingevuld per expertnummer; Module 4 koppelt dit later aan expert-logins.
|
||||
CREATE TABLE IF NOT EXISTS project_expert_scores (
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
expert INTEGER NOT NULL,
|
||||
duur_impact REAL,
|
||||
levensdomeinen REAL,
|
||||
basissituatie REAL,
|
||||
PRIMARY KEY (project_id, expert)
|
||||
);
|
||||
`);
|
||||
|
||||
// Velden uit _doc/Rikx rekenmethode uitleg.xlsx
|
||||
// Stap "Project plan" (ingevuld door de sociaal ondernemer)
|
||||
addColumnIfMissing(db, "projects", "projectkosten", "REAL");
|
||||
addColumnIfMissing(db, "projects", "deelnemers_werk", "INTEGER");
|
||||
addColumnIfMissing(db, "projects", "deelnemers_skills", "INTEGER");
|
||||
addColumnIfMissing(db, "projects", "financiering_extern_bedrag", "REAL");
|
||||
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)
|
||||
addColumnIfMissing(db, "projects", "rikx_calculatie_aan", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfMissing(db, "projects", "bonus_malus", "TEXT");
|
||||
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)");
|
||||
}
|
||||
|
||||
function addColumnIfMissing(db: DatabaseSync, table: string, column: string, definition: string) {
|
||||
const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
|
||||
if (!columns.some((c) => c.name === column)) {
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Ruimt de tabellen van het oude offerte-portaal op (clients, projects, quotes,
|
||||
// quote_items). Lege tabellen worden verwijderd; bevat een tabel onverwacht
|
||||
// gegevens, dan wordt die hernoemd naar legacy_<naam> zodat er niets verloren gaat.
|
||||
function dropLegacyQuoteTables(db: DatabaseSync) {
|
||||
const projectColumns = db.prepare("PRAGMA table_info(projects)").all() as { name: string }[];
|
||||
if (!projectColumns.some((c) => c.name === "client_id")) return;
|
||||
|
||||
const tables = ["quote_items", "quotes", "projects", "clients"];
|
||||
const existing = new Set(
|
||||
(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as { name: string }[]).map(
|
||||
(t) => t.name
|
||||
)
|
||||
);
|
||||
|
||||
db.exec("BEGIN");
|
||||
try {
|
||||
for (const table of tables) {
|
||||
if (!existing.has(table)) continue;
|
||||
const { n } = db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number };
|
||||
if (n === 0) {
|
||||
db.exec(`DROP TABLE ${table}`);
|
||||
} else {
|
||||
console.warn(`Oude tabel ${table} bevat ${n} rijen; hernoemd naar legacy_${table}`);
|
||||
db.exec(`ALTER TABLE ${table} RENAME TO legacy_${table}`);
|
||||
}
|
||||
}
|
||||
db.exec("COMMIT");
|
||||
} catch (err) {
|
||||
db.exec("ROLLBACK");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export type Project = {
|
||||
id: number;
|
||||
name: string;
|
||||
status: string;
|
||||
projectkosten: number | null;
|
||||
deelnemers_werk: number | null;
|
||||
deelnemers_skills: number | null;
|
||||
financiering_extern_bedrag: number | null;
|
||||
financiering_extern_pct: number | null;
|
||||
project_plan: string | null;
|
||||
rikx_calculatie_aan: number;
|
||||
bonus_malus: string | null;
|
||||
ronde_id: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
// Project met het label van de gekoppelde Rikx Ronde (voor overzichten)
|
||||
export type ProjectListItem = Project & { ronde_label: string | null };
|
||||
|
||||
export type ProjectExpertScore = {
|
||||
project_id: number;
|
||||
expert: number;
|
||||
duur_impact: number | null;
|
||||
levensdomeinen: number | null;
|
||||
basissituatie: number | null;
|
||||
};
|
||||
|
||||
export type RikxRonde = {
|
||||
id: number;
|
||||
label: string;
|
||||
rikx_waarde: number | null;
|
||||
rikx_prijs: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export function listRikxRondes(): RikxRonde[] {
|
||||
return (
|
||||
getDb().prepare("SELECT * FROM rikx_rondes ORDER BY created_at DESC, id DESC").all() as RikxRonde[]
|
||||
).map((r) => ({ ...r }));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
// Velden van stap 1 "Project plan". Eén plek voor labels, types en keuzelijsten:
|
||||
// het formulier en de server action werken allebei vanuit deze definitie.
|
||||
// Velden met `column: true` zijn echte kolommen in `projects` (nodig voor de Rikx calculatie);
|
||||
// alle andere waarden staan als JSON in `projects.project_plan`.
|
||||
//
|
||||
// Keuzelijsten komen uit de antwoorden in _doc/Rikx rekenmethode uitleg.xlsx,
|
||||
// tabblad "Projecten(rikx ronde 4)", behalve BUDGET_BESTEDING (voorstel, nog niet uit een bron).
|
||||
|
||||
export const ANDERS = "Anders, namelijk";
|
||||
|
||||
export const DOELGROEPEN = [
|
||||
"Werkzoekenden (WW/Participatiewet)",
|
||||
"Arbeidsbeperkten (WIA/Wajong/Garantiebanen/WSW)",
|
||||
"Jongeren zonder startkwalificatie",
|
||||
"Leerlingen (BOL/BBL) (VSO/praktijkonderwijs)",
|
||||
"Niet-uitkeringsgerechtigden",
|
||||
ANDERS,
|
||||
];
|
||||
|
||||
export const SECTOREN = [
|
||||
"Zorg & Welzijn",
|
||||
"Bouw, Techniek & Productie",
|
||||
"Handel & Dienstverlening",
|
||||
"Transport & Logistiek",
|
||||
"Horeca & Detailhandel",
|
||||
"ICT",
|
||||
"Media & Communicatie",
|
||||
"Onderwijs & Cultuur (Wetenschap)",
|
||||
"Justitie, veiligheid en openbaar bestuur",
|
||||
ANDERS,
|
||||
];
|
||||
|
||||
export const INDIRECTE_IMPACT = [
|
||||
"Groei menselijk kapitaal (vaardigheden, competenties, opleiding, stages, startkwalificatie)",
|
||||
"Wegnemen persoonlijke belemmeringen (multiproblematiek, stress, schulden, armoede)",
|
||||
"Inzet op motivationele aspecten (zelfbeeld, zelfvertrouwen)",
|
||||
"Betrekken sociale hulpbronnen (netwerk, ouders, omgeving)",
|
||||
];
|
||||
|
||||
export const LEVENSGEBIEDEN = [
|
||||
"Financiën",
|
||||
"Werk & Opleiding",
|
||||
"Huisvesting",
|
||||
"Lichamelijke gezondheid",
|
||||
"Geestelijke gezondheid",
|
||||
"Sociale activiteiten",
|
||||
"Sociaal Netwerk",
|
||||
"Verslaving",
|
||||
"Justitie",
|
||||
ANDERS,
|
||||
];
|
||||
|
||||
export const DUUR_PROFIJT = [
|
||||
"Minder dan een jaar",
|
||||
"Een jaar tot een aantal jaren",
|
||||
"Meer dan 5 jaar",
|
||||
"Meer dan 10 jaar",
|
||||
"Levenslang",
|
||||
];
|
||||
|
||||
export const INTENSITEIT = [
|
||||
"Een hele week (bijvoorbeeld 5 dagen)",
|
||||
"Een aantal uren verspreid over een langere periode",
|
||||
];
|
||||
|
||||
export const BUDGET_BESTEDING = [
|
||||
"Personeelskosten (begeleiding, training, coaching)",
|
||||
"Opleiding en certificering",
|
||||
"Materialen en middelen",
|
||||
"Vergoeding voor deelnemers",
|
||||
"Werving van deelnemers",
|
||||
"Huisvesting en locatie",
|
||||
"Overhead / indirecte kosten",
|
||||
ANDERS,
|
||||
];
|
||||
|
||||
export const JA_NEE = ["Ja", "Nee"];
|
||||
export const JA_NEE_GEDEELTELIJK = ["Ja", "Nee", "Gedeeltelijk"];
|
||||
|
||||
type Base = {
|
||||
key: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
// Volle breedte in het formulier
|
||||
wide?: boolean;
|
||||
// Alleen tonen (en opslaan) als een ander veld deze waarde heeft
|
||||
showIf?: { key: string; equals: string };
|
||||
// Vervolgvraag: direct onder de vorige vraag, ingesprongen, zodat zichtbaar is waar hij bij hoort
|
||||
followUp?: boolean;
|
||||
// Begint op een nieuwe rij (bv. een vraag met vervolgvraag)
|
||||
newRow?: boolean;
|
||||
};
|
||||
|
||||
export type PlanField = Base &
|
||||
(
|
||||
| { kind: "textarea" }
|
||||
| { kind: "number"; column?: boolean; min?: number; max?: number; step?: number | "any" }
|
||||
| { kind: "date" }
|
||||
| { kind: "select"; options: string[] }
|
||||
| { kind: "multiselect"; options: string[] }
|
||||
);
|
||||
|
||||
export type PlanGroup = { title: string; fields: PlanField[] };
|
||||
|
||||
const aantal = (key: string, label: string): PlanField => ({ key, label, kind: "number", min: 0, step: 1 });
|
||||
|
||||
export const PROJECT_PLAN_GROUPS: PlanGroup[] = [
|
||||
{
|
||||
title: "Project",
|
||||
fields: [
|
||||
{ key: "korte_omschrijving", label: "Korte projectomschrijving", kind: "textarea", wide: true },
|
||||
{ key: "concrete_resultaten", label: "Welke concrete resultaten verwacht je te behalen?", kind: "textarea", wide: true },
|
||||
{ key: "startdatum", label: "Geplande startdatum project", kind: "date" },
|
||||
{ key: "einddatum", label: "Geplande einddatum project", kind: "date" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Doelgroep en bereik",
|
||||
fields: [
|
||||
{ key: "doelgroepen", label: "Op welke doelgroep(en) richt je project zich primair?", kind: "multiselect", options: DOELGROEPEN },
|
||||
{ key: "sectoren", label: "In welke sector(en) vinden de activiteiten en/of uitstroom naar werk plaats?", kind: "multiselect", options: SECTOREN },
|
||||
{ ...aantal("deelnemers_totaal", "Hoeveel deelnemers verwacht je in totaal te kunnen bereiken met dit project?"), wide: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Verwachte resultaten deelnemers",
|
||||
fields: [
|
||||
{
|
||||
key: "deelnemers_werk",
|
||||
label: "Totaal aantal deelnemers begeleid naar duurzame uitstroom werk",
|
||||
hint: "Verwacht aantal deelnemers dat het project succesvol verlaat. Telt mee in de Rikx calculatie.",
|
||||
kind: "number",
|
||||
column: true,
|
||||
min: 0,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: "deelnemers_skills",
|
||||
label: "Totaal aantal deelnemers voorzien van nieuwe skills",
|
||||
hint: "Deelnemers die niet per se uitstromen maar wel nieuwe vaardigheden opdoen. Telt mee in de Rikx calculatie.",
|
||||
kind: "number",
|
||||
column: true,
|
||||
min: 0,
|
||||
step: 1,
|
||||
},
|
||||
aantal("aantal_opleiding", "Aantal deelnemers dat naar verwachting een erkende opleiding (MBO), certificaat of training zal afronden"),
|
||||
aantal("aantal_betaalde_baan", "Aantal deelnemers dat naar verwachting zal doorstromen naar een betaalde baan"),
|
||||
aantal("aantal_vervolgopleiding", "Aantal deelnemers dat naar verwachting zal doorstromen naar een vervolgopleiding"),
|
||||
aantal("aantal_stage", "Aantal deelnemers dat naar verwachting zal doorstromen naar een stage of werkervaringsplaats"),
|
||||
aantal("aantal_dagbesteding", "Aantal deelnemers dat naar verwachting dagbesteding zal vinden"),
|
||||
aantal("aantal_vaardigheden", "Aantal deelnemers dat naar verwachting vaardigheden zal ontwikkelen"),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Impact",
|
||||
fields: [
|
||||
{ key: "levensgebieden", label: "Op welke levensgebieden verwacht je dat de deelnemers positief worden beïnvloed?", kind: "multiselect", options: LEVENSGEBIEDEN },
|
||||
{ key: "indirecte_impact", label: "Indirecte impact categorieën", kind: "multiselect", options: INDIRECTE_IMPACT },
|
||||
{ key: "levensgebieden_toelichting", label: "Licht kort toe hoe jouw project impact heeft op de geselecteerde levensgebieden", kind: "textarea", wide: true },
|
||||
{ key: "duur_profijt", label: "Hoelang verwacht je dat de deelnemers profijt hebben van de impact van het project?", kind: "select", options: DUUR_PROFIJT },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Begeleiding en aanpak",
|
||||
fields: [
|
||||
{ key: "begeleiding", label: "Hoe wordt begeleiding geregeld tijdens het traject?", kind: "textarea", wide: true },
|
||||
{ key: "uren_per_week", label: "Hoeveel uur training, opleiding of coaching besteed je gemiddeld per deelnemer per week?", kind: "number", min: 0, step: "any" },
|
||||
{ key: "intensiteit", label: "Is dit een hele week (bijvoorbeeld 5 dagen) of een aantal uren verspreid over een langere periode?", kind: "select", options: INTENSITEIT },
|
||||
{ key: "na_afronding", label: "Wat gebeurt er na afronding van het project met de deelnemers?", kind: "textarea", wide: true },
|
||||
{ key: "aanpassingen_afstand", label: "Welke aanpassingen maakt jouw project voor deelnemers met een afstand tot de arbeidsmarkt?", kind: "textarea", wide: true },
|
||||
{ key: "diversiteit", label: "Heeft jouw project specifieke aandacht voor diversiteit en inclusie op de arbeidsmarkt?", kind: "select", options: JA_NEE, newRow: true },
|
||||
{ key: "diversiteit_toelichting", label: "Zo ja, licht toe", kind: "textarea", wide: true, followUp: true, showIf: { key: "diversiteit", equals: "Ja" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Monitoring en evaluatie",
|
||||
fields: [
|
||||
{ key: "meetinstrumenten", label: "Welke meetinstrumenten of evaluatiemethoden ga je gebruiken?", kind: "textarea", wide: true },
|
||||
{ key: "monitoring", label: "Hoe ga je de voortgang en resultaten van jouw project monitoren?", kind: "textarea", wide: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Financiën",
|
||||
fields: [
|
||||
{ key: "projectkosten", label: "Projectkosten (€)", hint: "Benodigd bedrag om het project te kunnen starten.", kind: "number", column: true, min: 0, step: 0.01 },
|
||||
{ key: "projectkosten_omschrijving", label: "Projectkosten omschrijving", kind: "textarea", wide: true },
|
||||
{
|
||||
key: "financiering_extern_bedrag",
|
||||
label: "Financiëring extern bedrag (€)",
|
||||
hint: "Deel van de projectkosten dat niet via Rikx wordt gefinancierd.",
|
||||
kind: "number",
|
||||
column: true,
|
||||
min: 0,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
key: "financiering_extern_pct",
|
||||
label: "Percentage financiëring extern (%)",
|
||||
hint: "In te vullen door een Rikx-medewerker.",
|
||||
kind: "number",
|
||||
column: true,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: "any",
|
||||
},
|
||||
{ key: "financiering_extern_toelichting", label: "Financiëring extern toelichting", kind: "textarea", wide: true },
|
||||
{ key: "budget_besteding", label: "Hoe zal het aangevraagde budget worden besteed?", kind: "multiselect", options: BUDGET_BESTEDING },
|
||||
{ key: "financieel_duurzaam", label: "Is dit project financieel duurzaam?", kind: "select", options: JA_NEE_GEDEELTELIJK, newRow: true },
|
||||
{ key: "financieel_duurzaam_toelichting", label: "Licht toe", kind: "textarea", wide: true, followUp: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Overig",
|
||||
fields: [
|
||||
{ key: "vergelijkbare_projecten", label: "Zijn er vergelijkbare projecten waarvan aangetoond is dat ze effectief zijn?", kind: "select", options: JA_NEE, newRow: true },
|
||||
{ key: "vergelijkbare_projecten_toelichting", label: "Licht toe", kind: "textarea", wide: true, followUp: true },
|
||||
{ key: "expertise_partners", label: "Heb je specifieke expertise of samenwerkingspartners die bijdragen aan het succes van dit project?", kind: "textarea", wide: true },
|
||||
{ key: "overig", label: "Is er iets anders wat je wilt delen over jouw project?", kind: "textarea", wide: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const PROJECT_PLAN_FIELDS = PROJECT_PLAN_GROUPS.flatMap((g) => g.fields);
|
||||
|
||||
export type ProjectPlanValue = string | number | string[];
|
||||
export type ProjectPlan = Record<string, ProjectPlanValue>;
|
||||
|
||||
// Sleutel van het "Anders, namelijk"-tekstveld bij een keuzelijst
|
||||
export const andersKey = (key: string) => `${key}_anders`;
|
||||
|
||||
export function parseProjectPlanJson(json: string | null): ProjectPlan {
|
||||
if (!json) return {};
|
||||
try {
|
||||
const value = JSON.parse(json);
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_TEXT = 10_000;
|
||||
|
||||
// Leest de JSON-velden van het project plan uit het formulier.
|
||||
// Geeft een foutcode terug bij ongeldige invoer; kolomvelden worden hier overgeslagen.
|
||||
export function readProjectPlan(formData: FormData): { plan: ProjectPlan } | { error: "invalid" | "datum" } {
|
||||
const plan: ProjectPlan = {};
|
||||
const text = (key: string) => ((formData.get(key) as string | null) ?? "").trim().slice(0, MAX_TEXT);
|
||||
|
||||
for (const field of PROJECT_PLAN_FIELDS) {
|
||||
if (field.kind === "number" && field.column) continue;
|
||||
if (field.showIf && text(field.showIf.key) !== field.showIf.equals) continue;
|
||||
|
||||
switch (field.kind) {
|
||||
case "textarea": {
|
||||
const v = text(field.key);
|
||||
if (v) plan[field.key] = v;
|
||||
break;
|
||||
}
|
||||
case "number": {
|
||||
const raw = text(field.key);
|
||||
if (!raw) break;
|
||||
const n = Number(raw.replace(",", "."));
|
||||
if (!Number.isFinite(n) || n < (field.min ?? -Infinity) || n > (field.max ?? Infinity)) {
|
||||
return { error: "invalid" };
|
||||
}
|
||||
plan[field.key] = n;
|
||||
break;
|
||||
}
|
||||
case "date": {
|
||||
const v = text(field.key);
|
||||
if (!v) break;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(v) || Number.isNaN(Date.parse(v))) return { error: "datum" };
|
||||
plan[field.key] = v;
|
||||
break;
|
||||
}
|
||||
case "select": {
|
||||
const v = text(field.key);
|
||||
if (field.options.includes(v)) plan[field.key] = v;
|
||||
break;
|
||||
}
|
||||
case "multiselect": {
|
||||
const values = formData.getAll(field.key).filter((v): v is string => typeof v === "string" && field.options.includes(v));
|
||||
if (values.length) plan[field.key] = [...new Set(values)];
|
||||
if (values.includes(ANDERS)) {
|
||||
const anders = text(andersKey(field.key));
|
||||
if (anders) plan[andersKey(field.key)] = anders;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof plan.startdatum === "string" && typeof plan.einddatum === "string" && plan.einddatum < plan.startdatum) {
|
||||
return { error: "datum" };
|
||||
}
|
||||
return { plan };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// badgeClass/columnClass: kleuren van de statuskolommen in het oude Rikx-portaal
|
||||
export const PROJECT_STATUSES = [
|
||||
{ id: "aanvraag", label: "Aanvraag", badgeClass: "bg-[#f8c6c6] text-[#7a1f1f]", columnClass: "bg-[#f8c6c6]" },
|
||||
{ id: "in_behandeling", label: "In behandeling", badgeClass: "bg-[#fbdccb] text-[#7a3a14]", columnClass: "bg-[#fbdccb]" },
|
||||
{ id: "waardebepaling", label: "Waardebepaling", badgeClass: "bg-[#f5e1a0] text-[#6b5208]", columnClass: "bg-[#f5e1a0]" },
|
||||
{ id: "goedgekeurd", label: "Goedgekeurd", badgeClass: "bg-[#fcefcd] text-[#6b5208]", columnClass: "bg-[#fcefcd]" },
|
||||
{ id: "afgekeurd", label: "Afgekeurd", badgeClass: "bg-[#f6f4c3] text-[#5c5a10]", columnClass: "bg-[#f6f4c3]" },
|
||||
{ id: "open_voor_investering", label: "Open voor investering", badgeClass: "bg-[#e8f5c8] text-[#3d5a10]", columnClass: "bg-[#e8f5c8]" },
|
||||
{ id: "monitoring", label: "Monitoring", badgeClass: "bg-[#d6ecf5] text-[#14506b]", columnClass: "bg-[#d6ecf5]" },
|
||||
{ id: "archief", label: "Archief", badgeClass: "bg-zinc-200 text-zinc-700", columnClass: "bg-zinc-200" },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_PROJECT_STATUS = "aanvraag";
|
||||
|
||||
export function isProjectStatus(value: unknown): value is string {
|
||||
return typeof value === "string" && PROJECT_STATUSES.some((s) => s.id === value);
|
||||
}
|
||||
|
||||
export function projectStatusLabel(id: string): string {
|
||||
return PROJECT_STATUSES.find((s) => s.id === id)?.label ?? id;
|
||||
}
|
||||
|
||||
export function projectStatusBadgeClass(id: string): string {
|
||||
return PROJECT_STATUSES.find((s) => s.id === id)?.badgeClass ?? "bg-zinc-100 text-zinc-700";
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Rikx-rekenmethode, overgenomen uit _doc/Rikx rekenmethode uitleg.xlsx (tabblad "Uitleg").
|
||||
// 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;
|
||||
|
||||
export const EXPERT_CRITERIA = [
|
||||
{ id: "duur_impact", label: "Duur van impact" },
|
||||
{ id: "levensdomeinen", label: "Aantal levensdomeinen" },
|
||||
{ id: "basissituatie", label: "Basissituatie doelgroep" },
|
||||
] as const;
|
||||
|
||||
export const EXPERT_COUNT = 3;
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user