From 7274baf1e169ab33068f5a5e2efaf43b6e43d337 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Wed, 6 May 2026 19:29:17 +0200 Subject: [PATCH] feat(client-archive): bulk-archive wizard with per-high-stakes confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single window.confirm() with a 3-stage wizard: - preflight: counts auto/needs-reason/blocked (POST /bulk-archive-preflight) - reasons: carousel through high-stakes clients capturing per-client reason (≥5 chars) — bulk endpoint accepts reasonsByClientId map - confirm: shows the final archivable count and submits Low-stakes still auto-archives with safe defaults; blocked clients are skipped with a per-row reason in the preflight summary. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../clients/bulk-archive-preflight/route.ts | 68 ++++ src/app/api/v1/clients/bulk/route.ts | 20 +- .../clients/bulk-archive-wizard.tsx | 312 ++++++++++++++++++ src/components/clients/client-list.tsx | 18 +- 4 files changed, 404 insertions(+), 14 deletions(-) create mode 100644 src/app/api/v1/clients/bulk-archive-preflight/route.ts create mode 100644 src/components/clients/bulk-archive-wizard.tsx diff --git a/src/app/api/v1/clients/bulk-archive-preflight/route.ts b/src/app/api/v1/clients/bulk-archive-preflight/route.ts new file mode 100644 index 0000000..3f6ba2b --- /dev/null +++ b/src/app/api/v1/clients/bulk-archive-preflight/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { getClientArchiveDossier } from '@/lib/services/client-archive-dossier.service'; +import { errorResponse } from '@/lib/errors'; + +const bodySchema = z.object({ + ids: z.array(z.string().min(1)).min(1).max(100), +}); + +interface PreflightItem { + clientId: string; + fullName: string; + stakeLevel: 'low' | 'high'; + highStakesStage: string | null; + blockers: string[]; + summary: { berths: number; yachts: number; reservations: number; signedDocs: number }; +} + +/** + * Preflight check for the bulk-archive wizard. Returns the per-client + * stake level + a small summary so the UI can: + * - count how many will auto-archive + * - cycle through high-stakes clients to capture per-client reasons + * - surface blockers (e.g. "has unpaid invoices") for the operator + */ +export const POST = withAuth( + withPermission('clients', 'delete', async (req, ctx) => { + try { + const { ids } = await parseBody(req, bodySchema); + const items: PreflightItem[] = []; + for (const id of ids) { + try { + const d = await getClientArchiveDossier(id, ctx.portId); + items.push({ + clientId: d.client.id, + fullName: d.client.fullName, + stakeLevel: d.stakeLevel, + highStakesStage: d.highStakesStage, + blockers: d.blockers, + summary: { + berths: d.berths.length, + yachts: d.yachts.length, + reservations: d.reservations.length, + signedDocs: d.documents.filter( + (doc) => doc.status === 'completed' || doc.status === 'signed', + ).length, + }, + }); + } catch (err) { + items.push({ + clientId: id, + fullName: '(unknown)', + stakeLevel: 'low', + highStakesStage: null, + blockers: [err instanceof Error ? err.message : 'Failed to load dossier'], + summary: { berths: 0, yachts: 0, reservations: 0, signedDocs: 0 }, + }); + } + } + return NextResponse.json({ data: items }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/clients/bulk/route.ts b/src/app/api/v1/clients/bulk/route.ts index 7a84b6d..1c635c9 100644 --- a/src/app/api/v1/clients/bulk/route.ts +++ b/src/app/api/v1/clients/bulk/route.ts @@ -20,6 +20,10 @@ const bulkSchema = z.discriminatedUnion('action', [ z.object({ action: z.literal('archive'), ids: z.array(z.string().min(1)).min(1).max(100), + /** When provided, lifts the high-stakes block on listed clients + * individually. The bulk-archive wizard collects these from the + * operator one client at a time. Reasons must be ≥5 characters. */ + reasonsByClientId: z.record(z.string(), z.string().min(5).max(2000)).optional(), }), z.object({ action: z.literal('add_tag'), @@ -59,18 +63,21 @@ export const POST = withAuth(async (req, ctx) => { userAgent: ctx.userAgent, }; + const reasonsByClientId = body.action === 'archive' ? (body.reasonsByClientId ?? {}) : {}; + const { results, summary } = await runBulk(body.ids, async (id) => { if (body.action === 'archive') { // Bulk archive uses the smart-archive backend with sensible // low-stakes defaults: release available/under-offer berths, // retain sold ones, cancel active reservations, leave invoices, - // leave Documenso envelopes pending. High-stakes clients are - // refused — the operator must use the single-client smart dialog - // for those (which captures the per-client reason + decisions). + // leave Documenso envelopes pending. High-stakes clients require + // a per-client reason supplied via reasonsByClientId; the bulk- + // archive wizard captures these one at a time before submitting. const dossier = await getClientArchiveDossier(id, ctx.portId); - if (dossier.stakeLevel === 'high') { + const perClientReason = reasonsByClientId[id]; + if (dossier.stakeLevel === 'high' && !perClientReason) { throw new Error( - `Client at ${dossier.highStakesStage} requires individual archive (open the client to confirm + supply a reason).`, + `Client at ${dossier.highStakesStage} requires a per-client reason; supply one in reasonsByClientId.`, ); } if (dossier.blockers.length > 0) { @@ -79,10 +86,11 @@ export const POST = withAuth(async (req, ctx) => { const hasSignedDocs = dossier.documents.some( (d) => d.status === 'completed' || d.status === 'signed', ); + const reason = perClientReason ?? 'Bulk archive (low-stakes auto-mode)'; await archiveClientWithDecisions({ dossier, decisions: { - reason: 'Bulk archive (low-stakes auto-mode)', + reason, acknowledgedSignedDocuments: hasSignedDocs, berthDecisions: dossier.berths.map((b) => ({ berthId: b.berthId, diff --git a/src/components/clients/bulk-archive-wizard.tsx b/src/components/clients/bulk-archive-wizard.tsx new file mode 100644 index 0000000..0aa76b2 --- /dev/null +++ b/src/components/clients/bulk-archive-wizard.tsx @@ -0,0 +1,312 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { AlertTriangle, ArrowLeft, ArrowRight, CheckCircle2, Loader2 } 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 { Textarea } from '@/components/ui/textarea'; +import { apiFetch } from '@/lib/api/client'; + +interface PreflightItem { + clientId: string; + fullName: string; + stakeLevel: 'low' | 'high'; + highStakesStage: string | null; + blockers: string[]; + summary: { berths: number; yachts: number; reservations: number; signedDocs: number }; +} + +interface Props { + open: boolean; + onOpenChange: (next: boolean) => void; + clientIds: string[]; + onSuccess?: () => void; +} + +type Stage = 'preflight' | 'reasons' | 'confirm'; + +export function BulkArchiveWizard({ open, onOpenChange, clientIds, onSuccess }: Props) { + const qc = useQueryClient(); + const [stage, setStage] = useState('preflight'); + const [reasons, setReasons] = useState>({}); + const [carouselIndex, setCarouselIndex] = useState(0); + + const preflight = useQuery({ + queryKey: ['bulk-archive-preflight', clientIds.join(',')], + queryFn: () => + apiFetch<{ data: PreflightItem[] }>('/api/v1/clients/bulk-archive-preflight', { + method: 'POST', + body: { ids: clientIds }, + }).then((r) => r.data), + enabled: open && clientIds.length > 0, + }); + + useEffect(() => { + if (open) { + setStage('preflight'); + setReasons({}); + setCarouselIndex(0); + } + }, [open]); + + const items = preflight.data ?? []; + const blocked = useMemo(() => items.filter((i) => i.blockers.length > 0), [items]); + const highStakes = useMemo( + () => items.filter((i) => i.stakeLevel === 'high' && i.blockers.length === 0), + [items], + ); + const lowStakes = useMemo( + () => items.filter((i) => i.stakeLevel === 'low' && i.blockers.length === 0), + [items], + ); + const archivable = useMemo(() => [...lowStakes, ...highStakes], [lowStakes, highStakes]); + + const allHighStakesReasoned = useMemo( + () => highStakes.every((i) => (reasons[i.clientId]?.trim().length ?? 0) >= 5), + [highStakes, reasons], + ); + + const archiveMutation = useMutation({ + mutationFn: () => + apiFetch<{ data: { summary: { total: number; succeeded: number; failed: number } } }>( + '/api/v1/clients/bulk', + { + method: 'POST', + body: { + action: 'archive', + ids: archivable.map((i) => i.clientId), + reasonsByClientId: reasons, + }, + }, + ), + onSuccess: (res) => { + const s = res.data.summary; + if (s.failed === 0) { + toast.success(`${s.succeeded} client${s.succeeded === 1 ? '' : 's'} archived.`); + } else { + toast.warning(`${s.succeeded} of ${s.total} archived. ${s.failed} failed.`); + } + qc.invalidateQueries({ queryKey: ['clients'] }); + onOpenChange(false); + onSuccess?.(); + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : 'Bulk archive failed'); + }, + }); + + const currentHighStakes = highStakes[carouselIndex]; + + return ( + + + + Bulk archive · {clientIds.length} clients + + Smart archive runs the same backend per client. Late-stage deals require an individual + reason; everything else auto-archives with safe defaults. + + + + {preflight.isLoading ? ( +
+ + Checking each client… +
+ ) : preflight.error ? ( +
+ Preflight failed:{' '} + {preflight.error instanceof Error ? preflight.error.message : 'unknown error'} +
+ ) : ( + <> + {stage === 'preflight' && ( +
+
+
+
{lowStakes.length}
+
Auto-archive
+
+
+
{highStakes.length}
+
Need reason
+
+
+
{blocked.length}
+
Blocked, will skip
+
+
+ + {blocked.length > 0 && ( +
+
+ Blocked +
+ {blocked.slice(0, 5).map((b) => ( +
+ {b.fullName}: {b.blockers[0]} +
+ ))} + {blocked.length > 5 &&
…and {blocked.length - 5} more
} +
+ )} + +
+ Low-stakes defaults: release available/under-offer berths, keep sold ones, cancel + reservations, leave invoices/Documenso envelopes alone. Yachts stay on the + archived client. To customise per-client, archive that client individually + instead. +
+
+ )} + + {stage === 'reasons' && currentHighStakes && ( +
+
+ + Reason {carouselIndex + 1} of {highStakes.length} + + + {highStakes.map((_, idx) => ( + = 5 + ? 'bg-amber-300' + : 'bg-muted' + }`} + /> + ))} + +
+
+
+ + {currentHighStakes.fullName} + + {currentHighStakes.highStakesStage} + +
+
+ {currentHighStakes.summary.berths > 0 + ? `${currentHighStakes.summary.berths} berth(s), ` + : ''} + {currentHighStakes.summary.signedDocs > 0 + ? `${currentHighStakes.summary.signedDocs} signed doc(s), ` + : ''} + {currentHighStakes.summary.reservations > 0 + ? `${currentHighStakes.summary.reservations} reservation(s)` + : ''} +
+
+