feat(externally-signed): mark contract/reservation as signed without file

Step 4 second slice. Adds the "Mark as signed without file" action to
contract + reservation tabs per PRE-DEPLOY-PLAN § 1.5.14.

Service: `markExternallySigned(interestId, portId, docType, reason)`
flips the relevant doc-status column ('contract_doc_status' /
'reservation_doc_status' / 'eoi_doc_status') to 'signed', writes an
audit log entry with `metadata.type='externally_signed'` capturing
the optional reason, and fires the appropriate berth-rule trigger
(eoi_signed / contract_signed) so downstream automation (berth
status flips, notifications) treats it identically to a Documenso-
signed completion.

Route: POST /api/v1/interests/[id]/mark-externally-signed gated on
interests.edit. Validates docType against the canonical 3-value enum.

UI: <MarkExternallySignedDialog> AlertDialog with optional reason
textarea + per-docType copy. Wired into EmptyContractState and
EmptyReservationState empty-state buttons. The action sits alongside
"Upload draft for signing" and "Upload paper-signed copy" as a third
option for reps whose canonical paper lives elsewhere.

EOI not yet wired into a UI surface — the eoi flow already has a
full upload pipeline. Service supports it for completeness.

Followup: quick brochure/PDF download buttons + per-user reminder
digest schedule still pending in Step 4 backlog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 15:42:21 +02:00
parent a77b3c670a
commit 4182652d49
5 changed files with 306 additions and 0 deletions

View File

@@ -19,6 +19,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
import { MarkExternallySignedDialog } from '@/components/interests/mark-externally-signed-dialog';
import { SigningProgress } from '@/components/documents/signing-progress';
import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
import { apiFetch } from '@/lib/api/client';
@@ -91,6 +92,7 @@ export function InterestContractTab({ interestId, clientId: _clientId }: Interes
const portSlug = useUIStore((s) => s.currentPortSlug);
const [uploadSignedOpen, setUploadSignedOpen] = useState(false);
const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false);
const [markExternalOpen, setMarkExternalOpen] = useState(false);
const { data: docsRes, isLoading: docsLoading } = useQuery<{ data: DocumentRow[] }>({
queryKey: ['documents', { interestId, documentType: 'contract' }],
@@ -118,6 +120,7 @@ export function InterestContractTab({ interestId, clientId: _clientId }: Interes
<EmptyContractState
onUploadSigned={() => setUploadSignedOpen(true)}
onUploadForSigning={() => setUploadForSigningOpen(true)}
onMarkExternal={() => setMarkExternalOpen(true)}
/>
)}
@@ -181,6 +184,17 @@ export function InterestContractTab({ interestId, clientId: _clientId }: Interes
documentType="contract"
/>
)}
{/* "Mark as signed externally" — flips the contract doc-status
to 'signed' without uploading a file. Used when the rep is
keeping the canonical copy elsewhere and just wants the CRM
state to reflect the close. */}
<MarkExternallySignedDialog
open={markExternalOpen}
onOpenChange={setMarkExternalOpen}
interestId={interestId}
docType="contract"
/>
</div>
);
}
@@ -336,9 +350,11 @@ function ActiveContractCard({
function EmptyContractState({
onUploadSigned,
onUploadForSigning,
onMarkExternal,
}: {
onUploadSigned: () => void;
onUploadForSigning: () => void;
onMarkExternal: () => void;
}) {
return (
<section className="rounded-xl border border-dashed bg-muted/20 p-8 text-center">
@@ -361,6 +377,9 @@ function EmptyContractState({
<Upload className="size-4" aria-hidden />
Upload paper-signed copy
</Button>
<Button onClick={onMarkExternal} variant="ghost" size="sm" className="gap-1.5">
Mark as signed without file
</Button>
</div>
</section>
);