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:
@@ -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
|
## Stack
|
||||||
- Next.js 16 + TypeScript + Tailwind CSS
|
- 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
|
# Vul SESSION_SECRET in (.env.local) — minimaal 32 tekens willekeurig
|
||||||
mkdir -p data
|
mkdir -p data
|
||||||
npm run dev # http://localhost:3000
|
npm run dev # http://localhost:3000
|
||||||
|
# Lokaal de platformdatabase gebruiken: RIKX_PLATFORM_DB_PATH=./data/platform.db
|
||||||
```
|
```
|
||||||
|
|
||||||
## Admin aanmaken
|
## Admin aanmaken
|
||||||
@@ -26,20 +36,24 @@ npm run create-admin maarten@rikx.nl wachtwoord "Maarten"
|
|||||||
- Locatie lokaal: `./data/app.db`
|
- Locatie lokaal: `./data/app.db`
|
||||||
- Locatie productie: `/data/app.db` (absoluut pad — Next.js standalone doet process.chdir bij start)
|
- 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
|
- 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)
|
## Infrastructuur (mx-platform.com)
|
||||||
- **Platform:** Coolify v4 op mx-platform.com (37.97.203.131)
|
- **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)
|
- **Dockerfile:** `/Dockerfile` (Node 22 Alpine, Next.js standalone)
|
||||||
- **Port:** 3000
|
- **Port:** 3000
|
||||||
- **URL:** https://rikx.mx-platform.com
|
- **URL:** https://beheer.rikxplatform.nl
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
| Variabele | Standaard | Uitleg |
|
| Variabele | Standaard | Uitleg |
|
||||||
|-----------|-----------|--------|
|
|-----------|-----------|--------|
|
||||||
| `SESSION_SECRET` | — | HMAC-secret voor sessie-cookies — per omgeving uniek genereren |
|
| `SESSION_SECRET` | — | HMAC-secret voor sessie-cookies — per omgeving uniek genereren |
|
||||||
| `DB_PATH` | `./data/app.db` | **In productie: `/data/app.db`** (absoluut pad) |
|
| `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` |
|
| `NODE_ENV` | `development` | In productie: `production` |
|
||||||
| `SMTP_HOST` | — | SMTP server voor email-notificaties |
|
| `SMTP_HOST` | — | SMTP server voor email-notificaties |
|
||||||
| `SMTP_PORT` | `587` | SMTP poort |
|
| `SMTP_PORT` | `587` | SMTP poort |
|
||||||
|
|||||||
Binary file not shown.
@@ -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 (
|
||||||
|
<svg viewBox="0 0 10 6" aria-hidden className="w-2.5 h-2.5 fill-current opacity-80">
|
||||||
|
<path d="M0 0h10L5 6z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<header className="bg-rikx-orange">
|
||||||
|
<div className="max-w-[96rem] mx-auto px-4 sm:px-6 py-3 flex flex-wrap items-center gap-x-8 gap-y-2">
|
||||||
|
<Link href="/projecten" className="text-white text-4xl font-extrabold tracking-tight leading-none">
|
||||||
|
Rikx
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="order-3 lg:order-none w-full lg:w-auto lg:flex-1 flex flex-wrap items-center lg:justify-center gap-x-6 gap-y-1 text-base sm:text-lg font-semibold">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const isActive = active === item.section;
|
||||||
|
if (!item.items) {
|
||||||
|
return item.href ? (
|
||||||
|
<Link key={item.label} href={item.href} className={linkClass(isActive)}>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span key={item.label} title="Binnenkort" className="text-white/60 cursor-default">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={item.label} className="relative group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex items-center gap-1.5 py-1 ${linkClass(isActive)}`}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
<Caret />
|
||||||
|
</button>
|
||||||
|
<div className="absolute left-0 top-full z-20 hidden group-hover:block group-focus-within:block pt-1">
|
||||||
|
<ul className="min-w-52 rounded-lg bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-black/5 py-1 text-sm font-medium">
|
||||||
|
{item.items.map((sub) => (
|
||||||
|
<li key={sub.label}>
|
||||||
|
{sub.href ? (
|
||||||
|
<Link
|
||||||
|
href={sub.href}
|
||||||
|
className="block px-4 py-2 text-zinc-800 dark:text-zinc-200 hover:bg-orange-50 dark:hover:bg-zinc-800 hover:text-rikx-orange-dark"
|
||||||
|
>
|
||||||
|
{sub.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center justify-between gap-3 px-4 py-2 text-zinc-400 cursor-default">
|
||||||
|
{sub.label}
|
||||||
|
<span className="text-xs font-normal">binnenkort</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="relative group ml-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Account"
|
||||||
|
aria-haspopup="true"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-full bg-white/90 text-zinc-400 hover:bg-white"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden className="w-7 h-7 fill-current">
|
||||||
|
<path d="M12 12a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9Zm0 2c-4.4 0-8 2.2-8 5v1h16v-1c0-2.8-3.6-5-8-5Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className="absolute right-0 top-full z-20 hidden group-hover:block group-focus-within:block pt-1">
|
||||||
|
<div className="min-w-56 rounded-lg bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-black/5 py-1 text-sm">
|
||||||
|
<p className="px-4 py-2 text-zinc-500 truncate">{email}</p>
|
||||||
|
<form action={logout}>
|
||||||
|
<button className="w-full text-left px-4 py-2 text-zinc-800 dark:text-zinc-200 hover:bg-orange-50 dark:hover:bg-zinc-800">
|
||||||
|
Uitloggen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,13 @@
|
|||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
|
|||||||
@@ -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 <select {...props} onChange={(e) => e.currentTarget.form?.requestSubmit()} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { ProjectListItem } from "../../lib/db";
|
||||||
|
import { PROJECT_STATUSES } from "../../lib/projectStatus";
|
||||||
|
|
||||||
|
// Kanban met één kolom per status, zoals de pipeline in het oude Rikx-portaal.
|
||||||
|
export function ProjectPipeline({ projects, status }: { projects: ProjectListItem[]; status: string }) {
|
||||||
|
const columns = status ? PROJECT_STATUSES.filter((s) => s.id === status) : PROJECT_STATUSES;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-4 -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||||
|
{columns.map((column) => {
|
||||||
|
const items = projects.filter((p) => p.status === column.id);
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={column.id}
|
||||||
|
className={`${column.columnClass} rounded-xl p-3 w-72 shrink-0 flex flex-col gap-3 min-h-96`}
|
||||||
|
>
|
||||||
|
<h2 className="flex items-center justify-between px-1 text-sm font-semibold text-zinc-800">
|
||||||
|
{column.label}
|
||||||
|
<span className="font-normal text-zinc-600">{items.length}</span>
|
||||||
|
</h2>
|
||||||
|
{items.map((p) => (
|
||||||
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
href={`/projecten/${p.id}`}
|
||||||
|
className="block rounded-lg bg-white dark:bg-zinc-900 p-3 shadow-sm ring-1 ring-black/5 hover:ring-rikx-orange transition-shadow"
|
||||||
|
>
|
||||||
|
<p className="font-medium text-sm text-zinc-900 dark:text-zinc-100 break-words">{p.name}</p>
|
||||||
|
{p.ronde_label && (
|
||||||
|
<p className="mt-1 text-xs font-medium text-rikx-orange-dark">{p.ronde_label}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-zinc-500">
|
||||||
|
Aangemaakt {new Date(p.created_at).toLocaleDateString("nl-NL")}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { ProjectListItem } from "../../lib/db";
|
||||||
|
import { projectStatusBadgeClass, projectStatusLabel } from "../../lib/projectStatus";
|
||||||
|
|
||||||
|
export function ProjectTable({ projects, isFiltered }: { projects: ProjectListItem[]; isFiltered: boolean }) {
|
||||||
|
if (projects.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
{isFiltered ? "Geen projecten gevonden met deze filters." : "Nog geen projecten toegevoegd."}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-zinc-50 dark:bg-zinc-900 text-zinc-500 dark:text-zinc-400">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Projectnaam</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Rikx Ronde</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Aangemaakt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||||
|
{projects.map((p) => (
|
||||||
|
<tr key={p.id} className="bg-white dark:bg-zinc-950 hover:bg-orange-50/60 dark:hover:bg-zinc-900 transition-colors">
|
||||||
|
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
|
<Link href={`/projecten/${p.id}`} className="hover:text-rikx-orange-dark hover:underline">
|
||||||
|
{p.name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap ${projectStatusBadgeClass(p.status)}`}
|
||||||
|
>
|
||||||
|
{projectStatusLabel(p.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-700 dark:text-zinc-300 whitespace-nowrap">{p.ronde_label ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">
|
||||||
|
{new Date(p.created_at).toLocaleDateString("nl-NL")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
// Dropdown waarin meerdere opties aangevinkt kunnen worden. De keuzes gaan als
|
||||||
|
// checkboxes met dezelfde `name` mee in het formulier (FormData.getAll).
|
||||||
|
export function MultiSelectDropdown({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
options,
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
options: string[];
|
||||||
|
defaultValue: string[];
|
||||||
|
onChange?: (values: string[]) => void;
|
||||||
|
className: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<string[]>(defaultValue);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const close = (e: MouseEvent | KeyboardEvent) => {
|
||||||
|
if (e instanceof KeyboardEvent ? e.key === "Escape" : !rootRef.current?.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", close);
|
||||||
|
document.addEventListener("keydown", close);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", close);
|
||||||
|
document.removeEventListener("keydown", close);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function toggle(option: string, checked: boolean) {
|
||||||
|
const next = checked ? options.filter((o) => o === option || selected.includes(o)) : selected.filter((o) => o !== option);
|
||||||
|
setSelected(next);
|
||||||
|
onChange?.(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
selected.length === 0 ? "Maak een keuze" : selected.length === 1 ? selected[0] : `${selected.length} geselecteerd`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className="relative">
|
||||||
|
<button
|
||||||
|
id={id}
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className={`${className} flex items-center justify-between gap-2 text-left`}
|
||||||
|
>
|
||||||
|
<span className={`truncate ${selected.length ? "" : "text-zinc-400"}`}>{summary}</span>
|
||||||
|
<svg viewBox="0 0 10 6" aria-hidden className="w-2.5 h-2.5 shrink-0 fill-zinc-500">
|
||||||
|
<path d="M0 0h10L5 6z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/* Altijd gerenderd zodat de checkboxes meegaan in het formulier, ook als de lijst dicht is. */}
|
||||||
|
<div
|
||||||
|
hidden={!open}
|
||||||
|
className="absolute left-0 right-0 top-full z-30 mt-1 max-h-72 overflow-y-auto rounded-lg bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-black/10 py-1"
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<label
|
||||||
|
key={option}
|
||||||
|
className="flex items-start gap-2 px-3 py-2 text-sm text-zinc-800 dark:text-zinc-200 hover:bg-orange-50 dark:hover:bg-zinc-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name={name}
|
||||||
|
value={option}
|
||||||
|
checked={selected.includes(option)}
|
||||||
|
onChange={(e) => toggle(option, e.target.checked)}
|
||||||
|
className="mt-0.5 accent-rikx-orange"
|
||||||
|
/>
|
||||||
|
{option}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selected.length > 1 && (
|
||||||
|
<p className="mt-1 text-xs text-zinc-500">{selected.join(" · ")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className={labelClass}>{label}</span>
|
||||||
|
{children}
|
||||||
|
{hint && <span className="text-xs text-zinc-500">{hint}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepHeading({ step, title, description }: { step: number; title: string; description: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 mb-5">
|
||||||
|
<span className="flex items-center justify-center w-7 h-7 shrink-0 rounded-full bg-rikx-orange text-white text-sm font-semibold">
|
||||||
|
{step}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100">{title}</h2>
|
||||||
|
<p className="text-sm text-zinc-500">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectDetailForm({
|
||||||
|
project,
|
||||||
|
expertScores,
|
||||||
|
rondes,
|
||||||
|
}: {
|
||||||
|
project: Project;
|
||||||
|
expertScores: ProjectExpertScore[];
|
||||||
|
rondes: RikxRonde[];
|
||||||
|
}) {
|
||||||
|
const formRef = useRef<HTMLFormElement>(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 (
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
action={updateProject}
|
||||||
|
onInput={() => formRef.current && setInput(readInput(formRef.current, rondes))}
|
||||||
|
className="flex flex-col gap-6"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={project.id} />
|
||||||
|
|
||||||
|
<section className={`${cardClass} grid grid-cols-1 sm:grid-cols-3 gap-4`}>
|
||||||
|
<Field label="Projectnaam">
|
||||||
|
<input name="name" required defaultValue={project.name} className={inputClass} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Status">
|
||||||
|
<select name="status" defaultValue={project.status} className={inputClass}>
|
||||||
|
{PROJECT_STATUSES.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Rikx Ronde">
|
||||||
|
<select name="ronde_id" defaultValue={project.ronde_id ?? ""} className={inputClass}>
|
||||||
|
<option value="">Geen ronde</option>
|
||||||
|
{rondes.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={cardClass}>
|
||||||
|
<StepHeading step={1} title="Project plan" description="In te vullen door de sociaal ondernemer." />
|
||||||
|
<ProjectPlanFields project={project} plan={parseProjectPlanJson(project.project_plan)} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-zinc-900 dark:text-zinc-100">Rikx calculatie</p>
|
||||||
|
<p className="text-sm text-zinc-500">Toon de waardebepaling volgens de Rikx-rekenmethode.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={calcOn}
|
||||||
|
aria-label="Rikx calculatie tonen"
|
||||||
|
onClick={toggleCalc}
|
||||||
|
disabled={pending}
|
||||||
|
className={`relative inline-flex h-7 w-12 shrink-0 items-center rounded-full transition-colors disabled:opacity-60 ${
|
||||||
|
calcOn ? "bg-rikx-orange" : "bg-zinc-300 dark:bg-zinc-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-5 w-5 rounded-full bg-white shadow transition-transform ${
|
||||||
|
calcOn ? "translate-x-6" : "translate-x-1"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Verborgen in plaats van weggelaten, zodat de waarden bij opslaan bewaard blijven. */}
|
||||||
|
<section className={cardClass} hidden={!calcOn}>
|
||||||
|
<StepHeading
|
||||||
|
step={2}
|
||||||
|
title="Rikx calculatie"
|
||||||
|
description="Waardebepaling door het expertteam en Rikx. Uitkomsten worden live berekend."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Expertscores (1–10)</h3>
|
||||||
|
<div className="overflow-x-auto mb-6">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-zinc-500">
|
||||||
|
<th className="text-left font-medium py-2 pr-3">Criterium</th>
|
||||||
|
{Array.from({ length: EXPERT_COUNT }, (_, i) => (
|
||||||
|
<th key={i} className="text-left font-medium py-2 px-1 min-w-24">
|
||||||
|
Expert {i + 1}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{EXPERT_CRITERIA.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td className="py-1 pr-3 text-zinc-700 dark:text-zinc-300 whitespace-nowrap">{c.label}</td>
|
||||||
|
{Array.from({ length: EXPERT_COUNT }, (_, i) => (
|
||||||
|
<td key={i} className="py-1 px-1">
|
||||||
|
<input
|
||||||
|
name={`expert_${i + 1}_${c.id}`}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
step="0.5"
|
||||||
|
aria-label={`${c.label}, expert ${i + 1}`}
|
||||||
|
defaultValue={input.expert_scores[i][c.id] ?? ""}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">Voorlopig handmatig; met Module 4 vullen de experts dit zelf in.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||||
|
<Field label="Bonus / Malus" hint="Collectief oordeel van de experts: ++ 1,2 · + 1,1 · geen 1,0 · - 0,9 · -- 0,8">
|
||||||
|
<select name="bonus_malus" defaultValue={project.bonus_malus ?? ""} className={inputClass}>
|
||||||
|
{BONUS_MALUS_OPTIONS.map((o) => (
|
||||||
|
<option key={o.label} value={o.id}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className={labelClass}>Rikx waarde en Rikx prijs</span>
|
||||||
|
{ronde ? (
|
||||||
|
<p className="h-10 flex items-center gap-4 rounded-lg bg-zinc-50 dark:bg-zinc-800/50 px-3 text-sm tabular-nums text-zinc-800 dark:text-zinc-200">
|
||||||
|
<span>Waarde {fmt(ronde.rikx_waarde)}</span>
|
||||||
|
<span>Prijs {fmt(ronde.rikx_prijs, "euro")}</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="h-10 flex items-center rounded-lg bg-zinc-50 dark:bg-zinc-800/50 px-3 text-sm text-zinc-500">
|
||||||
|
Kies eerst een Rikx Ronde
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
Uit de Rikx Ronde{ronde ? ` "${ronde.label}"` : ""}; aan te passen bij{" "}
|
||||||
|
<Link href="/rondes" className="underline hover:text-rikx-orange-dark">
|
||||||
|
Rikx rondes
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Uitkomsten</h3>
|
||||||
|
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 rounded-lg bg-zinc-50 dark:bg-zinc-800/50 px-4 py-2">
|
||||||
|
{results.map((r) => (
|
||||||
|
<div key={r.label} className="flex items-center justify-between gap-4 py-2 border-b border-zinc-200/70 dark:border-zinc-700/70">
|
||||||
|
<dt className="text-sm text-zinc-600 dark:text-zinc-400">{r.label}</dt>
|
||||||
|
<dd className={`text-sm tabular-nums ${r.strong ? "font-semibold text-zinc-900 dark:text-zinc-100" : "text-zinc-800 dark:text-zinc-200"}`}>
|
||||||
|
{r.value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg bg-rikx-maroon hover:bg-rikx-maroon-light px-5 py-2 text-sm font-medium text-white transition-colors"
|
||||||
|
>
|
||||||
|
Opslaan
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/projecten"
|
||||||
|
className="rounded-lg border border-zinc-300 dark:border-zinc-700 px-5 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <textarea id={id} name={field.key} defaultValue={typeof saved === "string" ? saved : ""} className={textareaClass} />;
|
||||||
|
case "number": {
|
||||||
|
const value = field.column ? project[field.key as ColumnKey] : saved;
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
name={field.key}
|
||||||
|
type="number"
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
step={field.step}
|
||||||
|
defaultValue={typeof value === "number" ? value : ""}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "date":
|
||||||
|
return <input id={id} name={field.key} type="date" defaultValue={typeof saved === "string" ? saved : ""} className={inputClass} />;
|
||||||
|
case "select":
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
id={id}
|
||||||
|
name={field.key}
|
||||||
|
defaultValue={typeof saved === "string" ? saved : ""}
|
||||||
|
onChange={(e) => onSelectChange(field.key, e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="">Maak een keuze</option>
|
||||||
|
{field.options.map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
case "multiselect": {
|
||||||
|
const andersValue = plan[andersKey(field.key)];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MultiSelectDropdown
|
||||||
|
id={id}
|
||||||
|
name={field.key}
|
||||||
|
options={field.options}
|
||||||
|
defaultValue={Array.isArray(saved) ? saved : []}
|
||||||
|
onChange={(values) => setAnders(values.includes(ANDERS))}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
{anders && (
|
||||||
|
<input
|
||||||
|
name={andersKey(field.key)}
|
||||||
|
aria-label={`${field.label} – anders, namelijk`}
|
||||||
|
placeholder="Anders, namelijk…"
|
||||||
|
defaultValue={typeof andersValue === "string" ? andersValue : ""}
|
||||||
|
className={`${inputClass} mt-2`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stap 1 "Project plan": alle velden uit lib/projectPlan.ts, gegroepeerd.
|
||||||
|
export function ProjectPlanFields({ project, plan }: { project: Project; plan: ProjectPlan }) {
|
||||||
|
// Huidige waarden van selects, voor velden met showIf (bv. "Zo ja, licht toe")
|
||||||
|
const [selects, setSelects] = useState<Record<string, string>>(() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
PROJECT_PLAN_GROUPS.flatMap((g) => g.fields)
|
||||||
|
.filter((f) => f.kind === "select")
|
||||||
|
.map((f) => [f.key, typeof plan[f.key] === "string" ? (plan[f.key] as string) : ""])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
{PROJECT_PLAN_GROUPS.map((group) => (
|
||||||
|
<fieldset key={group.title} className="flex flex-col gap-4">
|
||||||
|
<legend className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-3 pb-2 border-b border-zinc-200 dark:border-zinc-800 w-full">
|
||||||
|
{group.title}
|
||||||
|
</legend>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-5">
|
||||||
|
{group.fields
|
||||||
|
.filter((f) => !f.showIf || selects[f.showIf.key] === f.showIf.equals)
|
||||||
|
.map((field) => (
|
||||||
|
<div
|
||||||
|
key={field.key}
|
||||||
|
className={[
|
||||||
|
"flex flex-col gap-1",
|
||||||
|
field.wide ? "sm:col-span-2" : "",
|
||||||
|
field.newRow ? "sm:col-start-1" : "",
|
||||||
|
field.followUp ? "-mt-3 ml-2 pl-4 border-l-2 border-rikx-orange" : "",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{/* Minimaal twee regels hoog, zodat velden naast elkaar uitlijnen bij labels van ongelijke lengte */}
|
||||||
|
<label
|
||||||
|
htmlFor={`plan-${field.key}`}
|
||||||
|
className={`text-sm font-medium text-zinc-700 dark:text-zinc-300 ${field.wide || field.newRow ? "" : "sm:min-h-10"}`}
|
||||||
|
>
|
||||||
|
{field.label}
|
||||||
|
</label>
|
||||||
|
<FieldInput
|
||||||
|
field={field}
|
||||||
|
project={project}
|
||||||
|
plan={plan}
|
||||||
|
onSelectChange={(key, value) => setSelects((s) => ({ ...s, [key]: value }))}
|
||||||
|
/>
|
||||||
|
{field.hint && <span className="text-xs text-zinc-500">{field.hint}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getSession } from "../../../lib/auth";
|
||||||
|
import { getDb, listRikxRondes, type Project, type ProjectExpertScore } from "../../../lib/db";
|
||||||
|
import { projectStatusBadgeClass, projectStatusLabel } from "../../../lib/projectStatus";
|
||||||
|
import { AppHeader } from "../../AppHeader";
|
||||||
|
import { ProjectDetailForm } from "./ProjectDetailForm";
|
||||||
|
|
||||||
|
export default async function ProjectPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ error?: string; opgeslagen?: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const { error, opgeslagen } = await searchParams;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const row = db.prepare("SELECT * FROM projects WHERE id = ?").get(Number(id)) as Project | undefined;
|
||||||
|
if (!row) notFound();
|
||||||
|
// node:sqlite levert objecten met null-prototype; spreiden maakt ze doorgeefbaar aan client components.
|
||||||
|
const project = { ...row };
|
||||||
|
const expertScores = (
|
||||||
|
db.prepare("SELECT * FROM project_expert_scores WHERE project_id = ? ORDER BY expert").all(project.id) as ProjectExpertScore[]
|
||||||
|
).map((s) => ({ ...s }));
|
||||||
|
|
||||||
|
const message =
|
||||||
|
error === "missing"
|
||||||
|
? { tone: "error", text: "Vul een projectnaam in." }
|
||||||
|
: error === "invalid"
|
||||||
|
? { tone: "error", text: "Niet opgeslagen: controleer de getallen (percentage 0–100, expertscores 1–10, geen negatieve waarden)." }
|
||||||
|
: error === "datum"
|
||||||
|
? { tone: "error", text: "Niet opgeslagen: controleer de datums (de einddatum mag niet vóór de startdatum liggen)." }
|
||||||
|
: opgeslagen
|
||||||
|
? { tone: "ok", text: "Project opgeslagen." }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||||
|
<AppHeader email={session.email} active="projecten" />
|
||||||
|
|
||||||
|
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-8 flex flex-col gap-6">
|
||||||
|
<Link
|
||||||
|
href="/projecten"
|
||||||
|
className="text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors"
|
||||||
|
>
|
||||||
|
← Terug naar projecten
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">{project.name}</h1>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${projectStatusBadgeClass(project.status)}`}
|
||||||
|
>
|
||||||
|
{projectStatusLabel(project.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{message && (
|
||||||
|
<p
|
||||||
|
className={
|
||||||
|
message.tone === "error"
|
||||||
|
? "text-sm text-red-600 dark:text-red-400"
|
||||||
|
: "text-sm text-green-700 dark:text-green-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ProjectDetailForm project={project} expertScores={expertScores} rondes={listRikxRondes()} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getDb } from "../../lib/db";
|
||||||
|
import { getSession } from "../../lib/auth";
|
||||||
|
import { DEFAULT_PROJECT_STATUS, isProjectStatus } from "../../lib/projectStatus";
|
||||||
|
import { readProjectPlan } from "../../lib/projectPlan";
|
||||||
|
import { BONUS_MALUS_OPTIONS, EXPERT_COUNT, EXPERT_CRITERIA } from "../../lib/rikxCalculatie";
|
||||||
|
|
||||||
|
export async function createProject(formData: FormData) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const name = ((formData.get("name") as string) ?? "").trim();
|
||||||
|
const rawStatus = formData.get("status");
|
||||||
|
const status = isProjectStatus(rawStatus) ? rawStatus : DEFAULT_PROJECT_STATUS;
|
||||||
|
|
||||||
|
if (!name) redirect("/projecten/nieuw?error=missing");
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare("INSERT INTO projects (name, status, ronde_id) VALUES (?, ?, ?)").run(
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
readRondeId(db, formData)
|
||||||
|
);
|
||||||
|
|
||||||
|
redirect("/projecten");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geeft het id van een bestaande Rikx Ronde terug, of null bij "geen ronde"/onbekend id.
|
||||||
|
function readRondeId(db: ReturnType<typeof getDb>, formData: FormData): number | null {
|
||||||
|
const id = Number(formData.get("ronde_id"));
|
||||||
|
if (!id) return null;
|
||||||
|
return db.prepare("SELECT id FROM rikx_rondes WHERE id = ?").get(id) ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leeg veld → null; accepteert zowel 1234.5 als 1234,5
|
||||||
|
function parseNumber(value: FormDataEntryValue | null): number | null {
|
||||||
|
if (typeof value !== "string" || value.trim() === "") return null;
|
||||||
|
const n = Number(value.replace(",", "."));
|
||||||
|
return Number.isFinite(n) ? n : NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inRange(n: number | null, min: number, max = Infinity) {
|
||||||
|
return n === null || (n >= min && n <= max);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProject(formData: FormData) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const id = Number(formData.get("id"));
|
||||||
|
if (!id) redirect("/projecten");
|
||||||
|
|
||||||
|
const name = ((formData.get("name") as string) ?? "").trim();
|
||||||
|
const rawStatus = formData.get("status");
|
||||||
|
if (!name) redirect(`/projecten/${id}?error=missing`);
|
||||||
|
|
||||||
|
const projectkosten = parseNumber(formData.get("projectkosten"));
|
||||||
|
const deelnemersWerk = parseNumber(formData.get("deelnemers_werk"));
|
||||||
|
const deelnemersSkills = parseNumber(formData.get("deelnemers_skills"));
|
||||||
|
const externBedrag = parseNumber(formData.get("financiering_extern_bedrag"));
|
||||||
|
const externPct = parseNumber(formData.get("financiering_extern_pct"));
|
||||||
|
const rawBonusMalus = (formData.get("bonus_malus") as string) ?? "";
|
||||||
|
const bonusMalus = BONUS_MALUS_OPTIONS.some((o) => o.id === rawBonusMalus) && rawBonusMalus ? rawBonusMalus : null;
|
||||||
|
|
||||||
|
const expertScores = Array.from({ length: EXPERT_COUNT }, (_, i) =>
|
||||||
|
EXPERT_CRITERIA.map((c) => parseNumber(formData.get(`expert_${i + 1}_${c.id}`)))
|
||||||
|
);
|
||||||
|
|
||||||
|
const valid =
|
||||||
|
inRange(projectkosten, 0) &&
|
||||||
|
inRange(deelnemersWerk, 0) &&
|
||||||
|
inRange(deelnemersSkills, 0) &&
|
||||||
|
inRange(externBedrag, 0) &&
|
||||||
|
inRange(externPct, 0, 100) &&
|
||||||
|
expertScores.every((scores) => scores.every((s) => inRange(s, 1, 10)));
|
||||||
|
if (!valid) redirect(`/projecten/${id}?error=invalid`);
|
||||||
|
|
||||||
|
const planResult = readProjectPlan(formData);
|
||||||
|
if ("error" in planResult) redirect(`/projecten/${id}?error=${planResult.error}`);
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
db.exec("BEGIN");
|
||||||
|
try {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE projects SET
|
||||||
|
name = ?, status = COALESCE(?, status),
|
||||||
|
projectkosten = ?, deelnemers_werk = ?, deelnemers_skills = ?,
|
||||||
|
financiering_extern_bedrag = ?, financiering_extern_pct = ?, project_plan = ?,
|
||||||
|
bonus_malus = ?, ronde_id = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?`
|
||||||
|
).run(
|
||||||
|
name,
|
||||||
|
isProjectStatus(rawStatus) ? rawStatus : null,
|
||||||
|
projectkosten,
|
||||||
|
deelnemersWerk,
|
||||||
|
deelnemersSkills,
|
||||||
|
externBedrag,
|
||||||
|
externPct,
|
||||||
|
JSON.stringify(planResult.plan),
|
||||||
|
bonusMalus,
|
||||||
|
readRondeId(db, formData),
|
||||||
|
id
|
||||||
|
);
|
||||||
|
const upsert = db.prepare(
|
||||||
|
`INSERT INTO project_expert_scores (project_id, expert, duur_impact, levensdomeinen, basissituatie)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (project_id, expert) DO UPDATE SET
|
||||||
|
duur_impact = excluded.duur_impact,
|
||||||
|
levensdomeinen = excluded.levensdomeinen,
|
||||||
|
basissituatie = excluded.basissituatie`
|
||||||
|
);
|
||||||
|
expertScores.forEach((scores, i) => upsert.run(id, i + 1, ...scores));
|
||||||
|
db.exec("COMMIT");
|
||||||
|
} catch (err) {
|
||||||
|
db.exec("ROLLBACK");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(`/projecten/${id}?opgeslagen=1`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schuif "Rikx calculatie" op de projectpagina; wordt direct opgeslagen.
|
||||||
|
export async function setRikxCalculatie(id: number, aan: boolean) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
getDb()
|
||||||
|
.prepare("UPDATE projects SET rikx_calculatie_aan = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
|
.run(aan ? 1 : 0, id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getSession } from "../../../lib/auth";
|
||||||
|
import { listRikxRondes } from "../../../lib/db";
|
||||||
|
import { DEFAULT_PROJECT_STATUS, PROJECT_STATUSES } from "../../../lib/projectStatus";
|
||||||
|
import { AppHeader } from "../../AppHeader";
|
||||||
|
import { createProject } from "../actions";
|
||||||
|
|
||||||
|
export default async function NieuwProjectPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ error?: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const { error } = await searchParams;
|
||||||
|
const rondes = listRikxRondes();
|
||||||
|
const errorMsg = error === "missing" ? "Vul een projectnaam in." : null;
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white dark:bg-zinc-950">
|
||||||
|
<AppHeader email={session.email} active="projecten" />
|
||||||
|
|
||||||
|
<main className="max-w-6xl mx-auto px-4 sm:px-6 py-10 flex flex-col gap-6">
|
||||||
|
<Link
|
||||||
|
href="/projecten"
|
||||||
|
className="text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors"
|
||||||
|
>
|
||||||
|
← Terug naar projecten
|
||||||
|
</Link>
|
||||||
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Nieuw project</h2>
|
||||||
|
{errorMsg && <p className="text-sm text-red-600 dark:text-red-400">{errorMsg}</p>}
|
||||||
|
<form
|
||||||
|
action={createProject}
|
||||||
|
className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6 grid grid-cols-1 sm:grid-cols-3 gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="name" className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
Projectnaam<span className="text-red-500 ml-1">*</span>
|
||||||
|
</label>
|
||||||
|
<input id="name" type="text" name="name" required autoFocus className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="status" className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<select id="status" name="status" defaultValue={DEFAULT_PROJECT_STATUS} className={inputClass}>
|
||||||
|
{PROJECT_STATUSES.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="ronde_id" className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
Rikx Ronde
|
||||||
|
</label>
|
||||||
|
<select id="ronde_id" name="ronde_id" defaultValue="" className={inputClass}>
|
||||||
|
<option value="">Geen ronde</option>
|
||||||
|
{rondes.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-3 flex gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg bg-rikx-maroon hover:bg-rikx-maroon-light px-5 py-2 text-sm font-medium text-white transition-colors"
|
||||||
|
>
|
||||||
|
Opslaan
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/projecten"
|
||||||
|
className="rounded-lg border border-zinc-300 dark:border-zinc-700 px-5 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getSession } from "../../lib/auth";
|
||||||
|
import { getDb, listRikxRondes, type ProjectListItem } from "../../lib/db";
|
||||||
|
import { PROJECT_STATUSES, isProjectStatus } from "../../lib/projectStatus";
|
||||||
|
import { AppHeader } from "../AppHeader";
|
||||||
|
import { AutoSubmitSelect } from "./AutoSubmitSelect";
|
||||||
|
import { ProjectPipeline } from "./ProjectPipeline";
|
||||||
|
import { ProjectTable } from "./ProjectTable";
|
||||||
|
|
||||||
|
const fieldClass =
|
||||||
|
"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 placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-rikx-orange";
|
||||||
|
const labelClass = "block text-sm text-zinc-700 dark:text-zinc-300 mb-1";
|
||||||
|
|
||||||
|
export default async function ProjectenPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ q?: string; status?: string; ronde?: string; weergave?: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const params = await searchParams;
|
||||||
|
const q = (params.q ?? "").trim();
|
||||||
|
const status = isProjectStatus(params.status) ? params.status : "";
|
||||||
|
const rondes = listRikxRondes();
|
||||||
|
// "geen" = projecten zonder ronde; anders het id van een bestaande ronde
|
||||||
|
const ronde =
|
||||||
|
params.ronde === "geen" || rondes.some((r) => String(r.id) === params.ronde) ? params.ronde! : "";
|
||||||
|
// Pipeline is altijd de standaardweergave; de tabel alleen als die expliciet gekozen is.
|
||||||
|
const view = params.weergave === "tabel" ? "tabel" : "pipeline";
|
||||||
|
|
||||||
|
const where: string[] = [];
|
||||||
|
const args: (string | number)[] = [];
|
||||||
|
if (q) {
|
||||||
|
where.push("p.name LIKE ? ESCAPE '\\'");
|
||||||
|
args.push(`%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
where.push("p.status = ?");
|
||||||
|
args.push(status);
|
||||||
|
}
|
||||||
|
if (ronde === "geen") {
|
||||||
|
where.push("p.ronde_id IS NULL");
|
||||||
|
} else if (ronde) {
|
||||||
|
where.push("p.ronde_id = ?");
|
||||||
|
args.push(Number(ronde));
|
||||||
|
}
|
||||||
|
const projects = getDb()
|
||||||
|
.prepare(
|
||||||
|
`SELECT p.*, r.label AS ronde_label
|
||||||
|
FROM projects p LEFT JOIN rikx_rondes r ON r.id = p.ronde_id
|
||||||
|
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
||||||
|
ORDER BY p.created_at DESC, p.id DESC`
|
||||||
|
)
|
||||||
|
.all(...args) as ProjectListItem[];
|
||||||
|
const isFiltered = Boolean(q || status || ronde);
|
||||||
|
|
||||||
|
function viewHref(target: "pipeline" | "tabel") {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (target === "tabel") sp.set("weergave", "tabel");
|
||||||
|
if (status) sp.set("status", status);
|
||||||
|
if (ronde) sp.set("ronde", ronde);
|
||||||
|
if (q) sp.set("q", q);
|
||||||
|
const qs = sp.toString();
|
||||||
|
return qs ? `/projecten?${qs}` : "/projecten";
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleClass = (isActive: boolean) =>
|
||||||
|
isActive
|
||||||
|
? "rounded-lg bg-zinc-900 dark:bg-zinc-100 px-5 py-2.5 text-sm font-medium text-white dark:text-zinc-900"
|
||||||
|
: "rounded-lg px-5 py-2.5 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||||
|
<AppHeader email={session.email} active="projecten" />
|
||||||
|
|
||||||
|
<main className="max-w-[96rem] mx-auto px-4 sm:px-6 py-6 flex flex-col gap-6">
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-4 sm:p-5 flex flex-wrap items-end gap-4">
|
||||||
|
<nav aria-label="Weergave" className="flex items-center gap-1">
|
||||||
|
<Link href={viewHref("pipeline")} className={toggleClass(view === "pipeline")} aria-current={view === "pipeline" ? "page" : undefined}>
|
||||||
|
Pipeline
|
||||||
|
</Link>
|
||||||
|
<Link href={viewHref("tabel")} className={toggleClass(view === "tabel")} aria-current={view === "tabel" ? "page" : undefined}>
|
||||||
|
Tabel
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<form action="/projecten" className="contents">
|
||||||
|
{view === "tabel" && <input type="hidden" name="weergave" value="tabel" />}
|
||||||
|
<div className="w-full sm:w-56">
|
||||||
|
<label htmlFor="filter-status" className={labelClass}>
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<AutoSubmitSelect id="filter-status" name="status" defaultValue={status} className={fieldClass}>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{PROJECT_STATUSES.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</AutoSubmitSelect>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-56">
|
||||||
|
<label htmlFor="filter-ronde" className={labelClass}>
|
||||||
|
Rikx Ronde
|
||||||
|
</label>
|
||||||
|
<AutoSubmitSelect id="filter-ronde" name="ronde" defaultValue={ronde} className={fieldClass}>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{rondes.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="geen">Zonder ronde</option>
|
||||||
|
</AutoSubmitSelect>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-auto sm:flex-1 sm:min-w-56">
|
||||||
|
<label htmlFor="filter-q" className={labelClass}>
|
||||||
|
Zoeken
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filter-q"
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
defaultValue={q}
|
||||||
|
placeholder="Projectnaam"
|
||||||
|
className={fieldClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/projecten/nieuw"
|
||||||
|
className="rounded-lg bg-rikx-maroon hover:bg-rikx-maroon-light px-5 py-2.5 text-sm font-medium text-white transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
+ Project toevoegen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFiltered && (
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<p className="text-zinc-500">
|
||||||
|
{projects.length} {projects.length === 1 ? "project" : "projecten"} gevonden
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={view === "tabel" ? "/projecten?weergave=tabel" : "/projecten"}
|
||||||
|
className="text-rikx-orange-dark hover:underline"
|
||||||
|
>
|
||||||
|
Filters wissen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "pipeline" ? (
|
||||||
|
<ProjectPipeline projects={projects} status={status} />
|
||||||
|
) : (
|
||||||
|
<ProjectTable projects={projects} isFiltered={isFiltered} />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { deleteRonde } from "./actions";
|
||||||
|
|
||||||
|
export function DeleteRondeButton({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
projectCount,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
projectCount: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={deleteRonde}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
const extra =
|
||||||
|
projectCount === 1
|
||||||
|
? " Het gekoppelde project blijft bestaan, maar zonder ronde."
|
||||||
|
: projectCount > 1
|
||||||
|
? ` De ${projectCount} gekoppelde projecten blijven bestaan, maar zonder ronde.`
|
||||||
|
: "";
|
||||||
|
if (!confirm(`Weet je zeker dat je Rikx Ronde "${label}" wilt verwijderen?${extra}`)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={id} />
|
||||||
|
<button type="submit" className="text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors">
|
||||||
|
Verwijderen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { RikxRonde } from "../../lib/db";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
export const RONDE_ERRORS: Record<string, string> = {
|
||||||
|
missing: "Vul een ronde label in.",
|
||||||
|
invalid: "Rikx waarde en Rikx prijs mogen niet negatief zijn.",
|
||||||
|
label: "Er bestaat al een ronde met dit label.",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gedeelde velden voor toevoegen en bewerken van een Rikx Ronde.
|
||||||
|
export function RondeFields({ ronde }: { ronde?: RikxRonde }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className={labelClass}>
|
||||||
|
Ronde label<span className="text-red-500 ml-1">*</span>
|
||||||
|
</span>
|
||||||
|
<input name="label" required defaultValue={ronde?.label} placeholder="Rikx Ronde 5 – Rotterdam" className={inputClass} />
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className={labelClass}>Rikx waarde</span>
|
||||||
|
<input name="rikx_waarde" type="number" min={0} step="any" defaultValue={ronde?.rikx_waarde ?? ""} className={inputClass} />
|
||||||
|
<span className="text-xs text-zinc-500">Kosten/impact van het 75%-ste project in de ronde.</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className={labelClass}>Rikx prijs (€)</span>
|
||||||
|
<input name="rikx_prijs" type="number" min={0} step="0.01" defaultValue={ronde?.rikx_prijs ?? ""} className={inputClass} />
|
||||||
|
<span className="text-xs text-zinc-500">Leeg laten = Rikx waarde / 10.</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getSession } from "../../../lib/auth";
|
||||||
|
import { getDb, type RikxRonde } from "../../../lib/db";
|
||||||
|
import { AppHeader } from "../../AppHeader";
|
||||||
|
import { updateRonde } from "../actions";
|
||||||
|
import { RONDE_ERRORS, RondeFields } from "../RondeFields";
|
||||||
|
|
||||||
|
export default async function EditRondePage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ error?: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const { error } = await searchParams;
|
||||||
|
const row = getDb().prepare("SELECT * FROM rikx_rondes WHERE id = ?").get(Number(id)) as RikxRonde | undefined;
|
||||||
|
if (!row) notFound();
|
||||||
|
const ronde = { ...row };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||||
|
<AppHeader email={session.email} active="projecten" />
|
||||||
|
|
||||||
|
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-10 flex flex-col gap-6">
|
||||||
|
<Link href="/rondes" className="text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors">
|
||||||
|
← Terug naar Rikx rondes
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Rikx Ronde bewerken</h1>
|
||||||
|
{error && RONDE_ERRORS[error] && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{RONDE_ERRORS[error]}</p>
|
||||||
|
)}
|
||||||
|
<form
|
||||||
|
action={updateRonde}
|
||||||
|
className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6 grid grid-cols-1 sm:grid-cols-3 gap-4"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={ronde.id} />
|
||||||
|
<RondeFields ronde={ronde} />
|
||||||
|
<p className="sm:col-span-3 text-xs text-zinc-500">
|
||||||
|
Een andere Rikx waarde of prijs verandert de Rikx calculatie van alle projecten in deze ronde.
|
||||||
|
</p>
|
||||||
|
<div className="sm:col-span-3 flex gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg bg-rikx-maroon hover:bg-rikx-maroon-light px-5 py-2 text-sm font-medium text-white transition-colors"
|
||||||
|
>
|
||||||
|
Opslaan
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/rondes"
|
||||||
|
className="rounded-lg border border-zinc-300 dark:border-zinc-700 px-5 py-2 text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
Annuleren
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getDb } from "../../lib/db";
|
||||||
|
import { getSession } from "../../lib/auth";
|
||||||
|
import { defaultRikxPrijs } from "../../lib/rikxCalculatie";
|
||||||
|
|
||||||
|
// Leeg veld → null; accepteert zowel 1234.5 als 1234,5
|
||||||
|
function parseNumber(value: FormDataEntryValue | null): number | null {
|
||||||
|
if (typeof value !== "string" || value.trim() === "") return null;
|
||||||
|
const n = Number(value.replace(",", "."));
|
||||||
|
return Number.isFinite(n) ? n : NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leest en controleert het rondeformulier; geeft een foutcode terug bij ongeldige invoer.
|
||||||
|
function readRonde(formData: FormData) {
|
||||||
|
const label = ((formData.get("label") as string) ?? "").trim();
|
||||||
|
const rikxWaarde = parseNumber(formData.get("rikx_waarde"));
|
||||||
|
let rikxPrijs = parseNumber(formData.get("rikx_prijs"));
|
||||||
|
if (!label) return { error: "missing" } as const;
|
||||||
|
if ((rikxWaarde !== null && !(rikxWaarde >= 0)) || (rikxPrijs !== null && !(rikxPrijs >= 0))) {
|
||||||
|
return { error: "invalid" } as const;
|
||||||
|
}
|
||||||
|
if (rikxPrijs === null && rikxWaarde !== null) rikxPrijs = defaultRikxPrijs(rikxWaarde);
|
||||||
|
return { label, rikxWaarde, rikxPrijs } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRonde(formData: FormData) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const ronde = readRonde(formData);
|
||||||
|
if ("error" in ronde) redirect(`/rondes?error=${ronde.error}`);
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
if (db.prepare("SELECT id FROM rikx_rondes WHERE label = ?").get(ronde.label)) {
|
||||||
|
redirect("/rondes?error=label");
|
||||||
|
}
|
||||||
|
db.prepare("INSERT INTO rikx_rondes (label, rikx_waarde, rikx_prijs) VALUES (?, ?, ?)").run(
|
||||||
|
ronde.label,
|
||||||
|
ronde.rikxWaarde,
|
||||||
|
ronde.rikxPrijs
|
||||||
|
);
|
||||||
|
|
||||||
|
redirect("/rondes");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRonde(formData: FormData) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const id = Number(formData.get("id"));
|
||||||
|
if (!id) redirect("/rondes");
|
||||||
|
|
||||||
|
const ronde = readRonde(formData);
|
||||||
|
if ("error" in ronde) redirect(`/rondes/${id}?error=${ronde.error}`);
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
if (db.prepare("SELECT id FROM rikx_rondes WHERE label = ? AND id != ?").get(ronde.label, id)) {
|
||||||
|
redirect(`/rondes/${id}?error=label`);
|
||||||
|
}
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE rikx_rondes SET label = ?, rikx_waarde = ?, rikx_prijs = ?, updated_at = datetime('now') WHERE id = ?"
|
||||||
|
).run(ronde.label, ronde.rikxWaarde, ronde.rikxPrijs, id);
|
||||||
|
|
||||||
|
redirect("/rondes");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRonde(formData: FormData) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const id = Number(formData.get("id"));
|
||||||
|
// Projecten in deze ronde blijven bestaan; hun ronde_id wordt NULL (ON DELETE SET NULL).
|
||||||
|
if (id) getDb().prepare("DELETE FROM rikx_rondes WHERE id = ?").run(id);
|
||||||
|
|
||||||
|
redirect("/rondes");
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getSession } from "../../lib/auth";
|
||||||
|
import { getDb, type RikxRonde } from "../../lib/db";
|
||||||
|
import { AppHeader } from "../AppHeader";
|
||||||
|
import { createRonde } from "./actions";
|
||||||
|
import { DeleteRondeButton } from "./DeleteRondeButton";
|
||||||
|
import { RONDE_ERRORS, RondeFields } from "./RondeFields";
|
||||||
|
|
||||||
|
const euro = new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" });
|
||||||
|
const getal = new Intl.NumberFormat("nl-NL", { maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
export default async function RondesPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ error?: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const { error } = await searchParams;
|
||||||
|
const rondes = (
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`SELECT r.*, COUNT(p.id) AS project_count
|
||||||
|
FROM rikx_rondes r LEFT JOIN projects p ON p.ronde_id = r.id
|
||||||
|
GROUP BY r.id ORDER BY r.created_at DESC, r.id DESC`
|
||||||
|
)
|
||||||
|
.all() as (RikxRonde & { project_count: number })[]
|
||||||
|
).map((r) => ({ ...r }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||||
|
<AppHeader email={session.email} active="projecten" />
|
||||||
|
|
||||||
|
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-10 flex flex-col gap-10">
|
||||||
|
<section>
|
||||||
|
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100 mb-4">
|
||||||
|
Rikx rondes ({rondes.length})
|
||||||
|
</h1>
|
||||||
|
{rondes.length === 0 ? (
|
||||||
|
<p className="text-sm text-zinc-500">Nog geen Rikx rondes aangemaakt.</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-zinc-50 dark:bg-zinc-900 text-zinc-500 dark:text-zinc-400">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Ronde label</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Rikx waarde</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Rikx prijs</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Projecten</th>
|
||||||
|
<th className="px-4 py-3" colSpan={2}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||||
|
{rondes.map((r) => (
|
||||||
|
<tr key={r.id} className="bg-white dark:bg-zinc-950">
|
||||||
|
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{r.label}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-zinc-700 dark:text-zinc-300">
|
||||||
|
{r.rikx_waarde === null ? "—" : getal.format(r.rikx_waarde)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-zinc-700 dark:text-zinc-300">
|
||||||
|
{r.rikx_prijs === null ? "—" : euro.format(r.rikx_prijs)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<Link href={`/projecten?ronde=${r.id}`} className="text-rikx-orange-dark hover:underline">
|
||||||
|
{r.project_count}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<Link
|
||||||
|
href={`/rondes/${r.id}`}
|
||||||
|
className="text-xs text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 transition-colors"
|
||||||
|
>
|
||||||
|
Bewerken
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<DeleteRondeButton id={r.id} label={r.label} projectCount={r.project_count} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100 mb-4">Nieuwe Rikx Ronde</h2>
|
||||||
|
{error && RONDE_ERRORS[error] && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400 mb-4">{RONDE_ERRORS[error]}</p>
|
||||||
|
)}
|
||||||
|
<form
|
||||||
|
action={createRonde}
|
||||||
|
className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6 grid grid-cols-1 sm:grid-cols-3 gap-4"
|
||||||
|
>
|
||||||
|
<RondeFields />
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg bg-rikx-maroon hover:bg-rikx-maroon-light px-5 py-2 text-sm font-medium text-white transition-colors"
|
||||||
|
>
|
||||||
|
Ronde toevoegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation";
|
|||||||
import { getSession } from "../../../lib/auth";
|
import { getSession } from "../../../lib/auth";
|
||||||
import { getAccount } from "../../../lib/platform-db";
|
import { getAccount } from "../../../lib/platform-db";
|
||||||
import { updateTenant } from "../actions";
|
import { updateTenant } from "../actions";
|
||||||
|
import { AppHeader } from "../../AppHeader";
|
||||||
|
|
||||||
export default async function EditTenantPage({
|
export default async function EditTenantPage({
|
||||||
params,
|
params,
|
||||||
@@ -31,12 +32,7 @@ export default async function EditTenantPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||||
<header className="bg-white dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-800">
|
<AppHeader email={session.email} active="crm" />
|
||||||
<div className="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
|
|
||||||
<span className="font-semibold text-zinc-900 dark:text-zinc-100">Rikx Beheer</span>
|
|
||||||
<span className="text-sm text-zinc-500">{session.email}</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="max-w-5xl mx-auto px-6 py-10 flex flex-col gap-6">
|
<main className="max-w-5xl mx-auto px-6 py-10 flex flex-col gap-6">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
+2
-14
@@ -2,8 +2,8 @@ import { redirect } from "next/navigation";
|
|||||||
import { getSession } from "../../lib/auth";
|
import { getSession } from "../../lib/auth";
|
||||||
import { listAccounts } from "../../lib/platform-db";
|
import { listAccounts } from "../../lib/platform-db";
|
||||||
import { createTenant } from "./actions";
|
import { createTenant } from "./actions";
|
||||||
import { logout } from "./logout";
|
|
||||||
import { DeleteTenantButton } from "./DeleteTenantButton";
|
import { DeleteTenantButton } from "./DeleteTenantButton";
|
||||||
|
import { AppHeader } from "../AppHeader";
|
||||||
|
|
||||||
export default async function TenantsPage({
|
export default async function TenantsPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -25,19 +25,7 @@ export default async function TenantsPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||||
<header className="bg-white dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-800">
|
<AppHeader email={session.email} active="crm" />
|
||||||
<div className="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
|
|
||||||
<span className="font-semibold text-zinc-900 dark:text-zinc-100">Rikx Beheer</span>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-sm text-zinc-500">{session.email}</span>
|
|
||||||
<form action={logout}>
|
|
||||||
<button className="text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors">
|
|
||||||
Uitloggen
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="max-w-5xl mx-auto px-6 py-10 flex flex-col gap-10">
|
<main className="max-w-5xl mx-auto px-6 py-10 flex flex-col gap-10">
|
||||||
{/* Overzicht */}
|
{/* Overzicht */}
|
||||||
|
|||||||
@@ -26,49 +26,139 @@ function migrate(db: DatabaseSync) {
|
|||||||
role TEXT NOT NULL DEFAULT 'admin',
|
role TEXT NOT NULL DEFAULT 'admin',
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS clients (
|
dropLegacyQuoteTables(db);
|
||||||
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'))
|
|
||||||
);
|
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
client_id INTEGER NOT NULL REFERENCES clients(id),
|
name TEXT NOT NULL,
|
||||||
title TEXT NOT NULL,
|
status TEXT NOT NULL DEFAULT 'aanvraag',
|
||||||
description TEXT,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_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,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
label TEXT NOT NULL UNIQUE,
|
||||||
title TEXT NOT NULL,
|
rikx_waarde REAL,
|
||||||
status TEXT NOT NULL DEFAULT 'draft',
|
rikx_prijs REAL,
|
||||||
notes TEXT,
|
|
||||||
sent_at TEXT,
|
|
||||||
accepted_at TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS quote_items (
|
-- Expertscores per project voor de Rikx calculatie (Impactscore M).
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
-- Voorlopig handmatig ingevuld per expertnummer; Module 4 koppelt dit later aan expert-logins.
|
||||||
quote_id INTEGER NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
CREATE TABLE IF NOT EXISTS project_expert_scores (
|
||||||
description TEXT NOT NULL,
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
quantity REAL NOT NULL DEFAULT 1,
|
expert INTEGER NOT NULL,
|
||||||
unit TEXT,
|
duur_impact REAL,
|
||||||
unit_price REAL NOT NULL DEFAULT 0,
|
levensdomeinen REAL,
|
||||||
total REAL GENERATED ALWAYS AS (quantity * unit_price) VIRTUAL,
|
basissituatie REAL,
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0
|
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