feat(uat-batch): U66 — EOI berth-scope picker inside generate dialog

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<berthId, {isInEoiBundle, isSpecificInterest}> 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 13:07:29 +02:00
parent adf4e2ba78
commit ef379013e6

View File

@@ -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<string, { isInEoiBundle: boolean; isSpecificInterest: boolean }>
>(new Map());
const [berthScopeInitialized, setBerthScopeInitialized] = useState(false);
useEffect(() => {
if (berthScopeInitialized || !linkedBerthsRes) return;
const next = new Map<string, { isInEoiBundle: boolean; isSpecificInterest: boolean }>();
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<string, { isInEoiBundle: boolean; isSpecificInterest: boolean }>();
for (const link of linkedBerthsRes?.data ?? []) {
initial.set(link.berthId, {
isInEoiBundle: link.isInEoiBundle,
isSpecificInterest: link.isSpecificInterest,
});
}
const patches: Array<Promise<unknown>> = [];
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({
/>
</dl>
</div>
{linkedBerthsRes && linkedBerthsRes.data.length > 0 ? (
<div className="border-t pt-2 space-y-2">
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
EOI scope ({linkedBerthsRes.data.length} linked berth
{linkedBerthsRes.data.length === 1 ? '' : 's'})
</p>
<p className="text-[11px] text-muted-foreground">
Confirm signature scope and public-map visibility for each berth before
generating. Defaults reflect what&apos;s saved on the interest.
</p>
</div>
<div className="rounded-md border bg-muted/20 divide-y">
{linkedBerthsRes.data.map((link) => {
const draft = berthScope.get(link.berthId) ?? {
isInEoiBundle: link.isInEoiBundle,
isSpecificInterest: link.isSpecificInterest,
};
return (
<div
key={link.berthId}
className="grid grid-cols-[1fr_auto_auto] items-center gap-3 px-3 py-2"
>
<div className="flex items-center gap-2 min-w-0">
<span className="font-mono text-sm">{link.mooringNumber ?? '—'}</span>
{link.isPrimary ? (
<span className="text-[10px] uppercase tracking-wide text-primary">
Primary
</span>
) : null}
</div>
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox
checked={draft.isInEoiBundle}
onCheckedChange={(v) =>
setBerthFlag(link.berthId, 'isInEoiBundle', v === true)
}
/>
<span>In EOI</span>
</label>
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox
checked={draft.isSpecificInterest}
onCheckedChange={(v) =>
setBerthFlag(link.berthId, 'isSpecificInterest', v === true)
}
/>
<span>Public map</span>
</label>
</div>
);
})}
</div>
<p className="text-[10px] text-muted-foreground">
<strong>In EOI</strong>: covered by this signed envelope.{' '}
<strong>Public map</strong>: shown as &quot;Under Offer&quot; on the marketing
site.
</p>
</div>
) : null}
{portSlug && clientId && (
<div className="border-t pt-2 space-y-1">
<p className="text-[11px] text-muted-foreground">