feat: React 19 SPA frontend (v3.0) — volledige moderne UI op /app

- 24 pagina's: dashboard, clients, engagements, tasks, calendar, email,
  projects, time, invoices, finance, racks (visuele rack-view), networking,
  diagrams, market intel, AI studio (providers+chat), notifications,
  search, settings (algemeen/users/backup/audit)
- Vite + React 19 + Tailwind v4 + recharts + lucide, dark neon thema, NL
- Build gedeployed naar public/spa, geserveerd door Express op /app
This commit is contained in:
mo
2026-07-21 21:42:25 +02:00
parent 9cb0ba7639
commit bd5b80b853
42 changed files with 10483 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
import { useEffect, useState } from 'react';
import { Plus, ArrowLeft, ArrowRight, Trash2, CheckSquare } from 'lucide-react';
import { api, fmt } from '../api';
import { Btn, Field, inputCls, Modal, Spinner, ErrorBox, StatusBadge, Badge, EmptyState } from '../components/ui';
export const TASK_STATUS_LABELS = { todo: 'Te doen', in_progress: 'Bezig', done: 'Klaar', cancelled: 'Geannuleerd' };
export const PRIORITY_LABELS = { critical: 'Kritiek', high: 'Hoog', medium: 'Gemiddeld', low: 'Laag' };
const COLUMNS = ['todo', 'in_progress', 'done', 'cancelled'];
const FLOW = ['todo', 'in_progress', 'done'];
const EMPTY_FORM = { engagement_id: '', title: '', priority: 'medium', due_date: '' };
function TaskFormModal({ open, onClose, engagements, onSaved }) {
const [form, setForm] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!open) return;
setError(null);
setForm(EMPTY_FORM);
}, [open]);
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
const submit = async (e) => {
e.preventDefault();
setSaving(true);
setError(null);
try {
await api.post('/tasks', { ...form, due_date: form.due_date || null });
onSaved();
} catch (err) {
setError(err);
} finally {
setSaving(false);
}
};
return (
<Modal open={open} onClose={onClose} title="Nieuwe taak">
<form onSubmit={submit} className="space-y-3">
<ErrorBox error={error} />
<Field label="Engagement *">
<select className={inputCls} value={form.engagement_id} onChange={set('engagement_id')} required>
<option value=""> Kies een engagement </option>
{engagements.map((e) => (
<option key={e.id} value={e.id}>{e.title} {e.client_name}</option>
))}
</select>
</Field>
<Field label="Titel *">
<input className={inputCls} value={form.title} onChange={set('title')} required autoFocus />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Prioriteit">
<select className={inputCls} value={form.priority} onChange={set('priority')}>
{Object.entries(PRIORITY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</Field>
<Field label="Deadline">
<input type="date" className={inputCls} value={form.due_date} onChange={set('due_date')} />
</Field>
</div>
<div className="flex justify-end gap-2 pt-1">
<Btn type="button" variant="secondary" onClick={onClose}>Annuleren</Btn>
<Btn type="submit" loading={saving}>Aanmaken</Btn>
</div>
</form>
</Modal>
);
}
function TaskCard({ task, onMove, onDelete }) {
const today = new Date().toISOString().slice(0, 10);
const overdue = task.due_date && task.due_date < today && task.status !== 'done' && task.status !== 'cancelled';
const idx = FLOW.indexOf(task.status);
return (
<div className="bg-card border border-border-soft rounded-lg p-2.5 space-y-1.5">
<div className="text-[13px] font-medium">{task.title}</div>
<div className="text-[11px] text-muted truncate">{task.client_name} · {task.engagement_title}</div>
<div className="flex items-center justify-between gap-2">
<StatusBadge status={task.priority} labels={PRIORITY_LABELS} />
{task.due_date && (
<span className={`text-[11px] ${overdue ? 'text-red font-medium' : 'text-muted'}`}>
{fmt.date(task.due_date)}
</span>
)}
</div>
<div className="flex items-center justify-between pt-1.5 border-t border-border-soft/50">
<div className="flex gap-0.5">
{idx > 0 && (
<button
onClick={() => onMove(task, FLOW[idx - 1])}
className="p-1 rounded text-muted hover:text-text hover:bg-bg-soft"
title={`Naar '${TASK_STATUS_LABELS[FLOW[idx - 1]]}'`}
>
<ArrowLeft className="w-3.5 h-3.5" />
</button>
)}
{idx >= 0 && idx < FLOW.length - 1 && (
<button
onClick={() => onMove(task, FLOW[idx + 1])}
className="p-1 rounded text-muted hover:text-text hover:bg-bg-soft"
title={`Naar '${TASK_STATUS_LABELS[FLOW[idx + 1]]}'`}
>
<ArrowRight className="w-3.5 h-3.5" />
</button>
)}
</div>
<button
onClick={() => onDelete(task)}
className="p-1 rounded text-muted hover:text-red hover:bg-bg-soft"
title="Verwijderen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}
export default function Tasks() {
const [data, setData] = useState(null);
const [engagements, setEngagements] = useState([]);
const [error, setError] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const load = () => {
setError(null);
api.get('/tasks').then(setData).catch(setError);
};
useEffect(load, []);
useEffect(() => {
api.get('/engagements').then(setEngagements).catch(() => { /* opties voor modal */ });
}, []);
const move = async (task, status) => {
try {
await api.post(`/tasks/${task.id}/status`, { status });
load();
} catch (err) {
setError(err);
}
};
const remove = async (task) => {
if (!window.confirm(`Taak "${task.title}" verwijderen?`)) return;
try {
await api.del(`/tasks/${task.id}`);
load();
} catch (err) {
setError(err);
}
};
return (
<div className="space-y-4 fade-in">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h1 className="text-xl font-bold tracking-tight">Taken</h1>
<p className="text-[13px] text-muted mt-0.5">Kanban-overzicht van alle taken</p>
</div>
<Btn onClick={() => setModalOpen(true)}>
<Plus className="w-4 h-4" /> Nieuwe taak
</Btn>
</div>
{error && <ErrorBox error={error} onRetry={load} />}
{!data && !error && <Spinner />}
{data && data.length === 0 && (
<EmptyState
icon={CheckSquare}
title="Nog geen taken"
hint="Maak je eerste taak aan om te beginnen."
action={<Btn onClick={() => setModalOpen(true)}><Plus className="w-4 h-4" /> Nieuwe taak</Btn>}
/>
)}
{data && data.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3 items-start">
{COLUMNS.map((col) => {
const colTasks = data.filter((t) => t.status === col);
return (
<div key={col} className="bg-bg-soft border border-border-soft rounded-xl p-2.5">
<div className="flex items-center justify-between px-1 pb-2">
<span className="text-[12px] font-semibold">{TASK_STATUS_LABELS[col]}</span>
<Badge color="muted">{colTasks.length}</Badge>
</div>
<div className="space-y-2">
{colTasks.map((t) => (
<TaskCard key={t.id} task={t} onMove={move} onDelete={remove} />
))}
{colTasks.length === 0 && (
<div className="text-center text-[11px] text-muted py-4">Geen taken</div>
)}
</div>
</div>
);
})}
</div>
)}
<TaskFormModal
open={modalOpen}
onClose={() => setModalOpen(false)}
engagements={engagements}
onSaved={() => { setModalOpen(false); load(); }}
/>
</div>
);
}