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, ArrowLeft, ArrowRight, CheckCircle2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
@@ -16,6 +16,7 @@ import {
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import { WarningCallout } from '@/components/ui/warning-callout';
import { apiFetch } from '@/lib/api/client';
interface PreflightItem {
@@ -36,7 +37,19 @@ interface Props {
type Stage = 'preflight' | 'reasons' | 'confirm';
export function BulkArchiveWizard({ open, onOpenChange, clientIds, onSuccess }: Props) {
export function BulkArchiveWizard(props: Props) {
// Key-based remount: body keyed on open + clientIds so its useState
// initializers re-run each time the wizard opens fresh. Replaces the
// useEffect(setState, [open]) reset the Compiler flagged.
return (
<BulkArchiveWizardBody
key={props.open ? `open:${props.clientIds.join(',')}` : 'closed'}
{...props}
/>
);
}
function BulkArchiveWizardBody({ open, onOpenChange, clientIds, onSuccess }: Props) {
const qc = useQueryClient();
const [stage, setStage] = useState<Stage>('preflight');
const [reasons, setReasons] = useState<Record<string, string>>({});
@@ -52,14 +65,6 @@ export function BulkArchiveWizard({ open, onOpenChange, clientIds, onSuccess }:
enabled: open && clientIds.length > 0,
});
useEffect(() => {
if (open) {
setStage('preflight');
setReasons({});
setCarouselIndex(0);
}
}, [open]);
const items = preflight.data ?? [];
const blocked = useMemo(() => items.filter((i) => i.blockers.length > 0), [items]);
const highStakes = useMemo(
@@ -192,15 +197,17 @@ export function BulkArchiveWizard({ open, onOpenChange, clientIds, onSuccess }:
))}
</span>
</div>
<div className="rounded-md border border-amber-300 bg-amber-50 p-3">
<div className="flex items-center gap-2 mb-1.5">
<AlertTriangle className="h-4 w-4 text-amber-700" />
<span className="font-medium text-amber-900">{currentHighStakes.fullName}</span>
<Badge variant="secondary" className="text-xs">
{currentHighStakes.highStakesStage}
</Badge>
</div>
<div className="text-xs text-amber-900">
<WarningCallout
title={
<span className="flex items-center gap-2">
<span>{currentHighStakes.fullName}</span>
<Badge variant="secondary" className="text-xs">
{currentHighStakes.highStakesStage}
</Badge>
</span>
}
>
<span className="text-xs">
{currentHighStakes.summary.berths > 0
? `${currentHighStakes.summary.berths} berth(s), `
: ''}
@@ -210,8 +217,8 @@ export function BulkArchiveWizard({ open, onOpenChange, clientIds, onSuccess }:
{currentHighStakes.summary.reservations > 0
? `${currentHighStakes.summary.reservations} reservation(s)`
: ''}
</div>
</div>
</span>
</WarningCallout>
<Textarea
value={reasons[currentHighStakes.clientId] ?? ''}
onChange={(e) =>

View File

@@ -9,6 +9,7 @@ import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client';
import type { FileRow } from '@/components/files/file-grid';
@@ -19,6 +20,7 @@ interface ClientFilesTabProps {
export function ClientFilesTab({ clientId }: ClientFilesTabProps) {
const queryClient = useQueryClient();
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
const { confirm, dialog: confirmDialog } = useConfirmation();
const { data, isLoading } = usePaginatedQuery<FileRow>({
queryKey: ['files', { clientId }],
@@ -47,7 +49,12 @@ export function ClientFilesTab({ clientId }: ClientFilesTabProps) {
};
const handleDelete = async (file: FileRow) => {
if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return;
const ok = await confirm({
title: 'Delete file',
description: `Delete "${file.filename}"? This cannot be undone.`,
confirmLabel: 'Delete',
});
if (!ok) return;
try {
await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' });
queryClient.invalidateQueries({ queryKey: ['files', { clientId }] });
@@ -83,6 +90,7 @@ export function ClientFilesTab({ clientId }: ClientFilesTabProps) {
fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined}
/>
{confirmDialog}
</div>
);
}

View File

@@ -11,7 +11,7 @@ import { ArrowRight, CheckCircle2, ChevronRight, Circle, Plus } from 'lucide-rea
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/shared/empty-state';
import { Skeleton } from '@/components/ui/skeleton';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/shared/drawer';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
import { cn } from '@/lib/utils';
@@ -46,10 +46,10 @@ function InterestRowItem({
const yachtLabel = interest.yachtName ?? null;
return (
// Tap opens a bottom-sheet preview drawer rather than navigating to the
// full interest page. The drawer covers ~80% of mobile interactions
// ("what stage is this at, when did we last touch it"). For deeper
// edits the drawer has an "Open full page" CTA.
// Tap opens a right-side Sheet preview rather than navigating to the
// full interest page. The sheet covers ~80% of interactions ("what
// stage is this at, when did we last touch it"). For deeper edits
// the sheet has an "Open full page" CTA.
<button
type="button"
onClick={() => onOpen(interest)}
@@ -214,17 +214,17 @@ function InterestPreviewDrawer({
const notesPreview = fullDetail?.notes?.trim() || null;
return (
<Drawer
<Sheet
open={open}
onOpenChange={(next) => {
if (!next) onClose();
}}
>
<DrawerContent className="max-h-[85vh]">
<DrawerHeader>
<div className="flex items-start justify-between gap-3">
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-md">
<SheetHeader>
<div className="flex items-start justify-between gap-3 pr-8">
<div className="min-w-0 flex-1">
<DrawerTitle className="truncate">{berthLabel}</DrawerTitle>
<SheetTitle className="truncate text-left">{berthLabel}</SheetTitle>
{yachtLabel ? (
<p className="mt-0.5 truncate text-sm text-muted-foreground">{yachtLabel}</p>
) : null}
@@ -240,9 +240,9 @@ function InterestPreviewDrawer({
</span>
) : null}
</div>
</DrawerHeader>
</SheetHeader>
<div className="space-y-5 overflow-y-auto px-4 pb-4">
<div className="mt-5 space-y-5">
{/* Pipeline-stepper segmented bar - the same primitive used on the
row card, so the at-a-glance progress hint is consistent
across surfaces. */}
@@ -357,8 +357,8 @@ function InterestPreviewDrawer({
</Link>
</Button>
</div>
</DrawerContent>
</Drawer>
</SheetContent>
</Sheet>
);
}

View File

@@ -26,6 +26,7 @@ import {
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlinePhoneField } from '@/components/shared/inline-phone-field';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils';
@@ -57,6 +58,7 @@ const CHANNEL_ICONS: Record<string, React.ComponentType<{ className?: string }>>
export function ContactsEditor({ clientId, contacts }: { clientId: string; contacts: Contact[] }) {
const qc = useQueryClient();
const [adding, setAdding] = useState(false);
const { confirm, dialog: confirmDialog } = useConfirmation();
function invalidate() {
qc.invalidateQueries({ queryKey: ['clients', clientId] });
@@ -112,7 +114,12 @@ export function ContactsEditor({ clientId, contacts }: { clientId: string; conta
contact={c}
onUpdate={(patch) => updateMutation.mutateAsync({ contactId: c.id, patch })}
onRemove={async () => {
if (!confirm('Remove this contact?')) return;
const ok = await confirm({
title: 'Remove contact',
description: 'Remove this contact?',
confirmLabel: 'Remove',
});
if (!ok) return;
await removeMutation.mutateAsync(c.id);
}}
/>
@@ -138,6 +145,7 @@ export function ContactsEditor({ clientId, contacts }: { clientId: string; conta
Add contact
</Button>
)}
{confirmDialog}
</div>
);
}

View File

@@ -16,6 +16,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { WarningCallout } from '@/components/ui/warning-callout';
import { apiFetch } from '@/lib/api/client';
interface Props {
@@ -108,22 +109,19 @@ function HardDeleteDialogBody({ onOpenChange, clientId, clientName, onDeleted }:
Permanent deletion is reserved for archived clients only. We&rsquo;ll email a 4-digit
confirmation code to your account address. The code expires in 10 minutes.
</p>
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900">
<p className="font-medium flex items-center gap-2">
<AlertTriangle className="h-4 w-4" /> What gets deleted
</p>
<WarningCallout title="What gets deleted">
<ul className="mt-1.5 list-disc pl-5 text-xs space-y-0.5">
<li>Client record + addresses, contacts, notes, tags</li>
<li>Portal user account + GDPR consent records</li>
<li>All pipeline interests + reservations for this client</li>
</ul>
<p className="font-medium mt-2 flex items-center gap-2">What is preserved</p>
<p className="font-medium mt-2">What is preserved</p>
<ul className="mt-1.5 list-disc pl-5 text-xs space-y-0.5">
<li>Signed documents (detached from client, kept for legal history)</li>
<li>Email threads, files, reminders (detached)</li>
<li>Audit log entries</li>
</ul>
</div>
</WarningCallout>
</div>
) : (
<div className="space-y-3">

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">

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,
@@ -59,7 +59,16 @@ function iconFor(kind: string) {
return <Wrench className="h-3 w-3" />;
}
export function SmartRestoreDialog({ open, onOpenChange, clientId, clientName, onSuccess }: Props) {
export function SmartRestoreDialog(props: Props) {
// Key-based remount: the body is keyed on open + clientId so its
// useState({}) initializer runs fresh each open. Replaces the prior
// useEffect(setSelected, [open, dossier]) reset.
return (
<SmartRestoreDialogBody key={props.open ? `open:${props.clientId}` : 'closed'} {...props} />
);
}
function SmartRestoreDialogBody({ open, onOpenChange, clientId, clientName, onSuccess }: Props) {
const qc = useQueryClient();
const dossierQuery = useQuery({
@@ -73,11 +82,6 @@ export function SmartRestoreDialog({ open, onOpenChange, clientId, clientName, o
const [selected, setSelected] = useState<Record<string, boolean>>({});
useEffect(() => {
if (!open || !dossier) return;
setSelected({});
}, [open, dossier]);
const restoreMutation = useMutation({
mutationFn: () => {
const applyReversals = Object.entries(selected)