diff --git a/next.config.ts b/next.config.ts index aee8047..8451a91 100644 --- a/next.config.ts +++ b/next.config.ts @@ -20,13 +20,19 @@ const isProd = process.env.NODE_ENV === 'production'; * - img-src https: is wide because port branding pulls from * s3.portnimara.com plus per-port image URLs configured at runtime. */ +// Dev-only allow-list: react-grab (the in-page click-to-source devtool) +// is fetched from unpkg, so script/style/connect must allow it. Strip +// these entries in prod via the conditional below. +const devScriptHosts = isProd ? '' : ' http://unpkg.com https://unpkg.com'; +const devConnectHosts = isProd ? '' : ' http://unpkg.com https://unpkg.com'; + const csp = [ "default-src 'self'", - `script-src 'self' 'unsafe-inline'${isProd ? '' : " 'unsafe-eval'"}`, + `script-src 'self' 'unsafe-inline'${isProd ? '' : " 'unsafe-eval'"}${devScriptHosts}`, "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob: https:", "font-src 'self' data:", - "connect-src 'self' ws: wss: https:", + `connect-src 'self' ws: wss: https:${devConnectHosts}`, "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", diff --git a/src/components/clients/client-detail-header.tsx b/src/components/clients/client-detail-header.tsx index 9ee531f..0904f5e 100644 --- a/src/components/clients/client-detail-header.tsx +++ b/src/components/clients/client-detail-header.tsx @@ -9,6 +9,7 @@ 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 { SmartArchiveDialog } from '@/components/clients/smart-archive-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'; @@ -41,15 +42,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) { const isArchived = !!client.archivedAt; - const archiveMutation = useMutation({ - mutationFn: () => apiFetch(`/api/v1/clients/${client.id}`, { method: 'DELETE' }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['clients', client.id] }); - queryClient.invalidateQueries({ queryKey: ['clients'] }); - setArchiveOpen(false); - }, - }); - const restoreMutation = useMutation({ mutationFn: () => apiFetch(`/api/v1/clients/${client.id}/restore`, { method: 'POST' }), onSuccess: () => { @@ -187,21 +179,27 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) { - { - if (isArchived) { - restoreMutation.mutate(); - } else { - archiveMutation.mutate(); - } - }} - isLoading={archiveMutation.isPending || restoreMutation.isPending} - /> + {/* 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} + /> + ) : ( + + )} ); } diff --git a/src/components/clients/smart-archive-dialog.tsx b/src/components/clients/smart-archive-dialog.tsx new file mode 100644 index 0000000..370e93f --- /dev/null +++ b/src/components/clients/smart-archive-dialog.tsx @@ -0,0 +1,554 @@ +'use client'; + +import { useEffect, 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'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Textarea } from '@/components/ui/textarea'; +import { apiFetch } from '@/lib/api/client'; + +interface DossierBerth { + berthId: string; + mooringNumber: string; + status: string; + otherInterests: Array<{ + interestId: string; + clientId: string | null; + clientName: string | null; + pipelineStage: string; + daysSinceUpdate: number; + }>; +} +interface DossierDocument { + documentId: string; + templateName: string | null; + status: string; + documensoEnvelopeId: string | null; + isInFlight: boolean; +} +interface DossierYacht { + yachtId: string; + name: string; + hullNumber: string | null; + status: string; +} +interface DossierReservation { + reservationId: string; + berthId: string; + mooringNumber: string; + status: string; + startDate: string; +} +interface DossierInvoice { + invoiceId: string; + invoiceNumber: string; + status: string; + total: string; + currency: string; +} +interface DossierInterest { + interestId: string; + pipelineStage: string; + primaryBerthMooring: string | null; + hasSignedEoi: boolean; +} +interface ArchiveDossier { + client: { id: string; fullName: string; portId: string; archivedAt: string | null }; + stakeLevel: 'low' | 'high'; + highStakesStage: string | null; + interests: DossierInterest[]; + berths: DossierBerth[]; + yachts: DossierYacht[]; + companies: Array<{ companyId: string; name: string; membershipRole: string | null }>; + reservations: DossierReservation[]; + invoices: DossierInvoice[]; + documents: DossierDocument[]; + hasPortalUser: boolean; + blockers: string[]; +} + +type BerthAction = 'release' | 'retain'; +type YachtAction = 'transfer' | 'mark_sold_away' | 'retain'; +type ReservationAction = 'cancel' | 'transfer'; +type InvoiceAction = 'void' | 'write_off' | 'leave'; +type DocumentAction = 'void_documenso' | 'leave'; + +interface Props { + open: boolean; + onOpenChange: (next: boolean) => void; + clientId: string; + clientName: string; + /** Called after successful archive. */ + onSuccess?: () => void; +} + +export function SmartArchiveDialog({ 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; + + // ─── Local decision state ──────────────────────────────────────────────── + const [reason, setReason] = useState(''); + const [acknowledged, setAcknowledged] = useState(false); + const [berthDecisions, setBerthDecisions] = useState>({}); + const [yachtDecisions, setYachtDecisions] = useState>({}); + const [reservationDecisions, setReservationDecisions] = useState< + Record + >({}); + const [invoiceDecisions, setInvoiceDecisions] = useState>({}); + const [documentDecisions, setDocumentDecisions] = useState>({}); + + // 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 = {}; + 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]); + + const hasSignedDocs = useMemo( + () => + dossier?.documents.some((d) => d.status === 'completed' || d.status === 'signed') ?? false, + [dossier], + ); + + const canSubmit = useMemo(() => { + if (!dossier) return false; + if (dossier.blockers.length > 0) return false; + if (dossier.stakeLevel === 'high' && reason.trim().length < 5) return false; + if (hasSignedDocs && !acknowledged) return false; + return true; + }, [dossier, reason, hasSignedDocs, acknowledged]); + + const archiveMutation = useMutation({ + mutationFn: () => { + if (!dossier) throw new Error('No dossier'); + const berthDec = dossier.berths.map((b) => ({ + berthId: b.berthId, + // The interestId for this berth — use the first interest in the + // dossier that has this berth as its primary. Fallback to the + // first interest at all (the API only needs the link reference). + interestId: + dossier.interests.find((i) => i.primaryBerthMooring === b.mooringNumber)?.interestId ?? + dossier.interests[0]?.interestId ?? + '', + action: berthDecisions[b.berthId] ?? 'retain', + })); + return apiFetch<{ data: { releasedBerths: Array<{ mooringNumber: string }> } }>( + `/api/v1/clients/${clientId}/archive`, + { + method: 'POST', + body: { + reason, + acknowledgedSignedDocuments: acknowledged, + berthDecisions: berthDec, + yachtDecisions: dossier.yachts.map((y) => ({ + yachtId: y.yachtId, + action: yachtDecisions[y.yachtId] ?? 'retain', + })), + reservationDecisions: dossier.reservations.map((r) => ({ + reservationId: r.reservationId, + action: reservationDecisions[r.reservationId] ?? 'cancel', + })), + invoiceDecisions: dossier.invoices.map((i) => ({ + invoiceId: i.invoiceId, + action: invoiceDecisions[i.invoiceId] ?? 'leave', + })), + documentDecisions: dossier.documents.map((d) => ({ + documentId: d.documentId, + action: documentDecisions[d.documentId] ?? 'leave', + })), + }, + }, + ); + }, + onSuccess: (res) => { + const released = res.data.releasedBerths; + toast.success( + released.length > 0 + ? `${clientName} archived. ${released.length} berth${released.length === 1 ? '' : 's'} released.` + : `${clientName} archived.`, + ); + qc.invalidateQueries({ queryKey: ['clients'] }); + qc.invalidateQueries({ queryKey: ['berths'] }); + qc.invalidateQueries({ queryKey: ['interests'] }); + onOpenChange(false); + onSuccess?.(); + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : 'Archive failed'); + }, + }); + + return ( + + + + Archive {clientName} + + Archive is reversible — the client can be restored from the archived list. Decide what + should happen to the relationships below before continuing. + + + + {dossierQuery.isLoading ? ( +
+ + Loading dossier… +
+ ) : dossierQuery.error || !dossier ? ( +
+ Failed to load dossier:{' '} + {dossierQuery.error instanceof Error ? dossierQuery.error.message : 'unknown error'} +
+ ) : ( +
+ {dossier.blockers.length > 0 && ( + + + + Cannot archive + + + + {dossier.blockers.map((b, i) => ( +

{b}

+ ))} +
+
+ )} + + {dossier.stakeLevel === 'high' && ( + + + + + Late-stage deal — confirmation required + + + + This client is at {dossier.highStakesStage}. + Provide a reason explaining why you’re archiving them at this stage. The + reason is recorded in the audit log. + + + )} + + {/* Interests + signed-doc acknowledgment */} + {dossier.interests.length > 0 && ( + + + + Pipeline interests ({dossier.interests.length}) + + + + {dossier.interests.map((i) => ( +
+ {i.interestId.slice(0, 8)} + + + {i.pipelineStage} + + {i.hasSignedEoi && Signed EOI} + +
+ ))} +
+
+ )} + + {hasSignedDocs && ( + + + + + + )} + + {/* Berths */} + {dossier.berths.length > 0 && ( + + + + Berths ({dossier.berths.length}) + + + + {dossier.berths.map((b) => ( +
+
+
+ Berth {b.mooringNumber} + + {b.status} + +
+ +
+ {b.status === 'sold' && ( +

+ Sold berths stay sold. Process a refund separately if needed. +

+ )} + {b.otherInterests.length > 0 && berthDecisions[b.berthId] === 'release' && ( +

+ Releasing will notify the sales rep. Other interests on this berth:{' '} + {b.otherInterests + .slice(0, 3) + .map((o) => `${o.clientName ?? '?'} (${o.pipelineStage})`) + .join(', ')} + {b.otherInterests.length > 3 ? ` +${b.otherInterests.length - 3}` : ''} +

+ )} +
+ ))} +
+
+ )} + + {/* Yachts */} + {dossier.yachts.length > 0 && ( + + + + Yachts owned ({dossier.yachts.length}) + + + + {dossier.yachts.map((y) => ( +
+ {y.name} + +
+ ))} +
+
+ )} + + {/* Reservations */} + {dossier.reservations.length > 0 && ( + + + + Active reservations ( + {dossier.reservations.length}) + + + + {dossier.reservations.map((r) => ( +
+ Berth {r.mooringNumber} + +
+ ))} +
+
+ )} + + {/* Invoices */} + {dossier.invoices.length > 0 && ( + + + + Outstanding invoices ({dossier.invoices.length}) + + + + {dossier.invoices.map((i) => ( +
+ + {i.invoiceNumber} · {i.total} {i.currency} + + +
+ ))} +
+
+ )} + + {/* In-flight Documenso envelopes */} + {dossier.documents.filter((d) => d.isInFlight).length > 0 && ( + + + + In-flight Documenso envelopes + + + + {dossier.documents + .filter((d) => d.isInFlight) + .map((d) => ( +
+ {d.templateName ?? d.documentId.slice(0, 8)} + +
+ ))} +
+
+ )} + + {/* Auto-handled summary */} + + + + Automatically handled + + + +

EOI documents — retained for audit (always)

+ {dossier.hasPortalUser &&

Portal user — deactivated (login revoked)

} + {dossier.companies.length > 0 && ( +

Company memberships — end-dated to today (history preserved)

+ )} +

Notes, contacts, tags, addresses — survive on the archived client

+
+
+ + {/* Reason field */} +
+ +