Voeg Module 4 experts, waardebepalingspagina en projectverbeteringen toe
Experts (Module 4) - Pagina Experts (CRM): experts uitnodigen per e-mail (zonder SMTP wordt de link getoond), rondes koppelen, opnieuw uitnodigen, verwijderen en "Inloggen als" (alleen lezen) om mee te kijken - Uitnodigingspagina waar een expert zijn wachtwoord instelt (token 7 dagen geldig, eenmalig, alleen als hash opgeslagen) - Expertpagina: projecten in waardebepaling uit de eigen rondes, project plan lezen en eigen beoordeling geven (3 scores, advies Bonus/Malus, toelichting) - Rollen admin/expert: alle beheerpagina's en -acties controleren de rol - Rikx calculatie gebruikt de beoordelingen van de experts in plaats van de handmatige expertkolommen; experts koppelen ook vanaf de rondepagina Waardebepaling projecten - Nieuwe pagina met projecten in waardebepaling op kosten/impact (laag naar hoog), filter op ronde, Rikx Waarde-veld dat live doorrekent en opslaat in de ronde, en een 75%-lijn bij een gekozen ronde Projecten - Rikx calculatie als tab, zichtbaar vanaf status Waardebepaling, met keuze rekenmethode (Arbeid; Jeugd nog niet beschikbaar) en beoordeling goedgekeurd/afgekeurd met reden - Impactmaker gekoppeld aan een sociaal ondernemer - Opslaan-knop bovenaan; ongeldige velden op een verborgen tab worden getoond - Project toevoegen maakt direct een project aan; aparte pagina verwijderd - Waarderingssystematiek Rikx juni 2023 toegevoegd aan _doc Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,9 +4,15 @@ Beheerportaal voor de Rikx-methodiek. Beheert de sociaal ondernemers (accounts i
|
|||||||
|
|
||||||
## Pagina's
|
## Pagina's
|
||||||
- `/tenants` — sociaal ondernemers aanmaken, bewerken, verwijderen, auto-inlog (leest/schrijft de platformdatabase via `lib/platform-db.ts`)
|
- `/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)
|
- `/projecten` — projecten als pipeline (standaard, kolom per status) of tabel (`?weergave=tabel`), met filter op status, Rikx Ronde en zoeken. De knop "+ Project toevoegen" maakt direct een project aan ("Nieuw project", status Aanvraag) en opent de projectpagina
|
||||||
- `/rondes` — Rikx rondes beheren (label, Rikx waarde, Rikx prijs); een project hoort optioneel bij één ronde (`projects.ronde_id`)
|
- `/waardebepaling` — Waardebepaling projecten: tabel met projecten in status Waardebepaling (projectnaam, impactmaker, kosten/impact), gerangschikt op kosten/impact van laag naar hoog, filter op Rikx Ronde. Alleen bij een gekozen Rikx ronde (niet bij Alle/Zonder ronde) een dikke oranje lijn onder het 75%-project: plek ⌈0,75 × n⌉ van de projecten met een berekende kosten/impact (zie `index75Procent`), volgens de Waarderingssystematiek; erboven de 75% meest efficiënte. Bovenaan een Rikx Waarde-veld: rekent live door met Rikx prijs = 10% voor de getoonde projecten met een ronde, en slaat waarde en prijs (na 0,8 s of bij verlaten van het veld) op in de Rikx ronde(s) van die projecten (`app/waardebepaling/actions.ts`)
|
||||||
- `/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`
|
- Impactmaker van een project = sociaal ondernemer: `projects.impactmaker_account_id` verwijst naar `accounts.id` in de platformdatabase (geen foreign key; namen via `listAccountNames()` in `lib/platform-db.ts`)
|
||||||
|
- `/rondes` — Rikx rondes beheren (label, Rikx waarde, Rikx prijs, gekoppelde experts; dezelfde koppeling `expert_rondes` als op `/experts`); een project hoort optioneel bij één ronde (`projects.ronde_id`)
|
||||||
|
- `/projecten/[id]` — projectpagina met tabs: Project plan (velden en keuzelijsten gedefinieerd in `lib/projectPlan.ts`) en Rikx calculatie (alleen zichtbaar vanaf status Waardebepaling, zie `RIKX_CALCULATIE_VANAF_STATUS` in `lib/projectStatus.ts`; rekenmethode `arbeid`, `jeugd` is zichtbaar maar nog niet beschikbaar). Onderaan de tab: Beoordeling (`beoordeling`: goedgekeurd/afgekeurd/leeg) met `beoordeling_reden`. Kolom `rikx_calculatie_aan` is van de oude schuif en wordt niet meer gebruikt. Uitkomsten worden live berekend met `lib/rikxCalculatie.ts`
|
||||||
|
- `/experts` (CRM → Experts) — experts uitnodigen, rondes koppelen, opnieuw uitnodigen (ook voor een vergeten wachtwoord), verwijderen, en "Inloggen als": een admin bekijkt `/expert` als die expert (alleen lezen; sessie met `impersonatorId`, terug via "Terug naar beheer"). Uitnodiging per e-mail via SMTP; zonder SMTP_HOST toont de pagina de link om zelf door te geven
|
||||||
|
- `/uitnodiging/[token]` — openbaar: expert stelt wachtwoord in (token 7 dagen geldig, eenmalig; alleen de SHA-256-hash staat in de database)
|
||||||
|
- `/expert` — de enige pagina voor experts: projecten in Waardebepaling uit hun rondes, project plan (alleen lezen) en hun eigen beoordeling (3 scores 1–10, advies Bonus/Malus, toelichting). Experts zien elkaars beoordeling niet
|
||||||
|
- Rollen: `users.role` = `admin` of `expert`. Elke beheerpagina en -actie begint met `requireAdmin()`, de expertpagina met `requireExpert()` (`lib/auth.ts`); de rol komt uit de database, niet uit de cookie
|
||||||
- Gedeelde kop met navigatie: `app/AppHeader.tsx` (oranje Rikx-balk, menu per module; items zonder pagina tonen "binnenkort")
|
- 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`
|
- 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`)
|
- Conventie: server actions in `actions.ts` per map, geen API-routes (behalve `app/api/auto-login`)
|
||||||
@@ -36,8 +42,8 @@ npm run create-admin maarten@rikx.nl wachtwoord "Maarten"
|
|||||||
- Locatie lokaal: `./data/app.db`
|
- Locatie 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`, `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)
|
- Tabellen: `users`, `projects` (`name`, `status`; statussen in `lib/projectStatus.ts`, standaard `aanvraag`; plus de velden uit de rekenmethode als kolommen; overige project plan-velden als JSON in `project_plan`), `expert_beoordelingen` (per project en expert; Impactscore M = gemiddelde over de experts; de Rikx calculatie-tab toont ze alleen-lezen), `expert_rondes`, `expert_uitnodigingen`. `project_expert_scores` (oude handmatige scores) wordt niet meer gebruikt maar blijft staan, `rikx_rondes` (Rikx waarde en prijs per ronde; de calculatie haalt ze uit de ronde van het project, prijs standaard waarde / 10)
|
||||||
- Rekenmethode: `_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)
|
- Rekenmethode (Rikx Waardeberekening Arbeid): `_doc/Rikx rekenmethode uitleg.xlsx`, tabblad Uitleg; achtergrond in `_doc/Waarderingssystematiek Rikx juni 2023.pdf`. Afwijkingen van de sheet: N-score = ((werk×5)+skills)/6 (sheet mist haakjes), Bonus/Malus `-` = 0,9 en `--` = 0,8 (sheet heeft typefouten)
|
||||||
- De oude offerte-tabellen (`clients`, `projects` met `client_id`, `quotes`, `quote_items`) worden bij de migratie verwijderd als ze leeg zijn; met gegevens worden ze hernoemd naar `legacy_*`
|
- 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
|
- Tweede database (alleen-platform): `RIKX_PLATFORM_DB_PATH` (standaard `/rikx-platform-data/app.db`) met `accounts` en `users` van het ZRM-platform
|
||||||
|
|
||||||
@@ -55,7 +61,7 @@ npm run create-admin maarten@rikx.nl wachtwoord "Maarten"
|
|||||||
| `DB_PATH` | `./data/app.db` | **In productie: `/data/app.db`** (absoluut pad) |
|
| `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) |
|
| `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 e-mail (o.a. uitnodigingen van experts); leeg = geen e-mail, link wordt in beeld getoond |
|
||||||
| `SMTP_PORT` | `587` | SMTP poort |
|
| `SMTP_PORT` | `587` | SMTP poort |
|
||||||
| `SMTP_USER` | — | SMTP gebruikersnaam |
|
| `SMTP_USER` | — | SMTP gebruikersnaam |
|
||||||
| `SMTP_PASS` | — | SMTP wachtwoord |
|
| `SMTP_PASS` | — | SMTP wachtwoord |
|
||||||
|
|||||||
Binary file not shown.
+2
-2
@@ -12,7 +12,7 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
section: "projecten",
|
section: "projecten",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Overzicht", href: "/projecten" },
|
{ label: "Overzicht", href: "/projecten" },
|
||||||
{ label: "Project toevoegen", href: "/projecten/nieuw" },
|
{ label: "Waardebepaling projecten", href: "/waardebepaling" },
|
||||||
{ label: "Rikx rondes", href: "/rondes" },
|
{ label: "Rikx rondes", href: "/rondes" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -24,7 +24,7 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{
|
{
|
||||||
label: "CRM",
|
label: "CRM",
|
||||||
section: "crm",
|
section: "crm",
|
||||||
items: [{ label: "Sociaal ondernemers", href: "/tenants" }, { label: "Investeerders" }, { label: "Beoordelaars" }],
|
items: [{ label: "Sociaal ondernemers", href: "/tenants" }, { label: "Investeerders" }, { label: "Experts", href: "/experts" }],
|
||||||
},
|
},
|
||||||
{ label: "Publieke Website", section: "website" },
|
{ label: "Publieke Website", section: "website" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { createHmac } from "crypto";
|
import { createHmac } from "crypto";
|
||||||
import { getSession } from "../../../lib/auth";
|
import { getCurrentUser } from "../../../lib/auth";
|
||||||
import { getPlatformDb } from "../../../lib/platform-db";
|
import { getPlatformDb } from "../../../lib/platform-db";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const session = await getSession();
|
// Alleen admins mogen inloggen bij een sociaal ondernemer
|
||||||
if (!session) return NextResponse.redirect(new URL("/login", request.url));
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.redirect(new URL("/login", request.url));
|
||||||
|
if (user.role !== "admin") return NextResponse.redirect(new URL("/expert", request.url));
|
||||||
|
|
||||||
const accountId = Number(request.nextUrl.searchParams.get("account_id"));
|
const accountId = Number(request.nextUrl.searchParams.get("account_id"));
|
||||||
const slug = request.nextUrl.searchParams.get("slug");
|
const slug = request.nextUrl.searchParams.get("slug");
|
||||||
if (!accountId || !slug) return NextResponse.redirect(new URL("/tenants", request.url));
|
if (!accountId || !slug) return NextResponse.redirect(new URL("/tenants", request.url));
|
||||||
|
|
||||||
const db = getPlatformDb();
|
const db = getPlatformDb();
|
||||||
const user = db
|
const platformUser = db
|
||||||
.prepare("SELECT id FROM users WHERE account_id = ? AND role = 'admin' LIMIT 1")
|
.prepare("SELECT id FROM users WHERE account_id = ? AND role = 'admin' LIMIT 1")
|
||||||
.get(accountId) as { id: number } | undefined;
|
.get(accountId) as { id: number } | undefined;
|
||||||
|
|
||||||
if (!user) return NextResponse.redirect(new URL("/tenants?error=no-admin", request.url));
|
if (!platformUser) return NextResponse.redirect(new URL("/tenants?error=no-admin", request.url));
|
||||||
|
|
||||||
const secret = process.env.AUTO_LOGIN_SECRET!;
|
const secret = process.env.AUTO_LOGIN_SECRET!;
|
||||||
const expires = Date.now() + 60_000;
|
const expires = Date.now() + 60_000;
|
||||||
const payload = `${user.id}:${accountId}:${expires}`;
|
const payload = `${platformUser.id}:${accountId}:${expires}`;
|
||||||
const sig = createHmac("sha256", secret).update(payload).digest("hex");
|
const sig = createHmac("sha256", secret).update(payload).digest("hex");
|
||||||
const token = encodeURIComponent(`${payload}:${sig}`);
|
const token = encodeURIComponent(`${payload}:${sig}`);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Project } from "../../lib/db";
|
||||||
|
import { ANDERS, PROJECT_PLAN_GROUPS, andersKey, parseProjectPlanJson, type PlanField } from "../../lib/projectPlan";
|
||||||
|
|
||||||
|
const getal = new Intl.NumberFormat("nl-NL", { maximumFractionDigits: 2 });
|
||||||
|
const euro = new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" });
|
||||||
|
const datum = new Intl.DateTimeFormat("nl-NL", { day: "numeric", month: "long", year: "numeric" });
|
||||||
|
|
||||||
|
type ColumnKey = "projectkosten" | "deelnemers_werk" | "deelnemers_skills" | "financiering_extern_bedrag" | "financiering_extern_pct";
|
||||||
|
|
||||||
|
function waarde(field: PlanField, project: Project, plan: Record<string, unknown>): string | null {
|
||||||
|
const v = field.kind === "number" && field.column ? project[field.key as ColumnKey] : plan[field.key];
|
||||||
|
if (v === null || v === undefined || v === "" || (Array.isArray(v) && v.length === 0)) return null;
|
||||||
|
switch (field.kind) {
|
||||||
|
case "number":
|
||||||
|
return field.label.includes("(€)") ? euro.format(v as number) : getal.format(v as number);
|
||||||
|
case "date":
|
||||||
|
return datum.format(new Date(`${v}T12:00:00`));
|
||||||
|
case "multiselect": {
|
||||||
|
const anders = plan[andersKey(field.key)];
|
||||||
|
return (v as string[]).map((o) => (o === ANDERS && typeof anders === "string" ? `Anders: ${anders}` : o)).join(" · ");
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alleen-lezen weergave van het project plan voor experts
|
||||||
|
export function ProjectPlanView({ project }: { project: Project }) {
|
||||||
|
const plan = parseProjectPlanJson(project.project_plan);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{PROJECT_PLAN_GROUPS.map((group) => (
|
||||||
|
<section key={group.title}>
|
||||||
|
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 pb-2 mb-3 border-b border-zinc-200 dark:border-zinc-800">
|
||||||
|
{group.title}
|
||||||
|
</h3>
|
||||||
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
|
||||||
|
{group.fields.map((field) => {
|
||||||
|
const v = waarde(field, project, plan);
|
||||||
|
const wide = field.wide || field.kind === "textarea";
|
||||||
|
return (
|
||||||
|
<div key={field.key} className={wide ? "sm:col-span-2" : ""}>
|
||||||
|
<dt className="text-xs font-medium text-zinc-500">{field.label}</dt>
|
||||||
|
<dd className={`mt-0.5 text-sm whitespace-pre-line ${v ? "text-zinc-900 dark:text-zinc-100" : "text-zinc-400"}`}>
|
||||||
|
{v ?? "Niet ingevuld"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { createSession, getCurrentUser, requireExpert } from "../../lib/auth";
|
||||||
|
import { getDb } from "../../lib/db";
|
||||||
|
import { magBeoordelen } from "../../lib/experts";
|
||||||
|
import { BONUS_MALUS_OPTIONS } from "../../lib/rikxCalculatie";
|
||||||
|
|
||||||
|
function readScore(formData: FormData, key: string): number | null | "ongeldig" {
|
||||||
|
const raw = ((formData.get(key) as string) ?? "").trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const n = Number(raw.replace(",", "."));
|
||||||
|
return Number.isFinite(n) && n >= 1 && n <= 10 ? n : "ongeldig";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBeoordeling(formData: FormData) {
|
||||||
|
const expert = await requireExpert();
|
||||||
|
const projectId = Number(formData.get("project_id"));
|
||||||
|
// Een admin die meekijkt mag niet namens de expert beoordelen
|
||||||
|
if (expert.impersonator) redirect(`/expert?project=${projectId}&error=meekijken`);
|
||||||
|
if (!projectId || !magBeoordelen(expert.id, projectId)) redirect("/expert");
|
||||||
|
|
||||||
|
const duur = readScore(formData, "duur_impact");
|
||||||
|
const domeinen = readScore(formData, "levensdomeinen");
|
||||||
|
const basis = readScore(formData, "basissituatie");
|
||||||
|
if (duur === "ongeldig" || domeinen === "ongeldig" || basis === "ongeldig") {
|
||||||
|
redirect(`/expert?project=${projectId}&error=score`);
|
||||||
|
}
|
||||||
|
const rawAdvies = (formData.get("bonus_malus_advies") as string) ?? "";
|
||||||
|
const advies = rawAdvies && BONUS_MALUS_OPTIONS.some((o) => o.id === rawAdvies) ? rawAdvies : null;
|
||||||
|
const toelichting = ((formData.get("toelichting") as string) ?? "").trim().slice(0, 10_000) || null;
|
||||||
|
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO expert_beoordelingen
|
||||||
|
(project_id, expert_id, duur_impact, levensdomeinen, basissituatie, bonus_malus_advies, toelichting)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (project_id, expert_id) DO UPDATE SET
|
||||||
|
duur_impact = excluded.duur_impact, levensdomeinen = excluded.levensdomeinen,
|
||||||
|
basissituatie = excluded.basissituatie, bonus_malus_advies = excluded.bonus_malus_advies,
|
||||||
|
toelichting = excluded.toelichting, updated_at = datetime('now')`
|
||||||
|
)
|
||||||
|
.run(projectId, expert.id, duur, domeinen, basis, advies, toelichting);
|
||||||
|
|
||||||
|
redirect(`/expert?project=${projectId}&opgeslagen=1`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einde van "Inloggen als": terug naar de eigen admin-sessie
|
||||||
|
export async function stopImpersonation() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user?.impersonator) redirect("/login");
|
||||||
|
await createSession({ userId: user.impersonator.id, email: user.impersonator.email, name: user.impersonator.name });
|
||||||
|
redirect("/experts");
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { requireExpert } from "../../lib/auth";
|
||||||
|
import { listAccountNames } from "../../lib/platform-db";
|
||||||
|
import { listBeoordelingen, listProjectenVoorExpert } from "../../lib/experts";
|
||||||
|
import { BONUS_MALUS_OPTIONS, EXPERT_CRITERIA } from "../../lib/rikxCalculatie";
|
||||||
|
import { logout } from "../tenants/logout";
|
||||||
|
import { saveBeoordeling, stopImpersonation } from "./actions";
|
||||||
|
import { ProjectPlanView } from "./ProjectPlanView";
|
||||||
|
|
||||||
|
// De enige pagina voor experts: projecten in waardebepaling uit hun rondes, het project plan om te lezen
|
||||||
|
// en hun eigen beoordeling. Experts zien niet wat andere experts hebben ingevuld.
|
||||||
|
export default async function ExpertPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ project?: string; opgeslagen?: string; error?: string }>;
|
||||||
|
}) {
|
||||||
|
const expert = await requireExpert();
|
||||||
|
const { project: projectParam, opgeslagen, error } = await searchParams;
|
||||||
|
|
||||||
|
const projecten = listProjectenVoorExpert(expert.id);
|
||||||
|
const eigen = listBeoordelingen(projecten.map((p) => p.id)).filter((b) => b.expert_id === expert.id);
|
||||||
|
const impactmakers = new Map(listAccountNames().map((a) => [a.id, a.name]));
|
||||||
|
const gekozen = projecten.find((p) => String(p.id) === projectParam) ?? projecten[0] ?? null;
|
||||||
|
const beoordeling = gekozen ? eigen.find((b) => b.project_id === gekozen.id) ?? null : null;
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full h-10 rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-rikx-orange";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||||
|
<header className="bg-rikx-orange">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-3 flex items-center gap-6">
|
||||||
|
<span className="text-white text-4xl font-extrabold tracking-tight leading-none">Rikx</span>
|
||||||
|
<span className="text-white/90 text-lg font-semibold">Expertteam</span>
|
||||||
|
<div className="ml-auto flex items-center gap-4 text-sm text-white">
|
||||||
|
<span className="hidden sm:inline">{expert.name}</span>
|
||||||
|
{!expert.impersonator && (
|
||||||
|
<form action={logout}>
|
||||||
|
<button className="hover:text-rikx-maroon transition-colors">Uitloggen</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{expert.impersonator && (
|
||||||
|
<div className="bg-rikx-maroon text-white">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-2.5 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
|
||||||
|
<span>
|
||||||
|
Je bekijkt deze pagina als <strong>{expert.name}</strong>. Alleen lezen: beoordelingen kun je hier niet
|
||||||
|
opslaan.
|
||||||
|
</span>
|
||||||
|
<form action={stopImpersonation} className="ml-auto">
|
||||||
|
<button className="rounded-md bg-white px-3 py-1 text-sm font-medium text-rikx-maroon hover:bg-orange-50">
|
||||||
|
Terug naar beheer
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<main className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||||
|
{projecten.length === 0 ? (
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-8 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
Er staan op dit moment geen projecten voor je klaar om te beoordelen.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[18rem_1fr] gap-6 items-start">
|
||||||
|
<nav aria-label="Projecten" className="flex flex-col gap-2 lg:sticky lg:top-6">
|
||||||
|
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
Projecten in waardebepaling ({projecten.length})
|
||||||
|
</h2>
|
||||||
|
{projecten.map((p) => {
|
||||||
|
const klaar = eigen.some((b) => b.project_id === p.id);
|
||||||
|
const actief = gekozen?.id === p.id;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
href={`/expert?project=${p.id}`}
|
||||||
|
aria-current={actief ? "page" : undefined}
|
||||||
|
className={`rounded-lg border p-3 transition-colors ${
|
||||||
|
actief
|
||||||
|
? "border-rikx-orange bg-orange-50 dark:bg-zinc-800"
|
||||||
|
: "border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 hover:border-rikx-orange"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="block text-sm font-medium text-zinc-900 dark:text-zinc-100">{p.name}</span>
|
||||||
|
<span className="block text-xs text-zinc-500">{p.ronde_label}</span>
|
||||||
|
<span
|
||||||
|
className={`mt-2 inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
|
klaar ? "bg-green-100 text-green-800" : "bg-zinc-100 text-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{klaar ? "Beoordeeld" : "Nog te beoordelen"}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{gekozen && (
|
||||||
|
<div className="flex flex-col gap-6 min-w-0">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">{gekozen.name}</h1>
|
||||||
|
<p className="text-sm text-zinc-500 mt-1">
|
||||||
|
{gekozen.ronde_label}
|
||||||
|
{gekozen.impactmaker_account_id && impactmakers.get(gekozen.impactmaker_account_id)
|
||||||
|
? ` · Impactmaker: ${impactmakers.get(gekozen.impactmaker_account_id)}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6">
|
||||||
|
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100 mb-5">Project plan</h2>
|
||||||
|
<ProjectPlanView project={gekozen} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form
|
||||||
|
action={saveBeoordeling}
|
||||||
|
// Nieuwe beginwaarden per project
|
||||||
|
key={gekozen.id}
|
||||||
|
className="bg-white dark:bg-zinc-900 rounded-xl border-2 border-rikx-orange p-6 flex flex-col gap-5"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="project_id" value={gekozen.id} />
|
||||||
|
{/* Bij meekijken door een admin: alles zichtbaar maar niet te wijzigen */}
|
||||||
|
<fieldset disabled={Boolean(expert.impersonator)} className="contents">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100">Jouw waardebepaling</h2>
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
Geef per criterium een rapportcijfer van 1 tot 10. Andere experts zien jouw beoordeling niet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{opgeslagen && <p className="text-sm text-green-700 dark:text-green-400">Je beoordeling is opgeslagen.</p>}
|
||||||
|
{error === "meekijken" && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">Niet opgeslagen: je kijkt mee als admin.</p>
|
||||||
|
)}
|
||||||
|
{error === "score" && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">Niet opgeslagen: cijfers moeten tussen 1 en 10 liggen.</p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
{EXPERT_CRITERIA.map((c) => (
|
||||||
|
<label key={c.id} className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">{c.label}</span>
|
||||||
|
<input
|
||||||
|
name={c.id}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
step="any"
|
||||||
|
inputMode="decimal"
|
||||||
|
defaultValue={beoordeling?.[c.id] ?? ""}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-zinc-500">{c.schaal}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label className="flex flex-col gap-1 sm:max-w-xs">
|
||||||
|
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Advies Bonus / Malus</span>
|
||||||
|
<select name="bonus_malus_advies" defaultValue={beoordeling?.bonus_malus_advies ?? ""} className={inputClass}>
|
||||||
|
{BONUS_MALUS_OPTIONS.map((o) => (
|
||||||
|
<option key={o.label} value={o.id}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
Extra waardering of aftrek op basis van de aanvullende criteria (evidence, monitoring, innovatie, risico's).
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Toelichting</span>
|
||||||
|
<textarea
|
||||||
|
name="toelichting"
|
||||||
|
defaultValue={beoordeling?.toelichting ?? ""}
|
||||||
|
placeholder="Onderbouwing van je cijfers: sterke punten en aandachtspunten…"
|
||||||
|
className="w-full min-h-36 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"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Beoordeling opslaan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useState } from "react";
|
||||||
|
import type { RikxRonde } from "../../lib/db";
|
||||||
|
import { createExpert, deleteExpert, resendInvite, type InviteState } from "./actions";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
// Melding na het aanmaken of opnieuw uitnodigen; bij een link (geen SMTP) met kopieerknop
|
||||||
|
export function InviteResult({ state }: { state: InviteState }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
if (state.status === "idle") return null;
|
||||||
|
if (state.status === "error") return <p className="text-sm text-red-600 dark:text-red-400">{state.message}</p>;
|
||||||
|
if (state.status === "sent") return <p className="text-sm text-green-700 dark:text-green-400">{state.message}</p>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg bg-orange-50 dark:bg-zinc-800 px-4 py-3 text-sm">
|
||||||
|
<p className="text-zinc-800 dark:text-zinc-200">{state.message}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input readOnly value={state.link} onFocus={(e) => e.target.select()} className={`${inputClass} flex-1 min-w-0 font-mono text-xs`} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
await navigator.clipboard.writeText(state.link);
|
||||||
|
setCopied(true);
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-zinc-300 dark:border-zinc-700 px-3 h-10 text-sm hover:bg-white dark:hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
{copied ? "Gekopieerd" : "Kopiëren"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RondeCheckboxes({ rondes, selected = [] }: { rondes: RikxRonde[]; selected?: number[] }) {
|
||||||
|
if (rondes.length === 0) {
|
||||||
|
return <p className="text-sm text-zinc-500">Er zijn nog geen Rikx rondes.</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||||
|
{rondes.map((r) => (
|
||||||
|
<label key={r.id} className="flex items-center gap-2 text-sm text-zinc-800 dark:text-zinc-200">
|
||||||
|
<input type="checkbox" name="ronde_ids" value={r.id} defaultChecked={selected.includes(r.id)} className="accent-rikx-orange" />
|
||||||
|
{r.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewExpertForm({ rondes }: { rondes: RikxRonde[] }) {
|
||||||
|
const [state, action, pending] = useActionState(createExpert, { status: "idle" } as InviteState);
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={action}
|
||||||
|
// Na succes leeg beginnen voor de volgende expert
|
||||||
|
key={state.status === "sent" || state.status === "link" ? state.message : "form"}
|
||||||
|
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-2 gap-4"
|
||||||
|
>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className={labelClass}>Naam</span>
|
||||||
|
<input name="name" required className={inputClass} />
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className={labelClass}>E-mailadres</span>
|
||||||
|
<input name="email" type="email" required className={inputClass} />
|
||||||
|
</label>
|
||||||
|
<fieldset className="sm:col-span-2 flex flex-col gap-2">
|
||||||
|
<legend className={`${labelClass} mb-2`}>Rikx rondes</legend>
|
||||||
|
<RondeCheckboxes rondes={rondes} />
|
||||||
|
<span className="text-xs text-zinc-500">De expert ziet alleen projecten in waardebepaling uit deze rondes.</span>
|
||||||
|
</fieldset>
|
||||||
|
<div className="sm:col-span-2 flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded-lg bg-rikx-maroon hover:bg-rikx-maroon-light px-5 py-2 text-sm font-medium text-white transition-colors disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{pending ? "Bezig…" : "Expert uitnodigen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<InviteResult state={state} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResendInviteButton({ id, label }: { id: number; label: string }) {
|
||||||
|
const [state, action, pending] = useActionState(resendInvite, { status: "idle" } as InviteState);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<form action={action}>
|
||||||
|
<input type="hidden" name="id" value={id} />
|
||||||
|
<button type="submit" disabled={pending} className="text-xs text-rikx-orange-dark hover:underline disabled:opacity-60">
|
||||||
|
{pending ? "Bezig…" : label}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{state.status !== "idle" && (
|
||||||
|
<div className="w-80 max-w-full text-left">
|
||||||
|
<InviteResult state={state} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteExpertButton({ id, name, aantalBeoordelingen }: { id: number; name: string; aantalBeoordelingen: number }) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={deleteExpert}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
const extra =
|
||||||
|
aantalBeoordelingen > 0
|
||||||
|
? ` Ook ${aantalBeoordelingen === 1 ? "de beoordeling" : `de ${aantalBeoordelingen} beoordelingen`} van deze expert ${aantalBeoordelingen === 1 ? "wordt" : "worden"} verwijderd; dat verandert de Rikx calculatie van die projecten.`
|
||||||
|
: "";
|
||||||
|
if (!confirm(`Weet je zeker dat je expert "${name}" 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,73 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { requireAdmin } from "../../../lib/auth";
|
||||||
|
import { listRikxRondes } from "../../../lib/db";
|
||||||
|
import { getExpert } from "../../../lib/experts";
|
||||||
|
import { AppHeader } from "../../AppHeader";
|
||||||
|
import { updateExpert } from "../actions";
|
||||||
|
import { RondeCheckboxes } from "../InviteForms";
|
||||||
|
|
||||||
|
export default async function EditExpertPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ error?: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const { id } = await params;
|
||||||
|
const { error } = await searchParams;
|
||||||
|
const expert = getExpert(Number(id));
|
||||||
|
if (!expert) notFound();
|
||||||
|
|
||||||
|
const errorMsg =
|
||||||
|
error === "email" ? "Er bestaat al een gebruiker met dit e-mailadres." : error === "missing" ? "Vul een naam en een geldig e-mailadres in." : null;
|
||||||
|
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";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||||
|
<AppHeader email={session.email} active="crm" />
|
||||||
|
|
||||||
|
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-10 flex flex-col gap-6">
|
||||||
|
<Link href="/experts" className="text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors">
|
||||||
|
← Terug naar experts
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Expert bewerken</h1>
|
||||||
|
{errorMsg && <p className="text-sm text-red-600 dark:text-red-400">{errorMsg}</p>}
|
||||||
|
<form
|
||||||
|
action={updateExpert}
|
||||||
|
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-2 gap-4"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={expert.id} />
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Naam</span>
|
||||||
|
<input name="name" required defaultValue={expert.name} className={inputClass} />
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">E-mailadres</span>
|
||||||
|
<input name="email" type="email" required defaultValue={expert.email} className={inputClass} />
|
||||||
|
</label>
|
||||||
|
<fieldset className="sm:col-span-2 flex flex-col gap-2">
|
||||||
|
<legend className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">Rikx rondes</legend>
|
||||||
|
<RondeCheckboxes rondes={listRikxRondes()} selected={expert.rondeIds} />
|
||||||
|
</fieldset>
|
||||||
|
<div className="sm:col-span-2 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="/experts"
|
||||||
|
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,130 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { createSession, requireAdmin } from "../../lib/auth";
|
||||||
|
import { getDb } from "../../lib/db";
|
||||||
|
import { UITNODIGING_GELDIG_DAGEN, createInvite, getExpert, setExpertRondes } from "../../lib/experts";
|
||||||
|
import { isMailConfigured, sendMail } from "../../lib/mail";
|
||||||
|
|
||||||
|
export type InviteState =
|
||||||
|
| { status: "idle" }
|
||||||
|
| { status: "error"; message: string }
|
||||||
|
| { status: "sent"; message: string }
|
||||||
|
| { status: "link"; message: string; link: string };
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
function readRondeIds(formData: FormData): number[] {
|
||||||
|
const bestaand = new Set(
|
||||||
|
(getDb().prepare("SELECT id FROM rikx_rondes").all() as { id: number }[]).map((r) => r.id)
|
||||||
|
);
|
||||||
|
return formData
|
||||||
|
.getAll("ronde_ids")
|
||||||
|
.map(Number)
|
||||||
|
.filter((id) => bestaand.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function baseUrl(): Promise<string> {
|
||||||
|
const h = await headers();
|
||||||
|
const host = h.get("x-forwarded-host") ?? h.get("host") ?? "localhost:3000";
|
||||||
|
const proto = h.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
|
||||||
|
return `${proto}://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maakt een uitnodiging en mailt die; zonder SMTP komt de link terug om zelf door te geven.
|
||||||
|
async function invite(userId: number, name: string, email: string): Promise<InviteState> {
|
||||||
|
const link = `${await baseUrl()}/uitnodiging/${createInvite(userId)}`;
|
||||||
|
if (!isMailConfigured()) {
|
||||||
|
return {
|
||||||
|
status: "link",
|
||||||
|
message: `E-mail is nog niet ingesteld (SMTP). Geef deze link zelf door aan ${name}; hij is ${UITNODIGING_GELDIG_DAGEN} dagen geldig.`,
|
||||||
|
link,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await sendMail({
|
||||||
|
to: email,
|
||||||
|
subject: "Uitnodiging Rikx expertteam",
|
||||||
|
text: `Beste ${name},\n\nJe bent uitgenodigd voor het Rikx expertteam. Via onderstaande link stel je je wachtwoord in en kun je de projecten in waardebepaling beoordelen.\n\n${link}\n\nDe link is ${UITNODIGING_GELDIG_DAGEN} dagen geldig.\n\nMet vriendelijke groet,\nRikx`,
|
||||||
|
});
|
||||||
|
return { status: "sent", message: `Uitnodiging verstuurd naar ${email}.` };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Uitnodiging versturen mislukt:", err);
|
||||||
|
return {
|
||||||
|
status: "link",
|
||||||
|
message: `Versturen van de e-mail is mislukt. Geef deze link zelf door aan ${name}; hij is ${UITNODIGING_GELDIG_DAGEN} dagen geldig.`,
|
||||||
|
link,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createExpert(_prev: InviteState, formData: FormData): Promise<InviteState> {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const name = ((formData.get("name") as string) ?? "").trim();
|
||||||
|
const email = ((formData.get("email") as string) ?? "").trim().toLowerCase();
|
||||||
|
if (!name || !EMAIL_RE.test(email)) return { status: "error", message: "Vul een naam en een geldig e-mailadres in." };
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
if (db.prepare("SELECT id FROM users WHERE lower(email) = ?").get(email)) {
|
||||||
|
return { status: "error", message: "Er bestaat al een gebruiker met dit e-mailadres." };
|
||||||
|
}
|
||||||
|
// Lege password_hash: inloggen kan pas na het instellen van een wachtwoord via de uitnodiging
|
||||||
|
const result = db
|
||||||
|
.prepare("INSERT INTO users (email, password_hash, name, role) VALUES (?, '', ?, 'expert')")
|
||||||
|
.run(email, name);
|
||||||
|
const id = Number(result.lastInsertRowid);
|
||||||
|
setExpertRondes(id, readRondeIds(formData));
|
||||||
|
// Lijst bovenaan de pagina bijwerken (het formulier blijft staan om de link te tonen)
|
||||||
|
revalidatePath("/experts");
|
||||||
|
|
||||||
|
return invite(id, name, email);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resendInvite(_prev: InviteState, formData: FormData): Promise<InviteState> {
|
||||||
|
await requireAdmin();
|
||||||
|
const expert = getExpert(Number(formData.get("id")));
|
||||||
|
if (!expert) return { status: "error", message: "Expert niet gevonden." };
|
||||||
|
return invite(expert.id, expert.name, expert.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateExpert(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const id = Number(formData.get("id"));
|
||||||
|
const expert = getExpert(id);
|
||||||
|
if (!expert) redirect("/experts");
|
||||||
|
|
||||||
|
const name = ((formData.get("name") as string) ?? "").trim();
|
||||||
|
const email = ((formData.get("email") as string) ?? "").trim().toLowerCase();
|
||||||
|
if (!name || !EMAIL_RE.test(email)) redirect(`/experts/${id}?error=missing`);
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
if (db.prepare("SELECT id FROM users WHERE lower(email) = ? AND id != ?").get(email, id)) {
|
||||||
|
redirect(`/experts/${id}?error=email`);
|
||||||
|
}
|
||||||
|
db.prepare("UPDATE users SET name = ?, email = ? WHERE id = ? AND role = 'expert'").run(name, email, id);
|
||||||
|
setExpertRondes(id, readRondeIds(formData));
|
||||||
|
|
||||||
|
redirect("/experts");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteExpert(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const id = Number(formData.get("id"));
|
||||||
|
// Beoordelingen, rondes en uitnodigingen van de expert verdwijnen mee (ON DELETE CASCADE)
|
||||||
|
if (id) getDb().prepare("DELETE FROM users WHERE id = ? AND role = 'expert'").run(id);
|
||||||
|
redirect("/experts");
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Inloggen als": de admin bekijkt de expertpagina zoals deze expert hem ziet (alleen lezen).
|
||||||
|
// Terug via "Terug naar beheer" op de expertpagina (app/expert/actions.ts).
|
||||||
|
export async function impersonateExpert(formData: FormData) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const expert = getExpert(Number(formData.get("id")));
|
||||||
|
if (!expert) redirect("/experts");
|
||||||
|
await createSession({ userId: expert.id, email: expert.email, name: expert.name, impersonatorId: admin.id });
|
||||||
|
redirect("/expert");
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { requireAdmin } from "../../lib/auth";
|
||||||
|
import { listRikxRondes } from "../../lib/db";
|
||||||
|
import { listExperts } from "../../lib/experts";
|
||||||
|
import { AppHeader } from "../AppHeader";
|
||||||
|
import { impersonateExpert } from "./actions";
|
||||||
|
import { DeleteExpertButton, NewExpertForm, ResendInviteButton } from "./InviteForms";
|
||||||
|
|
||||||
|
export default async function ExpertsPage() {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const experts = listExperts();
|
||||||
|
const rondes = listRikxRondes();
|
||||||
|
const rondeLabel = new Map(rondes.map((r) => [r.id, r.label]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||||
|
<AppHeader email={session.email} active="crm" />
|
||||||
|
|
||||||
|
<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-1">Experts ({experts.length})</h1>
|
||||||
|
<p className="text-sm text-zinc-500 mb-4">
|
||||||
|
Experts loggen in op hun eigen pagina en beoordelen daar de projecten in waardebepaling van hun rondes.
|
||||||
|
</p>
|
||||||
|
{experts.length === 0 ? (
|
||||||
|
<p className="text-sm text-zinc-500">Nog geen experts uitgenodigd.</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">Naam</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">E-mailadres</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Rikx rondes</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Beoordelingen</th>
|
||||||
|
<th className="px-4 py-3" colSpan={4}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||||
|
{experts.map((e) => (
|
||||||
|
<tr key={e.id} className="bg-white dark:bg-zinc-950 align-top">
|
||||||
|
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{e.name}</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-600 dark:text-zinc-400">{e.email}</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-700 dark:text-zinc-300">
|
||||||
|
{e.rondeIds.length ? e.rondeIds.map((id) => rondeLabel.get(id)).join(", ") : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||||
|
e.actief ? "bg-green-100 text-green-800" : "bg-zinc-100 text-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{e.actief ? "Actief" : "Uitgenodigd"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{e.aantalBeoordelingen}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{/* Opnieuw uitnodigen is ook de manier om een vergeten wachtwoord opnieuw in te stellen */}
|
||||||
|
<ResendInviteButton id={e.id} label={e.actief ? "Nieuwe inloglink" : "Opnieuw uitnodigen"} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{/* Bekijk de expertpagina zoals deze expert hem ziet (alleen lezen) */}
|
||||||
|
<form action={impersonateExpert}>
|
||||||
|
<input type="hidden" name="id" value={e.id} />
|
||||||
|
<button type="submit" className="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 whitespace-nowrap">
|
||||||
|
Inloggen als ↗
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<Link href={`/experts/${e.id}`} className="text-xs text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100">
|
||||||
|
Bewerken
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<DeleteExpertButton id={e.id} name={e.name} aantalBeoordelingen={e.aantalBeoordelingen} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100 mb-4">Nieuwe expert uitnodigen</h2>
|
||||||
|
<NewExpertForm rondes={rondes} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getUserByEmail, verifyPassword, createSession } from "../../lib/auth";
|
import { getUserByEmail, verifyPassword, createSession, homeForRole } from "../../lib/auth";
|
||||||
|
|
||||||
export async function login(formData: FormData) {
|
export async function login(formData: FormData) {
|
||||||
const email = formData.get("email") as string;
|
const email = formData.get("email") as string;
|
||||||
@@ -11,5 +11,5 @@ export async function login(formData: FormData) {
|
|||||||
redirect("/login?error=1");
|
redirect("/login?error=1");
|
||||||
}
|
}
|
||||||
await createSession({ userId: user.id, email: user.email, name: user.name });
|
await createSession({ userId: user.id, email: user.email, name: user.name });
|
||||||
redirect("/tenants");
|
redirect(homeForRole(user.role === "expert" ? "expert" : "admin"));
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getSession } from "../../lib/auth";
|
import { getCurrentUser, homeForRole } from "../../lib/auth";
|
||||||
import { login } from "./actions";
|
import { login } from "./actions";
|
||||||
|
|
||||||
export default async function LoginPage({
|
export default async function LoginPage({
|
||||||
@@ -7,8 +7,8 @@ export default async function LoginPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ error?: string }>;
|
searchParams: Promise<{ error?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await getSession();
|
const user = await getCurrentUser();
|
||||||
if (session) redirect("/tenants");
|
if (user) redirect(homeForRole(user.role));
|
||||||
|
|
||||||
const { error } = await searchParams;
|
const { error } = await searchParams;
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getSession } from "../lib/auth";
|
import { getCurrentUser, homeForRole } from "../lib/auth";
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const session = await getSession();
|
const user = await getCurrentUser();
|
||||||
redirect(session ? "/tenants" : "/login");
|
redirect(user ? homeForRole(user.role) : "/login");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { ExpertBeoordeling } from "../../../lib/db";
|
||||||
|
import { BONUS_MALUS_OPTIONS, EXPERT_CRITERIA } from "../../../lib/rikxCalculatie";
|
||||||
|
|
||||||
|
export type BeoordelingMetNaam = ExpertBeoordeling & { expert_name: string };
|
||||||
|
|
||||||
|
const getal = new Intl.NumberFormat("nl-NL", { maximumFractionDigits: 2 });
|
||||||
|
const fmt = (v: number | null) => (v === null ? "—" : getal.format(v));
|
||||||
|
|
||||||
|
function gemiddelde(b: BeoordelingMetNaam): number | null {
|
||||||
|
const scores = [b.duur_impact, b.levensdomeinen, b.basissituatie].filter((v): v is number => v !== null);
|
||||||
|
return scores.length ? scores.reduce((a, c) => a + c, 0) / scores.length : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bonusMalusLabel(id: string | null): string {
|
||||||
|
if (!id) return "Geen";
|
||||||
|
return BONUS_MALUS_OPTIONS.find((o) => o.id === id)?.label ?? id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alleen-lezen overzicht van de beoordelingen die experts via hun eigen pagina hebben ingevuld.
|
||||||
|
// Impactscore M in de Rikx calculatie is het gemiddelde van de kolom "Gemiddelde".
|
||||||
|
export function ExpertBeoordelingen({ beoordelingen }: { beoordelingen: BeoordelingMetNaam[] }) {
|
||||||
|
if (beoordelingen.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-zinc-500 rounded-lg bg-zinc-50 dark:bg-zinc-800/50 px-4 py-3">
|
||||||
|
Nog geen beoordelingen. Experts vullen hun scores in op hun eigen pagina; beheer de experts bij{" "}
|
||||||
|
<Link href="/experts" className="underline hover:text-rikx-orange-dark">
|
||||||
|
CRM → Experts
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cell = "py-2 px-3 text-right tabular-nums";
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-zinc-500 border-b border-zinc-200 dark:border-zinc-800">
|
||||||
|
<th className="text-left font-medium py-2 pr-3">Criterium</th>
|
||||||
|
{beoordelingen.map((b) => (
|
||||||
|
<th key={b.expert_id} className="text-right font-medium py-2 px-3 whitespace-nowrap">
|
||||||
|
{b.expert_name}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||||
|
{EXPERT_CRITERIA.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td className="py-2 pr-3 text-zinc-700 dark:text-zinc-300 whitespace-nowrap">
|
||||||
|
{c.label}
|
||||||
|
<span className="block text-xs text-zinc-500">{c.schaal}</span>
|
||||||
|
</td>
|
||||||
|
{beoordelingen.map((b) => (
|
||||||
|
<td key={b.expert_id} className={cell}>
|
||||||
|
{fmt(b[c.id])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
<td className="py-2 pr-3">Gemiddelde</td>
|
||||||
|
{beoordelingen.map((b) => (
|
||||||
|
<td key={b.expert_id} className={cell}>
|
||||||
|
{fmt(gemiddelde(b))}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 pr-3 text-zinc-700 dark:text-zinc-300">Advies Bonus / Malus</td>
|
||||||
|
{beoordelingen.map((b) => (
|
||||||
|
<td key={b.expert_id} className={cell}>
|
||||||
|
{bonusMalusLabel(b.bonus_malus_advies)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{beoordelingen.some((b) => b.toelichting) && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Toelichting van de experts</h4>
|
||||||
|
{beoordelingen
|
||||||
|
.filter((b) => b.toelichting)
|
||||||
|
.map((b) => (
|
||||||
|
<blockquote
|
||||||
|
key={b.expert_id}
|
||||||
|
className="rounded-lg border-l-2 border-rikx-orange bg-zinc-50 dark:bg-zinc-800/50 px-4 py-2 text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-line"
|
||||||
|
>
|
||||||
|
<span className="block text-xs font-medium text-zinc-500 mb-1">{b.expert_name}</span>
|
||||||
|
{b.toelichting}
|
||||||
|
</blockquote>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRef, useState, useTransition } from "react";
|
import { useRef, useState } from "react";
|
||||||
import type { Project, ProjectExpertScore, RikxRonde } from "../../../lib/db";
|
import type { Project, RikxRonde } from "../../../lib/db";
|
||||||
import { PROJECT_STATUSES } from "../../../lib/projectStatus";
|
import { PROJECT_STATUSES, RIKX_CALCULATIE_VANAF_STATUS, isStatusVanaf } from "../../../lib/projectStatus";
|
||||||
import {
|
import {
|
||||||
BONUS_MALUS_OPTIONS,
|
BONUS_MALUS_OPTIONS,
|
||||||
EXPERT_COUNT,
|
REKENMETHODES,
|
||||||
EXPERT_CRITERIA,
|
|
||||||
calculateRikx,
|
calculateRikx,
|
||||||
|
rikxInputFromProject,
|
||||||
type ExpertScore,
|
type ExpertScore,
|
||||||
} from "../../../lib/rikxCalculatie";
|
} from "../../../lib/rikxCalculatie";
|
||||||
import { parseProjectPlanJson } from "../../../lib/projectPlan";
|
import { parseProjectPlanJson } from "../../../lib/projectPlan";
|
||||||
import { setRikxCalculatie, updateProject } from "../actions";
|
import { updateProject } from "../actions";
|
||||||
|
import { PROJECT_FORM_ID } from "./formId";
|
||||||
import { ProjectPlanFields } from "./ProjectPlanFields";
|
import { ProjectPlanFields } from "./ProjectPlanFields";
|
||||||
|
import { ExpertBeoordelingen, type BeoordelingMetNaam } from "./ExpertBeoordelingen";
|
||||||
|
|
||||||
const inputClass =
|
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";
|
"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";
|
||||||
@@ -30,7 +32,7 @@ function rondeById(rondes: RikxRonde[], id: number | null) {
|
|||||||
return rondes.find((r) => r.id === id) ?? null;
|
return rondes.find((r) => r.id === id) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readInput(form: HTMLFormElement, rondes: RikxRonde[]) {
|
function readInput(form: HTMLFormElement, rondes: RikxRonde[], expertScores: ExpertScore[]) {
|
||||||
const fd = new FormData(form);
|
const fd = new FormData(form);
|
||||||
const ronde = rondeById(rondes, Number(fd.get("ronde_id")) || null);
|
const ronde = rondeById(rondes, Number(fd.get("ronde_id")) || null);
|
||||||
return {
|
return {
|
||||||
@@ -42,9 +44,8 @@ function readInput(form: HTMLFormElement, rondes: RikxRonde[]) {
|
|||||||
bonus_malus: (fd.get("bonus_malus") as string) || null,
|
bonus_malus: (fd.get("bonus_malus") as string) || null,
|
||||||
rikx_waarde: ronde?.rikx_waarde ?? null,
|
rikx_waarde: ronde?.rikx_waarde ?? null,
|
||||||
rikx_prijs: ronde?.rikx_prijs ?? null,
|
rikx_prijs: ronde?.rikx_prijs ?? null,
|
||||||
expert_scores: Array.from({ length: EXPERT_COUNT }, (_, i) =>
|
// Expertscores komen van de experts zelf en zijn hier niet te wijzigen
|
||||||
Object.fromEntries(EXPERT_CRITERIA.map((c) => [c.id, toNumber(fd.get(`expert_${i + 1}_${c.id}`))]))
|
expert_scores: expertScores,
|
||||||
) as ExpertScore[],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,58 +72,95 @@ function Field({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StepHeading({ step, title, description }: { step: number; title: string; description: string }) {
|
function TabHeading({ title, description }: { title: string; description: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 mb-5">
|
<div className="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>
|
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100">{title}</h2>
|
||||||
<p className="text-sm text-zinc-500">{description}</p>
|
<p className="text-sm text-zinc-500">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectTab = "plan" | "calculatie";
|
||||||
|
|
||||||
|
const BEOORDELINGEN = [
|
||||||
|
{ id: "goedgekeurd", label: "Goedgekeurd", activeClass: "bg-green-600 text-white" },
|
||||||
|
{ id: "afgekeurd", label: "Afgekeurd", activeClass: "bg-red-600 text-white" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Schakelaar Goedgekeurd | Afgekeurd. Begint leeg (nog niet beoordeeld);
|
||||||
|
// nogmaals op de actieve keuze klikken maakt de keuze ongedaan.
|
||||||
|
function BeoordelingSwitch({ defaultValue }: { defaultValue: string | null }) {
|
||||||
|
const [value, setValue] = useState(defaultValue ?? "");
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<input type="hidden" name="beoordeling" value={value} />
|
||||||
|
<div role="radiogroup" aria-label="Beoordeling" className="inline-flex rounded-lg bg-zinc-100 dark:bg-zinc-800 p-1">
|
||||||
|
{BEOORDELINGEN.map((b) => {
|
||||||
|
const active = value === b.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={b.id}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={active}
|
||||||
|
onClick={() => setValue(active ? "" : b.id)}
|
||||||
|
className={`rounded-md px-5 py-2 text-sm font-medium transition-colors ${
|
||||||
|
active ? b.activeClass : "text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-zinc-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{b.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{!value && <span className="text-xs text-zinc-500">Nog niet beoordeeld</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectDetailForm({
|
export function ProjectDetailForm({
|
||||||
project,
|
project,
|
||||||
expertScores,
|
beoordelingen,
|
||||||
rondes,
|
rondes,
|
||||||
|
impactmakers,
|
||||||
|
initialTab,
|
||||||
}: {
|
}: {
|
||||||
project: Project;
|
project: Project;
|
||||||
expertScores: ProjectExpertScore[];
|
beoordelingen: BeoordelingMetNaam[];
|
||||||
rondes: RikxRonde[];
|
rondes: RikxRonde[];
|
||||||
|
impactmakers: { id: number; name: string }[];
|
||||||
|
initialTab: ProjectTab;
|
||||||
}) {
|
}) {
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const [calcOn, setCalcOn] = useState(project.rikx_calculatie_aan === 1);
|
// Live status uit de dropdown, zodat de tab Rikx calculatie direct verschijnt of verdwijnt.
|
||||||
const [pending, startTransition] = useTransition();
|
const [status, setStatus] = useState(project.status);
|
||||||
|
const calculatieZichtbaar = isStatusVanaf(status, RIKX_CALCULATIE_VANAF_STATUS);
|
||||||
|
const [tab, setTab] = useState<ProjectTab>(initialTab);
|
||||||
|
const activeTab: ProjectTab = tab === "calculatie" && calculatieZichtbaar ? "calculatie" : "plan";
|
||||||
const initialRonde = rondeById(rondes, project.ronde_id);
|
const initialRonde = rondeById(rondes, project.ronde_id);
|
||||||
const [input, setInput] = useState(() => ({
|
const [input, setInput] = useState(() => ({
|
||||||
ronde_id: initialRonde?.id ?? null,
|
ronde_id: initialRonde?.id ?? null,
|
||||||
projectkosten: project.projectkosten,
|
...rikxInputFromProject(project, beoordelingen, initialRonde),
|
||||||
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 result = calculateRikx(input);
|
||||||
const ronde = rondeById(rondes, input.ronde_id);
|
const ronde = rondeById(rondes, input.ronde_id);
|
||||||
|
|
||||||
function toggleCalc() {
|
// De browser weigert te versturen bij een ongeldig veld, maar toont geen melding als dat veld op een
|
||||||
const next = !calcOn;
|
// verborgen tab staat. Daarom: naar die tab gaan en de melding bij het eerste ongeldige veld tonen.
|
||||||
setCalcOn(next);
|
const invalidHandledRef = useRef(false);
|
||||||
startTransition(() => setRikxCalculatie(project.id, next));
|
function handleInvalid(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
if (invalidHandledRef.current) return;
|
||||||
|
const field = e.target as HTMLInputElement;
|
||||||
|
const panel = field.closest<HTMLElement>("[role=tabpanel]");
|
||||||
|
if (!panel?.hidden) return;
|
||||||
|
invalidHandledRef.current = true;
|
||||||
|
setTab(panel.id === "panel-calculatie" ? "calculatie" : "plan");
|
||||||
|
setTimeout(() => {
|
||||||
|
invalidHandledRef.current = false;
|
||||||
|
field.reportValidity();
|
||||||
|
field.focus();
|
||||||
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: { label: string; value: string; strong?: boolean }[] = [
|
const results: { label: string; value: string; strong?: boolean }[] = [
|
||||||
@@ -142,19 +180,22 @@ export function ProjectDetailForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
|
id={PROJECT_FORM_ID}
|
||||||
ref={formRef}
|
ref={formRef}
|
||||||
action={updateProject}
|
action={updateProject}
|
||||||
onInput={() => formRef.current && setInput(readInput(formRef.current, rondes))}
|
onInput={() => formRef.current && setInput(readInput(formRef.current, rondes, input.expert_scores))}
|
||||||
|
onInvalidCapture={handleInvalid}
|
||||||
className="flex flex-col gap-6"
|
className="flex flex-col gap-6"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={project.id} />
|
<input type="hidden" name="id" value={project.id} />
|
||||||
|
<input type="hidden" name="tab" value={activeTab} />
|
||||||
|
|
||||||
<section className={`${cardClass} grid grid-cols-1 sm:grid-cols-3 gap-4`}>
|
<section className={`${cardClass} grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4`}>
|
||||||
<Field label="Projectnaam">
|
<Field label="Projectnaam">
|
||||||
<input name="name" required defaultValue={project.name} className={inputClass} />
|
<input name="name" required defaultValue={project.name} className={inputClass} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Status">
|
<Field label="Status">
|
||||||
<select name="status" defaultValue={project.status} className={inputClass}>
|
<select name="status" value={status} onChange={(e) => setStatus(e.target.value)} className={inputClass}>
|
||||||
{PROJECT_STATUSES.map((s) => (
|
{PROJECT_STATUSES.map((s) => (
|
||||||
<option key={s.id} value={s.id}>
|
<option key={s.id} value={s.id}>
|
||||||
{s.label}
|
{s.label}
|
||||||
@@ -162,6 +203,16 @@ export function ProjectDetailForm({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Impactmaker">
|
||||||
|
<select name="impactmaker_account_id" defaultValue={project.impactmaker_account_id ?? ""} className={inputClass}>
|
||||||
|
<option value="">Geen impactmaker</option>
|
||||||
|
{impactmakers.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
<Field label="Rikx Ronde">
|
<Field label="Rikx Ronde">
|
||||||
<select name="ronde_id" defaultValue={project.ronde_id ?? ""} className={inputClass}>
|
<select name="ronde_id" defaultValue={project.ronde_id ?? ""} className={inputClass}>
|
||||||
<option value="">Geen ronde</option>
|
<option value="">Geen ronde</option>
|
||||||
@@ -174,79 +225,89 @@ export function ProjectDetailForm({
|
|||||||
</Field>
|
</Field>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={cardClass}>
|
<div role="tablist" aria-label="Projectonderdelen" className="flex gap-1 border-b border-zinc-200 dark:border-zinc-800 -mb-6">
|
||||||
<StepHeading step={1} title="Project plan" description="In te vullen door de sociaal ondernemer." />
|
{([
|
||||||
|
{ id: "plan", label: "Project plan", zichtbaar: true },
|
||||||
|
{ id: "calculatie", label: "Rikx calculatie", zichtbaar: calculatieZichtbaar },
|
||||||
|
] as const)
|
||||||
|
.filter((t) => t.zichtbaar)
|
||||||
|
.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id={`tab-${t.id}`}
|
||||||
|
aria-selected={activeTab === t.id}
|
||||||
|
aria-controls={`panel-${t.id}`}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className={`-mb-px px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === t.id
|
||||||
|
? "border-rikx-orange text-zinc-900 dark:text-zinc-100"
|
||||||
|
: "border-transparent text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs worden verborgen in plaats van weggelaten, zodat alle waarden bij opslaan meegaan. */}
|
||||||
|
<section id="panel-plan" role="tabpanel" aria-labelledby="tab-plan" hidden={activeTab !== "plan"} className={`${cardClass} rounded-tl-none`}>
|
||||||
|
<TabHeading title="Project plan" description="In te vullen door de sociaal ondernemer." />
|
||||||
<ProjectPlanFields project={project} plan={parseProjectPlanJson(project.project_plan)} />
|
<ProjectPlanFields project={project} plan={parseProjectPlanJson(project.project_plan)} />
|
||||||
</section>
|
</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">
|
<section
|
||||||
<div>
|
id="panel-calculatie"
|
||||||
<p className="font-medium text-zinc-900 dark:text-zinc-100">Rikx calculatie</p>
|
role="tabpanel"
|
||||||
<p className="text-sm text-zinc-500">Toon de waardebepaling volgens de Rikx-rekenmethode.</p>
|
aria-labelledby="tab-calculatie"
|
||||||
</div>
|
hidden={activeTab !== "calculatie"}
|
||||||
<button
|
className={`${cardClass} rounded-tl-none`}
|
||||||
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
|
<TabHeading
|
||||||
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"
|
title="Rikx calculatie"
|
||||||
description="Waardebepaling door het expertteam en Rikx. Uitkomsten worden live berekend."
|
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>
|
<fieldset className="mb-6">
|
||||||
<div className="overflow-x-auto mb-6">
|
<legend className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Rekenmethode</legend>
|
||||||
<table className="w-full text-sm">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<thead>
|
{REKENMETHODES.map((m) => (
|
||||||
<tr className="text-zinc-500">
|
<label
|
||||||
<th className="text-left font-medium py-2 pr-3">Criterium</th>
|
key={m.id}
|
||||||
{Array.from({ length: EXPERT_COUNT }, (_, i) => (
|
className={`flex items-start gap-3 rounded-lg border p-4 ${
|
||||||
<th key={i} className="text-left font-medium py-2 px-1 min-w-24">
|
m.beschikbaar
|
||||||
Expert {i + 1}
|
? "cursor-pointer border-zinc-300 dark:border-zinc-700 has-[:checked]:border-rikx-orange has-[:checked]:bg-orange-50 dark:has-[:checked]:bg-zinc-800"
|
||||||
</th>
|
: "cursor-not-allowed border-dashed border-zinc-200 dark:border-zinc-800 opacity-60"
|
||||||
))}
|
}`}
|
||||||
</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
|
<input
|
||||||
name={`expert_${i + 1}_${c.id}`}
|
type="radio"
|
||||||
type="number"
|
name="rekenmethode"
|
||||||
min={1}
|
value={m.id}
|
||||||
max={10}
|
disabled={!m.beschikbaar}
|
||||||
step="0.5"
|
defaultChecked={m.beschikbaar && (project.rekenmethode ?? "arbeid") === m.id}
|
||||||
aria-label={`${c.label}, expert ${i + 1}`}
|
className="mt-0.5 accent-rikx-orange"
|
||||||
defaultValue={input.expert_scores[i][c.id] ?? ""}
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
</td>
|
<span className="flex flex-col gap-0.5">
|
||||||
|
<span className="flex items-center gap-2 text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
|
{m.label}
|
||||||
|
{!m.beschikbaar && (
|
||||||
|
<span className="rounded-full bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 text-xs font-normal text-zinc-500">
|
||||||
|
binnenkort
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-zinc-500">{m.omschrijving}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</div>
|
||||||
))}
|
</fieldset>
|
||||||
</tbody>
|
|
||||||
</table>
|
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Beoordelingen van de experts (1–10)</h3>
|
||||||
<p className="text-xs text-zinc-500 mt-1">Voorlopig handmatig; met Module 4 vullen de experts dit zelf in.</p>
|
<div className="mb-6">
|
||||||
|
<ExpertBeoordelingen beoordelingen={beoordelingen} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||||
@@ -292,6 +353,20 @@ export function ProjectDetailForm({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-6 border-t border-zinc-200 dark:border-zinc-800 flex flex-col gap-4">
|
||||||
|
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Beoordeling</h3>
|
||||||
|
<BeoordelingSwitch defaultValue={project.beoordeling} />
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className={labelClass}>Reden</span>
|
||||||
|
<textarea
|
||||||
|
name="beoordeling_reden"
|
||||||
|
defaultValue={project.beoordeling_reden ?? ""}
|
||||||
|
placeholder="Onderbouwing van het oordeel, sterke punten en aandachtspunten…"
|
||||||
|
className="w-full min-h-40 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"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Id van het projectformulier, zodat de Opslaan-knop bovenaan de pagina (buiten het formulier) het kan versturen.
|
||||||
|
// Apart bestand: constanten uit een "use client"-module zijn op de server niet als gewone waarde bruikbaar.
|
||||||
|
export const PROJECT_FORM_ID = "project-form";
|
||||||
+25
-11
@@ -1,9 +1,12 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { getSession } from "../../../lib/auth";
|
import { requireAdmin } from "../../../lib/auth";
|
||||||
import { getDb, listRikxRondes, type Project, type ProjectExpertScore } from "../../../lib/db";
|
import { getDb, listRikxRondes, type Project } from "../../../lib/db";
|
||||||
|
import { listBeoordelingen } from "../../../lib/experts";
|
||||||
import { projectStatusBadgeClass, projectStatusLabel } from "../../../lib/projectStatus";
|
import { projectStatusBadgeClass, projectStatusLabel } from "../../../lib/projectStatus";
|
||||||
|
import { listAccountNames } from "../../../lib/platform-db";
|
||||||
import { AppHeader } from "../../AppHeader";
|
import { AppHeader } from "../../AppHeader";
|
||||||
|
import { PROJECT_FORM_ID } from "./formId";
|
||||||
import { ProjectDetailForm } from "./ProjectDetailForm";
|
import { ProjectDetailForm } from "./ProjectDetailForm";
|
||||||
|
|
||||||
export default async function ProjectPage({
|
export default async function ProjectPage({
|
||||||
@@ -11,28 +14,25 @@ export default async function ProjectPage({
|
|||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
searchParams: Promise<{ error?: string; opgeslagen?: string }>;
|
searchParams: Promise<{ error?: string; opgeslagen?: string; tab?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await getSession();
|
const session = await requireAdmin();
|
||||||
if (!session) redirect("/login");
|
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { error, opgeslagen } = await searchParams;
|
const { error, opgeslagen, tab } = await searchParams;
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const row = db.prepare("SELECT * FROM projects WHERE id = ?").get(Number(id)) as Project | undefined;
|
const row = db.prepare("SELECT * FROM projects WHERE id = ?").get(Number(id)) as Project | undefined;
|
||||||
if (!row) notFound();
|
if (!row) notFound();
|
||||||
// node:sqlite levert objecten met null-prototype; spreiden maakt ze doorgeefbaar aan client components.
|
// node:sqlite levert objecten met null-prototype; spreiden maakt ze doorgeefbaar aan client components.
|
||||||
const project = { ...row };
|
const project = { ...row };
|
||||||
const expertScores = (
|
const beoordelingen = listBeoordelingen([project.id]);
|
||||||
db.prepare("SELECT * FROM project_expert_scores WHERE project_id = ? ORDER BY expert").all(project.id) as ProjectExpertScore[]
|
|
||||||
).map((s) => ({ ...s }));
|
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
error === "missing"
|
error === "missing"
|
||||||
? { tone: "error", text: "Vul een projectnaam in." }
|
? { tone: "error", text: "Vul een projectnaam in." }
|
||||||
: error === "invalid"
|
: error === "invalid"
|
||||||
? { tone: "error", text: "Niet opgeslagen: controleer de getallen (percentage 0–100, expertscores 1–10, geen negatieve waarden)." }
|
? { tone: "error", text: "Niet opgeslagen: controleer de getallen (percentage 0–100, geen negatieve waarden)." }
|
||||||
: error === "datum"
|
: error === "datum"
|
||||||
? { tone: "error", text: "Niet opgeslagen: controleer de datums (de einddatum mag niet vóór de startdatum liggen)." }
|
? { tone: "error", text: "Niet opgeslagen: controleer de datums (de einddatum mag niet vóór de startdatum liggen)." }
|
||||||
: opgeslagen
|
: opgeslagen
|
||||||
@@ -57,6 +57,14 @@ export default async function ProjectPage({
|
|||||||
>
|
>
|
||||||
{projectStatusLabel(project.status)}
|
{projectStatusLabel(project.status)}
|
||||||
</span>
|
</span>
|
||||||
|
{/* Verstuurt het projectformulier hieronder (form-attribuut) */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
form={PROJECT_FORM_ID}
|
||||||
|
className="ml-auto rounded-lg bg-rikx-maroon hover:bg-rikx-maroon-light px-5 py-2 text-sm font-medium text-white transition-colors"
|
||||||
|
>
|
||||||
|
Opslaan
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{message && (
|
{message && (
|
||||||
<p
|
<p
|
||||||
@@ -69,7 +77,13 @@ export default async function ProjectPage({
|
|||||||
{message.text}
|
{message.text}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ProjectDetailForm project={project} expertScores={expertScores} rondes={listRikxRondes()} />
|
<ProjectDetailForm
|
||||||
|
project={project}
|
||||||
|
beoordelingen={beoordelingen}
|
||||||
|
rondes={listRikxRondes()}
|
||||||
|
impactmakers={listAccountNames()}
|
||||||
|
initialTab={tab === "calculatie" ? "calculatie" : "plan"}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+42
-57
@@ -2,29 +2,26 @@
|
|||||||
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getDb } from "../../lib/db";
|
import { getDb } from "../../lib/db";
|
||||||
import { getSession } from "../../lib/auth";
|
import { requireAdmin } from "../../lib/auth";
|
||||||
import { DEFAULT_PROJECT_STATUS, isProjectStatus } from "../../lib/projectStatus";
|
import { DEFAULT_PROJECT_STATUS, isProjectStatus } from "../../lib/projectStatus";
|
||||||
|
import { listAccountNames } from "../../lib/platform-db";
|
||||||
import { readProjectPlan } from "../../lib/projectPlan";
|
import { readProjectPlan } from "../../lib/projectPlan";
|
||||||
import { BONUS_MALUS_OPTIONS, EXPERT_COUNT, EXPERT_CRITERIA } from "../../lib/rikxCalculatie";
|
import {
|
||||||
|
BONUS_MALUS_OPTIONS,
|
||||||
|
DEFAULT_REKENMETHODE,
|
||||||
|
isBeschikbareRekenmethode,
|
||||||
|
} from "../../lib/rikxCalculatie";
|
||||||
|
|
||||||
export async function createProject(formData: FormData) {
|
// "+ Project toevoegen": maakt direct een nieuw project aan (status Aanvraag) en opent de projectpagina,
|
||||||
const session = await getSession();
|
// waar naam, ronde, impactmaker en het project plan worden ingevuld.
|
||||||
if (!session) redirect("/login");
|
export async function createProject() {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
|
||||||
const name = ((formData.get("name") as string) ?? "").trim();
|
const result = getDb()
|
||||||
const rawStatus = formData.get("status");
|
.prepare("INSERT INTO projects (name, status) VALUES (?, ?)")
|
||||||
const status = isProjectStatus(rawStatus) ? rawStatus : DEFAULT_PROJECT_STATUS;
|
.run("Nieuw project", DEFAULT_PROJECT_STATUS);
|
||||||
|
|
||||||
if (!name) redirect("/projecten/nieuw?error=missing");
|
redirect(`/projecten/${Number(result.lastInsertRowid)}`);
|
||||||
|
|
||||||
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.
|
// Geeft het id van een bestaande Rikx Ronde terug, of null bij "geen ronde"/onbekend id.
|
||||||
@@ -34,6 +31,13 @@ function readRondeId(db: ReturnType<typeof getDb>, formData: FormData): number |
|
|||||||
return db.prepare("SELECT id FROM rikx_rondes WHERE id = ?").get(id) ? id : null;
|
return db.prepare("SELECT id FROM rikx_rondes WHERE id = ?").get(id) ? id : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Geeft het id van een bestaande sociaal ondernemer (platform-account) terug, of null.
|
||||||
|
function readImpactmakerId(formData: FormData): number | null {
|
||||||
|
const id = Number(formData.get("impactmaker_account_id"));
|
||||||
|
if (!id) return null;
|
||||||
|
return listAccountNames().some((a) => a.id === id) ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
// Leeg veld → null; accepteert zowel 1234.5 als 1234,5
|
// Leeg veld → null; accepteert zowel 1234.5 als 1234,5
|
||||||
function parseNumber(value: FormDataEntryValue | null): number | null {
|
function parseNumber(value: FormDataEntryValue | null): number | null {
|
||||||
if (typeof value !== "string" || value.trim() === "") return null;
|
if (typeof value !== "string" || value.trim() === "") return null;
|
||||||
@@ -46,49 +50,50 @@ function inRange(n: number | null, min: number, max = Infinity) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProject(formData: FormData) {
|
export async function updateProject(formData: FormData) {
|
||||||
const session = await getSession();
|
const session = await requireAdmin();
|
||||||
if (!session) redirect("/login");
|
|
||||||
|
|
||||||
const id = Number(formData.get("id"));
|
const id = Number(formData.get("id"));
|
||||||
if (!id) redirect("/projecten");
|
if (!id) redirect("/projecten");
|
||||||
|
// Terug naar de tab waar de gebruiker op stond, ook bij een fout
|
||||||
|
const tab = formData.get("tab") === "calculatie" ? "&tab=calculatie" : "";
|
||||||
|
|
||||||
const name = ((formData.get("name") as string) ?? "").trim();
|
const name = ((formData.get("name") as string) ?? "").trim();
|
||||||
const rawStatus = formData.get("status");
|
const rawStatus = formData.get("status");
|
||||||
if (!name) redirect(`/projecten/${id}?error=missing`);
|
if (!name) redirect(`/projecten/${id}?error=missing${tab}`);
|
||||||
|
|
||||||
const projectkosten = parseNumber(formData.get("projectkosten"));
|
const projectkosten = parseNumber(formData.get("projectkosten"));
|
||||||
const deelnemersWerk = parseNumber(formData.get("deelnemers_werk"));
|
const deelnemersWerk = parseNumber(formData.get("deelnemers_werk"));
|
||||||
const deelnemersSkills = parseNumber(formData.get("deelnemers_skills"));
|
const deelnemersSkills = parseNumber(formData.get("deelnemers_skills"));
|
||||||
const externBedrag = parseNumber(formData.get("financiering_extern_bedrag"));
|
const externBedrag = parseNumber(formData.get("financiering_extern_bedrag"));
|
||||||
const externPct = parseNumber(formData.get("financiering_extern_pct"));
|
const externPct = parseNumber(formData.get("financiering_extern_pct"));
|
||||||
|
const rawRekenmethode = formData.get("rekenmethode");
|
||||||
|
const rekenmethode = isBeschikbareRekenmethode(rawRekenmethode) ? rawRekenmethode : DEFAULT_REKENMETHODE;
|
||||||
|
const rawBeoordeling = formData.get("beoordeling");
|
||||||
|
const beoordeling = rawBeoordeling === "goedgekeurd" || rawBeoordeling === "afgekeurd" ? rawBeoordeling : null;
|
||||||
|
const beoordelingReden = ((formData.get("beoordeling_reden") as string) ?? "").trim().slice(0, 10_000) || null;
|
||||||
const rawBonusMalus = (formData.get("bonus_malus") as string) ?? "";
|
const rawBonusMalus = (formData.get("bonus_malus") as string) ?? "";
|
||||||
const bonusMalus = BONUS_MALUS_OPTIONS.some((o) => o.id === rawBonusMalus) && rawBonusMalus ? rawBonusMalus : null;
|
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 =
|
const valid =
|
||||||
inRange(projectkosten, 0) &&
|
inRange(projectkosten, 0) &&
|
||||||
inRange(deelnemersWerk, 0) &&
|
inRange(deelnemersWerk, 0) &&
|
||||||
inRange(deelnemersSkills, 0) &&
|
inRange(deelnemersSkills, 0) &&
|
||||||
inRange(externBedrag, 0) &&
|
inRange(externBedrag, 0) &&
|
||||||
inRange(externPct, 0, 100) &&
|
inRange(externPct, 0, 100);
|
||||||
expertScores.every((scores) => scores.every((s) => inRange(s, 1, 10)));
|
if (!valid) redirect(`/projecten/${id}?error=invalid${tab}`);
|
||||||
if (!valid) redirect(`/projecten/${id}?error=invalid`);
|
|
||||||
|
|
||||||
const planResult = readProjectPlan(formData);
|
const planResult = readProjectPlan(formData);
|
||||||
if ("error" in planResult) redirect(`/projecten/${id}?error=${planResult.error}`);
|
if ("error" in planResult) redirect(`/projecten/${id}?error=${planResult.error}${tab}`);
|
||||||
|
|
||||||
|
// Expertscores worden niet hier opgeslagen maar door de experts zelf (app/expert)
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
db.exec("BEGIN");
|
|
||||||
try {
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE projects SET
|
`UPDATE projects SET
|
||||||
name = ?, status = COALESCE(?, status),
|
name = ?, status = COALESCE(?, status),
|
||||||
projectkosten = ?, deelnemers_werk = ?, deelnemers_skills = ?,
|
projectkosten = ?, deelnemers_werk = ?, deelnemers_skills = ?,
|
||||||
financiering_extern_bedrag = ?, financiering_extern_pct = ?, project_plan = ?,
|
financiering_extern_bedrag = ?, financiering_extern_pct = ?, project_plan = ?,
|
||||||
bonus_malus = ?, ronde_id = ?, updated_at = datetime('now')
|
rekenmethode = ?, bonus_malus = ?, beoordeling = ?, beoordeling_reden = ?, ronde_id = ?,
|
||||||
|
impactmaker_account_id = ?, updated_at = datetime('now')
|
||||||
WHERE id = ?`
|
WHERE id = ?`
|
||||||
).run(
|
).run(
|
||||||
name,
|
name,
|
||||||
@@ -99,34 +104,14 @@ export async function updateProject(formData: FormData) {
|
|||||||
externBedrag,
|
externBedrag,
|
||||||
externPct,
|
externPct,
|
||||||
JSON.stringify(planResult.plan),
|
JSON.stringify(planResult.plan),
|
||||||
|
rekenmethode,
|
||||||
bonusMalus,
|
bonusMalus,
|
||||||
|
beoordeling,
|
||||||
|
beoordelingReden,
|
||||||
readRondeId(db, formData),
|
readRondeId(db, formData),
|
||||||
|
readImpactmakerId(formData),
|
||||||
id
|
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`);
|
redirect(`/projecten/${id}?opgeslagen=1${tab}`);
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getSession } from "../../lib/auth";
|
import { requireAdmin } from "../../lib/auth";
|
||||||
import { getDb, listRikxRondes, type ProjectListItem } from "../../lib/db";
|
import { getDb, listRikxRondes, type ProjectListItem } from "../../lib/db";
|
||||||
import { PROJECT_STATUSES, isProjectStatus } from "../../lib/projectStatus";
|
import { PROJECT_STATUSES, isProjectStatus } from "../../lib/projectStatus";
|
||||||
import { AppHeader } from "../AppHeader";
|
import { AppHeader } from "../AppHeader";
|
||||||
|
import { createProject } from "./actions";
|
||||||
import { AutoSubmitSelect } from "./AutoSubmitSelect";
|
import { AutoSubmitSelect } from "./AutoSubmitSelect";
|
||||||
import { ProjectPipeline } from "./ProjectPipeline";
|
import { ProjectPipeline } from "./ProjectPipeline";
|
||||||
import { ProjectTable } from "./ProjectTable";
|
import { ProjectTable } from "./ProjectTable";
|
||||||
@@ -17,8 +18,7 @@ export default async function ProjectenPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ q?: string; status?: string; ronde?: string; weergave?: string }>;
|
searchParams: Promise<{ q?: string; status?: string; ronde?: string; weergave?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await getSession();
|
const session = await requireAdmin();
|
||||||
if (!session) redirect("/login");
|
|
||||||
|
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const q = (params.q ?? "").trim();
|
const q = (params.q ?? "").trim();
|
||||||
@@ -130,12 +130,14 @@ export default async function ProjectenPage({
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Link
|
<form action={createProject}>
|
||||||
href="/projecten/nieuw"
|
<button
|
||||||
|
type="submit"
|
||||||
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"
|
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
|
+ Project toevoegen
|
||||||
</Link>
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isFiltered && (
|
{isFiltered && (
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { Expert } from "../../lib/experts";
|
||||||
|
|
||||||
|
// Experts koppelen aan een ronde; dezelfde koppeling als op de pagina Experts
|
||||||
|
export function ExpertCheckboxes({ experts, selected = [] }: { experts: Expert[]; selected?: number[] }) {
|
||||||
|
return (
|
||||||
|
<fieldset className="sm:col-span-3 flex flex-col gap-2">
|
||||||
|
<legend className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">Experts</legend>
|
||||||
|
{experts.length === 0 ? (
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
Er zijn nog geen experts.{" "}
|
||||||
|
<Link href="/experts" className="underline hover:text-rikx-orange-dark">
|
||||||
|
Expert uitnodigen
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||||
|
{experts.map((e) => (
|
||||||
|
<label key={e.id} className="flex items-center gap-2 text-sm text-zinc-800 dark:text-zinc-200">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="expert_ids"
|
||||||
|
value={e.id}
|
||||||
|
defaultChecked={selected.includes(e.id)}
|
||||||
|
className="accent-rikx-orange"
|
||||||
|
/>
|
||||||
|
{e.name}
|
||||||
|
{!e.actief && <span className="text-xs text-zinc-500">(uitgenodigd)</span>}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
Gekoppelde experts zien de projecten van deze ronde zodra die in waardebepaling staan.
|
||||||
|
</span>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { getSession } from "../../../lib/auth";
|
import { requireAdmin } from "../../../lib/auth";
|
||||||
import { getDb, type RikxRonde } from "../../../lib/db";
|
import { getDb, type RikxRonde } from "../../../lib/db";
|
||||||
import { AppHeader } from "../../AppHeader";
|
import { AppHeader } from "../../AppHeader";
|
||||||
import { updateRonde } from "../actions";
|
import { updateRonde } from "../actions";
|
||||||
|
import { listExperts } from "../../../lib/experts";
|
||||||
|
import { ExpertCheckboxes } from "../ExpertCheckboxes";
|
||||||
import { RONDE_ERRORS, RondeFields } from "../RondeFields";
|
import { RONDE_ERRORS, RondeFields } from "../RondeFields";
|
||||||
|
|
||||||
export default async function EditRondePage({
|
export default async function EditRondePage({
|
||||||
@@ -13,14 +15,14 @@ export default async function EditRondePage({
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
searchParams: Promise<{ error?: string }>;
|
searchParams: Promise<{ error?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await getSession();
|
const session = await requireAdmin();
|
||||||
if (!session) redirect("/login");
|
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { error } = await searchParams;
|
const { error } = await searchParams;
|
||||||
const row = getDb().prepare("SELECT * FROM rikx_rondes WHERE id = ?").get(Number(id)) as RikxRonde | undefined;
|
const row = getDb().prepare("SELECT * FROM rikx_rondes WHERE id = ?").get(Number(id)) as RikxRonde | undefined;
|
||||||
if (!row) notFound();
|
if (!row) notFound();
|
||||||
const ronde = { ...row };
|
const ronde = { ...row };
|
||||||
|
const experts = listExperts();
|
||||||
|
|
||||||
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">
|
||||||
@@ -40,6 +42,7 @@ export default async function EditRondePage({
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={ronde.id} />
|
<input type="hidden" name="id" value={ronde.id} />
|
||||||
<RondeFields ronde={ronde} />
|
<RondeFields ronde={ronde} />
|
||||||
|
<ExpertCheckboxes experts={experts} selected={experts.filter((e) => e.rondeIds.includes(ronde.id)).map((e) => e.id)} />
|
||||||
<p className="sm:col-span-3 text-xs text-zinc-500">
|
<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.
|
Een andere Rikx waarde of prijs verandert de Rikx calculatie van alle projecten in deze ronde.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+12
-8
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getDb } from "../../lib/db";
|
import { getDb } from "../../lib/db";
|
||||||
import { getSession } from "../../lib/auth";
|
import { requireAdmin } from "../../lib/auth";
|
||||||
|
import { setRondeExperts } from "../../lib/experts";
|
||||||
import { defaultRikxPrijs } from "../../lib/rikxCalculatie";
|
import { defaultRikxPrijs } from "../../lib/rikxCalculatie";
|
||||||
|
|
||||||
// Leeg veld → null; accepteert zowel 1234.5 als 1234,5
|
// Leeg veld → null; accepteert zowel 1234.5 als 1234,5
|
||||||
@@ -25,9 +26,12 @@ function readRonde(formData: FormData) {
|
|||||||
return { label, rikxWaarde, rikxPrijs } as const;
|
return { label, rikxWaarde, rikxPrijs } as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readExpertIds(formData: FormData): number[] {
|
||||||
|
return formData.getAll("expert_ids").map(Number).filter((id) => Number.isInteger(id) && id > 0);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createRonde(formData: FormData) {
|
export async function createRonde(formData: FormData) {
|
||||||
const session = await getSession();
|
const session = await requireAdmin();
|
||||||
if (!session) redirect("/login");
|
|
||||||
|
|
||||||
const ronde = readRonde(formData);
|
const ronde = readRonde(formData);
|
||||||
if ("error" in ronde) redirect(`/rondes?error=${ronde.error}`);
|
if ("error" in ronde) redirect(`/rondes?error=${ronde.error}`);
|
||||||
@@ -36,18 +40,18 @@ export async function createRonde(formData: FormData) {
|
|||||||
if (db.prepare("SELECT id FROM rikx_rondes WHERE label = ?").get(ronde.label)) {
|
if (db.prepare("SELECT id FROM rikx_rondes WHERE label = ?").get(ronde.label)) {
|
||||||
redirect("/rondes?error=label");
|
redirect("/rondes?error=label");
|
||||||
}
|
}
|
||||||
db.prepare("INSERT INTO rikx_rondes (label, rikx_waarde, rikx_prijs) VALUES (?, ?, ?)").run(
|
const result = db.prepare("INSERT INTO rikx_rondes (label, rikx_waarde, rikx_prijs) VALUES (?, ?, ?)").run(
|
||||||
ronde.label,
|
ronde.label,
|
||||||
ronde.rikxWaarde,
|
ronde.rikxWaarde,
|
||||||
ronde.rikxPrijs
|
ronde.rikxPrijs
|
||||||
);
|
);
|
||||||
|
setRondeExperts(Number(result.lastInsertRowid), readExpertIds(formData));
|
||||||
|
|
||||||
redirect("/rondes");
|
redirect("/rondes");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRonde(formData: FormData) {
|
export async function updateRonde(formData: FormData) {
|
||||||
const session = await getSession();
|
const session = await requireAdmin();
|
||||||
if (!session) redirect("/login");
|
|
||||||
|
|
||||||
const id = Number(formData.get("id"));
|
const id = Number(formData.get("id"));
|
||||||
if (!id) redirect("/rondes");
|
if (!id) redirect("/rondes");
|
||||||
@@ -62,13 +66,13 @@ export async function updateRonde(formData: FormData) {
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
"UPDATE rikx_rondes SET label = ?, rikx_waarde = ?, rikx_prijs = ?, updated_at = datetime('now') WHERE id = ?"
|
"UPDATE rikx_rondes SET label = ?, rikx_waarde = ?, rikx_prijs = ?, updated_at = datetime('now') WHERE id = ?"
|
||||||
).run(ronde.label, ronde.rikxWaarde, ronde.rikxPrijs, id);
|
).run(ronde.label, ronde.rikxWaarde, ronde.rikxPrijs, id);
|
||||||
|
setRondeExperts(id, readExpertIds(formData));
|
||||||
|
|
||||||
redirect("/rondes");
|
redirect("/rondes");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRonde(formData: FormData) {
|
export async function deleteRonde(formData: FormData) {
|
||||||
const session = await getSession();
|
const session = await requireAdmin();
|
||||||
if (!session) redirect("/login");
|
|
||||||
|
|
||||||
const id = Number(formData.get("id"));
|
const id = Number(formData.get("id"));
|
||||||
// Projecten in deze ronde blijven bestaan; hun ronde_id wordt NULL (ON DELETE SET NULL).
|
// Projecten in deze ronde blijven bestaan; hun ronde_id wordt NULL (ON DELETE SET NULL).
|
||||||
|
|||||||
+13
-3
@@ -1,10 +1,12 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getSession } from "../../lib/auth";
|
import { requireAdmin } from "../../lib/auth";
|
||||||
import { getDb, type RikxRonde } from "../../lib/db";
|
import { getDb, type RikxRonde } from "../../lib/db";
|
||||||
import { AppHeader } from "../AppHeader";
|
import { AppHeader } from "../AppHeader";
|
||||||
import { createRonde } from "./actions";
|
import { createRonde } from "./actions";
|
||||||
import { DeleteRondeButton } from "./DeleteRondeButton";
|
import { DeleteRondeButton } from "./DeleteRondeButton";
|
||||||
|
import { listExperts } from "../../lib/experts";
|
||||||
|
import { ExpertCheckboxes } from "./ExpertCheckboxes";
|
||||||
import { RONDE_ERRORS, RondeFields } from "./RondeFields";
|
import { RONDE_ERRORS, RondeFields } from "./RondeFields";
|
||||||
|
|
||||||
const euro = new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" });
|
const euro = new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" });
|
||||||
@@ -15,10 +17,10 @@ export default async function RondesPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ error?: string }>;
|
searchParams: Promise<{ error?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await getSession();
|
const session = await requireAdmin();
|
||||||
if (!session) redirect("/login");
|
|
||||||
|
|
||||||
const { error } = await searchParams;
|
const { error } = await searchParams;
|
||||||
|
const experts = listExperts();
|
||||||
const rondes = (
|
const rondes = (
|
||||||
getDb()
|
getDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -49,6 +51,7 @@ export default async function RondesPage({
|
|||||||
<th className="text-right px-4 py-3 font-medium">Rikx waarde</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">Rikx prijs</th>
|
||||||
<th className="text-right px-4 py-3 font-medium">Projecten</th>
|
<th className="text-right px-4 py-3 font-medium">Projecten</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Experts</th>
|
||||||
<th className="px-4 py-3" colSpan={2}></th>
|
<th className="px-4 py-3" colSpan={2}></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -67,6 +70,12 @@ export default async function RondesPage({
|
|||||||
{r.project_count}
|
{r.project_count}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-700 dark:text-zinc-300">
|
||||||
|
{experts
|
||||||
|
.filter((e) => e.rondeIds.includes(r.id))
|
||||||
|
.map((e) => e.name)
|
||||||
|
.join(", ") || "—"}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<Link
|
<Link
|
||||||
href={`/rondes/${r.id}`}
|
href={`/rondes/${r.id}`}
|
||||||
@@ -96,6 +105,7 @@ export default async function RondesPage({
|
|||||||
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"
|
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 />
|
<RondeFields />
|
||||||
|
<ExpertCheckboxes experts={experts} />
|
||||||
<div className="sm:col-span-3">
|
<div className="sm:col-span-3">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { getSession } from "../../../lib/auth";
|
import { requireAdmin } 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";
|
import { AppHeader } from "../../AppHeader";
|
||||||
@@ -12,8 +12,7 @@ export default async function EditTenantPage({
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
searchParams: Promise<{ error?: string }>;
|
searchParams: Promise<{ error?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await getSession();
|
const session = await requireAdmin();
|
||||||
if (!session) redirect("/login");
|
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { error } = await searchParams;
|
const { error } = await searchParams;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { randomBytes, scryptSync } from "crypto";
|
import { randomBytes, scryptSync } from "crypto";
|
||||||
import { getPlatformDb } from "../../lib/platform-db";
|
import { getPlatformDb } from "../../lib/platform-db";
|
||||||
import { getSession } from "../../lib/auth";
|
import { requireAdmin } from "../../lib/auth";
|
||||||
|
|
||||||
function requireAuth() {
|
function requireAuth() {
|
||||||
// Called inside server actions — session check is synchronous-safe via cache
|
// Called inside server actions — session check is synchronous-safe via cache
|
||||||
@@ -16,8 +16,7 @@ function hashPassword(password: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createTenant(formData: FormData) {
|
export async function createTenant(formData: FormData) {
|
||||||
const session = await getSession();
|
const session = await requireAdmin();
|
||||||
if (!session) redirect("/login");
|
|
||||||
|
|
||||||
const name = (formData.get("name") as string).trim();
|
const name = (formData.get("name") as string).trim();
|
||||||
const slug = (formData.get("slug") as string).trim().toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
const slug = (formData.get("slug") as string).trim().toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
||||||
@@ -45,8 +44,7 @@ export async function createTenant(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTenant(formData: FormData) {
|
export async function deleteTenant(formData: FormData) {
|
||||||
const session = await getSession();
|
const session = await requireAdmin();
|
||||||
if (!session) redirect("/login");
|
|
||||||
|
|
||||||
const accountId = Number(formData.get("account_id"));
|
const accountId = Number(formData.get("account_id"));
|
||||||
if (!accountId) redirect("/tenants");
|
if (!accountId) redirect("/tenants");
|
||||||
@@ -59,8 +57,7 @@ export async function deleteTenant(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTenant(formData: FormData) {
|
export async function updateTenant(formData: FormData) {
|
||||||
const session = await getSession();
|
const session = await requireAdmin();
|
||||||
if (!session) redirect("/login");
|
|
||||||
|
|
||||||
const accountId = Number(formData.get("account_id"));
|
const accountId = Number(formData.get("account_id"));
|
||||||
const name = ((formData.get("name") as string) ?? "").trim();
|
const name = ((formData.get("name") as string) ?? "").trim();
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
|
|
||||||
import { createHmac } from "crypto";
|
import { createHmac } from "crypto";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getSession } from "../../lib/auth";
|
import { requireAdmin } from "../../lib/auth";
|
||||||
import { getPlatformDb } from "../../lib/platform-db";
|
import { getPlatformDb } from "../../lib/platform-db";
|
||||||
|
|
||||||
export async function autoLogin(formData: FormData) {
|
export async function autoLogin(formData: FormData) {
|
||||||
const session = await getSession();
|
const session = await requireAdmin();
|
||||||
if (!session) redirect("/login");
|
|
||||||
|
|
||||||
const accountId = Number(formData.get("account_id"));
|
const accountId = Number(formData.get("account_id"));
|
||||||
const slug = formData.get("slug") as string;
|
const slug = formData.get("slug") as string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getSession } from "../../lib/auth";
|
import { requireAdmin } from "../../lib/auth";
|
||||||
import { listAccounts } from "../../lib/platform-db";
|
import { listAccounts } from "../../lib/platform-db";
|
||||||
import { createTenant } from "./actions";
|
import { createTenant } from "./actions";
|
||||||
import { DeleteTenantButton } from "./DeleteTenantButton";
|
import { DeleteTenantButton } from "./DeleteTenantButton";
|
||||||
@@ -10,8 +10,7 @@ export default async function TenantsPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ error?: string }>;
|
searchParams: Promise<{ error?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await getSession();
|
const session = await requireAdmin();
|
||||||
if (!session) redirect("/login");
|
|
||||||
|
|
||||||
const { error } = await searchParams;
|
const { error } = await searchParams;
|
||||||
const accounts = listAccounts();
|
const accounts = listAccounts();
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { createSession, hashPassword } from "../../../lib/auth";
|
||||||
|
import { getDb } from "../../../lib/db";
|
||||||
|
import { MIN_WACHTWOORD_LENGTE, findInvite, markInviteUsed } from "../../../lib/experts";
|
||||||
|
|
||||||
|
export async function setPassword(formData: FormData) {
|
||||||
|
const token = (formData.get("token") as string) ?? "";
|
||||||
|
const password = (formData.get("password") as string) ?? "";
|
||||||
|
const herhaal = (formData.get("password_herhaal") as string) ?? "";
|
||||||
|
|
||||||
|
const invite = findInvite(token);
|
||||||
|
if (!invite) redirect(`/uitnodiging/${encodeURIComponent(token)}`);
|
||||||
|
if (password.length < MIN_WACHTWOORD_LENGTE) redirect(`/uitnodiging/${encodeURIComponent(token)}?error=kort`);
|
||||||
|
if (password !== herhaal) redirect(`/uitnodiging/${encodeURIComponent(token)}?error=verschil`);
|
||||||
|
|
||||||
|
getDb().prepare("UPDATE users SET password_hash = ? WHERE id = ? AND role = 'expert'").run(hashPassword(password), invite.user_id);
|
||||||
|
markInviteUsed(invite.id);
|
||||||
|
|
||||||
|
await createSession({ userId: invite.user_id, email: invite.email, name: invite.name });
|
||||||
|
redirect("/expert");
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { MIN_WACHTWOORD_LENGTE, findInvite } from "../../../lib/experts";
|
||||||
|
import { setPassword } from "./actions";
|
||||||
|
|
||||||
|
// Openbare pagina (zonder login): een uitgenodigde expert stelt hier zijn wachtwoord in.
|
||||||
|
export default async function UitnodigingPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ token: string }>;
|
||||||
|
searchParams: Promise<{ error?: string }>;
|
||||||
|
}) {
|
||||||
|
const { token } = await params;
|
||||||
|
const { error } = await searchParams;
|
||||||
|
const invite = findInvite(token);
|
||||||
|
|
||||||
|
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 errorMsg =
|
||||||
|
error === "kort"
|
||||||
|
? `Kies een wachtwoord van minimaal ${MIN_WACHTWOORD_LENGTE} tekens.`
|
||||||
|
: error === "verschil"
|
||||||
|
? "De wachtwoorden zijn niet gelijk."
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-zinc-50 dark:bg-zinc-950">
|
||||||
|
<header className="bg-rikx-orange">
|
||||||
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-3">
|
||||||
|
<span className="text-white text-4xl font-extrabold tracking-tight leading-none">Rikx</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 flex items-start justify-center px-4 py-16">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
{!invite ? (
|
||||||
|
<div className="bg-white dark:bg-zinc-900 p-8 rounded-xl border border-zinc-200 dark:border-zinc-800 flex flex-col gap-3">
|
||||||
|
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Uitnodiging niet geldig</h1>
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
Deze link is verlopen of al gebruikt. Vraag een nieuwe uitnodiging aan bij Rikx, of log in als je al een
|
||||||
|
wachtwoord hebt ingesteld.
|
||||||
|
</p>
|
||||||
|
<Link href="/login" className="text-sm text-rikx-orange-dark hover:underline">
|
||||||
|
Naar inloggen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
action={setPassword}
|
||||||
|
className="flex flex-col gap-4 bg-white dark:bg-zinc-900 p-8 rounded-xl border border-zinc-200 dark:border-zinc-800"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Welkom, {invite.name}</h1>
|
||||||
|
<p className="text-sm text-zinc-500 mt-1">
|
||||||
|
Stel een wachtwoord in voor het Rikx expertteam. Je logt daarna in met {invite.email}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{errorMsg && <p className="text-sm text-red-600 dark:text-red-400">{errorMsg}</p>}
|
||||||
|
<input type="hidden" name="token" value={token} />
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Wachtwoord</span>
|
||||||
|
<input name="password" type="password" required minLength={MIN_WACHTWOORD_LENGTE} autoComplete="new-password" className={inputClass} />
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Herhaal wachtwoord</span>
|
||||||
|
<input name="password_herhaal" type="password" required minLength={MIN_WACHTWOORD_LENGTE} autoComplete="new-password" className={inputClass} />
|
||||||
|
</label>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Wachtwoord instellen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { calculateRikx, defaultRikxPrijs, type RikxInput } from "../../lib/rikxCalculatie";
|
||||||
|
import { saveRondeWaarde } from "./actions";
|
||||||
|
|
||||||
|
export type WaardebepalingRow = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
impactmaker: string | null;
|
||||||
|
ronde: { id: number; label: string; rikxWaarde: number | null } | null;
|
||||||
|
input: RikxInput;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SaveState =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "saving" }
|
||||||
|
| { kind: "saved"; labels: string[] }
|
||||||
|
| { kind: "error"; message: string };
|
||||||
|
|
||||||
|
// signDisplay "negative": geen "-0,00" als een klein negatief getal op nul wordt afgerond
|
||||||
|
const getal = new Intl.NumberFormat("nl-NL", { minimumFractionDigits: 2, maximumFractionDigits: 2, signDisplay: "negative" });
|
||||||
|
const euro = new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR", signDisplay: "negative" });
|
||||||
|
const fmt = (v: number | null, kind: "getal" | "euro" = "getal") =>
|
||||||
|
v === null ? "—" : kind === "euro" ? euro.format(v) : getal.format(v);
|
||||||
|
|
||||||
|
// Wachttijd na de laatste toetsaanslag voordat de waarde in de ronde wordt opgeslagen
|
||||||
|
const SAVE_DELAY_MS = 800;
|
||||||
|
|
||||||
|
function toNumber(value: string): number | null {
|
||||||
|
if (value.trim() === "") return null;
|
||||||
|
const n = Number(value.replace(",", "."));
|
||||||
|
return Number.isFinite(n) && n >= 0 ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positie (0-based) van het 75%-project in de lijst van LAAG naar HOOG op kosten/impact, volgens de
|
||||||
|
// Waarderingssystematiek: het laatste project binnen de 75% meest efficiënte, dus plek ⌈0,75 × n⌉.
|
||||||
|
// Alleen projecten met een berekende kosten/impact tellen mee. -1 als er geen zijn.
|
||||||
|
export function index75Procent(aantalBerekend: number): number {
|
||||||
|
return aantalBerekend > 0 ? Math.ceil(0.75 * aantalBerekend) - 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Laagste kosten/impact eerst (meest efficiënt); projecten zonder berekening onderaan. Bij gelijke stand op naam en id,
|
||||||
|
// zodat server en browser altijd dezelfde volgorde geven (anders volgt een hydration-fout).
|
||||||
|
function compareRows(
|
||||||
|
a: { id: number; name: string; result: { kosten_impact: number | null } },
|
||||||
|
b: { id: number; name: string; result: { kosten_impact: number | null } }
|
||||||
|
): number {
|
||||||
|
const ka = a.result.kosten_impact;
|
||||||
|
const kb = b.result.kosten_impact;
|
||||||
|
if (ka !== kb) {
|
||||||
|
if (ka === null) return 1;
|
||||||
|
if (kb === null) return -1;
|
||||||
|
return ka - kb;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name, "nl") || a.id - b.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tabel met een Rikx Waarde-veld erboven. Een ingevulde Rikx Waarde geldt direct voor de getoonde
|
||||||
|
// projecten die bij een ronde horen (Rikx prijs = 10%), de berekening wordt live doorgerekend
|
||||||
|
// en de waarde wordt opgeslagen op de Rikx ronde(s) van die projecten.
|
||||||
|
export function WaardebepalingTable({
|
||||||
|
rows,
|
||||||
|
filter,
|
||||||
|
emptyMessage,
|
||||||
|
toon75Procent,
|
||||||
|
}: {
|
||||||
|
rows: WaardebepalingRow[];
|
||||||
|
// Alleen bij een gekozen Rikx ronde; bij "Alle" of "Zonder ronde" geen 75%-lijn
|
||||||
|
toon75Procent: boolean;
|
||||||
|
// Rikx Ronde-filter (server-side formulier); staat in dezelfde rij als het Rikx Waarde-veld
|
||||||
|
filter: ReactNode;
|
||||||
|
emptyMessage: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const rondes = [...new Map(rows.flatMap((r) => (r.ronde ? [[r.ronde.id, r.ronde]] : []))).values()];
|
||||||
|
const rondeIds = rondes.map((r) => r.id);
|
||||||
|
// Startwaarde: de Rikx waarde van de getoonde ronde(s), als die voor allemaal gelijk is
|
||||||
|
const gedeeldeWaarde = new Set(rondes.map((r) => r.rikxWaarde)).size === 1 ? rondes[0]?.rikxWaarde ?? null : null;
|
||||||
|
|
||||||
|
const [waardeTekst, setWaardeTekst] = useState(gedeeldeWaarde === null ? "" : String(gedeeldeWaarde));
|
||||||
|
const [saveState, setSaveState] = useState<SaveState>({ kind: "idle" });
|
||||||
|
const opgeslagenRef = useRef<number | null>(gedeeldeWaarde);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const waarde = toNumber(waardeTekst);
|
||||||
|
const prijs = waarde === null ? null : defaultRikxPrijs(waarde);
|
||||||
|
|
||||||
|
async function save(value: number) {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
if (value === opgeslagenRef.current || rondeIds.length === 0) return;
|
||||||
|
setSaveState({ kind: "saving" });
|
||||||
|
const result = await saveRondeWaarde(rondeIds, value);
|
||||||
|
if (result.ok) {
|
||||||
|
opgeslagenRef.current = value;
|
||||||
|
setSaveState({ kind: "saved", labels: result.labels });
|
||||||
|
// Serverdata (rondewaarden) verversen, zodat alles overal klopt
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
setSaveState({ kind: "error", message: result.error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChange(value: string) {
|
||||||
|
setWaardeTekst(value);
|
||||||
|
setSaveState({ kind: "idle" });
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
const n = toNumber(value);
|
||||||
|
if (n !== null) timerRef.current = setTimeout(() => save(n), SAVE_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const results = rows
|
||||||
|
.map((r) => {
|
||||||
|
const aangepast = waarde !== null && r.ronde !== null;
|
||||||
|
const input = aangepast ? { ...r.input, rikx_waarde: waarde, rikx_prijs: prijs } : r.input;
|
||||||
|
return { ...r, input, result: calculateRikx(input), aangepast };
|
||||||
|
})
|
||||||
|
.sort(compareRows);
|
||||||
|
// Berekende projecten staan na het sorteren vooraan, dus de index geldt direct voor `results`
|
||||||
|
const berekend = results.filter((r) => r.result.kosten_impact !== null);
|
||||||
|
const grensIndex = toon75Procent ? index75Procent(berekend.length) : -1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="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-x-4 gap-y-3">
|
||||||
|
{filter}
|
||||||
|
<label className="flex flex-col gap-1 w-full sm:w-52">
|
||||||
|
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Rikx Waarde</span>
|
||||||
|
<span className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-zinc-500">€</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
value={waardeTekst}
|
||||||
|
disabled={rondeIds.length === 0}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={() => waarde !== null && save(waarde)}
|
||||||
|
placeholder="0,00"
|
||||||
|
className="w-full h-10 rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 pl-7 pr-3 text-sm tabular-nums text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-rikx-orange disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="text-sm flex flex-col gap-0.5 sm:flex-1 sm:min-w-64 min-h-10 justify-center">
|
||||||
|
<span className="text-zinc-500">
|
||||||
|
{rows.length} {rows.length === 1 ? "project" : "projecten"}
|
||||||
|
</span>
|
||||||
|
{rondeIds.length === 0 ? (
|
||||||
|
<span className="text-zinc-500">Geen projecten met een Rikx ronde op dit scherm.</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-zinc-700 dark:text-zinc-300">
|
||||||
|
{prijs === null ? (
|
||||||
|
"Vul een Rikx Waarde in om de berekening door te rekenen."
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Rikx prijs <strong className="tabular-nums">{euro.format(prijs)}</strong> (10%) voor{" "}
|
||||||
|
{rondes.length === 1 ? `ronde "${rondes[0].label}"` : `${rondes.length} rondes`}.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-live="polite"
|
||||||
|
className={
|
||||||
|
saveState.kind === "error"
|
||||||
|
? "text-red-600 dark:text-red-400"
|
||||||
|
: saveState.kind === "saved"
|
||||||
|
? "text-green-700 dark:text-green-400"
|
||||||
|
: "text-zinc-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{saveState.kind === "saving"
|
||||||
|
? "Opslaan in ronde…"
|
||||||
|
: saveState.kind === "saved"
|
||||||
|
? `Opgeslagen in ${saveState.labels.join(", ")}`
|
||||||
|
: saveState.kind === "error"
|
||||||
|
? `Niet opgeslagen: ${saveState.message}`
|
||||||
|
: rondes.length > 1
|
||||||
|
? "Let op: de waarde wordt op al deze rondes opgeslagen."
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="text-sm text-zinc-500">{emptyMessage}</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">Projectnaam</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Impactmaker</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium whitespace-nowrap">Kosten/impact</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium whitespace-nowrap">Rikx prijs</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium whitespace-nowrap">Totaal aantal Rikx</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium whitespace-nowrap">Totale impactwaarde</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium whitespace-nowrap">Meerwaarde</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium whitespace-nowrap">Efficiëntie</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||||
|
{results.map((r, i) => {
|
||||||
|
const cell = "px-4 py-3 text-right tabular-nums whitespace-nowrap text-zinc-900 dark:text-zinc-100";
|
||||||
|
const is75 = i === grensIndex;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={r.id}
|
||||||
|
className={`bg-white dark:bg-zinc-950 hover:bg-orange-50/60 dark:hover:bg-zinc-900 transition-colors ${
|
||||||
|
is75 ? "border-b-4 border-b-rikx-orange" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
|
<Link href={`/projecten/${r.id}?tab=calculatie`} className="hover:text-rikx-orange-dark hover:underline">
|
||||||
|
{r.name}
|
||||||
|
</Link>
|
||||||
|
{is75 && (
|
||||||
|
<span className="ml-2 inline-flex items-center rounded-full bg-rikx-orange px-2 py-0.5 text-xs font-semibold text-white align-middle">
|
||||||
|
75%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="block text-xs font-normal text-zinc-500">{r.ronde?.label ?? "Geen ronde"}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-700 dark:text-zinc-300">{r.impactmaker ?? "—"}</td>
|
||||||
|
<td className={cell}>{fmt(r.result.kosten_impact)}</td>
|
||||||
|
<td className={cell}>{fmt(r.input.rikx_prijs, "euro")}</td>
|
||||||
|
<td className={cell}>{fmt(r.result.totaal_aantal_rikx)}</td>
|
||||||
|
<td className={cell}>{fmt(r.result.totale_impactwaarde, "euro")}</td>
|
||||||
|
<td className={cell}>{fmt(r.result.meerwaarde, "euro")}</td>
|
||||||
|
<td className={cell}>{fmt(r.result.efficientie_waarde)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{grensIndex >= 0 && (
|
||||||
|
<p className="-mt-3 text-xs text-zinc-500">
|
||||||
|
<span className="inline-block w-6 h-1 rounded bg-rikx-orange align-middle mr-2" />
|
||||||
|
75%-project: {berekend[grensIndex].name} (kosten/impact {fmt(berekend[grensIndex].result.kosten_impact)}).
|
||||||
|
Tot en met deze lijn staan de 75% meest efficiënte projecten (laagste kosten/impact); eronder de overige 25%.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getDb } from "../../lib/db";
|
||||||
|
import { getCurrentUser } from "../../lib/auth";
|
||||||
|
import { defaultRikxPrijs } from "../../lib/rikxCalculatie";
|
||||||
|
|
||||||
|
export type SaveRondeWaardeResult = { ok: true; labels: string[] } | { ok: false; error: string };
|
||||||
|
|
||||||
|
// Zet de Rikx waarde (en Rikx prijs = 10%) op de opgegeven Rikx rondes.
|
||||||
|
// Aangeroepen vanuit het Rikx Waarde-veld op de pagina Waardebepaling projecten.
|
||||||
|
export async function saveRondeWaarde(rondeIds: number[], rikxWaarde: number): Promise<SaveRondeWaardeResult> {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user || user.role !== "admin") return { ok: false, error: "Geen toegang" };
|
||||||
|
|
||||||
|
if (!Number.isFinite(rikxWaarde) || rikxWaarde < 0) return { ok: false, error: "Ongeldige Rikx waarde" };
|
||||||
|
const ids = [...new Set(rondeIds.map(Number))].filter((id) => Number.isInteger(id) && id > 0);
|
||||||
|
if (ids.length === 0) return { ok: false, error: "Geen ronde om bij te werken" };
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const placeholders = ids.map(() => "?").join(",");
|
||||||
|
const rondes = db.prepare(`SELECT id, label FROM rikx_rondes WHERE id IN (${placeholders})`).all(...ids) as {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
}[];
|
||||||
|
if (rondes.length === 0) return { ok: false, error: "Ronde niet gevonden" };
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE rikx_rondes SET rikx_waarde = ?, rikx_prijs = ?, updated_at = datetime('now') WHERE id IN (${placeholders})`
|
||||||
|
).run(rikxWaarde, defaultRikxPrijs(rikxWaarde), ...ids);
|
||||||
|
|
||||||
|
return { ok: true, labels: rondes.map((r) => r.label) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { requireAdmin } from "../../lib/auth";
|
||||||
|
import { getDb, listRikxRondes, type Project } from "../../lib/db";
|
||||||
|
import { listBeoordelingen } from "../../lib/experts";
|
||||||
|
import { listAccountNames } from "../../lib/platform-db";
|
||||||
|
import { rikxInputFromProject } from "../../lib/rikxCalculatie";
|
||||||
|
import { AppHeader } from "../AppHeader";
|
||||||
|
import { AutoSubmitSelect } from "../projecten/AutoSubmitSelect";
|
||||||
|
import { WaardebepalingTable, type WaardebepalingRow } from "./WaardebepalingTable";
|
||||||
|
|
||||||
|
// Alle projecten met status Waardebepaling, gerangschikt op Kosten/impact van laag naar hoog (laag = efficiënt).
|
||||||
|
export default async function WaardebepalingPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ ronde?: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
|
||||||
|
const params = await searchParams;
|
||||||
|
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! : "";
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const where = ["status = 'waardebepaling'"];
|
||||||
|
const args: number[] = [];
|
||||||
|
if (ronde === "geen") {
|
||||||
|
where.push("ronde_id IS NULL");
|
||||||
|
} else if (ronde) {
|
||||||
|
where.push("ronde_id = ?");
|
||||||
|
args.push(Number(ronde));
|
||||||
|
}
|
||||||
|
const projects = (db.prepare(`SELECT * FROM projects WHERE ${where.join(" AND ")} ORDER BY id`).all(...args) as Project[]).map(
|
||||||
|
(p) => ({ ...p })
|
||||||
|
);
|
||||||
|
const scores = listBeoordelingen(projects.map((p) => p.id));
|
||||||
|
const impactmakers = new Map(listAccountNames().map((a) => [a.id, a.name]));
|
||||||
|
|
||||||
|
const rows: WaardebepalingRow[] = projects.map((p) => {
|
||||||
|
const projectRonde = rondes.find((r) => r.id === p.ronde_id) ?? null;
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
impactmaker: p.impactmaker_account_id ? impactmakers.get(p.impactmaker_account_id) ?? null : null,
|
||||||
|
ronde: projectRonde ? { id: projectRonde.id, label: projectRonde.label, rikxWaarde: projectRonde.rikx_waarde } : null,
|
||||||
|
input: rikxInputFromProject(
|
||||||
|
p,
|
||||||
|
scores.filter((s) => s.project_id === p.id),
|
||||||
|
projectRonde
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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 focus:outline-none focus:ring-2 focus:ring-rikx-orange";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||||
|
<AppHeader email={session.email} active="projecten" />
|
||||||
|
|
||||||
|
<main className="max-w-6xl mx-auto px-4 sm:px-6 py-8 flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">Waardebepaling projecten</h1>
|
||||||
|
<p className="text-sm text-zinc-500 mt-1">
|
||||||
|
Projecten met status Waardebepaling, gerangschikt op kosten/impact (laagste eerst).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WaardebepalingTable
|
||||||
|
rows={rows}
|
||||||
|
toon75Procent={ronde !== "" && ronde !== "geen"}
|
||||||
|
filter={
|
||||||
|
// `contents`: het formulier zelf neemt geen ruimte in, zodat het filter in dezelfde rij
|
||||||
|
// komt te staan als het Rikx Waarde-veld (zie WaardebepalingTable)
|
||||||
|
// autoComplete="off": de browser mag de keuze niet herstellen na "Terug", anders toont
|
||||||
|
// de dropdown een andere ronde dan de lijst
|
||||||
|
<form action="/waardebepaling" autoComplete="off" className="contents">
|
||||||
|
<div className="w-full sm:w-64">
|
||||||
|
<label htmlFor="filter-ronde" className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">
|
||||||
|
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>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
emptyMessage={
|
||||||
|
ronde ? "Geen projecten in waardebepaling voor deze ronde." : "Er zijn geen projecten in waardebepaling."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+62
-1
@@ -1,4 +1,5 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { createHmac, timingSafeEqual, randomBytes, scryptSync } from "crypto";
|
import { createHmac, timingSafeEqual, randomBytes, scryptSync } from "crypto";
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
|
|
||||||
@@ -6,7 +7,8 @@ const SESSION_SECRET = process.env.SESSION_SECRET!;
|
|||||||
const COOKIE_NAME = "session";
|
const COOKIE_NAME = "session";
|
||||||
const MAX_AGE = 60 * 60 * 24 * 7; // 7 dagen
|
const MAX_AGE = 60 * 60 * 24 * 7; // 7 dagen
|
||||||
|
|
||||||
type SessionPayload = { userId: number; email: string; name: string };
|
// impersonatorId: admin die via "Inloggen als" de pagina van een expert bekijkt (alleen lezen)
|
||||||
|
type SessionPayload = { userId: number; email: string; name: string; impersonatorId?: number };
|
||||||
|
|
||||||
function sign(payload: SessionPayload): string {
|
function sign(payload: SessionPayload): string {
|
||||||
const data = JSON.stringify(payload);
|
const data = JSON.stringify(payload);
|
||||||
@@ -57,6 +59,8 @@ export function hashPassword(password: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function verifyPassword(password: string, stored: string): boolean {
|
export function verifyPassword(password: string, stored: string): boolean {
|
||||||
|
// Uitgenodigde experts hebben nog geen wachtwoord (lege hash) en kunnen niet inloggen
|
||||||
|
if (!stored || !stored.includes(":")) return false;
|
||||||
const [salt, hash] = stored.split(":");
|
const [salt, hash] = stored.split(":");
|
||||||
const attempt = scryptSync(password, salt, 64).toString("hex");
|
const attempt = scryptSync(password, salt, 64).toString("hex");
|
||||||
return timingSafeEqual(Buffer.from(hash, "hex"), Buffer.from(attempt, "hex"));
|
return timingSafeEqual(Buffer.from(hash, "hex"), Buffer.from(attempt, "hex"));
|
||||||
@@ -68,3 +72,60 @@ export function getUserByEmail(email: string) {
|
|||||||
| { id: number; email: string; name: string; password_hash: string; role: string }
|
| { id: number; email: string; name: string; password_hash: string; role: string }
|
||||||
| undefined;
|
| undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Role = "admin" | "expert";
|
||||||
|
export type CurrentUser = {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: Role;
|
||||||
|
// Gezet als een admin via "Inloggen als" meekijkt; alleen geldig als die gebruiker nog admin is
|
||||||
|
impersonator: { id: number; email: string; name: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ingelogde gebruiker met zijn rol uit de database (niet uit de cookie), zodat een
|
||||||
|
// gewijzigde of verwijderde gebruiker direct geen toegang meer heeft.
|
||||||
|
export async function getCurrentUser(): Promise<CurrentUser | null> {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) return null;
|
||||||
|
const user = getDb().prepare("SELECT id, email, name, role FROM users WHERE id = ?").get(session.userId) as
|
||||||
|
| { id: number; email: string; name: string; role: string }
|
||||||
|
| undefined;
|
||||||
|
if (!user) return null;
|
||||||
|
let impersonator: CurrentUser["impersonator"] = null;
|
||||||
|
if (session.impersonatorId) {
|
||||||
|
const admin = getDb()
|
||||||
|
.prepare("SELECT id, email, name FROM users WHERE id = ? AND role = 'admin'")
|
||||||
|
.get(session.impersonatorId) as { id: number; email: string; name: string } | undefined;
|
||||||
|
// Een meekijk-sessie van een gebruiker die geen admin (meer) is, is ongeldig
|
||||||
|
if (!admin) return null;
|
||||||
|
impersonator = { id: admin.id, email: admin.email, name: admin.name };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role === "expert" ? "expert" : "admin",
|
||||||
|
impersonator,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function homeForRole(role: Role): string {
|
||||||
|
return role === "expert" ? "/expert" : "/tenants";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Voor alle beheerpagina's en -acties: alleen admins. Experts gaan naar hun eigen pagina.
|
||||||
|
export async function requireAdmin(): Promise<CurrentUser> {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
if (user.role !== "admin") redirect(homeForRole(user.role));
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Voor de expertpagina: alleen experts.
|
||||||
|
export async function requireExpert(): Promise<CurrentUser> {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
if (user.role !== "expert") redirect(homeForRole(user.role));
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,8 +52,40 @@ function migrate(db: DatabaseSync) {
|
|||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Expertscores per project voor de Rikx calculatie (Impactscore M).
|
-- Module 4: experts zijn gebruikers met role = 'expert'.
|
||||||
-- Voorlopig handmatig ingevuld per expertnummer; Module 4 koppelt dit later aan expert-logins.
|
-- Rondes waarvan een expert de projecten in waardebepaling ziet
|
||||||
|
CREATE TABLE IF NOT EXISTS expert_rondes (
|
||||||
|
expert_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
ronde_id INTEGER NOT NULL REFERENCES rikx_rondes(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (expert_id, ronde_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Uitnodigingen: alleen de SHA-256-hash van het token wordt bewaard
|
||||||
|
CREATE TABLE IF NOT EXISTS expert_uitnodigingen (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Beoordeling van een project door een expert; Impactscore M = gemiddelde over de experts
|
||||||
|
CREATE TABLE IF NOT EXISTS expert_beoordelingen (
|
||||||
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
expert_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
duur_impact REAL,
|
||||||
|
levensdomeinen REAL,
|
||||||
|
basissituatie REAL,
|
||||||
|
bonus_malus_advies TEXT,
|
||||||
|
toelichting TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (project_id, expert_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Oude, handmatig ingevulde expertscores (expert 1–3). Niet meer gebruikt sinds de expertbeoordelingen;
|
||||||
|
-- de tabel blijft bestaan omdat hij in productie al gegevens kan bevatten.
|
||||||
CREATE TABLE IF NOT EXISTS project_expert_scores (
|
CREATE TABLE IF NOT EXISTS project_expert_scores (
|
||||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
expert INTEGER NOT NULL,
|
expert INTEGER NOT NULL,
|
||||||
@@ -73,9 +105,16 @@ function migrate(db: DatabaseSync) {
|
|||||||
addColumnIfMissing(db, "projects", "financiering_extern_pct", "REAL");
|
addColumnIfMissing(db, "projects", "financiering_extern_pct", "REAL");
|
||||||
// Overige project plan-velden als JSON; definities in lib/projectPlan.ts
|
// Overige project plan-velden als JSON; definities in lib/projectPlan.ts
|
||||||
addColumnIfMissing(db, "projects", "project_plan", "TEXT");
|
addColumnIfMissing(db, "projects", "project_plan", "TEXT");
|
||||||
// Stap "Rikx calculatie" (alleen zichtbaar als rikx_calculatie_aan = 1)
|
// Tab "Rikx calculatie". rikx_calculatie_aan is de oude schuif (niet meer gebruikt;
|
||||||
|
// de tab is nu zichtbaar vanaf status waardebepaling) en blijft staan omdat hij al in productie bestaat.
|
||||||
addColumnIfMissing(db, "projects", "rikx_calculatie_aan", "INTEGER NOT NULL DEFAULT 0");
|
addColumnIfMissing(db, "projects", "rikx_calculatie_aan", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
addColumnIfMissing(db, "projects", "rekenmethode", "TEXT NOT NULL DEFAULT 'arbeid'");
|
||||||
addColumnIfMissing(db, "projects", "bonus_malus", "TEXT");
|
addColumnIfMissing(db, "projects", "bonus_malus", "TEXT");
|
||||||
|
// Beoordeling onderaan de tab Rikx calculatie: 'goedgekeurd', 'afgekeurd' of NULL (nog niet beoordeeld)
|
||||||
|
addColumnIfMissing(db, "projects", "beoordeling", "TEXT");
|
||||||
|
addColumnIfMissing(db, "projects", "beoordeling_reden", "TEXT");
|
||||||
|
// Impactmaker: de sociaal ondernemer (accounts.id in de platformdatabase; geen foreign key, andere database)
|
||||||
|
addColumnIfMissing(db, "projects", "impactmaker_account_id", "INTEGER");
|
||||||
addColumnIfMissing(db, "projects", "ronde_id", "INTEGER REFERENCES rikx_rondes(id) ON DELETE SET NULL");
|
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)");
|
db.exec("CREATE INDEX IF NOT EXISTS idx_projects_ronde ON projects(ronde_id)");
|
||||||
}
|
}
|
||||||
@@ -131,7 +170,11 @@ export type Project = {
|
|||||||
financiering_extern_pct: number | null;
|
financiering_extern_pct: number | null;
|
||||||
project_plan: string | null;
|
project_plan: string | null;
|
||||||
rikx_calculatie_aan: number;
|
rikx_calculatie_aan: number;
|
||||||
|
rekenmethode: string;
|
||||||
bonus_malus: string | null;
|
bonus_malus: string | null;
|
||||||
|
beoordeling: string | null;
|
||||||
|
beoordeling_reden: string | null;
|
||||||
|
impactmaker_account_id: number | null;
|
||||||
ronde_id: number | null;
|
ronde_id: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -140,6 +183,19 @@ export type Project = {
|
|||||||
// Project met het label van de gekoppelde Rikx Ronde (voor overzichten)
|
// Project met het label van de gekoppelde Rikx Ronde (voor overzichten)
|
||||||
export type ProjectListItem = Project & { ronde_label: string | null };
|
export type ProjectListItem = Project & { ronde_label: string | null };
|
||||||
|
|
||||||
|
export type ExpertBeoordeling = {
|
||||||
|
project_id: number;
|
||||||
|
expert_id: number;
|
||||||
|
duur_impact: number | null;
|
||||||
|
levensdomeinen: number | null;
|
||||||
|
basissituatie: number | null;
|
||||||
|
bonus_malus_advies: string | null;
|
||||||
|
toelichting: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Oude handmatige scores (niet meer gebruikt)
|
||||||
export type ProjectExpertScore = {
|
export type ProjectExpertScore = {
|
||||||
project_id: number;
|
project_id: number;
|
||||||
expert: number;
|
expert: number;
|
||||||
|
|||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
import { createHash, randomBytes } from "crypto";
|
||||||
|
import { getDb, type ExpertBeoordeling, type Project } from "./db";
|
||||||
|
|
||||||
|
// Module 4: experts zijn gebruikers (users.role = 'expert') die per Rikx ronde projecten beoordelen.
|
||||||
|
|
||||||
|
export type Expert = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
actief: boolean; // heeft een wachtwoord ingesteld via de uitnodiging
|
||||||
|
rondeIds: number[];
|
||||||
|
aantalBeoordelingen: number;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INVITE_DAGEN = 7;
|
||||||
|
|
||||||
|
export function listExperts(): Expert[] {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT u.id, u.name, u.email, u.password_hash, u.created_at,
|
||||||
|
(SELECT COUNT(*) FROM expert_beoordelingen b WHERE b.expert_id = u.id) AS aantal
|
||||||
|
FROM users u WHERE u.role = 'expert' ORDER BY u.name COLLATE NOCASE`
|
||||||
|
)
|
||||||
|
.all() as { id: number; name: string; email: string; password_hash: string; created_at: string; aantal: number }[];
|
||||||
|
const rondes = db.prepare("SELECT expert_id, ronde_id FROM expert_rondes").all() as {
|
||||||
|
expert_id: number;
|
||||||
|
ronde_id: number;
|
||||||
|
}[];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
email: r.email,
|
||||||
|
actief: r.password_hash.includes(":"),
|
||||||
|
rondeIds: rondes.filter((x) => x.expert_id === r.id).map((x) => x.ronde_id),
|
||||||
|
aantalBeoordelingen: r.aantal,
|
||||||
|
created_at: r.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExpert(id: number): Expert | undefined {
|
||||||
|
return listExperts().find((e) => e.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExpertRondeIds(expertId: number): number[] {
|
||||||
|
return (
|
||||||
|
getDb().prepare("SELECT ronde_id FROM expert_rondes WHERE expert_id = ?").all(expertId) as { ronde_id: number }[]
|
||||||
|
).map((r) => r.ronde_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setExpertRondes(expertId: number, rondeIds: number[]) {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare("DELETE FROM expert_rondes WHERE expert_id = ?").run(expertId);
|
||||||
|
const insert = db.prepare("INSERT OR IGNORE INTO expert_rondes (expert_id, ronde_id) VALUES (?, ?)");
|
||||||
|
for (const id of rondeIds) insert.run(expertId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Koppeling vanaf de kant van de ronde (pagina Rikx rondes); alleen bestaande experts tellen mee
|
||||||
|
export function setRondeExperts(rondeId: number, expertIds: number[]) {
|
||||||
|
const db = getDb();
|
||||||
|
const experts = new Set(
|
||||||
|
(db.prepare("SELECT id FROM users WHERE role = 'expert'").all() as { id: number }[]).map((u) => u.id)
|
||||||
|
);
|
||||||
|
db.prepare("DELETE FROM expert_rondes WHERE ronde_id = ?").run(rondeId);
|
||||||
|
const insert = db.prepare("INSERT OR IGNORE INTO expert_rondes (expert_id, ronde_id) VALUES (?, ?)");
|
||||||
|
for (const id of expertIds) if (experts.has(id)) insert.run(id, rondeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projecten die een expert ziet: status Waardebepaling, in een van zijn rondes
|
||||||
|
export function listProjectenVoorExpert(expertId: number): (Project & { ronde_label: string })[] {
|
||||||
|
const rondeIds = getExpertRondeIds(expertId);
|
||||||
|
if (rondeIds.length === 0) return [];
|
||||||
|
return (
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`SELECT p.*, r.label AS ronde_label FROM projects p JOIN rikx_rondes r ON r.id = p.ronde_id
|
||||||
|
WHERE p.status = 'waardebepaling' AND p.ronde_id IN (${rondeIds.map(() => "?").join(",")})
|
||||||
|
ORDER BY r.label COLLATE NOCASE, p.name COLLATE NOCASE`
|
||||||
|
)
|
||||||
|
.all(...rondeIds) as (Project & { ronde_label: string })[]
|
||||||
|
).map((p) => ({ ...p }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function magBeoordelen(expertId: number, projectId: number): boolean {
|
||||||
|
return listProjectenVoorExpert(expertId).some((p) => p.id === projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beoordelingen van alle experts voor de gegeven projecten, met de naam van de expert
|
||||||
|
export function listBeoordelingen(projectIds: number[]): (ExpertBeoordeling & { expert_name: string })[] {
|
||||||
|
if (projectIds.length === 0) return [];
|
||||||
|
return (
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`SELECT b.*, u.name AS expert_name FROM expert_beoordelingen b
|
||||||
|
JOIN users u ON u.id = b.expert_id
|
||||||
|
WHERE b.project_id IN (${projectIds.map(() => "?").join(",")})
|
||||||
|
ORDER BY u.name COLLATE NOCASE`
|
||||||
|
)
|
||||||
|
.all(...projectIds) as (ExpertBeoordeling & { expert_name: string })[]
|
||||||
|
).map((b) => ({ ...b }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
|
||||||
|
|
||||||
|
// Maakt een nieuwe uitnodiging; eerdere, ongebruikte uitnodigingen van deze gebruiker vervallen.
|
||||||
|
export function createInvite(userId: number): string {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare("DELETE FROM expert_uitnodigingen WHERE user_id = ? AND used_at IS NULL").run(userId);
|
||||||
|
const token = randomBytes(32).toString("base64url");
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO expert_uitnodigingen (user_id, token_hash, expires_at)
|
||||||
|
VALUES (?, ?, datetime('now', '+${INVITE_DAGEN} days'))`
|
||||||
|
).run(userId, hashToken(token));
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geldige (niet verlopen, niet gebruikte) uitnodiging bij een token, met de bijbehorende expert
|
||||||
|
export function findInvite(token: string) {
|
||||||
|
return getDb()
|
||||||
|
.prepare(
|
||||||
|
`SELECT i.id, i.user_id, u.name, u.email FROM expert_uitnodigingen i
|
||||||
|
JOIN users u ON u.id = i.user_id
|
||||||
|
WHERE i.token_hash = ? AND i.used_at IS NULL AND i.expires_at > datetime('now') AND u.role = 'expert'`
|
||||||
|
)
|
||||||
|
.get(hashToken(token)) as { id: number; user_id: number; name: string; email: string } | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markInviteUsed(inviteId: number) {
|
||||||
|
getDb().prepare("UPDATE expert_uitnodigingen SET used_at = datetime('now') WHERE id = ?").run(inviteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UITNODIGING_GELDIG_DAGEN = INVITE_DAGEN;
|
||||||
|
export const MIN_WACHTWOORD_LENGTE = 8;
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
// E-mail via SMTP (zie SMTP_* in CLAUDE.md). Zonder SMTP_HOST wordt er niets verstuurd;
|
||||||
|
// de aanroeper toont dan bijvoorbeeld de uitnodigingslink in beeld.
|
||||||
|
export function isMailConfigured(): boolean {
|
||||||
|
return Boolean(process.env.SMTP_HOST);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMail(message: { to: string; subject: string; text: string; html?: string }): Promise<void> {
|
||||||
|
const port = Number(process.env.SMTP_PORT || 587);
|
||||||
|
const transport = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port,
|
||||||
|
secure: port === 465,
|
||||||
|
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } : undefined,
|
||||||
|
});
|
||||||
|
await transport.sendMail({ from: process.env.SMTP_FROM || "noreply@rikx.nl", ...message });
|
||||||
|
}
|
||||||
@@ -46,3 +46,19 @@ export function getAccount(id: number): PlatformAccount | undefined {
|
|||||||
.prepare("SELECT * FROM accounts WHERE id = ?")
|
.prepare("SELECT * FROM accounts WHERE id = ?")
|
||||||
.get(id) as PlatformAccount | undefined;
|
.get(id) as PlatformAccount | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Namen van alle sociaal ondernemers, voor keuzelijsten en overzichten in Rikx Beheer.
|
||||||
|
// Geeft een lege lijst als de platformdatabase niet bereikbaar is, zodat projectpagina's blijven werken.
|
||||||
|
export function listAccountNames(): { id: number; name: string }[] {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
getPlatformDb().prepare("SELECT id, name FROM accounts ORDER BY name COLLATE NOCASE").all() as {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}[]
|
||||||
|
).map((a) => ({ id: a.id, name: a.name }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Platformdatabase niet bereikbaar:", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,3 +23,13 @@ export function projectStatusLabel(id: string): string {
|
|||||||
export function projectStatusBadgeClass(id: string): string {
|
export function projectStatusBadgeClass(id: string): string {
|
||||||
return PROJECT_STATUSES.find((s) => s.id === id)?.badgeClass ?? "bg-zinc-100 text-zinc-700";
|
return PROJECT_STATUSES.find((s) => s.id === id)?.badgeClass ?? "bg-zinc-100 text-zinc-700";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Statusvolgorde zoals in PROJECT_STATUSES; true als `status` gelijk aan of later dan `vanaf` is.
|
||||||
|
export function isStatusVanaf(status: string, vanaf: string): boolean {
|
||||||
|
const index = PROJECT_STATUSES.findIndex((s) => s.id === status);
|
||||||
|
const vanafIndex = PROJECT_STATUSES.findIndex((s) => s.id === vanaf);
|
||||||
|
return index !== -1 && vanafIndex !== -1 && index >= vanafIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// De tab "Rikx calculatie" op de projectpagina is zichtbaar vanaf deze status.
|
||||||
|
export const RIKX_CALCULATIE_VANAF_STATUS = "waardebepaling";
|
||||||
|
|||||||
+57
-5
@@ -1,4 +1,5 @@
|
|||||||
// Rikx-rekenmethode, overgenomen uit _doc/Rikx rekenmethode uitleg.xlsx (tabblad "Uitleg").
|
// Rikx Waardeberekening Arbeid, overgenomen uit _doc/Rikx rekenmethode uitleg.xlsx (tabblad "Uitleg")
|
||||||
|
// en _doc/Waarderingssystematiek Rikx juni 2023.pdf.
|
||||||
// Pure functies zonder database-toegang, zodat het formulier live kan rekenen.
|
// Pure functies zonder database-toegang, zodat het formulier live kan rekenen.
|
||||||
|
|
||||||
export const BONUS_MALUS_OPTIONS = [
|
export const BONUS_MALUS_OPTIONS = [
|
||||||
@@ -10,13 +11,35 @@ export const BONUS_MALUS_OPTIONS = [
|
|||||||
{ id: "--", label: "--", factor: 0.8 },
|
{ id: "--", label: "--", factor: 0.8 },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// Schalen volgens _doc/Waarderingssystematiek Rikx juni 2023.pdf ("Inhoudelijke focus")
|
||||||
export const EXPERT_CRITERIA = [
|
export const EXPERT_CRITERIA = [
|
||||||
{ id: "duur_impact", label: "Duur van impact" },
|
{ id: "duur_impact", label: "Duur van impact", schaal: "kortstondig → levenslang" },
|
||||||
{ id: "levensdomeinen", label: "Aantal levensdomeinen" },
|
{ id: "levensdomeinen", label: "Aantal levensdomeinen", schaal: "één levensdomein → alle levensdomeinen" },
|
||||||
{ id: "basissituatie", label: "Basissituatie doelgroep" },
|
{ id: "basissituatie", label: "Basissituatie doelgroep", schaal: "relatief eenvoudig → relatief zwaar" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const EXPERT_COUNT = 3;
|
// Rikx kent twee rekenmethodes. Alleen "Arbeid" is uitgewerkt (de formule hieronder);
|
||||||
|
// "Jeugd" is zichtbaar maar nog niet te kiezen.
|
||||||
|
export const REKENMETHODES = [
|
||||||
|
{
|
||||||
|
id: "arbeid",
|
||||||
|
label: "Rikx Waardeberekening Arbeid",
|
||||||
|
omschrijving: "Impact op de arbeidsmarkt: uitstroom naar werk en het opdoen van skills.",
|
||||||
|
beschikbaar: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "jeugd",
|
||||||
|
label: "Rikx Waardeberekening Jeugd",
|
||||||
|
omschrijving: "Nog niet beschikbaar.",
|
||||||
|
beschikbaar: false,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DEFAULT_REKENMETHODE = "arbeid";
|
||||||
|
|
||||||
|
export function isBeschikbareRekenmethode(value: unknown): value is string {
|
||||||
|
return REKENMETHODES.some((m) => m.id === value && m.beschikbaar);
|
||||||
|
}
|
||||||
|
|
||||||
export type ExpertCriterium = (typeof EXPERT_CRITERIA)[number]["id"];
|
export type ExpertCriterium = (typeof EXPERT_CRITERIA)[number]["id"];
|
||||||
export type ExpertScore = Record<ExpertCriterium, number | null>;
|
export type ExpertScore = Record<ExpertCriterium, number | null>;
|
||||||
@@ -116,3 +139,32 @@ export function calculateRikx(input: RikxInput): RikxResult {
|
|||||||
export function defaultRikxPrijs(rikxWaarde: number): number {
|
export function defaultRikxPrijs(rikxWaarde: number): number {
|
||||||
return rikxWaarde / 10;
|
return rikxWaarde / 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bouwt de invoer voor calculateRikx uit een opgeslagen project, de beoordelingen van de experts
|
||||||
|
// (elk aantal; Impactscore M = gemiddelde over de experts) en zijn Rikx Ronde.
|
||||||
|
export function rikxInputFromProject(
|
||||||
|
project: {
|
||||||
|
projectkosten: number | null;
|
||||||
|
deelnemers_werk: number | null;
|
||||||
|
deelnemers_skills: number | null;
|
||||||
|
financiering_extern_pct: number | null;
|
||||||
|
bonus_malus: string | null;
|
||||||
|
},
|
||||||
|
beoordelingen: { duur_impact: number | null; levensdomeinen: number | null; basissituatie: number | null }[],
|
||||||
|
ronde: { rikx_waarde: number | null; rikx_prijs: number | null } | null
|
||||||
|
): RikxInput {
|
||||||
|
return {
|
||||||
|
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: ronde?.rikx_waarde ?? null,
|
||||||
|
rikx_prijs: ronde?.rikx_prijs ?? null,
|
||||||
|
expert_scores: beoordelingen.map((b) => ({
|
||||||
|
duur_impact: b.duur_impact,
|
||||||
|
levensdomeinen: b.levensdomeinen,
|
||||||
|
basissituatie: b.basissituatie,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user