Files
rikx-beheer/lib/projectPlan.ts
T
remco-vermeer 9f0bd87c40 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>
2026-10-08 10:26:30 +02:00

298 lines
12 KiB
TypeScript

// 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 };
}