Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
200 lines
6.9 KiB
TypeScript
200 lines
6.9 KiB
TypeScript
import { useState } from 'react';
|
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
|
import { useForm } from 'react-hook-form';
|
|
import { ArrowLeft, Briefcase, Mail, MapPin, Phone, Trash2 } from 'lucide-react';
|
|
import { PageHeader } from '@/components/app/AppLayout';
|
|
import { Card, CardBody, CardHeader, EmptyState } from '@/components/ui/Card';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Input, Textarea } from '@/components/ui/Input';
|
|
import { Badge } from '@/components/ui/Badge';
|
|
import {
|
|
useClient,
|
|
useDeleteClient,
|
|
useUpdateClient,
|
|
type ClientInput,
|
|
} from '@/hooks/useClients';
|
|
import { useCases } from '@/hooks/useCases';
|
|
import { formatDate } from '@/lib/format';
|
|
|
|
export default function ClientDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const client = useClient(id);
|
|
const cases = useCases(id ? { clientId: id } : undefined);
|
|
const update = useUpdateClient(id ?? '');
|
|
const del = useDeleteClient();
|
|
const [editing, setEditing] = useState(false);
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
reset,
|
|
formState: { errors, isDirty },
|
|
} = useForm<ClientInput>({
|
|
values: client.data
|
|
? {
|
|
name: client.data.name,
|
|
email: client.data.email,
|
|
phone: client.data.phone,
|
|
address: client.data.address,
|
|
notes: client.data.notes,
|
|
}
|
|
: undefined,
|
|
});
|
|
|
|
if (client.isLoading) {
|
|
return <div className="px-10 py-16 text-sm text-ink-500">Loading…</div>;
|
|
}
|
|
if (!client.data) {
|
|
return (
|
|
<div className="px-10 py-16">
|
|
<p className="text-sm text-ink-500">Client not found.</p>
|
|
<Link to="/app/clients" className="text-sm text-brand-600 mt-3 inline-block">
|
|
← Back to clients
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
async function onSave(values: ClientInput) {
|
|
await update.mutateAsync({
|
|
name: values.name?.trim(),
|
|
email: values.email?.toString().trim() || null,
|
|
phone: values.phone?.toString().trim() || null,
|
|
address: values.address?.toString().trim() || null,
|
|
notes: values.notes?.toString().trim() || null,
|
|
});
|
|
setEditing(false);
|
|
}
|
|
|
|
async function onDelete() {
|
|
if (!id) return;
|
|
if (!confirm(`Delete ${client.data?.name}? This cannot be undone.`)) return;
|
|
await del.mutateAsync(id);
|
|
navigate('/app/clients', { replace: true });
|
|
}
|
|
|
|
return (
|
|
<div className="px-6 lg:px-10 py-8 max-w-7xl mx-auto w-full">
|
|
<Link
|
|
to="/app/clients"
|
|
className="inline-flex items-center gap-1.5 text-sm text-ink-500 hover:text-ink-800 mb-4"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Back to clients
|
|
</Link>
|
|
|
|
<PageHeader
|
|
title={client.data.name}
|
|
description={`Client since ${formatDate(client.data.createdAt)}`}
|
|
action={
|
|
editing ? (
|
|
<>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => {
|
|
reset();
|
|
setEditing(false);
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleSubmit(onSave)} disabled={update.isPending || !isDirty}>
|
|
{update.isPending ? 'Saving…' : 'Save changes'}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button variant="secondary" onClick={() => setEditing(true)}>
|
|
Edit
|
|
</Button>
|
|
<Button variant="danger" onClick={onDelete} disabled={del.isPending}>
|
|
<Trash2 className="h-4 w-4" />
|
|
Delete
|
|
</Button>
|
|
</>
|
|
)
|
|
}
|
|
/>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-3">
|
|
<Card className="lg:col-span-2">
|
|
<CardHeader title="Details" />
|
|
<CardBody>
|
|
{editing ? (
|
|
<form className="space-y-4" onSubmit={handleSubmit(onSave)}>
|
|
<Input label="Name" error={errors.name?.message} {...register('name', { required: 'Name is required' })} />
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<Input label="Email" type="email" {...register('email')} />
|
|
<Input label="Phone" {...register('phone')} />
|
|
</div>
|
|
<Input label="Address" {...register('address')} />
|
|
<Textarea label="Notes" rows={5} {...register('notes')} />
|
|
</form>
|
|
) : (
|
|
<dl className="grid gap-4 md:grid-cols-2 text-sm">
|
|
<Field icon={<Mail className="h-4 w-4" />} label="Email" value={client.data.email} />
|
|
<Field icon={<Phone className="h-4 w-4" />} label="Phone" value={client.data.phone} />
|
|
<Field icon={<MapPin className="h-4 w-4" />} label="Address" value={client.data.address} />
|
|
{client.data.notes && (
|
|
<div className="md:col-span-2">
|
|
<dt className="text-xs font-semibold uppercase tracking-wider text-ink-500">Notes</dt>
|
|
<dd className="mt-1 whitespace-pre-wrap text-ink-700">{client.data.notes}</dd>
|
|
</div>
|
|
)}
|
|
</dl>
|
|
)}
|
|
</CardBody>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader
|
|
title="Cases"
|
|
description={`${cases.data?.total ?? 0} case${(cases.data?.total ?? 0) === 1 ? '' : 's'}`}
|
|
action={
|
|
<Link to={`/app/cases?clientId=${id}`} className="text-xs font-semibold text-brand-600">
|
|
View all
|
|
</Link>
|
|
}
|
|
/>
|
|
{cases.data?.items.length ? (
|
|
<div className="divide-y divide-ink-100">
|
|
{cases.data.items.slice(0, 5).map((c) => (
|
|
<Link
|
|
key={c.id}
|
|
to={`/app/cases/${c.id}`}
|
|
className="flex items-center justify-between gap-3 px-5 py-3 hover:bg-ink-50/60 transition"
|
|
>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-ink-900 truncate">{c.title}</p>
|
|
<p className="text-xs text-ink-500">Opened {formatDate(c.openedAt)}</p>
|
|
</div>
|
|
<Badge tone={c.status === 'open' ? 'emerald' : 'neutral'}>{c.status}</Badge>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<EmptyState
|
|
icon={<Briefcase className="h-5 w-5" />}
|
|
title="No cases for this client"
|
|
description="Open a case from the Cases page."
|
|
/>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({ icon, label, value }: { icon: React.ReactNode; label: string; value: string | null }) {
|
|
return (
|
|
<div>
|
|
<dt className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-ink-500">
|
|
{icon}
|
|
{label}
|
|
</dt>
|
|
<dd className="mt-1 text-ink-700">{value || '—'}</dd>
|
|
</div>
|
|
);
|
|
}
|