Files
pn-new-crm/src/components/clients/smart-archive-dialog.tsx
Matt 4233aa3ac3 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>
2026-05-13 11:50:07 +02:00

612 lines
24 KiB
TypeScript

'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 (
<SmartArchiveDialogShell key={props.open ? `open:${props.clientId}` : 'closed'} {...props} />
);
}
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 (
<SmartArchiveDialogBody
key={dossier ? 'loaded' : 'loading'}
open={open}
onOpenChange={onOpenChange}
clientId={clientId}
clientName={clientName}
onSuccess={onSuccess}
dossier={dossier ?? null}
isLoading={dossierQuery.isLoading}
error={dossierQuery.error}
qc={qc}
/>
);
}
function SmartArchiveDialogBody({
open,
onOpenChange,
clientId,
clientName,
onSuccess,
dossier,
isLoading,
error,
qc,
}: Props & {
dossier: ArchiveDossier | null;
isLoading: boolean;
error: unknown;
qc: ReturnType<typeof useQueryClient>;
}) {
// ─── Local decision state ────────────────────────────────────────────────
const [reason, setReason] = useState('');
const [acknowledged, setAcknowledged] = useState(false);
const [berthDecisions, setBerthDecisions] = useState<Record<string, BerthAction>>(() =>
dossier
? Object.fromEntries(
dossier.berths.map((berth) => [
berth.berthId,
berth.status === 'sold' ? ('retain' as BerthAction) : ('release' as BerthAction),
]),
)
: {},
);
const [yachtDecisions, setYachtDecisions] = useState<Record<string, YachtAction>>(() =>
dossier
? Object.fromEntries(dossier.yachts.map((y) => [y.yachtId, 'retain' as YachtAction]))
: {},
);
const [reservationDecisions, setReservationDecisions] = useState<
Record<string, ReservationAction>
>(() =>
dossier
? Object.fromEntries(
dossier.reservations.map((r) => [r.reservationId, 'cancel' as ReservationAction]),
)
: {},
);
const [invoiceDecisions, setInvoiceDecisions] = useState<Record<string, InvoiceAction>>(() =>
dossier
? Object.fromEntries(dossier.invoices.map((i) => [i.invoiceId, 'leave' as InvoiceAction]))
: {},
);
const [documentDecisions, setDocumentDecisions] = useState<Record<string, DocumentAction>>(() =>
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Archive {clientName}</DialogTitle>
<DialogDescription>
Archive is reversible the client can be restored from the archived list. Decide what
should happen to the relationships below before continuing.
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />
Loading dossier
</div>
) : error || !dossier ? (
<div className="py-8 text-center text-sm text-red-600">
Failed to load dossier: {error instanceof Error ? error.message : 'unknown error'}
</div>
) : (
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
{dossier.blockers.length > 0 && (
<Card className="border-red-300 bg-red-50">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-red-800 flex items-center gap-2">
<AlertTriangle className="h-4 w-4" /> Cannot archive
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-red-700 space-y-1">
{dossier.blockers.map((b, i) => (
<p key={i}>{b}</p>
))}
</CardContent>
</Card>
)}
{dossier.stakeLevel === 'high' && (
<Card className="border-amber-300 bg-amber-50">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-amber-900 flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Late-stage deal confirmation required
</CardTitle>
</CardHeader>
<CardContent className="text-xs text-amber-900">
This client is at <Badge variant="secondary">{dossier.highStakesStage}</Badge>.
Provide a reason explaining why you&rsquo;re archiving them at this stage. The
reason is recorded in the audit log.
</CardContent>
</Card>
)}
{/* Interests + signed-doc acknowledgment */}
{dossier.interests.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<FileText className="h-4 w-4" /> Pipeline interests ({dossier.interests.length})
</CardTitle>
</CardHeader>
<CardContent className="text-xs space-y-1">
{dossier.interests.map((i) => (
<div key={i.interestId} className="flex items-center justify-between">
<span className="font-mono">{i.interestId.slice(0, 8)}</span>
<span className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{i.pipelineStage}
</Badge>
{i.hasSignedEoi && <Badge className="text-xs">Signed EOI</Badge>}
</span>
</div>
))}
</CardContent>
</Card>
)}
{hasSignedDocs && (
<Card className="border-blue-300 bg-blue-50">
<CardContent className="pt-4 text-xs text-blue-900">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
className="mt-0.5"
/>
<span>
I acknowledge this client has signed legal documents. The documents will be
retained in the system and remain binding regardless of archive status.
</span>
</label>
</CardContent>
</Card>
)}
{/* Berths */}
{dossier.berths.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Anchor className="h-4 w-4" /> Berths ({dossier.berths.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{dossier.berths.map((b) => (
<div key={b.berthId} className="text-xs">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="font-medium">Berth {b.mooringNumber}</span>
<Badge variant="secondary" className="text-xs">
{b.status}
</Badge>
</div>
<select
className="rounded border bg-background px-2 py-1 text-xs disabled:opacity-50"
value={berthDecisions[b.berthId] ?? 'retain'}
onChange={(e) =>
setBerthDecisions((prev) => ({
...prev,
[b.berthId]: e.target.value as BerthAction,
}))
}
disabled={b.status === 'sold'}
>
<option value="retain">Retain on archived client</option>
<option value="release">Release to available</option>
</select>
</div>
{b.status === 'sold' && (
<p className="text-muted-foreground italic">
Sold berths stay sold. Process a refund separately if needed.
</p>
)}
{b.otherInterests.length > 0 && berthDecisions[b.berthId] === 'release' && (
<p className="text-muted-foreground">
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}` : ''}
</p>
)}
</div>
))}
</CardContent>
</Card>
)}
{/* Yachts */}
{dossier.yachts.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Ship className="h-4 w-4" /> Yachts owned ({dossier.yachts.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{dossier.yachts.map((y) => (
<div key={y.yachtId} className="flex items-center justify-between text-xs">
<span className="font-medium">{y.name}</span>
<select
className="rounded border bg-background px-2 py-1 text-xs"
value={yachtDecisions[y.yachtId] ?? 'retain'}
onChange={(e) =>
setYachtDecisions((prev) => ({
...prev,
[y.yachtId]: e.target.value as YachtAction,
}))
}
>
<option value="retain">Leave on archived client</option>
<option value="mark_sold_away">Mark as sold-away</option>
{/* Transfer to another owner needs an owner picker; v1
offers retain + sold-away inline. Use the yacht
detail page to transfer to a specific owner. */}
</select>
</div>
))}
</CardContent>
</Card>
)}
{/* Reservations */}
{dossier.reservations.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Anchor className="h-4 w-4" /> Active reservations (
{dossier.reservations.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{dossier.reservations.map((r) => (
<div
key={r.reservationId}
className="flex items-center justify-between text-xs"
>
<span>Berth {r.mooringNumber}</span>
<select
className="rounded border bg-background px-2 py-1 text-xs"
value={reservationDecisions[r.reservationId] ?? 'cancel'}
onChange={(e) =>
setReservationDecisions((prev) => ({
...prev,
[r.reservationId]: e.target.value as ReservationAction,
}))
}
>
<option value="cancel">Cancel reservation</option>
</select>
</div>
))}
</CardContent>
</Card>
)}
{/* Invoices */}
{dossier.invoices.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Receipt className="h-4 w-4" /> Outstanding invoices ({dossier.invoices.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{dossier.invoices.map((i) => (
<div key={i.invoiceId} className="flex items-center justify-between text-xs">
<span>
{i.invoiceNumber} · {i.total} {i.currency}
</span>
<select
className="rounded border bg-background px-2 py-1 text-xs"
value={invoiceDecisions[i.invoiceId] ?? 'leave'}
onChange={(e) =>
setInvoiceDecisions((prev) => ({
...prev,
[i.invoiceId]: e.target.value as InvoiceAction,
}))
}
>
<option value="leave">Leave open</option>
<option value="void">Void</option>
<option value="write_off">Write off</option>
</select>
</div>
))}
</CardContent>
</Card>
)}
{/* In-flight signing envelopes */}
{dossier.documents.filter((d) => d.isInFlight).length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<FileText className="h-4 w-4" /> In-flight signing envelopes
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{dossier.documents
.filter((d) => d.isInFlight)
.map((d) => (
<div key={d.documentId} className="flex items-center justify-between text-xs">
<span>{d.templateName ?? d.documentId.slice(0, 8)}</span>
<select
className="rounded border bg-background px-2 py-1 text-xs"
value={documentDecisions[d.documentId] ?? 'leave'}
onChange={(e) =>
setDocumentDecisions((prev) => ({
...prev,
[d.documentId]: e.target.value as DocumentAction,
}))
}
>
<option value="leave">Leave envelope pending</option>
<option value="void_documenso">Void the signing envelope</option>
</select>
</div>
))}
</CardContent>
</Card>
)}
{/* Auto-handled summary */}
<Card className="border-muted">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Users className="h-4 w-4" /> Automatically handled
</CardTitle>
</CardHeader>
<CardContent className="text-xs text-muted-foreground space-y-1">
<p>EOI documents retained for audit (always)</p>
{dossier.hasPortalUser && <p>Portal user deactivated (login revoked)</p>}
{dossier.companies.length > 0 && (
<p>Company memberships end-dated to today (history preserved)</p>
)}
<p>Notes, contacts, tags, addresses survive on the archived client</p>
</CardContent>
</Card>
{/* Reason field */}
<div>
<label className="text-xs uppercase tracking-wide text-muted-foreground">
Reason {dossier.stakeLevel === 'high' && <span className="text-red-600">*</span>}
</label>
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={
dossier.stakeLevel === 'high'
? `Why are you archiving this client at ${dossier.highStakesStage}?`
: 'Optional: why is this client being archived?'
}
className="mt-1 min-h-[60px]"
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
disabled={!canSubmit || archiveMutation.isPending}
onClick={() => archiveMutation.mutate()}
>
{archiveMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
) : null}
Archive
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}