fix(audit-wave-9): standardize on Sheet for previews; doctrine in CLAUDE.md

Swap the one outlier (client-interests-tab.tsx) from Vaul Drawer to
Sheet side=right so every detail-preview surface uses the same
primitive. Document the doctrine: Sheet for side panels on both desktop
and mobile; Vaul Drawer reserved for mobile-only bottom-sheet UX
(currently just MoreSheet).

Closes ui/ux M11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 11:50:07 +02:00
parent b2588ecdd8
commit 4233aa3ac3
94 changed files with 1674 additions and 895 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Anchor, FileText, Loader2, Receipt, Ship, Users } from 'lucide-react';
import { toast } from 'sonner';
@@ -95,50 +95,96 @@ interface Props {
onSuccess?: () => void;
}
export function SmartArchiveDialog({ open, onOpenChange, clientId, clientName, onSuccess }: Props) {
const qc = useQueryClient();
export function SmartArchiveDialog(props: Props) {
// Key-based remount: body keyed on open + clientId; once the dossier
// loads, an inner key forces the decision-defaults to seed cleanly.
return (
<SmartArchiveDialogShell key={props.open ? `open:${props.clientId}` : 'closed'} {...props} />
);
}
function SmartArchiveDialogShell({ open, onOpenChange, clientId, clientName, onSuccess }: Props) {
const qc = useQueryClient();
const dossierQuery = useQuery({
queryKey: ['client-archive-dossier', clientId],
queryFn: () =>
apiFetch<{ data: ArchiveDossier }>(`/api/v1/clients/${clientId}/archive-dossier`),
enabled: open,
});
const dossier = dossierQuery.data?.data;
// While the dossier is loading the body's useState initializers can't
// derive defaults, so we delay-key the body so it mounts ONCE with the
// right seed when the data arrives. Replaces the prior
// useEffect(setState, [dossier]) sync that the Compiler flagged.
return (
<SmartArchiveDialogBody
key={dossier ? 'loaded' : 'loading'}
open={open}
onOpenChange={onOpenChange}
clientId={clientId}
clientName={clientName}
onSuccess={onSuccess}
dossier={dossier ?? null}
isLoading={dossierQuery.isLoading}
error={dossierQuery.error}
qc={qc}
/>
);
}
function SmartArchiveDialogBody({
open,
onOpenChange,
clientId,
clientName,
onSuccess,
dossier,
isLoading,
error,
qc,
}: Props & {
dossier: ArchiveDossier | null;
isLoading: boolean;
error: unknown;
qc: ReturnType<typeof useQueryClient>;
}) {
// ─── Local decision state ────────────────────────────────────────────────
const [reason, setReason] = useState('');
const [acknowledged, setAcknowledged] = useState(false);
const [berthDecisions, setBerthDecisions] = useState<Record<string, BerthAction>>({});
const [yachtDecisions, setYachtDecisions] = useState<Record<string, YachtAction>>({});
const [berthDecisions, setBerthDecisions] = useState<Record<string, BerthAction>>(() =>
dossier
? Object.fromEntries(
dossier.berths.map((berth) => [
berth.berthId,
berth.status === 'sold' ? ('retain' as BerthAction) : ('release' as BerthAction),
]),
)
: {},
);
const [yachtDecisions, setYachtDecisions] = useState<Record<string, YachtAction>>(() =>
dossier
? Object.fromEntries(dossier.yachts.map((y) => [y.yachtId, 'retain' as YachtAction]))
: {},
);
const [reservationDecisions, setReservationDecisions] = useState<
Record<string, ReservationAction>
>({});
const [invoiceDecisions, setInvoiceDecisions] = useState<Record<string, InvoiceAction>>({});
const [documentDecisions, setDocumentDecisions] = useState<Record<string, DocumentAction>>({});
// Reset state when the dialog opens / closes / dossier loads.
useEffect(() => {
if (!open || !dossier) return;
setReason('');
setAcknowledged(false);
// Sensible defaults: release all berths, retain all yachts, cancel
// active reservations, leave invoices, leave documents alone.
const b: Record<string, BerthAction> = {};
for (const berth of dossier.berths) {
// Sold berths can't be released; default to retain.
b[berth.berthId] = berth.status === 'sold' ? 'retain' : 'release';
}
setBerthDecisions(b);
setYachtDecisions(Object.fromEntries(dossier.yachts.map((y) => [y.yachtId, 'retain'])));
setReservationDecisions(
Object.fromEntries(dossier.reservations.map((r) => [r.reservationId, 'cancel'])),
);
setInvoiceDecisions(Object.fromEntries(dossier.invoices.map((i) => [i.invoiceId, 'leave'])));
setDocumentDecisions(Object.fromEntries(dossier.documents.map((d) => [d.documentId, 'leave'])));
}, [open, dossier]);
>(() =>
dossier
? Object.fromEntries(
dossier.reservations.map((r) => [r.reservationId, 'cancel' as ReservationAction]),
)
: {},
);
const [invoiceDecisions, setInvoiceDecisions] = useState<Record<string, InvoiceAction>>(() =>
dossier
? Object.fromEntries(dossier.invoices.map((i) => [i.invoiceId, 'leave' as InvoiceAction]))
: {},
);
const [documentDecisions, setDocumentDecisions] = useState<Record<string, DocumentAction>>(() =>
dossier
? Object.fromEntries(dossier.documents.map((d) => [d.documentId, 'leave' as DocumentAction]))
: {},
);
const hasSignedDocs = useMemo(
() =>
dossier?.documents.some((d) => d.status === 'completed' || d.status === 'signed') ?? false,
@@ -235,15 +281,14 @@ export function SmartArchiveDialog({ open, onOpenChange, clientId, clientName, o
</DialogDescription>
</DialogHeader>
{dossierQuery.isLoading ? (
{isLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />
Loading dossier
</div>
) : dossierQuery.error || !dossier ? (
) : error || !dossier ? (
<div className="py-8 text-center text-sm text-red-600">
Failed to load dossier:{' '}
{dossierQuery.error instanceof Error ? dossierQuery.error.message : 'unknown error'}
Failed to load dossier: {error instanceof Error ? error.message : 'unknown error'}
</div>
) : (
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">