bd5b80b853
- 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
216 lines
7.6 KiB
React
216 lines
7.6 KiB
React
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>
|
|
);
|
|
}
|