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:
@@ -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";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import type { Project, ProjectExpertScore, RikxRonde } from "../../../lib/db";
|
||||
import { PROJECT_STATUSES } from "../../../lib/projectStatus";
|
||||
import { useRef, useState } from "react";
|
||||
import type { Project, RikxRonde } from "../../../lib/db";
|
||||
import { PROJECT_STATUSES, RIKX_CALCULATIE_VANAF_STATUS, isStatusVanaf } from "../../../lib/projectStatus";
|
||||
import {
|
||||
BONUS_MALUS_OPTIONS,
|
||||
EXPERT_COUNT,
|
||||
EXPERT_CRITERIA,
|
||||
REKENMETHODES,
|
||||
calculateRikx,
|
||||
rikxInputFromProject,
|
||||
type ExpertScore,
|
||||
} from "../../../lib/rikxCalculatie";
|
||||
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 { ExpertBeoordelingen, type BeoordelingMetNaam } from "./ExpertBeoordelingen";
|
||||
|
||||
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";
|
||||
@@ -30,7 +32,7 @@ function rondeById(rondes: RikxRonde[], id: number | 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 ronde = rondeById(rondes, Number(fd.get("ronde_id")) || null);
|
||||
return {
|
||||
@@ -42,9 +44,8 @@ function readInput(form: HTMLFormElement, rondes: RikxRonde[]) {
|
||||
bonus_malus: (fd.get("bonus_malus") as string) || null,
|
||||
rikx_waarde: ronde?.rikx_waarde ?? null,
|
||||
rikx_prijs: ronde?.rikx_prijs ?? null,
|
||||
expert_scores: Array.from({ length: EXPERT_COUNT }, (_, i) =>
|
||||
Object.fromEntries(EXPERT_CRITERIA.map((c) => [c.id, toNumber(fd.get(`expert_${i + 1}_${c.id}`))]))
|
||||
) as ExpertScore[],
|
||||
// Expertscores komen van de experts zelf en zijn hier niet te wijzigen
|
||||
expert_scores: expertScores,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-start gap-3 mb-5">
|
||||
<span className="flex items-center justify-center w-7 h-7 shrink-0 rounded-full bg-rikx-orange text-white text-sm font-semibold">
|
||||
{step}
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100">{title}</h2>
|
||||
<p className="text-sm text-zinc-500">{description}</p>
|
||||
<div className="mb-5">
|
||||
<h2 className="font-semibold text-zinc-900 dark:text-zinc-100">{title}</h2>
|
||||
<p className="text-sm text-zinc-500">{description}</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectDetailForm({
|
||||
project,
|
||||
expertScores,
|
||||
beoordelingen,
|
||||
rondes,
|
||||
impactmakers,
|
||||
initialTab,
|
||||
}: {
|
||||
project: Project;
|
||||
expertScores: ProjectExpertScore[];
|
||||
beoordelingen: BeoordelingMetNaam[];
|
||||
rondes: RikxRonde[];
|
||||
impactmakers: { id: number; name: string }[];
|
||||
initialTab: ProjectTab;
|
||||
}) {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [calcOn, setCalcOn] = useState(project.rikx_calculatie_aan === 1);
|
||||
const [pending, startTransition] = useTransition();
|
||||
// Live status uit de dropdown, zodat de tab Rikx calculatie direct verschijnt of verdwijnt.
|
||||
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 [input, setInput] = useState(() => ({
|
||||
ronde_id: initialRonde?.id ?? null,
|
||||
projectkosten: project.projectkosten,
|
||||
deelnemers_werk: project.deelnemers_werk,
|
||||
deelnemers_skills: project.deelnemers_skills,
|
||||
financiering_extern_pct: project.financiering_extern_pct,
|
||||
bonus_malus: project.bonus_malus,
|
||||
rikx_waarde: initialRonde?.rikx_waarde ?? null,
|
||||
rikx_prijs: initialRonde?.rikx_prijs ?? null,
|
||||
expert_scores: Array.from({ length: EXPERT_COUNT }, (_, i) => {
|
||||
const s = expertScores.find((e) => e.expert === i + 1);
|
||||
return {
|
||||
duur_impact: s?.duur_impact ?? null,
|
||||
levensdomeinen: s?.levensdomeinen ?? null,
|
||||
basissituatie: s?.basissituatie ?? null,
|
||||
};
|
||||
}),
|
||||
...rikxInputFromProject(project, beoordelingen, initialRonde),
|
||||
}));
|
||||
const result = calculateRikx(input);
|
||||
const ronde = rondeById(rondes, input.ronde_id);
|
||||
|
||||
function toggleCalc() {
|
||||
const next = !calcOn;
|
||||
setCalcOn(next);
|
||||
startTransition(() => setRikxCalculatie(project.id, next));
|
||||
// De browser weigert te versturen bij een ongeldig veld, maar toont geen melding als dat veld op een
|
||||
// verborgen tab staat. Daarom: naar die tab gaan en de melding bij het eerste ongeldige veld tonen.
|
||||
const invalidHandledRef = useRef(false);
|
||||
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 }[] = [
|
||||
@@ -142,19 +180,22 @@ export function ProjectDetailForm({
|
||||
|
||||
return (
|
||||
<form
|
||||
id={PROJECT_FORM_ID}
|
||||
ref={formRef}
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<input name="name" required defaultValue={project.name} className={inputClass} />
|
||||
</Field>
|
||||
<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) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.label}
|
||||
@@ -162,6 +203,16 @@ export function ProjectDetailForm({
|
||||
))}
|
||||
</select>
|
||||
</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">
|
||||
<select name="ronde_id" defaultValue={project.ronde_id ?? ""} className={inputClass}>
|
||||
<option value="">Geen ronde</option>
|
||||
@@ -174,79 +225,89 @@ export function ProjectDetailForm({
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
<section className={cardClass}>
|
||||
<StepHeading step={1} title="Project plan" description="In te vullen door de sociaal ondernemer." />
|
||||
<div role="tablist" aria-label="Projectonderdelen" className="flex gap-1 border-b border-zinc-200 dark:border-zinc-800 -mb-6">
|
||||
{([
|
||||
{ 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)} />
|
||||
</section>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-6 py-4">
|
||||
<div>
|
||||
<p className="font-medium text-zinc-900 dark:text-zinc-100">Rikx calculatie</p>
|
||||
<p className="text-sm text-zinc-500">Toon de waardebepaling volgens de Rikx-rekenmethode.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={calcOn}
|
||||
aria-label="Rikx calculatie tonen"
|
||||
onClick={toggleCalc}
|
||||
disabled={pending}
|
||||
className={`relative inline-flex h-7 w-12 shrink-0 items-center rounded-full transition-colors disabled:opacity-60 ${
|
||||
calcOn ? "bg-rikx-orange" : "bg-zinc-300 dark:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 rounded-full bg-white shadow transition-transform ${
|
||||
calcOn ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Verborgen in plaats van weggelaten, zodat de waarden bij opslaan bewaard blijven. */}
|
||||
<section className={cardClass} hidden={!calcOn}>
|
||||
<StepHeading
|
||||
step={2}
|
||||
<section
|
||||
id="panel-calculatie"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-calculatie"
|
||||
hidden={activeTab !== "calculatie"}
|
||||
className={`${cardClass} rounded-tl-none`}
|
||||
>
|
||||
<TabHeading
|
||||
title="Rikx calculatie"
|
||||
description="Waardebepaling door het expertteam en Rikx. Uitkomsten worden live berekend."
|
||||
/>
|
||||
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Expertscores (1–10)</h3>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-zinc-500">
|
||||
<th className="text-left font-medium py-2 pr-3">Criterium</th>
|
||||
{Array.from({ length: EXPERT_COUNT }, (_, i) => (
|
||||
<th key={i} className="text-left font-medium py-2 px-1 min-w-24">
|
||||
Expert {i + 1}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{EXPERT_CRITERIA.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td className="py-1 pr-3 text-zinc-700 dark:text-zinc-300 whitespace-nowrap">{c.label}</td>
|
||||
{Array.from({ length: EXPERT_COUNT }, (_, i) => (
|
||||
<td key={i} className="py-1 px-1">
|
||||
<input
|
||||
name={`expert_${i + 1}_${c.id}`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
step="0.5"
|
||||
aria-label={`${c.label}, expert ${i + 1}`}
|
||||
defaultValue={input.expert_scores[i][c.id] ?? ""}
|
||||
className={inputClass}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-xs text-zinc-500 mt-1">Voorlopig handmatig; met Module 4 vullen de experts dit zelf in.</p>
|
||||
<fieldset className="mb-6">
|
||||
<legend className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Rekenmethode</legend>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{REKENMETHODES.map((m) => (
|
||||
<label
|
||||
key={m.id}
|
||||
className={`flex items-start gap-3 rounded-lg border p-4 ${
|
||||
m.beschikbaar
|
||||
? "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"
|
||||
: "cursor-not-allowed border-dashed border-zinc-200 dark:border-zinc-800 opacity-60"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="rekenmethode"
|
||||
value={m.id}
|
||||
disabled={!m.beschikbaar}
|
||||
defaultChecked={m.beschikbaar && (project.rekenmethode ?? "arbeid") === m.id}
|
||||
className="mt-0.5 accent-rikx-orange"
|
||||
/>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-2">Beoordelingen van de experts (1–10)</h3>
|
||||
<div className="mb-6">
|
||||
<ExpertBeoordelingen beoordelingen={beoordelingen} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
@@ -292,6 +353,20 @@ export function ProjectDetailForm({
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
|
||||
<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 { notFound, redirect } from "next/navigation";
|
||||
import { getSession } from "../../../lib/auth";
|
||||
import { getDb, listRikxRondes, type Project, type ProjectExpertScore } from "../../../lib/db";
|
||||
import { requireAdmin } from "../../../lib/auth";
|
||||
import { getDb, listRikxRondes, type Project } from "../../../lib/db";
|
||||
import { listBeoordelingen } from "../../../lib/experts";
|
||||
import { projectStatusBadgeClass, projectStatusLabel } from "../../../lib/projectStatus";
|
||||
import { listAccountNames } from "../../../lib/platform-db";
|
||||
import { AppHeader } from "../../AppHeader";
|
||||
import { PROJECT_FORM_ID } from "./formId";
|
||||
import { ProjectDetailForm } from "./ProjectDetailForm";
|
||||
|
||||
export default async function ProjectPage({
|
||||
@@ -11,28 +14,25 @@ export default async function ProjectPage({
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ error?: string; opgeslagen?: string }>;
|
||||
searchParams: Promise<{ error?: string; opgeslagen?: string; tab?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session) redirect("/login");
|
||||
const session = await requireAdmin();
|
||||
|
||||
const { id } = await params;
|
||||
const { error, opgeslagen } = await searchParams;
|
||||
const { error, opgeslagen, tab } = await searchParams;
|
||||
|
||||
const db = getDb();
|
||||
const row = db.prepare("SELECT * FROM projects WHERE id = ?").get(Number(id)) as Project | undefined;
|
||||
if (!row) notFound();
|
||||
// node:sqlite levert objecten met null-prototype; spreiden maakt ze doorgeefbaar aan client components.
|
||||
const project = { ...row };
|
||||
const expertScores = (
|
||||
db.prepare("SELECT * FROM project_expert_scores WHERE project_id = ? ORDER BY expert").all(project.id) as ProjectExpertScore[]
|
||||
).map((s) => ({ ...s }));
|
||||
const beoordelingen = listBeoordelingen([project.id]);
|
||||
|
||||
const message =
|
||||
error === "missing"
|
||||
? { tone: "error", text: "Vul een projectnaam in." }
|
||||
: error === "invalid"
|
||||
? { tone: "error", text: "Niet opgeslagen: controleer de getallen (percentage 0–100, expertscores 1–10, geen negatieve waarden)." }
|
||||
? { tone: "error", text: "Niet opgeslagen: controleer de getallen (percentage 0–100, geen negatieve waarden)." }
|
||||
: error === "datum"
|
||||
? { tone: "error", text: "Niet opgeslagen: controleer de datums (de einddatum mag niet vóór de startdatum liggen)." }
|
||||
: opgeslagen
|
||||
@@ -57,6 +57,14 @@ export default async function ProjectPage({
|
||||
>
|
||||
{projectStatusLabel(project.status)}
|
||||
</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>
|
||||
{message && (
|
||||
<p
|
||||
@@ -69,7 +77,13 @@ export default async function ProjectPage({
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
<ProjectDetailForm project={project} expertScores={expertScores} rondes={listRikxRondes()} />
|
||||
<ProjectDetailForm
|
||||
project={project}
|
||||
beoordelingen={beoordelingen}
|
||||
rondes={listRikxRondes()}
|
||||
impactmakers={listAccountNames()}
|
||||
initialTab={tab === "calculatie" ? "calculatie" : "plan"}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
+61
-76
@@ -2,29 +2,26 @@
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { getDb } from "../../lib/db";
|
||||
import { getSession } from "../../lib/auth";
|
||||
import { requireAdmin } from "../../lib/auth";
|
||||
import { DEFAULT_PROJECT_STATUS, isProjectStatus } from "../../lib/projectStatus";
|
||||
import { listAccountNames } from "../../lib/platform-db";
|
||||
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) {
|
||||
const session = await getSession();
|
||||
if (!session) redirect("/login");
|
||||
// "+ Project toevoegen": maakt direct een nieuw project aan (status Aanvraag) en opent de projectpagina,
|
||||
// waar naam, ronde, impactmaker en het project plan worden ingevuld.
|
||||
export async function createProject() {
|
||||
const session = await requireAdmin();
|
||||
|
||||
const name = ((formData.get("name") as string) ?? "").trim();
|
||||
const rawStatus = formData.get("status");
|
||||
const status = isProjectStatus(rawStatus) ? rawStatus : DEFAULT_PROJECT_STATUS;
|
||||
const result = getDb()
|
||||
.prepare("INSERT INTO projects (name, status) VALUES (?, ?)")
|
||||
.run("Nieuw project", DEFAULT_PROJECT_STATUS);
|
||||
|
||||
if (!name) redirect("/projecten/nieuw?error=missing");
|
||||
|
||||
const db = getDb();
|
||||
db.prepare("INSERT INTO projects (name, status, ronde_id) VALUES (?, ?, ?)").run(
|
||||
name,
|
||||
status,
|
||||
readRondeId(db, formData)
|
||||
);
|
||||
|
||||
redirect("/projecten");
|
||||
redirect(`/projecten/${Number(result.lastInsertRowid)}`);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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
|
||||
function parseNumber(value: FormDataEntryValue | null): number | null {
|
||||
if (typeof value !== "string" || value.trim() === "") return null;
|
||||
@@ -46,87 +50,68 @@ function inRange(n: number | null, min: number, max = Infinity) {
|
||||
}
|
||||
|
||||
export async function updateProject(formData: FormData) {
|
||||
const session = await getSession();
|
||||
if (!session) redirect("/login");
|
||||
const session = await requireAdmin();
|
||||
|
||||
const id = Number(formData.get("id"));
|
||||
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 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 deelnemersWerk = parseNumber(formData.get("deelnemers_werk"));
|
||||
const deelnemersSkills = parseNumber(formData.get("deelnemers_skills"));
|
||||
const externBedrag = parseNumber(formData.get("financiering_extern_bedrag"));
|
||||
const externPct = parseNumber(formData.get("financiering_extern_pct"));
|
||||
const 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 bonusMalus = BONUS_MALUS_OPTIONS.some((o) => o.id === rawBonusMalus) && rawBonusMalus ? rawBonusMalus : null;
|
||||
|
||||
const expertScores = Array.from({ length: EXPERT_COUNT }, (_, i) =>
|
||||
EXPERT_CRITERIA.map((c) => parseNumber(formData.get(`expert_${i + 1}_${c.id}`)))
|
||||
);
|
||||
|
||||
const valid =
|
||||
inRange(projectkosten, 0) &&
|
||||
inRange(deelnemersWerk, 0) &&
|
||||
inRange(deelnemersSkills, 0) &&
|
||||
inRange(externBedrag, 0) &&
|
||||
inRange(externPct, 0, 100) &&
|
||||
expertScores.every((scores) => scores.every((s) => inRange(s, 1, 10)));
|
||||
if (!valid) redirect(`/projecten/${id}?error=invalid`);
|
||||
inRange(externPct, 0, 100);
|
||||
if (!valid) redirect(`/projecten/${id}?error=invalid${tab}`);
|
||||
|
||||
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();
|
||||
db.exec("BEGIN");
|
||||
try {
|
||||
db.prepare(
|
||||
`UPDATE projects SET
|
||||
name = ?, status = COALESCE(?, status),
|
||||
projectkosten = ?, deelnemers_werk = ?, deelnemers_skills = ?,
|
||||
financiering_extern_bedrag = ?, financiering_extern_pct = ?, project_plan = ?,
|
||||
bonus_malus = ?, ronde_id = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
name,
|
||||
isProjectStatus(rawStatus) ? rawStatus : null,
|
||||
projectkosten,
|
||||
deelnemersWerk,
|
||||
deelnemersSkills,
|
||||
externBedrag,
|
||||
externPct,
|
||||
JSON.stringify(planResult.plan),
|
||||
bonusMalus,
|
||||
readRondeId(db, formData),
|
||||
id
|
||||
);
|
||||
const upsert = db.prepare(
|
||||
`INSERT INTO project_expert_scores (project_id, expert, duur_impact, levensdomeinen, basissituatie)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (project_id, expert) DO UPDATE SET
|
||||
duur_impact = excluded.duur_impact,
|
||||
levensdomeinen = excluded.levensdomeinen,
|
||||
basissituatie = excluded.basissituatie`
|
||||
);
|
||||
expertScores.forEach((scores, i) => upsert.run(id, i + 1, ...scores));
|
||||
db.exec("COMMIT");
|
||||
} catch (err) {
|
||||
db.exec("ROLLBACK");
|
||||
throw err;
|
||||
}
|
||||
db.prepare(
|
||||
`UPDATE projects SET
|
||||
name = ?, status = COALESCE(?, status),
|
||||
projectkosten = ?, deelnemers_werk = ?, deelnemers_skills = ?,
|
||||
financiering_extern_bedrag = ?, financiering_extern_pct = ?, project_plan = ?,
|
||||
rekenmethode = ?, bonus_malus = ?, beoordeling = ?, beoordeling_reden = ?, ronde_id = ?,
|
||||
impactmaker_account_id = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
name,
|
||||
isProjectStatus(rawStatus) ? rawStatus : null,
|
||||
projectkosten,
|
||||
deelnemersWerk,
|
||||
deelnemersSkills,
|
||||
externBedrag,
|
||||
externPct,
|
||||
JSON.stringify(planResult.plan),
|
||||
rekenmethode,
|
||||
bonusMalus,
|
||||
beoordeling,
|
||||
beoordelingReden,
|
||||
readRondeId(db, formData),
|
||||
readImpactmakerId(formData),
|
||||
id
|
||||
);
|
||||
|
||||
redirect(`/projecten/${id}?opgeslagen=1`);
|
||||
}
|
||||
|
||||
// Schuif "Rikx calculatie" op de projectpagina; wordt direct opgeslagen.
|
||||
export async function setRikxCalculatie(id: number, aan: boolean) {
|
||||
const session = await getSession();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
getDb()
|
||||
.prepare("UPDATE projects SET rikx_calculatie_aan = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.run(aan ? 1 : 0, id);
|
||||
redirect(`/projecten/${id}?opgeslagen=1${tab}`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+11
-9
@@ -1,9 +1,10 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "../../lib/auth";
|
||||
import { requireAdmin } from "../../lib/auth";
|
||||
import { getDb, listRikxRondes, type ProjectListItem } from "../../lib/db";
|
||||
import { PROJECT_STATUSES, isProjectStatus } from "../../lib/projectStatus";
|
||||
import { AppHeader } from "../AppHeader";
|
||||
import { createProject } from "./actions";
|
||||
import { AutoSubmitSelect } from "./AutoSubmitSelect";
|
||||
import { ProjectPipeline } from "./ProjectPipeline";
|
||||
import { ProjectTable } from "./ProjectTable";
|
||||
@@ -17,8 +18,7 @@ export default async function ProjectenPage({
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; status?: string; ronde?: string; weergave?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!session) redirect("/login");
|
||||
const session = await requireAdmin();
|
||||
|
||||
const params = await searchParams;
|
||||
const q = (params.q ?? "").trim();
|
||||
@@ -130,12 +130,14 @@ export default async function ProjectenPage({
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Link
|
||||
href="/projecten/nieuw"
|
||||
className="rounded-lg bg-rikx-maroon hover:bg-rikx-maroon-light px-5 py-2.5 text-sm font-medium text-white transition-colors whitespace-nowrap"
|
||||
>
|
||||
+ Project toevoegen
|
||||
</Link>
|
||||
<form action={createProject}>
|
||||
<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"
|
||||
>
|
||||
+ Project toevoegen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{isFiltered && (
|
||||
|
||||
Reference in New Issue
Block a user