From 4182652d499b8f57d49f728412e2dcc3089b4a3d Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 14 May 2026 15:42:21 +0200 Subject: [PATCH] feat(externally-signed): mark contract/reservation as signed without file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 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) --- .../[id]/mark-externally-signed/route.ts | 47 +++++++ .../interests/interest-contract-tab.tsx | 19 +++ .../interests/interest-reservation-tab.tsx | 15 +++ .../mark-externally-signed-dialog.tsx | 127 ++++++++++++++++++ src/lib/services/external-signing.service.ts | 98 ++++++++++++++ 5 files changed, 306 insertions(+) create mode 100644 src/app/api/v1/interests/[id]/mark-externally-signed/route.ts create mode 100644 src/components/interests/mark-externally-signed-dialog.tsx create mode 100644 src/lib/services/external-signing.service.ts diff --git a/src/app/api/v1/interests/[id]/mark-externally-signed/route.ts b/src/app/api/v1/interests/[id]/mark-externally-signed/route.ts new file mode 100644 index 00000000..fc5ace85 --- /dev/null +++ b/src/app/api/v1/interests/[id]/mark-externally-signed/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse, NotFoundError } from '@/lib/errors'; +import { markExternallySigned } from '@/lib/services/external-signing.service'; + +const bodySchema = z.object({ + docType: z.enum(['eoi', 'reservation', 'contract']), + reason: z.string().trim().max(2000).optional(), +}); + +/** + * POST /api/v1/interests/[id]/mark-externally-signed + * + * Marks the named document type as signed without requiring a file + * upload. Sets the relevant `*_doc_status` column to 'signed', writes + * an audit log entry capturing the reason, and fires the appropriate + * berth-rule trigger (eoi_signed / contract_signed) if any. + */ +export const POST = withAuth( + withPermission('interests', 'edit', async (req, ctx, params) => { + try { + const interestId = params.id; + if (!interestId) throw new NotFoundError('Interest'); + const input = await parseBody(req, bodySchema); + const result = await markExternallySigned( + { + interestId, + portId: ctx.portId, + docType: input.docType, + reason: input.reason ?? null, + }, + { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }, + ); + return NextResponse.json({ data: result }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/interests/interest-contract-tab.tsx b/src/components/interests/interest-contract-tab.tsx index bc98ff75..6884f147 100644 --- a/src/components/interests/interest-contract-tab.tsx +++ b/src/components/interests/interest-contract-tab.tsx @@ -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 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. */} + ); } @@ -336,9 +350,11 @@ function ActiveContractCard({ function EmptyContractState({ onUploadSigned, onUploadForSigning, + onMarkExternal, }: { onUploadSigned: () => void; onUploadForSigning: () => void; + onMarkExternal: () => void; }) { return (
@@ -361,6 +377,9 @@ function EmptyContractState({ Upload paper-signed copy +
); diff --git a/src/components/interests/interest-reservation-tab.tsx b/src/components/interests/interest-reservation-tab.tsx index 3463d018..787ebaaf 100644 --- a/src/components/interests/interest-reservation-tab.tsx +++ b/src/components/interests/interest-reservation-tab.tsx @@ -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'; @@ -94,6 +95,7 @@ export function InterestReservationTab({ 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: 'reservation_agreement' }], @@ -119,6 +121,7 @@ export function InterestReservationTab({ /> ) : ( setMarkExternalOpen(true)} onUploadSigned={() => setUploadSignedOpen(true)} onUploadForSigning={() => setUploadForSigningOpen(true)} /> @@ -181,6 +184,13 @@ export function InterestReservationTab({ documentType="reservation_agreement" /> )} + + ); } @@ -336,9 +346,11 @@ function ActiveReservationCard({ function EmptyReservationState({ onUploadSigned, onUploadForSigning, + onMarkExternal, }: { onUploadSigned: () => void; onUploadForSigning: () => void; + onMarkExternal: () => void; }) { return (
@@ -361,6 +373,9 @@ function EmptyReservationState({ Upload paper-signed copy +
); diff --git a/src/components/interests/mark-externally-signed-dialog.tsx b/src/components/interests/mark-externally-signed-dialog.tsx new file mode 100644 index 00000000..8d4f5b4c --- /dev/null +++ b/src/components/interests/mark-externally-signed-dialog.tsx @@ -0,0 +1,127 @@ +'use client'; + +/** + * Confirms a "mark as signed externally without file" action on an + * interest's contract / reservation / EOI sub-status. Used by the + * relevant interest tabs when the rep has paper or a digital copy + * filed elsewhere and doesn't want to duplicate-store it in the CRM + * just to flip the doc-status forward. + * + * The action is destructive in the sense that downstream berth-rules + * fire (eoi_signed / contract_signed triggers), so a confirmation + * step + optional reason capture is required. + */ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; + +type DocType = 'eoi' | 'reservation' | 'contract'; + +const LABELS: Record = { + eoi: { + title: 'Mark EOI as signed externally?', + description: + 'Flips the EOI sub-status to "signed" without uploading a file. Audit log captures who marked it + your reason. If a file turns up later, you can still upload it via the regular flow.', + }, + reservation: { + title: 'Mark reservation as signed externally?', + description: + 'Flips the reservation sub-status to "signed" without uploading a file. Audit log captures who marked it + your reason.', + }, + contract: { + title: 'Mark contract as signed externally?', + description: + 'Flips the contract sub-status to "signed" without uploading a file. The contract-signed berth-rule fires automatically (you can disable in admin/berth-rules).', + }, +}; + +export function MarkExternallySignedDialog({ + open, + onOpenChange, + interestId, + docType, + onSuccess, +}: { + open: boolean; + onOpenChange: (o: boolean) => void; + interestId: string; + docType: DocType; + onSuccess?: () => void; +}) { + const [reason, setReason] = useState(''); + const qc = useQueryClient(); + const labels = LABELS[docType]; + + const mutation = useMutation({ + mutationFn: async () => { + await apiFetch(`/api/v1/interests/${interestId}/mark-externally-signed`, { + method: 'POST', + body: { docType, reason: reason.trim() || undefined }, + }); + }, + onSuccess: () => { + toast.success(`Marked ${docType} as signed externally`); + qc.invalidateQueries({ queryKey: ['interest', interestId] }); + qc.invalidateQueries({ queryKey: ['interests'] }); + qc.invalidateQueries({ queryKey: ['documents'] }); + onOpenChange(false); + onSuccess?.(); + }, + onError: (e) => toastError(e), + }); + + return ( + + + + {labels.title} + {labels.description} + + +
+ +