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

@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/shared/page-header';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
@@ -93,6 +94,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
const router = useRouter();
const queryClient = useQueryClient();
const [isCancelling, setIsCancelling] = useState(false);
const { confirm, dialog: confirmDialog } = useConfirmation();
const { data, isLoading, error } = useQuery<DetailResponse>({
queryKey: ['document-detail', documentId],
@@ -157,8 +159,12 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
};
const handleCancel = async () => {
if (!confirm('Cancel this document? This voids the signing envelope and cannot be undone.'))
return;
const ok = await confirm({
title: 'Cancel document',
description: 'Cancel this document? This voids the signing envelope and cannot be undone.',
confirmLabel: 'Cancel document',
});
if (!ok) return;
setIsCancelling(true);
try {
await apiFetch(`/api/v1/documents/${documentId}/cancel`, { method: 'POST' });
@@ -395,6 +401,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
</section>
</div>
</div>
{confirmDialog}
</div>
);
}

View File

@@ -14,6 +14,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client';
import { MoveToFolderDialog } from './move-to-folder-dialog';
@@ -121,6 +122,7 @@ function DocRow({ doc, onDelete, onSend }: DocRowProps) {
export function DocumentList({ interestId, clientId, emptyState }: DocumentListProps) {
const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const queryParams = new URLSearchParams();
if (interestId) queryParams.set('interestId', interestId);
@@ -133,7 +135,12 @@ export function DocumentList({ interestId, clientId, emptyState }: DocumentListP
});
const handleDelete = async (doc: DocumentRow) => {
if (!confirm(`Delete "${doc.title}"? This cannot be undone.`)) return;
const ok = await confirm({
title: 'Delete document',
description: `Delete "${doc.title}"? This cannot be undone.`,
confirmLabel: 'Delete',
});
if (!ok) return;
try {
await apiFetch(`/api/v1/documents/${doc.id}`, { method: 'DELETE' });
queryClient.invalidateQueries({ queryKey: ['documents', { interestId, clientId }] });
@@ -181,6 +188,7 @@ export function DocumentList({ interestId, clientId, emptyState }: DocumentListP
))}
</tbody>
</table>
{confirmDialog}
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { Check, FolderInput } from 'lucide-react';
import { toast } from 'sonner';
@@ -35,85 +35,94 @@ interface MoveToFolderDialogProps {
onOpenChange: (open: boolean) => void;
}
export function MoveToFolderDialog({
export function MoveToFolderDialog(props: MoveToFolderDialogProps) {
// Key-based remount: the inner body is keyed on `open + currentFolderId`
// so its `useState` initializer re-runs each time the dialog is re-
// opened, replacing the prior `useEffect(setPickedId)` pattern that the
// React Compiler flagged as set-state-in-effect.
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className="sm:max-w-md">
{props.open ? (
<DialogBody
key={`${props.documentId}:${props.currentFolderId ?? '__root__'}`}
{...props}
/>
) : null}
</DialogContent>
</Dialog>
);
}
function DialogBody({
documentId,
documentTitle,
currentFolderId,
open,
onOpenChange,
}: MoveToFolderDialogProps) {
const { data: tree = [] } = useDocumentFolders();
const move = useMoveDocument();
const [pickedId, setPickedId] = useState<string | null>(currentFolderId);
useEffect(() => {
if (open) setPickedId(currentFolderId);
}, [open, currentFolderId]);
const paths = useMemo(() => buildFolderPaths(tree), [tree]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Move &ldquo;{documentTitle}&rdquo;</DialogTitle>
</DialogHeader>
<Command>
<CommandInput placeholder="Search folders…" />
<CommandList>
<CommandEmpty>No folders match.</CommandEmpty>
<CommandGroup heading="Special">
<CommandItem
value="Root (no folder)"
onSelect={() => setPickedId(null)}
className="flex items-center gap-2"
>
<Check
className={pickedId === null ? 'h-4 w-4 opacity-100' : 'h-4 w-4 opacity-0'}
/>
Root (no folder)
</CommandItem>
<>
<DialogHeader>
<DialogTitle>Move &ldquo;{documentTitle}&rdquo;</DialogTitle>
</DialogHeader>
<Command>
<CommandInput placeholder="Search folders…" />
<CommandList>
<CommandEmpty>No folders match.</CommandEmpty>
<CommandGroup heading="Special">
<CommandItem
value="Root (no folder)"
onSelect={() => setPickedId(null)}
className="flex items-center gap-2"
>
<Check className={pickedId === null ? 'h-4 w-4 opacity-100' : 'h-4 w-4 opacity-0'} />
Root (no folder)
</CommandItem>
</CommandGroup>
{paths.length > 0 ? (
<CommandGroup heading="Folders">
{paths.map((p) => (
<CommandItem
key={p.id}
value={p.path}
onSelect={() => setPickedId(p.id)}
className="flex items-center gap-2"
>
<Check
className={pickedId === p.id ? 'h-4 w-4 opacity-100' : 'h-4 w-4 opacity-0'}
/>
<span className="truncate">{p.path}</span>
</CommandItem>
))}
</CommandGroup>
{paths.length > 0 ? (
<CommandGroup heading="Folders">
{paths.map((p) => (
<CommandItem
key={p.id}
value={p.path}
onSelect={() => setPickedId(p.id)}
className="flex items-center gap-2"
>
<Check
className={pickedId === p.id ? 'h-4 w-4 opacity-100' : 'h-4 w-4 opacity-0'}
/>
<span className="truncate">{p.path}</span>
</CommandItem>
))}
</CommandGroup>
) : null}
</CommandList>
</Command>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
disabled={pickedId === currentFolderId || move.isPending}
onClick={async () => {
try {
await move.mutateAsync({ docId: documentId, folderId: pickedId });
toast.success('Document moved');
onOpenChange(false);
} catch (err) {
toastError(err);
}
}}
>
<FolderInput className="mr-1.5 h-4 w-4" />
Move
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : null}
</CommandList>
</Command>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
disabled={pickedId === currentFolderId || move.isPending}
onClick={async () => {
try {
await move.mutateAsync({ docId: documentId, folderId: pickedId });
toast.success('Document moved');
onOpenChange(false);
} catch (err) {
toastError(err);
}
}}
>
<FolderInput className="mr-1.5 h-4 w-4" />
Move
</Button>
</DialogFooter>
</>
);
}