diff --git a/CLAUDE.md b/CLAUDE.md index e647f12..fa4d0ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,9 +4,15 @@ Beheerportaal voor de Rikx-methodiek. Beheert de sociaal ondernemers (accounts i ## Pagina's - `/tenants` — sociaal ondernemers aanmaken, bewerken, verwijderen, auto-inlog (leest/schrijft de platformdatabase via `lib/platform-db.ts`) -- `/projecten` — projecten als pipeline (standaard, kolom per status) of tabel (`?weergave=tabel`), met filter op status, Rikx Ronde en zoeken; `/projecten/nieuw` — project toevoegen (naam + status) -- `/rondes` — Rikx rondes beheren (label, Rikx waarde, Rikx prijs); een project hoort optioneel bij één ronde (`projects.ronde_id`) -- `/projecten/[id]` — projectpagina in stappen: 1 Project plan (velden en keuzelijsten gedefinieerd in `lib/projectPlan.ts`), 2 Rikx calculatie (alleen zichtbaar als de schuif aan staat, `rikx_calculatie_aan`). Uitkomsten worden live berekend met `lib/rikxCalculatie.ts` +- `/projecten` — projecten als pipeline (standaard, kolom per status) of tabel (`?weergave=tabel`), met filter op status, Rikx Ronde en zoeken. De knop "+ Project toevoegen" maakt direct een project aan ("Nieuw project", status Aanvraag) en opent de projectpagina +- `/waardebepaling` — Waardebepaling projecten: tabel met projecten in status Waardebepaling (projectnaam, impactmaker, kosten/impact), gerangschikt op kosten/impact van laag naar hoog, filter op Rikx Ronde. Alleen bij een gekozen Rikx ronde (niet bij Alle/Zonder ronde) een dikke oranje lijn onder het 75%-project: plek ⌈0,75 × n⌉ van de projecten met een berekende kosten/impact (zie `index75Procent`), volgens de Waarderingssystematiek; erboven de 75% meest efficiënte. Bovenaan een Rikx Waarde-veld: rekent live door met Rikx prijs = 10% voor de getoonde projecten met een ronde, en slaat waarde en prijs (na 0,8 s of bij verlaten van het veld) op in de Rikx ronde(s) van die projecten (`app/waardebepaling/actions.ts`) +- Impactmaker van een project = sociaal ondernemer: `projects.impactmaker_account_id` verwijst naar `accounts.id` in de platformdatabase (geen foreign key; namen via `listAccountNames()` in `lib/platform-db.ts`) +- `/rondes` — Rikx rondes beheren (label, Rikx waarde, Rikx prijs, gekoppelde experts; dezelfde koppeling `expert_rondes` als op `/experts`); een project hoort optioneel bij één ronde (`projects.ronde_id`) +- `/projecten/[id]` — projectpagina met tabs: Project plan (velden en keuzelijsten gedefinieerd in `lib/projectPlan.ts`) en Rikx calculatie (alleen zichtbaar vanaf status Waardebepaling, zie `RIKX_CALCULATIE_VANAF_STATUS` in `lib/projectStatus.ts`; rekenmethode `arbeid`, `jeugd` is zichtbaar maar nog niet beschikbaar). Onderaan de tab: Beoordeling (`beoordeling`: goedgekeurd/afgekeurd/leeg) met `beoordeling_reden`. Kolom `rikx_calculatie_aan` is van de oude schuif en wordt niet meer gebruikt. Uitkomsten worden live berekend met `lib/rikxCalculatie.ts` +- `/experts` (CRM → Experts) — experts uitnodigen, rondes koppelen, opnieuw uitnodigen (ook voor een vergeten wachtwoord), verwijderen, en "Inloggen als": een admin bekijkt `/expert` als die expert (alleen lezen; sessie met `impersonatorId`, terug via "Terug naar beheer"). Uitnodiging per e-mail via SMTP; zonder SMTP_HOST toont de pagina de link om zelf door te geven +- `/uitnodiging/[token]` — openbaar: expert stelt wachtwoord in (token 7 dagen geldig, eenmalig; alleen de SHA-256-hash staat in de database) +- `/expert` — de enige pagina voor experts: projecten in Waardebepaling uit hun rondes, project plan (alleen lezen) en hun eigen beoordeling (3 scores 1–10, advies Bonus/Malus, toelichting). Experts zien elkaars beoordeling niet +- Rollen: `users.role` = `admin` of `expert`. Elke beheerpagina en -actie begint met `requireAdmin()`, de expertpagina met `requireExpert()` (`lib/auth.ts`); de rol komt uit de database, niet uit de cookie - Gedeelde kop met navigatie: `app/AppHeader.tsx` (oranje Rikx-balk, menu per module; items zonder pagina tonen "binnenkort") - Huisstijlkleuren als Tailwind-tokens in `app/globals.css`: `rikx-orange`, `rikx-purple` (actief menu-item), `rikx-maroon` (primaire knop). Statuskleuren in `lib/projectStatus.ts` - Conventie: server actions in `actions.ts` per map, geen API-routes (behalve `app/api/auto-login`) @@ -36,8 +42,8 @@ npm run create-admin maarten@rikx.nl wachtwoord "Maarten" - Locatie lokaal: `./data/app.db` - Locatie productie: `/data/app.db` (absoluut pad — Next.js standalone doet process.chdir bij start) - Schema: `lib/db.ts` — migraties draaien automatisch bij eerste verbinding -- Tabellen: `users`, `projects` (`name`, `status`; statussen in `lib/projectStatus.ts`, standaard `aanvraag`; plus de velden uit de rekenmethode als kolommen; overige project plan-velden als JSON in `project_plan`), `project_expert_scores` (3 experts × 3 criteria, voorlopig handmatig), `rikx_rondes` (Rikx waarde en prijs per ronde; de calculatie haalt ze uit de ronde van het project, prijs standaard waarde / 10) -- Rekenmethode: `_doc/Rikx rekenmethode uitleg.xlsx`, tabblad Uitleg. Afwijkingen van de sheet: N-score = ((werk×5)+skills)/6 (sheet mist haakjes), Bonus/Malus `-` = 0,9 en `--` = 0,8 (sheet heeft typefouten) +- Tabellen: `users`, `projects` (`name`, `status`; statussen in `lib/projectStatus.ts`, standaard `aanvraag`; plus de velden uit de rekenmethode als kolommen; overige project plan-velden als JSON in `project_plan`), `expert_beoordelingen` (per project en expert; Impactscore M = gemiddelde over de experts; de Rikx calculatie-tab toont ze alleen-lezen), `expert_rondes`, `expert_uitnodigingen`. `project_expert_scores` (oude handmatige scores) wordt niet meer gebruikt maar blijft staan, `rikx_rondes` (Rikx waarde en prijs per ronde; de calculatie haalt ze uit de ronde van het project, prijs standaard waarde / 10) +- Rekenmethode (Rikx Waardeberekening Arbeid): `_doc/Rikx rekenmethode uitleg.xlsx`, tabblad Uitleg; achtergrond in `_doc/Waarderingssystematiek Rikx juni 2023.pdf`. Afwijkingen van de sheet: N-score = ((werk×5)+skills)/6 (sheet mist haakjes), Bonus/Malus `-` = 0,9 en `--` = 0,8 (sheet heeft typefouten) - De oude offerte-tabellen (`clients`, `projects` met `client_id`, `quotes`, `quote_items`) worden bij de migratie verwijderd als ze leeg zijn; met gegevens worden ze hernoemd naar `legacy_*` - Tweede database (alleen-platform): `RIKX_PLATFORM_DB_PATH` (standaard `/rikx-platform-data/app.db`) met `accounts` en `users` van het ZRM-platform @@ -55,7 +61,7 @@ npm run create-admin maarten@rikx.nl wachtwoord "Maarten" | `DB_PATH` | `./data/app.db` | **In productie: `/data/app.db`** (absoluut pad) | | `RIKX_PLATFORM_DB_PATH` | `/rikx-platform-data/app.db` | Database van het ZRM-platform (sociaal ondernemers) | | `NODE_ENV` | `development` | In productie: `production` | -| `SMTP_HOST` | — | SMTP server voor email-notificaties | +| `SMTP_HOST` | — | SMTP server voor e-mail (o.a. uitnodigingen van experts); leeg = geen e-mail, link wordt in beeld getoond | | `SMTP_PORT` | `587` | SMTP poort | | `SMTP_USER` | — | SMTP gebruikersnaam | | `SMTP_PASS` | — | SMTP wachtwoord | diff --git a/_doc/Waarderingssystematiek Rikx juni 2023.pdf b/_doc/Waarderingssystematiek Rikx juni 2023.pdf new file mode 100644 index 0000000..b590d5a Binary files /dev/null and b/_doc/Waarderingssystematiek Rikx juni 2023.pdf differ diff --git a/app/AppHeader.tsx b/app/AppHeader.tsx index 2aab49a..ee1e91d 100644 --- a/app/AppHeader.tsx +++ b/app/AppHeader.tsx @@ -12,7 +12,7 @@ const NAV_ITEMS: NavItem[] = [ section: "projecten", items: [ { label: "Overzicht", href: "/projecten" }, - { label: "Project toevoegen", href: "/projecten/nieuw" }, + { label: "Waardebepaling projecten", href: "/waardebepaling" }, { label: "Rikx rondes", href: "/rondes" }, ], }, @@ -24,7 +24,7 @@ const NAV_ITEMS: NavItem[] = [ { label: "CRM", section: "crm", - items: [{ label: "Sociaal ondernemers", href: "/tenants" }, { label: "Investeerders" }, { label: "Beoordelaars" }], + items: [{ label: "Sociaal ondernemers", href: "/tenants" }, { label: "Investeerders" }, { label: "Experts", href: "/experts" }], }, { label: "Publieke Website", section: "website" }, ]; diff --git a/app/api/auto-login/route.ts b/app/api/auto-login/route.ts index 68b5994..728a364 100644 --- a/app/api/auto-login/route.ts +++ b/app/api/auto-login/route.ts @@ -1,26 +1,28 @@ import { NextRequest, NextResponse } from "next/server"; import { createHmac } from "crypto"; -import { getSession } from "../../../lib/auth"; +import { getCurrentUser } from "../../../lib/auth"; import { getPlatformDb } from "../../../lib/platform-db"; export async function GET(request: NextRequest) { - const session = await getSession(); - if (!session) return NextResponse.redirect(new URL("/login", request.url)); + // Alleen admins mogen inloggen bij een sociaal ondernemer + const user = await getCurrentUser(); + if (!user) return NextResponse.redirect(new URL("/login", request.url)); + if (user.role !== "admin") return NextResponse.redirect(new URL("/expert", request.url)); const accountId = Number(request.nextUrl.searchParams.get("account_id")); const slug = request.nextUrl.searchParams.get("slug"); if (!accountId || !slug) return NextResponse.redirect(new URL("/tenants", request.url)); const db = getPlatformDb(); - const user = db + const platformUser = db .prepare("SELECT id FROM users WHERE account_id = ? AND role = 'admin' LIMIT 1") .get(accountId) as { id: number } | undefined; - if (!user) return NextResponse.redirect(new URL("/tenants?error=no-admin", request.url)); + if (!platformUser) return NextResponse.redirect(new URL("/tenants?error=no-admin", request.url)); const secret = process.env.AUTO_LOGIN_SECRET!; const expires = Date.now() + 60_000; - const payload = `${user.id}:${accountId}:${expires}`; + const payload = `${platformUser.id}:${accountId}:${expires}`; const sig = createHmac("sha256", secret).update(payload).digest("hex"); const token = encodeURIComponent(`${payload}:${sig}`); diff --git a/app/expert/ProjectPlanView.tsx b/app/expert/ProjectPlanView.tsx new file mode 100644 index 0000000..f70b35e --- /dev/null +++ b/app/expert/ProjectPlanView.tsx @@ -0,0 +1,55 @@ +import type { Project } from "../../lib/db"; +import { ANDERS, PROJECT_PLAN_GROUPS, andersKey, parseProjectPlanJson, type PlanField } from "../../lib/projectPlan"; + +const getal = new Intl.NumberFormat("nl-NL", { maximumFractionDigits: 2 }); +const euro = new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" }); +const datum = new Intl.DateTimeFormat("nl-NL", { day: "numeric", month: "long", year: "numeric" }); + +type ColumnKey = "projectkosten" | "deelnemers_werk" | "deelnemers_skills" | "financiering_extern_bedrag" | "financiering_extern_pct"; + +function waarde(field: PlanField, project: Project, plan: Record): string | null { + const v = field.kind === "number" && field.column ? project[field.key as ColumnKey] : plan[field.key]; + if (v === null || v === undefined || v === "" || (Array.isArray(v) && v.length === 0)) return null; + switch (field.kind) { + case "number": + return field.label.includes("(€)") ? euro.format(v as number) : getal.format(v as number); + case "date": + return datum.format(new Date(`${v}T12:00:00`)); + case "multiselect": { + const anders = plan[andersKey(field.key)]; + return (v as string[]).map((o) => (o === ANDERS && typeof anders === "string" ? `Anders: ${anders}` : o)).join(" · "); + } + default: + return String(v); + } +} + +// Alleen-lezen weergave van het project plan voor experts +export function ProjectPlanView({ project }: { project: Project }) { + const plan = parseProjectPlanJson(project.project_plan); + return ( +
+ {PROJECT_PLAN_GROUPS.map((group) => ( +
+

+ {group.title} +

+
+ {group.fields.map((field) => { + const v = waarde(field, project, plan); + const wide = field.wide || field.kind === "textarea"; + return ( +
+
{field.label}
+
+ {v ?? "Niet ingevuld"} +
+
+ ); + })} +
+
+ ))} +
+ ); +} diff --git a/app/expert/actions.ts b/app/expert/actions.ts new file mode 100644 index 0000000..e8d83d4 --- /dev/null +++ b/app/expert/actions.ts @@ -0,0 +1,54 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { createSession, getCurrentUser, requireExpert } from "../../lib/auth"; +import { getDb } from "../../lib/db"; +import { magBeoordelen } from "../../lib/experts"; +import { BONUS_MALUS_OPTIONS } from "../../lib/rikxCalculatie"; + +function readScore(formData: FormData, key: string): number | null | "ongeldig" { + const raw = ((formData.get(key) as string) ?? "").trim(); + if (!raw) return null; + const n = Number(raw.replace(",", ".")); + return Number.isFinite(n) && n >= 1 && n <= 10 ? n : "ongeldig"; +} + +export async function saveBeoordeling(formData: FormData) { + const expert = await requireExpert(); + const projectId = Number(formData.get("project_id")); + // Een admin die meekijkt mag niet namens de expert beoordelen + if (expert.impersonator) redirect(`/expert?project=${projectId}&error=meekijken`); + if (!projectId || !magBeoordelen(expert.id, projectId)) redirect("/expert"); + + const duur = readScore(formData, "duur_impact"); + const domeinen = readScore(formData, "levensdomeinen"); + const basis = readScore(formData, "basissituatie"); + if (duur === "ongeldig" || domeinen === "ongeldig" || basis === "ongeldig") { + redirect(`/expert?project=${projectId}&error=score`); + } + const rawAdvies = (formData.get("bonus_malus_advies") as string) ?? ""; + const advies = rawAdvies && BONUS_MALUS_OPTIONS.some((o) => o.id === rawAdvies) ? rawAdvies : null; + const toelichting = ((formData.get("toelichting") as string) ?? "").trim().slice(0, 10_000) || null; + + getDb() + .prepare( + `INSERT INTO expert_beoordelingen + (project_id, expert_id, duur_impact, levensdomeinen, basissituatie, bonus_malus_advies, toelichting) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (project_id, expert_id) DO UPDATE SET + duur_impact = excluded.duur_impact, levensdomeinen = excluded.levensdomeinen, + basissituatie = excluded.basissituatie, bonus_malus_advies = excluded.bonus_malus_advies, + toelichting = excluded.toelichting, updated_at = datetime('now')` + ) + .run(projectId, expert.id, duur, domeinen, basis, advies, toelichting); + + redirect(`/expert?project=${projectId}&opgeslagen=1`); +} + +// Einde van "Inloggen als": terug naar de eigen admin-sessie +export async function stopImpersonation() { + const user = await getCurrentUser(); + if (!user?.impersonator) redirect("/login"); + await createSession({ userId: user.impersonator.id, email: user.impersonator.email, name: user.impersonator.name }); + redirect("/experts"); +} diff --git a/app/expert/page.tsx b/app/expert/page.tsx new file mode 100644 index 0000000..ab321ce --- /dev/null +++ b/app/expert/page.tsx @@ -0,0 +1,197 @@ +import Link from "next/link"; +import { requireExpert } from "../../lib/auth"; +import { listAccountNames } from "../../lib/platform-db"; +import { listBeoordelingen, listProjectenVoorExpert } from "../../lib/experts"; +import { BONUS_MALUS_OPTIONS, EXPERT_CRITERIA } from "../../lib/rikxCalculatie"; +import { logout } from "../tenants/logout"; +import { saveBeoordeling, stopImpersonation } from "./actions"; +import { ProjectPlanView } from "./ProjectPlanView"; + +// De enige pagina voor experts: projecten in waardebepaling uit hun rondes, het project plan om te lezen +// en hun eigen beoordeling. Experts zien niet wat andere experts hebben ingevuld. +export default async function ExpertPage({ + searchParams, +}: { + searchParams: Promise<{ project?: string; opgeslagen?: string; error?: string }>; +}) { + const expert = await requireExpert(); + const { project: projectParam, opgeslagen, error } = await searchParams; + + const projecten = listProjectenVoorExpert(expert.id); + const eigen = listBeoordelingen(projecten.map((p) => p.id)).filter((b) => b.expert_id === expert.id); + const impactmakers = new Map(listAccountNames().map((a) => [a.id, a.name])); + const gekozen = projecten.find((p) => String(p.id) === projectParam) ?? projecten[0] ?? null; + const beoordeling = gekozen ? eigen.find((b) => b.project_id === gekozen.id) ?? null : null; + + const inputClass = + "w-full h-10 rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-rikx-orange"; + + return ( +
+
+
+ Rikx + Expertteam +
+ {expert.name} + {!expert.impersonator && ( +
+ +
+ )} +
+
+
+ + {expert.impersonator && ( +
+
+ + Je bekijkt deze pagina als {expert.name}. Alleen lezen: beoordelingen kun je hier niet + opslaan. + +
+ +
+
+
+ )} + +
+ {projecten.length === 0 ? ( +
+ Er staan op dit moment geen projecten voor je klaar om te beoordelen. +
+ ) : ( +
+ + + {gekozen && ( +
+
+

{gekozen.name}

+

+ {gekozen.ronde_label} + {gekozen.impactmaker_account_id && impactmakers.get(gekozen.impactmaker_account_id) + ? ` · Impactmaker: ${impactmakers.get(gekozen.impactmaker_account_id)}` + : ""} +

+
+ +
+

Project plan

+ +
+ +
+ + {/* Bij meekijken door een admin: alles zichtbaar maar niet te wijzigen */} +
+
+

Jouw waardebepaling

+

+ Geef per criterium een rapportcijfer van 1 tot 10. Andere experts zien jouw beoordeling niet. +

+
+ {opgeslagen &&

Je beoordeling is opgeslagen.

} + {error === "meekijken" && ( +

Niet opgeslagen: je kijkt mee als admin.

+ )} + {error === "score" && ( +

Niet opgeslagen: cijfers moeten tussen 1 en 10 liggen.

+ )} +
+ {EXPERT_CRITERIA.map((c) => ( + + ))} +
+ +