'use client'; 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'; 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; linkedInterestIds: 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(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 ( ); } 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 ( ); } function SmartArchiveDialogBody({ open, onOpenChange, clientId, clientName, onSuccess, dossier, isLoading, error, qc, }: Props & { dossier: ArchiveDossier | null; isLoading: boolean; error: unknown; qc: ReturnType; }) { // ─── Local decision state ──────────────────────────────────────────────── const [reason, setReason] = useState(''); const [acknowledged, setAcknowledged] = useState(false); const [berthDecisions, setBerthDecisions] = useState>(() => dossier ? Object.fromEntries( dossier.berths.map((berth) => [ berth.berthId, berth.status === 'sold' ? ('retain' as BerthAction) : ('release' as BerthAction), ]), ) : {}, ); const [yachtDecisions, setYachtDecisions] = useState>(() => dossier ? Object.fromEntries(dossier.yachts.map((y) => [y.yachtId, 'retain' as YachtAction])) : {}, ); const [reservationDecisions, setReservationDecisions] = useState< Record >(() => dossier ? Object.fromEntries( dossier.reservations.map((r) => [r.reservationId, 'cancel' as ReservationAction]), ) : {}, ); const [invoiceDecisions, setInvoiceDecisions] = useState>(() => dossier ? Object.fromEntries(dossier.invoices.map((i) => [i.invoiceId, 'leave' as InvoiceAction])) : {}, ); const [documentDecisions, setDocumentDecisions] = useState>(() => 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, [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'); // Pick the first linked interest for this berth from the // authoritative dossier join. Berths with no linked interest for // this client are skipped — sending an empty interestId would // make the server-side delete silently match zero rows. const berthDec = dossier.berths .map((b) => { const interestId = b.linkedInterestIds[0]; if (!interestId) return null; return { berthId: b.berthId, interestId, action: berthDecisions[b.berthId] ?? 'retain', }; }) .filter( (x): x is { berthId: string; interestId: string; action: BerthAction } => x !== null, ); 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'] }); // Invalidate the single-client query AND the dossier so detail // pages re-fetch (header now shows Archived badge) and a re-open // of the dialog re-fetches a fresh dossier. qc.invalidateQueries({ queryKey: ['clients', clientId] }); qc.removeQueries({ queryKey: ['client-archive-dossier', clientId] }); 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. {isLoading ? (
Loading dossier…
) : error || !dossier ? (
Failed to load dossier: {error instanceof Error ? 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 signing envelopes */} {dossier.documents.filter((d) => d.isInFlight).length > 0 && ( In-flight signing 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 */}