Initial commit — eLegal Software monorepo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user