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:
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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’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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user