feat(external-eoi): auto-cancel + replace generated EOI on upload

When ExternalEoiUploadDialog mounts on an interest with a non-terminal
generated EOI (status sent / partially_signed / draft), it now surfaces
an amber banner naming the active envelope and offering two paths via
radio:

- "Cancel the generated envelope and replace it" (default + recommended):
  upload posts cancelActiveDocumentId; the service voids the upstream
  Documenso envelope + flips the local doc row to cancelled BEFORE the
  new external-EOI doc lands. Audit-log on the new doc carries
  metadata.replacedDocumentId so reps can trace cause + effect.
- "Keep both records (advanced)": legacy behaviour - leaves two EOIs on
  the deal. Useful only for backfilling intentionally-parallel records.

Cancel runs outside the upload transaction so a Documenso void error
doesn't block the upload the rep has already photographed. The dialog
already shares cache + envelope shape with InterestDetail, so the recent
B4 #4 fix means opening the dialog no longer blanks the page.

cancelMode='delete' is hardwired in the replace path (kill the upstream
envelope on void). Pairs with the existing keep_remote affordance on the
manual Cancel-document flow shipped earlier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 13:15:22 +02:00
parent cd6b19e173
commit 3a1c16ae71
3 changed files with 115 additions and 1 deletions

View File

@@ -74,6 +74,13 @@ export function ExternalEoiUploadDialog({
// the React Compiler bans.
const [signatoriesOverride, setSignatoriesOverride] = useState<SignatoryRow[] | null>(null);
const [notes, setNotes] = useState('');
// When a non-terminal generated EOI exists on the interest, this drives
// the warning banner + the replace-vs-keep choice. 'replace' (default)
// voids the generated envelope on Documenso + flips the local row to
// cancelled before the new upload lands; 'keep' leaves the prior doc
// alone (legacy behaviour). Hidden behind an Advanced disclosure since
// the replace path is the right default for the vast majority of cases.
const [replaceMode, setReplaceMode] = useState<'replace' | 'keep'>('replace');
// Fetched on open to power the default title:
// "External EOI - <Client> - <berth range> - YYYY-MM-DD". Without
@@ -125,6 +132,29 @@ export function ExternalEoiUploadDialog({
staleTime: 60_000,
});
// Detect a generated EOI in flight on this interest so the dialog can
// offer "Replace the generated envelope" instead of leaving two parallel
// EOIs on the deal. Only documents in non-terminal status count — already-
// cancelled / completed / rejected EOIs need no reconciliation.
const { data: docsData } = useQuery<{
data: Array<{ id: string; status: string; title: string; createdAt: string }>;
}>({
queryKey: ['documents', { interestId, documentType: 'eoi' }],
queryFn: () =>
apiFetch<{
data: Array<{ id: string; status: string; title: string; createdAt: string }>;
}>(`/api/v1/documents?interestId=${interestId}&documentType=eoi`),
enabled: open,
staleTime: 30_000,
});
const activeEoi = useMemo(
() =>
(docsData?.data ?? []).find(
(d) => d.status === 'sent' || d.status === 'partially_signed' || d.status === 'draft',
) ?? null,
[docsData],
);
const defaultTitle = useMemo(() => {
const date = signedAt || new Date().toISOString().slice(0, 10);
const moorings = (berthsData?.data ?? [])
@@ -164,6 +194,12 @@ export function ExternalEoiUploadDialog({
form.append('signerNames', cleanSignatories.map((s) => s.name).join(', '));
}
if (notes) form.append('notes', notes);
// When a generated EOI is active AND the rep didn't opt out via the
// Advanced toggle, tell the server to cancel it as part of this
// upload so the deal carries one canonical EOI.
if (activeEoi && replaceMode === 'replace') {
form.append('cancelActiveDocumentId', activeEoi.id);
}
const res = await fetch(`/api/v1/interests/${interestId}/external-eoi`, {
method: 'POST',
body: form,
@@ -212,6 +248,50 @@ export function ExternalEoiUploadDialog({
</DialogHeader>
<div className="space-y-3 py-2">
{activeEoi ? (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm dark:border-amber-900 dark:bg-amber-950/40">
<p className="font-medium text-amber-900 dark:text-amber-100">
A generated EOI is already in flight on this deal.
</p>
<p className="mt-1 text-amber-800 dark:text-amber-200">
{activeEoi.title} ({activeEoi.status.replace(/_/g, ' ')}, created{' '}
{new Date(activeEoi.createdAt).toLocaleDateString()}). Uploading a signed copy can
cancel the generated envelope so this deal carries one canonical EOI.
</p>
<div
role="radiogroup"
aria-label="What to do with the generated EOI"
className="mt-2 space-y-1"
>
<label className="flex cursor-pointer items-start gap-2">
<input
type="radio"
name="replace-mode"
value="replace"
checked={replaceMode === 'replace'}
onChange={() => setReplaceMode('replace')}
className="mt-1 h-4 w-4 cursor-pointer accent-amber-700"
/>
<span className="text-amber-900 dark:text-amber-100">
Cancel the generated envelope and replace it with this upload (recommended).
</span>
</label>
<label className="flex cursor-pointer items-start gap-2">
<input
type="radio"
name="replace-mode"
value="keep"
checked={replaceMode === 'keep'}
onChange={() => setReplaceMode('keep')}
className="mt-1 h-4 w-4 cursor-pointer accent-amber-700"
/>
<span className="text-amber-900 dark:text-amber-100">
Keep both records (advanced - leaves two EOIs on the deal).
</span>
</label>
</div>
</div>
) : null}
<div>
<Label>PDF file *</Label>
<div className="mt-1">