Files
elegalsoftware/apps/web/src/pages/app/ClientDetailPage.tsx
T
2026-04-26 02:42:42 -04:00

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