diff --git a/CLAUDE.md b/CLAUDE.md index 0f61ded..e647f12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,15 @@ -# rikx-platform +# rikx-beheer -Klantbeheer portaal voor projectbeheer en offertes. Inclusief workflow-stappen (statustransities, email-triggers) en calculaties per offerteregel. +Beheerportaal voor de Rikx-methodiek. Beheert de sociaal ondernemers (accounts in de ZRM-platformdatabase) en de projecten van Module 1 (Projects flow). + +## 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` +- 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`) ## Stack - Next.js 16 + TypeScript + Tailwind CSS @@ -15,6 +24,7 @@ cp .env.example .env.local # Vul SESSION_SECRET in (.env.local) — minimaal 32 tekens willekeurig mkdir -p data npm run dev # http://localhost:3000 +# Lokaal de platformdatabase gebruiken: RIKX_PLATFORM_DB_PATH=./data/platform.db ``` ## Admin aanmaken @@ -26,20 +36,24 @@ 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`, `clients`, `projects`, `quotes`, `quote_items` +- 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) +- 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 ## Infrastructuur (mx-platform.com) - **Platform:** Coolify v4 op mx-platform.com (37.97.203.131) -- **Git repo:** https://git.mx-platform.com/admin/rikx-platform +- **Git repo:** https://git.mx-platform.com/admin/rikx-beheer - **Dockerfile:** `/Dockerfile` (Node 22 Alpine, Next.js standalone) - **Port:** 3000 -- **URL:** https://rikx.mx-platform.com +- **URL:** https://beheer.rikxplatform.nl ## Environment variables | Variabele | Standaard | Uitleg | |-----------|-----------|--------| | `SESSION_SECRET` | — | HMAC-secret voor sessie-cookies — per omgeving uniek genereren | | `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_PORT` | `587` | SMTP poort | diff --git a/_doc/Rikx rekenmethode uitleg.xlsx b/_doc/Rikx rekenmethode uitleg.xlsx index bf06568..a824dce 100644 Binary files a/_doc/Rikx rekenmethode uitleg.xlsx and b/_doc/Rikx rekenmethode uitleg.xlsx differ diff --git a/app/AppHeader.tsx b/app/AppHeader.tsx new file mode 100644 index 0000000..2aab49a --- /dev/null +++ b/app/AppHeader.tsx @@ -0,0 +1,129 @@ +import Link from "next/link"; +import { logout } from "./tenants/logout"; + +type MenuItem = { label: string; href?: string }; +type NavItem = { label: string; section: string; href?: string; items?: MenuItem[] }; + +// Menu volgens de Rikx-modules. Items zonder href bestaan nog niet en tonen "binnenkort". +const NAV_ITEMS: NavItem[] = [ + { label: "Dashboard", section: "dashboard" }, + { + label: "Projecten", + section: "projecten", + items: [ + { label: "Overzicht", href: "/projecten" }, + { label: "Project toevoegen", href: "/projecten/nieuw" }, + { label: "Rikx rondes", href: "/rondes" }, + ], + }, + { + label: "Investeringen", + section: "investeringen", + items: [{ label: "Overzicht" }], + }, + { + label: "CRM", + section: "crm", + items: [{ label: "Sociaal ondernemers", href: "/tenants" }, { label: "Investeerders" }, { label: "Beoordelaars" }], + }, + { label: "Publieke Website", section: "website" }, +]; + +function Caret() { + return ( + + + + ); +} + +function linkClass(isActive: boolean) { + return isActive + ? "text-rikx-purple" + : "text-white hover:text-rikx-maroon transition-colors"; +} + +export function AppHeader({ email, active }: { email: string; active?: string }) { + return ( +
+
+ + Rikx + + + + +
+ +
+
+

{email}

+
+ +
+
+
+
+
+
+ ); +} diff --git a/app/globals.css b/app/globals.css index a2dc41e..5f1dae5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -10,6 +10,13 @@ --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + + /* Rikx-huisstijl (overgenomen van het oude Rikx-portaal) */ + --color-rikx-orange: #ff7a12; + --color-rikx-orange-dark: #e86a05; + --color-rikx-purple: #b112e8; + --color-rikx-maroon: #3d0a0a; + --color-rikx-maroon-light: #5c1414; } @media (prefers-color-scheme: dark) { diff --git a/app/projecten/AutoSubmitSelect.tsx b/app/projecten/AutoSubmitSelect.tsx new file mode 100644 index 0000000..bcd0291 --- /dev/null +++ b/app/projecten/AutoSubmitSelect.tsx @@ -0,0 +1,8 @@ +"use client"; + +import type { ComponentProps } from "react"; + +// Select die het omringende formulier direct verstuurt bij een wijziging. +export function AutoSubmitSelect(props: ComponentProps<"select">) { + return toggle(option, e.target.checked)} + className="mt-0.5 accent-rikx-orange" + /> + {option} + + ))} + + {selected.length > 1 && ( +

{selected.join(" · ")}

+ )} + + ); +} diff --git a/app/projecten/[id]/ProjectDetailForm.tsx b/app/projecten/[id]/ProjectDetailForm.tsx new file mode 100644 index 0000000..9a45023 --- /dev/null +++ b/app/projecten/[id]/ProjectDetailForm.tsx @@ -0,0 +1,313 @@ +"use client"; + +import Link from "next/link"; +import { useRef, useState, useTransition } from "react"; +import type { Project, ProjectExpertScore, RikxRonde } from "../../../lib/db"; +import { PROJECT_STATUSES } from "../../../lib/projectStatus"; +import { + BONUS_MALUS_OPTIONS, + EXPERT_COUNT, + EXPERT_CRITERIA, + calculateRikx, + type ExpertScore, +} from "../../../lib/rikxCalculatie"; +import { parseProjectPlanJson } from "../../../lib/projectPlan"; +import { setRikxCalculatie, updateProject } from "../actions"; +import { ProjectPlanFields } from "./ProjectPlanFields"; + +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"; +const labelClass = "text-sm font-medium text-zinc-700 dark:text-zinc-300"; +const cardClass = "bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6"; + +function toNumber(value: FormDataEntryValue | null): number | null { + if (typeof value !== "string" || value.trim() === "") return null; + const n = Number(value.replace(",", ".")); + return Number.isFinite(n) ? n : null; +} + +function rondeById(rondes: RikxRonde[], id: number | null) { + return rondes.find((r) => r.id === id) ?? null; +} + +function readInput(form: HTMLFormElement, rondes: RikxRonde[]) { + const fd = new FormData(form); + const ronde = rondeById(rondes, Number(fd.get("ronde_id")) || null); + return { + ronde_id: ronde?.id ?? null, + projectkosten: toNumber(fd.get("projectkosten")), + deelnemers_werk: toNumber(fd.get("deelnemers_werk")), + deelnemers_skills: toNumber(fd.get("deelnemers_skills")), + financiering_extern_pct: toNumber(fd.get("financiering_extern_pct")), + bonus_malus: (fd.get("bonus_malus") as string) || null, + rikx_waarde: ronde?.rikx_waarde ?? null, + rikx_prijs: ronde?.rikx_prijs ?? null, + expert_scores: Array.from({ length: EXPERT_COUNT }, (_, i) => + Object.fromEntries(EXPERT_CRITERIA.map((c) => [c.id, toNumber(fd.get(`expert_${i + 1}_${c.id}`))])) + ) as ExpertScore[], + }; +} + +const nl = (digits: number) => new Intl.NumberFormat("nl-NL", { maximumFractionDigits: digits }); +const euro = new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" }); +const fmt = (v: number | null, kind: "getal" | "euro" | "geheel" = "getal") => + v === null ? "—" : kind === "euro" ? euro.format(v) : nl(kind === "geheel" ? 0 : 2).format(v); + +function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +function StepHeading({ step, title, description }: { step: number; title: string; description: string }) { + return ( +
+ + {step} + +
+

{title}

+

{description}

+
+
+ ); +} + +export function ProjectDetailForm({ + project, + expertScores, + rondes, +}: { + project: Project; + expertScores: ProjectExpertScore[]; + rondes: RikxRonde[]; +}) { + const formRef = useRef(null); + const [calcOn, setCalcOn] = useState(project.rikx_calculatie_aan === 1); + const [pending, startTransition] = useTransition(); + const initialRonde = rondeById(rondes, project.ronde_id); + const [input, setInput] = useState(() => ({ + ronde_id: initialRonde?.id ?? null, + 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: initialRonde?.rikx_waarde ?? null, + rikx_prijs: initialRonde?.rikx_prijs ?? null, + expert_scores: Array.from({ length: EXPERT_COUNT }, (_, i) => { + const s = expertScores.find((e) => e.expert === i + 1); + return { + duur_impact: s?.duur_impact ?? null, + levensdomeinen: s?.levensdomeinen ?? null, + basissituatie: s?.basissituatie ?? null, + }; + }), + })); + const result = calculateRikx(input); + const ronde = rondeById(rondes, input.ronde_id); + + function toggleCalc() { + const next = !calcOn; + setCalcOn(next); + startTransition(() => setRikxCalculatie(project.id, next)); + } + + const results: { label: string; value: string; strong?: boolean }[] = [ + { label: "N-score", value: fmt(result.n_score) }, + { label: "Scale", value: fmt(result.scale, "geheel") }, + { label: "Impactscore M", value: fmt(result.impactscore_m) }, + { label: "IS +/- BM", value: fmt(result.is_bm) }, + { label: "Impactwaarde", value: fmt(result.impactwaarde) }, + { label: "Minus cofinanciering", value: fmt(result.minus_cofinanciering) }, + { label: "I - cofin", value: fmt(result.i_cofin) }, + { label: "Kosten/impact", value: fmt(result.kosten_impact) }, + { label: "Totale impactwaarde", value: fmt(result.totale_impactwaarde, "euro"), strong: true }, + { label: "Totaal aantal Rikx", value: fmt(result.totaal_aantal_rikx), strong: true }, + { label: "Meerwaarde", value: fmt(result.meerwaarde, "euro"), strong: true }, + { label: "Efficiëntiewaarde", value: fmt(result.efficientie_waarde), strong: true }, + ]; + + return ( +
formRef.current && setInput(readInput(formRef.current, rondes))} + className="flex flex-col gap-6" + > + + +
+ + + + + + + + + +
+ +
+ + +
+ +
+
+

Rikx calculatie

+

Toon de waardebepaling volgens de Rikx-rekenmethode.

+
+ +
+ + {/* Verborgen in plaats van weggelaten, zodat de waarden bij opslaan bewaard blijven. */} + + +
+ + + Annuleren + +
+
+ ); +} diff --git a/app/projecten/[id]/ProjectPlanFields.tsx b/app/projecten/[id]/ProjectPlanFields.tsx new file mode 100644 index 0000000..9ab8971 --- /dev/null +++ b/app/projecten/[id]/ProjectPlanFields.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState } from "react"; +import type { Project } from "../../../lib/db"; +import { + ANDERS, + PROJECT_PLAN_GROUPS, + andersKey, + type PlanField, + type ProjectPlan, +} from "../../../lib/projectPlan"; +import { MultiSelectDropdown } from "./MultiSelectDropdown"; + +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"; +const textareaClass = + "w-full min-h-28 rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-rikx-orange"; + +type ColumnKey = "projectkosten" | "deelnemers_werk" | "deelnemers_skills" | "financiering_extern_bedrag" | "financiering_extern_pct"; + +function FieldInput({ + field, + project, + plan, + onSelectChange, +}: { + field: PlanField; + project: Project; + plan: ProjectPlan; + onSelectChange: (key: string, value: string) => void; +}) { + const id = `plan-${field.key}`; + const saved = plan[field.key]; + const [anders, setAnders] = useState(Array.isArray(saved) && saved.includes(ANDERS)); + + switch (field.kind) { + case "textarea": + return