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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user