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>
128 lines
4.1 KiB
TypeScript
128 lines
4.1 KiB
TypeScript
'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<DocType, { title: string; description: string }> = {
|
|
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 (
|
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{labels.title}</AlertDialogTitle>
|
|
<AlertDialogDescription>{labels.description}</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="mes-reason">Reason (optional)</Label>
|
|
<Textarea
|
|
id="mes-reason"
|
|
rows={3}
|
|
placeholder="e.g. paper signed in-office; scan filed in shared drive"
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value.slice(0, 2000))}
|
|
/>
|
|
</div>
|
|
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={mutation.isPending}>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
disabled={mutation.isPending}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
mutation.mutate();
|
|
}}
|
|
>
|
|
{mutation.isPending ? (
|
|
<Loader2 className="size-4 animate-spin" aria-hidden />
|
|
) : (
|
|
'Mark as signed'
|
|
)}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
);
|
|
}
|