From ef379013e6988d0e2de94c55ca209d19a74d9fdc Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 13:07:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(uat-batch):=20U66=20=E2=80=94=20EOI=20bert?= =?UTF-8?q?h-scope=20picker=20inside=20generate=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes plan item 66 part (c). Parts (a)+(b) shipped earlier (05e727f defaults flip + linked-berths-list rename); this is the picker-inside-generate-dialog that the rep sees at the moment the "which berths does this EOI cover?" question is actually live in their head, instead of relying on them having visited LinkedBerthsList toggles upstream. EoiGenerateDialog gains: - A new useQuery against /api/v1/interests/[id]/berths returning every linked berth + its current isInEoiBundle / isSpecificInterest flags. - A local Map seeded once from the server snapshot and isolated from subsequent refetches (so a background refetch doesn't wipe pending checks). Resets when the dialog closes. - A new "EOI scope" section in the body listing every linked berth with two checkboxes ("In EOI" / "Public map"), primary-marked visually, plus a one-line legend explaining the bundle-vs-public distinction (matters more post-(a) since the two flags routinely diverge). - handleGenerate diffs the picker state against the server snapshot before kicking off the envelope; only changed berths get PATCHed, and we wait for all PATCHes to settle (so a 5xx surfaces before the EOI fires). Cache invalidation extended to bounce the new ['interests', id, 'berths'] queryKey so the LinkedBerthsList tab picks up the new state on navigation. The "Manage linked berths" cross-link below is preserved — the picker is the in-dialog fast path, not a replacement for the full management surface. 1454/1454 vitest, tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../documents/eoi-generate-dialog.tsx | 174 +++++++++++++++++- 1 file changed, 173 insertions(+), 1 deletion(-) diff --git a/src/components/documents/eoi-generate-dialog.tsx b/src/components/documents/eoi-generate-dialog.tsx index fddb361f..df02a88e 100644 --- a/src/components/documents/eoi-generate-dialog.tsx +++ b/src/components/documents/eoi-generate-dialog.tsx @@ -1,10 +1,12 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { AlertTriangle, ExternalLink, FileSignature, Pencil } from 'lucide-react'; +import { Checkbox } from '@/components/ui/checkbox'; + import { Sheet, SheetContent, @@ -231,6 +233,85 @@ export function EoiGenerateDialog({ enabled: open, }); + // U66 (c) — EOI berth-scope picker. Pulls every linked berth so the + // rep can confirm signature scope (`isInEoiBundle`) and public-map + // visibility (`isSpecificInterest`) at the moment of EOI generation + // — the moment the "which berths does this EOI cover?" question is + // actually live in their head — instead of relying on them having + // visited the LinkedBerthsList toggles upstream. Post-(a) defaults + // (in_bundle=true; specific=primary) mean the picker is mostly + // already correct; this surface lets them carve exceptions. + const { data: linkedBerthsRes } = useQuery<{ + data: Array<{ + berthId: string; + mooringNumber: string | null; + isPrimary: boolean; + isInEoiBundle: boolean; + isSpecificInterest: boolean; + }>; + }>({ + queryKey: ['interests', interestId, 'berths'], + queryFn: () => + apiFetch<{ + data: Array<{ + berthId: string; + mooringNumber: string | null; + isPrimary: boolean; + isInEoiBundle: boolean; + isSpecificInterest: boolean; + }>; + }>(`/api/v1/interests/${interestId}/berths`), + enabled: open, + staleTime: 30_000, + }); + + // Local draft of the two flags per berth. Initialized from the server + // values once the query resolves; subsequent server updates after that + // point are ignored (the rep is actively editing this surface, and we + // don't want a background refetch to wipe their pending checks). + const [berthScope, setBerthScope] = useState< + Map + >(new Map()); + const [berthScopeInitialized, setBerthScopeInitialized] = useState(false); + + useEffect(() => { + if (berthScopeInitialized || !linkedBerthsRes) return; + const next = new Map(); + for (const link of linkedBerthsRes.data) { + next.set(link.berthId, { + isInEoiBundle: link.isInEoiBundle, + isSpecificInterest: link.isSpecificInterest, + }); + } + // eslint-disable-next-line react-hooks/set-state-in-effect + setBerthScope(next); + + setBerthScopeInitialized(true); + }, [linkedBerthsRes, berthScopeInitialized]); + + // Reset the picker when the dialog closes so a re-open against a + // different interest doesn't show stale rows. + useEffect(() => { + if (open) return; + // eslint-disable-next-line react-hooks/set-state-in-effect + setBerthScopeInitialized(false); + + setBerthScope(new Map()); + }, [open]); + + function setBerthFlag( + berthId: string, + flag: 'isInEoiBundle' | 'isSpecificInterest', + value: boolean, + ) { + setBerthScope((prev) => { + const next = new Map(prev); + const current = next.get(berthId) ?? { isInEoiBundle: false, isSpecificInterest: false }; + next.set(berthId, { ...current, [flag]: value }); + return next; + }); + } + const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]); // Only show the template picker when there's a real choice — the // Documenso path is always present, so we show the dropdown once at @@ -397,6 +478,36 @@ export function EoiGenerateDialog({ setIsGenerating(true); setError(null); try { + // U66 (c) — persist any berth-scope edits BEFORE kicking off the + // envelope so the EOI/public-map state is consistent with what the + // rep just confirmed. Diff against the server snapshot so an + // unchanged scope is a no-op (avoids spurious audit-log rows). + const initial = new Map(); + for (const link of linkedBerthsRes?.data ?? []) { + initial.set(link.berthId, { + isInEoiBundle: link.isInEoiBundle, + isSpecificInterest: link.isSpecificInterest, + }); + } + const patches: Array> = []; + for (const [berthId, draft] of berthScope.entries()) { + const orig = initial.get(berthId); + if (!orig) continue; + const body: { isInEoiBundle?: boolean; isSpecificInterest?: boolean } = {}; + if (orig.isInEoiBundle !== draft.isInEoiBundle) body.isInEoiBundle = draft.isInEoiBundle; + if (orig.isSpecificInterest !== draft.isSpecificInterest) + body.isSpecificInterest = draft.isSpecificInterest; + if (Object.keys(body).length > 0) { + patches.push( + apiFetch(`/api/v1/interests/${interestId}/berths/${berthId}`, { + method: 'PATCH', + body, + }), + ); + } + } + if (patches.length > 0) await Promise.all(patches); + const isDocumenso = selectedTemplate === DOCUMENSO_TEMPLATE_VALUE; const url = `/api/v1/document-templates/${encodeURIComponent(selectedTemplate)}/generate-and-sign`; // Phase 3b — pack the per-field overrides the rep selected. Each @@ -463,6 +574,7 @@ export function EoiGenerateDialog({ queryClient.invalidateQueries({ queryKey: ['interests', interestId] }), queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] }), queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'timeline'] }), + queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'berths'] }), ]); onOpenChange(false); } catch (err) { @@ -624,6 +736,66 @@ export function EoiGenerateDialog({ /> + {linkedBerthsRes && linkedBerthsRes.data.length > 0 ? ( +
+
+

+ EOI scope ({linkedBerthsRes.data.length} linked berth + {linkedBerthsRes.data.length === 1 ? '' : 's'}) +

+

+ Confirm signature scope and public-map visibility for each berth before + generating. Defaults reflect what's saved on the interest. +

+
+
+ {linkedBerthsRes.data.map((link) => { + const draft = berthScope.get(link.berthId) ?? { + isInEoiBundle: link.isInEoiBundle, + isSpecificInterest: link.isSpecificInterest, + }; + return ( +
+
+ {link.mooringNumber ?? '—'} + {link.isPrimary ? ( + + Primary + + ) : null} +
+ + +
+ ); + })} +
+

+ In EOI: covered by this signed envelope.{' '} + Public map: shown as "Under Offer" on the marketing + site. +

+
+ ) : null} {portSlug && clientId && (