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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 “{documentTitle}”</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 “{documentTitle}”</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user