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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user