feat(client-archive): bulk-archive wizard with per-high-stakes confirmation
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) <noreply@anthropic.com>
This commit is contained in:
68
src/app/api/v1/clients/bulk-archive-preflight/route.ts
Normal file
68
src/app/api/v1/clients/bulk-archive-preflight/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user