feat(interests): upload externally-signed EOI (paper / non-Documenso)

Sales reps need to file EOIs that were signed outside Documenso —
on paper, in person at a boat show, or via an alternate e-sign vendor.
Until now the EOI flow assumed Documenso was the only path.

- external-eoi.service.uploadExternallySignedEoi creates BOTH the
  document row AND the signed-file record in one shot. Document is
  marked isManualUpload=true with status=completed and signedFileId
  set. Distinct from the existing uploadSignedManually which augments
  a document row that came from the Documenso pathway.
- POST /api/v1/interests/[id]/external-eoi accepts multipart with the
  PDF + optional title + signedAt date + comma-separated signer names
  + free-text notes. Gated on documents.upload_signed permission.
- Interest stage auto-advances to eoi_signed (only when the interest
  is currently at or before eoi_sent — past that, just file the doc).
- The signing date, signer names, and any notes are captured into
  document_events.eventData + the audit_log metadata so the audit
  trail records who said the document was signed and when.
- ExternalEoiUploadDialog renders a small modal: file picker, title
  override, signed-date (defaults to today), comma-separated signer
  names, notes. Wired into interest-detail-header behind an Upload
  icon button (gated on documents.upload_signed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 18:33:15 +02:00
parent 789656bc70
commit 8c02f88cbd
4 changed files with 419 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ import {
MessageCircle,
Phone,
AlarmClock,
Upload,
} from 'lucide-react';
import Link from 'next/link';
@@ -23,6 +24,7 @@ import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate';
import { InterestForm } from '@/components/interests/interest-form';
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
import { InlineStagePicker } from '@/components/interests/inline-stage-picker';
import { InterestOutcomeDialog } from '@/components/interests/interest-outcome-dialog';
import { apiFetch } from '@/lib/api/client';
@@ -102,6 +104,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
const [editOpen, setEditOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(null);
const [externalEoiOpen, setExternalEoiOpen] = useState(false);
const isArchived = !!interest.archivedAt;
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
@@ -376,6 +379,20 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
</>
)}
</PermissionGate>
<PermissionGate resource="documents" action="upload_signed">
<button
type="button"
onClick={() => setExternalEoiOpen(true)}
aria-label="Upload externally-signed EOI"
title="Upload externally-signed EOI (paper / outside Documenso)"
className={cn(
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
'hover:bg-foreground/5 hover:text-foreground',
)}
>
<Upload className="size-4" />
</button>
</PermissionGate>
<PermissionGate resource="interests" action="edit">
<button
type="button"
@@ -439,6 +456,12 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
}}
isLoading={archiveMutation.isPending || restoreMutation.isPending}
/>
<ExternalEoiUploadDialog
open={externalEoiOpen}
onOpenChange={setExternalEoiOpen}
interestId={interest.id}
/>
</>
);
}