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,441 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, Mail, Phone, User, Globe, Pencil, Trash2, X, AlertTriangle, Plus, Lightbulb
|
||||
} from 'lucide-react';
|
||||
import { api, fmt } from '../api';
|
||||
import { Card, Btn, Field, inputCls, Modal, Spinner, ErrorBox, Table, StatusBadge, Badge } from '../components/ui';
|
||||
import { EngagementFormModal, TYPE_LABELS, ENG_STATUS_LABELS } from './Engagements';
|
||||
|
||||
const STATUS_LABELS = { lead: 'Lead', active: 'Actief', paused: 'Gepauzeerd', archived: 'Gearchiveerd' };
|
||||
const INV_STATUS_LABELS = { draft: 'Concept', sent: 'Verzonden', paid: 'Betaald', overdue: 'Verlopen', cancelled: 'Geannuleerd' };
|
||||
|
||||
const EMPTY_FORM = {
|
||||
name: '', industry: '', website: '', contact_name: '', contact_email: '',
|
||||
contact_phone: '', status: 'lead', source: '', notes: ''
|
||||
};
|
||||
|
||||
// Zelfde formulier als in Clients.jsx (bewerk-modus)
|
||||
function ClientFormModal({ open, onClose, client, onSaved, onDelete }) {
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError(null);
|
||||
setForm(client ? {
|
||||
name: client.name || '',
|
||||
industry: client.industry || '',
|
||||
website: client.website || '',
|
||||
contact_name: client.contact_name || '',
|
||||
contact_email: client.contact_email || '',
|
||||
contact_phone: client.contact_phone || '',
|
||||
status: client.status || 'lead',
|
||||
source: client.source || '',
|
||||
notes: client.notes || ''
|
||||
} : EMPTY_FORM);
|
||||
}, [open, client]);
|
||||
|
||||
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.put(`/clients/${client.id}`, form);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Cliënt bewerken" wide>
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<ErrorBox error={error} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Field label="Bedrijfsnaam *" className="md:col-span-2">
|
||||
<input className={inputCls} value={form.name} onChange={set('name')} required autoFocus />
|
||||
</Field>
|
||||
<Field label="Branche">
|
||||
<input className={inputCls} value={form.industry} onChange={set('industry')} />
|
||||
</Field>
|
||||
<Field label="Website">
|
||||
<input className={inputCls} value={form.website} onChange={set('website')} placeholder="https://..." />
|
||||
</Field>
|
||||
<Field label="Contactpersoon">
|
||||
<input className={inputCls} value={form.contact_name} onChange={set('contact_name')} />
|
||||
</Field>
|
||||
<Field label="E-mail">
|
||||
<input type="email" className={inputCls} value={form.contact_email} onChange={set('contact_email')} />
|
||||
</Field>
|
||||
<Field label="Telefoon">
|
||||
<input className={inputCls} value={form.contact_phone} onChange={set('contact_phone')} />
|
||||
</Field>
|
||||
<Field label="Status">
|
||||
<select className={inputCls} value={form.status} onChange={set('status')}>
|
||||
{Object.entries(STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Bron" className="md:col-span-2">
|
||||
<input className={inputCls} value={form.source} onChange={set('source')} placeholder="Bijv. referral, website, netwerk" />
|
||||
</Field>
|
||||
<Field label="Notities" className="md:col-span-2">
|
||||
<textarea className={inputCls} rows={3} value={form.notes} onChange={set('notes')} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 pt-1">
|
||||
<Btn type="button" variant="danger" onClick={onDelete}>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Verwijderen
|
||||
</Btn>
|
||||
<div className="flex gap-2">
|
||||
<Btn type="button" variant="secondary" onClick={onClose}>Annuleren</Btn>
|
||||
<Btn type="submit" loading={saving}>Opslaan</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const ADVICE_COLORS = { critical: 'text-red', high: 'text-yellow', medium: 'text-blue', low: 'text-muted' };
|
||||
|
||||
export default function ClientDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [tab, setTab] = useState('overzicht');
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [engModalOpen, setEngModalOpen] = useState(false);
|
||||
const [noteText, setNoteText] = useState('');
|
||||
const [noteSaving, setNoteSaving] = useState(false);
|
||||
const [noteError, setNoteError] = useState(null);
|
||||
|
||||
const load = () => {
|
||||
setError(null);
|
||||
api.get(`/clients/${id}`).then(setData).catch(setError);
|
||||
};
|
||||
useEffect(load, [id]);
|
||||
|
||||
if (!data) {
|
||||
return error ? <ErrorBox error={error} onRetry={load} /> : <Spinner />;
|
||||
}
|
||||
|
||||
const { client, engagements, assessments, advice, diagrams, notes, timeEntries, invoices } = data;
|
||||
const totalHours = timeEntries.reduce((s, t) => s + (t.hours || 0), 0);
|
||||
const websiteHref = client.website
|
||||
? (/^https?:\/\//i.test(client.website) ? client.website : `https://${client.website}`)
|
||||
: null;
|
||||
|
||||
const dismissAdvice = async (adviceId) => {
|
||||
try {
|
||||
await api.post(`/clients/${id}/advice/${adviceId}/dismiss`);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const addNote = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!noteText.trim()) return;
|
||||
setNoteSaving(true);
|
||||
setNoteError(null);
|
||||
try {
|
||||
await api.post(`/clients/${id}/notes`, { content: noteText.trim() });
|
||||
setNoteText('');
|
||||
load();
|
||||
} catch (err) {
|
||||
setNoteError(err);
|
||||
} finally {
|
||||
setNoteSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeNote = async (noteId) => {
|
||||
if (!window.confirm('Deze notitie verwijderen?')) return;
|
||||
try {
|
||||
await api.del(`/clients/${id}/notes/${noteId}`);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const removeClient = async () => {
|
||||
if (!window.confirm(`Cliënt "${client.name}" verwijderen? Dit kan niet ongedaan worden gemaakt.`)) return;
|
||||
try {
|
||||
await api.del(`/clients/${client.id}`);
|
||||
navigate('/clients');
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
setEditOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ key: 'overzicht', label: 'Overzicht' },
|
||||
{ key: 'engagements', label: `Engagements (${engagements.length})` },
|
||||
{ key: 'notities', label: `Notities (${notes.length})` },
|
||||
{ key: 'uren', label: `Uren (${timeEntries.length})` },
|
||||
{ key: 'facturen', label: `Facturen (${invoices.length})` }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 fade-in">
|
||||
{error && <ErrorBox error={error} onRetry={load} />}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-xl font-bold tracking-tight">{client.name}</h1>
|
||||
<StatusBadge status={client.status} labels={STATUS_LABELS} />
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-1.5 text-[12px] text-muted flex-wrap">
|
||||
{client.industry && <span>{client.industry}</span>}
|
||||
{websiteHref && (
|
||||
<a href={websiteHref} target="_blank" rel="noreferrer" className="flex items-center gap-1 text-accent hover:underline">
|
||||
<Globe className="w-3.5 h-3.5" /> {client.website}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-1 text-[12px] text-muted flex-wrap">
|
||||
{client.contact_name && <span className="flex items-center gap-1"><User className="w-3.5 h-3.5" /> {client.contact_name}</span>}
|
||||
{client.contact_email && <span className="flex items-center gap-1"><Mail className="w-3.5 h-3.5" /> {client.contact_email}</span>}
|
||||
{client.contact_phone && <span className="flex items-center gap-1"><Phone className="w-3.5 h-3.5" /> {client.contact_phone}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Link
|
||||
to="/clients"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-[13px] bg-card border border-border text-text hover:bg-card-hover transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" /> Terug
|
||||
</Link>
|
||||
<Btn variant="secondary" onClick={() => setEditOpen(true)}>
|
||||
<Pencil className="w-3.5 h-3.5" /> Bewerken
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-border-soft overflow-x-auto">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-3 py-2 text-[13px] whitespace-nowrap border-b-2 -mb-px transition-colors ${tab === t.key ? 'border-accent text-text font-medium' : 'border-transparent text-muted hover:text-text'}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab: Overzicht */}
|
||||
{tab === 'overzicht' && (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<Card title="Actief advies" subtitle="Automatisch gegenereerd" className="xl:col-span-2">
|
||||
{advice.length === 0 ? (
|
||||
<div className="text-muted text-[13px] py-4 text-center flex flex-col items-center gap-2">
|
||||
<Lightbulb className="w-5 h-5" /> Alles onder controle
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{advice.map((a) => (
|
||||
<div key={a.id} className="flex items-start gap-2.5 px-2.5 py-2 rounded-lg bg-bg-soft">
|
||||
<AlertTriangle className={`w-4 h-4 mt-0.5 shrink-0 ${ADVICE_COLORS[a.priority] || 'text-blue'}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12px]">{a.advice_text}</div>
|
||||
<div className="text-[11px] text-muted mt-0.5">{fmt.datetime(a.created_at)}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dismissAdvice(a.id)}
|
||||
className="p-1 rounded text-muted hover:text-text shrink-0"
|
||||
title="Advies afronden"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Notities">
|
||||
{client.notes
|
||||
? <p className="text-[13px] whitespace-pre-wrap">{client.notes}</p>
|
||||
: <div className="text-muted text-[13px] py-2">Geen notities</div>}
|
||||
</Card>
|
||||
|
||||
<Card title="Diagrammen" actions={<Link to="/diagrams" className="text-[12px] text-accent hover:underline">Alles</Link>}>
|
||||
{diagrams.length === 0 ? (
|
||||
<div className="text-muted text-[13px] py-2">Nog geen diagrammen</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{diagrams.map((d) => (
|
||||
<Link key={d.id} to="/diagrams" className="flex items-center justify-between gap-2 px-2.5 py-2 rounded-lg hover:bg-card-hover transition-colors">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-medium truncate">{d.name}</div>
|
||||
<div className="text-[11px] text-muted">{d.diagram_type}</div>
|
||||
</div>
|
||||
<span className="text-[12px] text-muted shrink-0">{fmt.date(d.updated_at)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title={`Assessments (${assessments.length})`} className="xl:col-span-2">
|
||||
{assessments.length === 0 ? (
|
||||
<div className="text-muted text-[13px] py-2">Nog geen assessments</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{assessments.map((a) => (
|
||||
<div key={a.id} className="flex items-center justify-between gap-2 px-2.5 py-2 rounded-lg hover:bg-card-hover transition-colors">
|
||||
<span className="text-[13px] font-medium">Assessment #{a.id}</span>
|
||||
<span className="text-[12px] text-muted">{fmt.datetime(a.created_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Engagements */}
|
||||
{tab === 'engagements' && (
|
||||
<Card
|
||||
title="Engagements"
|
||||
actions={<Btn size="sm" onClick={() => setEngModalOpen(true)}><Plus className="w-3.5 h-3.5" /> Nieuw engagement</Btn>}
|
||||
>
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
label: 'Titel',
|
||||
render: (e) => (
|
||||
<Link to={`/engagements/${e.id}`} className="font-medium text-text hover:text-accent transition-colors">
|
||||
{e.title}
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
{ label: 'Type', render: (e) => TYPE_LABELS[e.type] || e.type },
|
||||
{ label: 'Status', render: (e) => <StatusBadge status={e.status} labels={ENG_STATUS_LABELS} /> },
|
||||
{ label: 'Start', render: (e) => <span className="text-muted">{fmt.date(e.start_date)}</span> }
|
||||
]}
|
||||
rows={engagements}
|
||||
keyFn={(e) => e.id}
|
||||
empty={<div className="text-muted text-[13px] py-4 text-center">Nog geen engagements</div>}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tab: Notities */}
|
||||
{tab === 'notities' && (
|
||||
<Card title="Notities">
|
||||
<form onSubmit={addNote} className="mb-4 space-y-2">
|
||||
{noteError && <ErrorBox error={noteError} />}
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
className={inputCls}
|
||||
rows={2}
|
||||
placeholder="Schrijf een notitie..."
|
||||
value={noteText}
|
||||
onChange={(e) => setNoteText(e.target.value)}
|
||||
/>
|
||||
<Btn type="submit" loading={noteSaving} className="shrink-0">Toevoegen</Btn>
|
||||
</div>
|
||||
</form>
|
||||
{notes.length === 0 ? (
|
||||
<div className="text-muted text-[13px] py-4 text-center">Nog geen notities</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{notes.map((n) => (
|
||||
<div key={n.id} className="flex items-start justify-between gap-2 px-2.5 py-2 rounded-lg hover:bg-card-hover transition-colors">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] whitespace-pre-wrap">{n.content}</div>
|
||||
<div className="text-[11px] text-muted mt-0.5">{n.created_by} · {fmt.datetime(n.created_at)}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeNote(n.id)}
|
||||
className="p-1.5 rounded-md text-muted hover:text-red hover:bg-bg-soft shrink-0"
|
||||
title="Verwijderen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tab: Uren */}
|
||||
{tab === 'uren' && (
|
||||
<Card title="Urenregistratie" subtitle={`Totaal: ${fmt.hours(totalHours)}`}>
|
||||
<Table
|
||||
columns={[
|
||||
{ label: 'Datum', render: (t) => fmt.date(t.date) },
|
||||
{ label: 'Gebruiker', render: (t) => t.username || <span className="text-muted">-</span> },
|
||||
{ label: 'Uren', render: (t) => fmt.hours(t.hours) },
|
||||
{ label: 'Omschrijving', render: (t) => t.description || <span className="text-muted">-</span> },
|
||||
{ label: 'Facturabel', render: (t) => <Badge color={t.billable ? 'green' : 'muted'}>{t.billable ? 'Ja' : 'Nee'}</Badge> },
|
||||
{ label: 'Tarief', render: (t) => <span className="text-muted">{fmt.euro(t.hourly_rate)}</span> }
|
||||
]}
|
||||
rows={timeEntries}
|
||||
keyFn={(t) => t.id}
|
||||
empty={<div className="text-muted text-[13px] py-4 text-center">Nog geen uren geregistreerd</div>}
|
||||
/>
|
||||
{timeEntries.length > 0 && (
|
||||
<div className="text-right text-[13px] mt-3 pt-3 border-t border-border-soft">
|
||||
Totaal: <span className="font-semibold">{fmt.hours(totalHours)}</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tab: Facturen */}
|
||||
{tab === 'facturen' && (
|
||||
<Card title="Facturen">
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
label: 'Nummer',
|
||||
render: (i) => (
|
||||
<Link to={`/invoices/${i.id}`} className="font-medium text-text hover:text-accent transition-colors">
|
||||
{i.number}
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
{ label: 'Datum', render: (i) => <span className="text-muted">{fmt.date(i.date)}</span> },
|
||||
{ label: 'Totaal', render: (i) => fmt.euro(i.total) },
|
||||
{ label: 'Status', render: (i) => <StatusBadge status={i.status} labels={INV_STATUS_LABELS} /> }
|
||||
]}
|
||||
rows={invoices}
|
||||
keyFn={(i) => i.id}
|
||||
empty={<div className="text-muted text-[13px] py-4 text-center">Nog geen facturen</div>}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ClientFormModal
|
||||
open={editOpen}
|
||||
client={client}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSaved={() => { setEditOpen(false); load(); }}
|
||||
onDelete={removeClient}
|
||||
/>
|
||||
<EngagementFormModal
|
||||
open={engModalOpen}
|
||||
onClose={() => setEngModalOpen(false)}
|
||||
clientId={client.id}
|
||||
onSaved={() => { setEngModalOpen(false); load(); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user