diff --git a/src/components/clients/client-detail-header.tsx b/src/components/clients/client-detail-header.tsx index 0904f5e..7b44e98 100644 --- a/src/components/clients/client-detail-header.tsx +++ b/src/components/clients/client-detail-header.tsx @@ -1,19 +1,20 @@ 'use client'; +import { useRouter } from 'next/navigation'; import { useState } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { Archive, Mail, MessageCircle, Phone, RotateCcw } from 'lucide-react'; +import { Archive, Mail, MessageCircle, Phone, RotateCcw, Trash2 } from 'lucide-react'; import { format } from 'date-fns'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { TagBadge } from '@/components/shared/tag-badge'; -import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog'; +import { PermissionGate } from '@/components/shared/permission-gate'; import { SmartArchiveDialog } from '@/components/clients/smart-archive-dialog'; +import { SmartRestoreDialog } from '@/components/clients/smart-restore-dialog'; +import { HardDeleteDialog } from '@/components/clients/hard-delete-dialog'; import { DetailHeaderStrip } from '@/components/shared/detail-header-strip'; import { PortalInviteButton } from '@/components/clients/portal-invite-button'; import { GdprExportButton } from '@/components/clients/gdpr-export-button'; -import { apiFetch } from '@/lib/api/client'; import { cn } from '@/lib/utils'; import { getCountryName } from '@/lib/i18n/countries'; @@ -37,20 +38,12 @@ interface ClientDetailHeaderProps { } export function ClientDetailHeader({ client }: ClientDetailHeaderProps) { - const queryClient = useQueryClient(); + const router = useRouter(); const [archiveOpen, setArchiveOpen] = useState(false); + const [hardDeleteOpen, setHardDeleteOpen] = useState(false); const isArchived = !!client.archivedAt; - const restoreMutation = useMutation({ - mutationFn: () => apiFetch(`/api/v1/clients/${client.id}/restore`, { method: 'POST' }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['clients', client.id] }); - queryClient.invalidateQueries({ queryKey: ['clients'] }); - setArchiveOpen(false); - }, - }); - const primaryEmail = client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ?? client.contacts?.find((c) => c.channel === 'email')?.value; @@ -161,36 +154,46 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) { )} - {/* Top-right: archive/restore as a small icon button - destructive - action sits out of the primary action flow. */} - + )} - > - {isArchived ? : } - + + - {/* Restore flow keeps the simple confirm dialog (the smart restore - wizard ships in a follow-on commit). Archive uses the new smart - dialog with the dossier + per-section decisions. */} {isArchived ? ( - restoreMutation.mutate()} - isLoading={restoreMutation.isPending} + clientId={client.id} + clientName={client.fullName} /> ) : ( )} + + {isArchived && ( + router.back()} + /> + )} ); } diff --git a/src/components/clients/smart-restore-dialog.tsx b/src/components/clients/smart-restore-dialog.tsx new file mode 100644 index 0000000..f2bfcde --- /dev/null +++ b/src/components/clients/smart-restore-dialog.tsx @@ -0,0 +1,245 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + AlertTriangle, + Anchor, + CheckCircle2, + FileText, + Loader2, + Lock, + Ship, + Wrench, +} from 'lucide-react'; +import { toast } from 'sonner'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { apiFetch } from '@/lib/api/client'; + +interface RestoreReversal { + id: string; + kind: string; + refId: string; + label: string; + reason: string; +} +interface LockedReversal extends RestoreReversal { + lockReason: string; +} +interface RestoreDossier { + client: { id: string; fullName: string; portId: string }; + autoReversible: RestoreReversal[]; + reversibleWithPrompt: RestoreReversal[]; + locked: LockedReversal[]; +} + +interface Props { + open: boolean; + onOpenChange: (next: boolean) => void; + clientId: string; + clientName: string; + onSuccess?: () => void; +} + +function iconFor(kind: string) { + if (kind.startsWith('berth_')) return ; + if (kind.startsWith('yacht_')) return ; + if (kind.startsWith('documenso_')) return ; + return ; +} + +export function SmartRestoreDialog({ open, onOpenChange, clientId, clientName, onSuccess }: Props) { + const qc = useQueryClient(); + + const dossierQuery = useQuery({ + queryKey: ['client-restore-dossier', clientId], + queryFn: () => + apiFetch<{ data: RestoreDossier }>(`/api/v1/clients/${clientId}/restore-dossier`), + enabled: open, + }); + + const dossier = dossierQuery.data?.data; + + const [selected, setSelected] = useState>({}); + + useEffect(() => { + if (!open || !dossier) return; + setSelected({}); + }, [open, dossier]); + + const restoreMutation = useMutation({ + mutationFn: () => { + const applyReversals = Object.entries(selected) + .filter(([, v]) => v) + .map(([k]) => k); + return apiFetch<{ + data: { autoReversed: number; promptedReversed: number; lockedSkipped: number }; + }>(`/api/v1/clients/${clientId}/restore`, { + method: 'POST', + body: { applyReversals }, + }); + }, + onSuccess: (res) => { + const d = res.data; + const parts: string[] = []; + if (d.autoReversed > 0) parts.push(`${d.autoReversed} auto-reversed`); + if (d.promptedReversed > 0) parts.push(`${d.promptedReversed} re-applied`); + if (d.lockedSkipped > 0) parts.push(`${d.lockedSkipped} locked`); + toast.success(`${clientName} restored${parts.length > 0 ? ` (${parts.join(', ')})` : ''}.`); + qc.invalidateQueries({ queryKey: ['clients'] }); + qc.invalidateQueries({ queryKey: ['clients', clientId] }); + qc.invalidateQueries({ queryKey: ['berths'] }); + qc.invalidateQueries({ queryKey: ['interests'] }); + onOpenChange(false); + onSuccess?.(); + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : 'Restore failed'); + }, + }); + + const nothingToShow = useMemo( + () => + !!dossier && + dossier.autoReversible.length === 0 && + dossier.reversibleWithPrompt.length === 0 && + dossier.locked.length === 0, + [dossier], + ); + + return ( + + + + Restore {clientName} + + Review what was changed at archive time and decide which changes to undo. The client + record itself will always be un-archived. + + + + {dossierQuery.isLoading ? ( +
+ + Loading restore dossier… +
+ ) : dossierQuery.error || !dossier ? ( +
+ Failed to load dossier:{' '} + {dossierQuery.error instanceof Error ? dossierQuery.error.message : 'unknown error'} +
+ ) : nothingToShow ? ( +
+ No tracked archive changes to review. The client will simply be un-archived. +
+ ) : ( +
+ {dossier.autoReversible.length > 0 && ( + + + + Auto-reversed ( + {dossier.autoReversible.length}) + + + + {dossier.autoReversible.map((r) => ( +
+ {iconFor(r.kind)} + + {r.label} — {r.reason} + +
+ ))} +
+
+ )} + + {dossier.reversibleWithPrompt.length > 0 && ( + + + + Opt-in to undo ( + {dossier.reversibleWithPrompt.length}) + + + + {dossier.reversibleWithPrompt.map((r) => ( + + ))} + + + )} + + {dossier.locked.length > 0 && ( + + + + Cannot be undone ({dossier.locked.length}) + + + + {dossier.locked.map((r) => ( +
+ {iconFor(r.kind)} + + {r.label} — {r.reason}.{' '} + {r.lockReason} + +
+ ))} +
+
+ )} +
+ )} + + + + + +
+
+ ); +}