Files
mek-tech-consulting/frontend/src/pages/ClientDetail.jsx
T
mo bd5b80b853 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
2026-07-21 21:42:25 +02:00

442 lines
18 KiB
React

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>
);
}