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 && (