'use client'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Document, Page, pdfjs } from 'react-pdf'; import { toast } from 'sonner'; import { ChevronLeft, ChevronRight, Loader2, Plus, Trash2, X, Pen, Calendar as CalendarIcon, Type, CheckSquare, Mail, User as UserIcon, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/TextLayer.css'; pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; /** * Phase 4 - Upload-for-Documenso-signing dialog. * * Four-step flow inside one dialog: * 1. select-file - drag/drop or click to upload a PDF * 2. configure-recipients - name/email/role per signer, with * client + developer + approver prefilled from port + interest * 3. place-fields - render the PDF page-by-page, run * auto-detect, let the rep drag/place/delete fields per signer * 4. sending - POST to /upload-for-signing, show spinner * * The implementation is intentionally compact - the field-overlay * uses native DOM drag rather than dnd-kit so the coordinate math * stays obvious. Auto-detect lives on the server (uses pdfjs-dist) so * the same parser ships once. */ interface Recipient { name: string; email: string; role: 'SIGNER' | 'APPROVER' | 'CC'; signingOrder: number; } type FieldType = | 'SIGNATURE' | 'FREE_SIGNATURE' | 'INITIALS' | 'DATE' | 'EMAIL' | 'NAME' | 'TEXT' | 'NUMBER' | 'CHECKBOX' | 'DROPDOWN' | 'RADIO'; interface PlacedField { /** Client-side id only - server doesn't see this. */ id: string; type: FieldType; recipientIndex: number; pageNumber: number; /** All 0..100 percent of page dimensions. */ pageX: number; pageY: number; pageWidth: number; pageHeight: number; } interface DetectedFieldResponse { type: FieldType; pageNumber: number; pageX: number; pageY: number; pageWidth: number; pageHeight: number; confidence: number; anchorText?: string; inferredRecipientLabel?: string | null; } interface SigningDefaults { developer: { name: string; email: string; label: string }; approver: { name: string; email: string; label: string }; sendMode: 'auto' | 'manual'; } const FIELD_DEFAULTS: Record< FieldType, { widthPct: number; heightPct: number; label: string; icon: typeof Pen } > = { SIGNATURE: { widthPct: 20, heightPct: 5, label: 'Signature', icon: Pen }, FREE_SIGNATURE: { widthPct: 20, heightPct: 5, label: 'Free signature', icon: Pen }, INITIALS: { widthPct: 8, heightPct: 5, label: 'Initials', icon: Pen }, DATE: { widthPct: 12, heightPct: 3, label: 'Date', icon: CalendarIcon }, EMAIL: { widthPct: 25, heightPct: 3, label: 'Email', icon: Mail }, NAME: { widthPct: 20, heightPct: 3, label: 'Name', icon: UserIcon }, TEXT: { widthPct: 25, heightPct: 3, label: 'Text', icon: Type }, NUMBER: { widthPct: 15, heightPct: 3, label: 'Number', icon: Type }, CHECKBOX: { widthPct: 3, heightPct: 3, label: 'Checkbox', icon: CheckSquare }, DROPDOWN: { widthPct: 20, heightPct: 3, label: 'Dropdown', icon: Type }, RADIO: { widthPct: 3, heightPct: 3, label: 'Radio', icon: CheckSquare }, }; const RECIPIENT_COLORS = [ 'rgb(59 130 246)', // blue-500 'rgb(168 85 247)', // purple-500 'rgb(34 197 94)', // green-500 'rgb(249 115 22)', // orange-500 'rgb(239 68 68)', // red-500 'rgb(20 184 166)', // teal-500 ]; export interface UploadForSigningEntity { type: 'client' | 'company' | 'yacht'; id: string; /** Display label only — used in the dialog header so the rep can * see which entity the doc will be filed under. */ label?: string; } interface UploadForSigningDialogProps { open: boolean; onOpenChange: (open: boolean) => void; /** Required for eoi / contract / reservation_agreement (the pipeline * side effects need it). MUST be null for documentType='generic' — * in that case the upload routes through the generic endpoint and * optionally files the doc against the supplied `entity`. */ interestId: string | null; documentType: 'eoi' | 'contract' | 'reservation_agreement' | 'generic'; /** Optional: client name/email to prefill the first recipient. * When omitted the dialog fetches from the interest (interest-scoped * flows) or leaves the recipient blank (generic flow). */ clientPrefill?: { name: string; email: string }; /** Generic flow only: routes the resulting file/document row to the * entity's FK column + auto-files it into the entity's system * folder. Ignored when `interestId` is set. */ entity?: UploadForSigningEntity; /** Generic flow only: explicit folder placement (e.g. rep is * uploading from within a Documents Hub folder). */ folderId?: string | null; /** Generic flow only: caller-supplied success hook. Receives the * new documentId and can invalidate caches / show a toast. */ onCreated?: (result: { documentId: string }) => void; } export function UploadForSigningDialog({ open, onOpenChange, interestId, documentType, clientPrefill, entity, folderId, onCreated, }: UploadForSigningDialogProps) { // Re-mount the body on every open so all state resets cleanly. Same // pattern as hard-delete-dialog (set-state-in-effect avoidance). if (!open) return null; const draftKey = interestId ?? entity?.id ?? 'generic'; return ( onOpenChange(false)} /> ); } type Step = 'select-file' | 'configure-recipients' | 'place-fields'; /** * localStorage key for draft persistence. Versioned (`v1`) so a future * shape change can invalidate stale drafts without crashing the parser. * Scoped per interest+documentType so a rep can have an in-flight * contract upload AND reservation upload in the same browser session * without them clobbering each other. */ function draftStorageKey(scopeId: string, documentType: string): string { return `pn-crm.upload-for-signing.draft.v1:${scopeId}:${documentType}`; } interface PersistedDraft { step: Step; title: string; recipients: Recipient[]; fields: PlacedField[]; invitationMessage: string; /** Saved at timestamp - surfaces in the UI as "Draft saved ". */ savedAt: string; } function loadDraft(scopeId: string, documentType: string): PersistedDraft | null { if (typeof window === 'undefined') return null; try { const raw = window.localStorage.getItem(draftStorageKey(scopeId, documentType)); if (!raw) return null; const parsed = JSON.parse(raw) as PersistedDraft; // Defensive shape check - drop drafts that look malformed rather // than crashing the dialog. if ( typeof parsed.title !== 'string' || !Array.isArray(parsed.recipients) || !Array.isArray(parsed.fields) ) { return null; } return parsed; } catch { return null; } } function saveDraft(scopeId: string, documentType: string, draft: PersistedDraft): void { if (typeof window === 'undefined') return; try { window.localStorage.setItem(draftStorageKey(scopeId, documentType), JSON.stringify(draft)); } catch { // localStorage may throw on private mode or quota - swallow. } } function clearDraft(scopeId: string, documentType: string): void { if (typeof window === 'undefined') return; try { window.localStorage.removeItem(draftStorageKey(scopeId, documentType)); } catch { // ignore } } function DialogBody({ interestId, documentType, clientPrefill, entity, folderId, onCreated, onClose, }: { interestId: string | null; documentType: 'eoi' | 'contract' | 'reservation_agreement' | 'generic'; clientPrefill?: { name: string; email: string }; entity?: UploadForSigningEntity; folderId?: string | null; onCreated?: (result: { documentId: string }) => void; onClose: () => void; }) { // Draft scope: interestId when scoped to a deal, otherwise the // entity id (so the rep can have one in-flight upload per entity), // else 'generic' for the root Documents Hub flow. const draftScopeId = interestId ?? entity?.id ?? 'generic'; // Hydrate from the persisted draft once on mount. The `key` prop on // the parent re-mounts this body on every open, so this useState // initializer runs once per dialog session. const initialDraft = useMemo( () => loadDraft(draftScopeId, documentType), [draftScopeId, documentType], ); const [step, setStep] = useState(initialDraft?.step ?? 'select-file'); const [file, setFile] = useState(null); const [title, setTitle] = useState(initialDraft?.title ?? ''); const [recipients, setRecipients] = useState(initialDraft?.recipients ?? []); const [fields, setFields] = useState(initialDraft?.fields ?? []); const [selectedFieldId, setSelectedFieldId] = useState(null); // Phase 6 polish - optional rep-authored note that appears above the // CTA in every invitation email for this doc. Empty string means // "no custom note - use the template default copy". const [invitationMessage, setInvitationMessage] = useState(initialDraft?.invitationMessage ?? ''); const [draftSavedAt, setDraftSavedAt] = useState(initialDraft?.savedAt ?? null); const docLabel = documentType === 'contract' ? 'Sales Contract' : documentType === 'eoi' ? 'Expression of Interest' : documentType === 'reservation_agreement' ? 'Reservation Agreement' : 'Document'; // Defaults endpoint - drives the developer/approver prefill. const { data: defaults } = useQuery<{ data: SigningDefaults }>({ queryKey: ['documents', 'signing-defaults'], queryFn: () => apiFetch<{ data: SigningDefaults }>('/api/v1/documents/signing-defaults'), }); // Interest endpoint - used to prefill the client recipient when the // caller didn't supply one. Cached so the same dialog open/reopen // hits the cache. Skipped entirely on the generic path (no interest). const { data: interestData } = useQuery<{ data: { client: { fullName: string; email: string | null } }; }>({ queryKey: ['interest', interestId, 'prefill'], queryFn: () => apiFetch<{ data: { client: { fullName: string; email: string | null } } }>( `/api/v1/interests/${interestId}`, ), enabled: Boolean(interestId) && !clientPrefill, }); /** * Build the prefill recipient list from the async query data. The * dialog reads this on the "Next" button click in the file-picker * step to seed `recipients` - keeping the seeding as a user-event * handler rather than an effect avoids the cascading-render lint * (react-hooks/set-state-in-effect, Wave 3) that earlier versions * tripped. Returns an empty array until the defaults query resolves; * the file-picker step's Next button stays clickable so the rep can * still proceed and add recipients manually if the defaults endpoint * is slow. */ const prefillRecipients = useMemo(() => { if (!defaults?.data) return []; const client = clientPrefill ?? { name: interestData?.data?.client?.fullName ?? '', email: interestData?.data?.client?.email ?? '', }; const next: Recipient[] = []; if (client.name && client.email) { next.push({ name: client.name, email: client.email, role: 'SIGNER', signingOrder: 1 }); } if (defaults.data.developer.email) { next.push({ name: defaults.data.developer.name || defaults.data.developer.label, email: defaults.data.developer.email, role: 'SIGNER', signingOrder: next.length + 1, }); } if (defaults.data.approver.email) { next.push({ name: defaults.data.approver.name || defaults.data.approver.label, email: defaults.data.approver.email, role: 'APPROVER', signingOrder: next.length + 1, }); } return next; }, [defaults, interestData, clientPrefill]); // We previously passed an object URL into react-pdf, but PDF.js runs // its parser in a Web Worker loaded from unpkg.com (a different // origin from localhost). Cross-origin workers can't fetch blob URLs // minted on the main page - the worker XHR returns response (0) and // the preview surfaces "Unexpected server response (0)". Reading the // file into an ArrayBuffer once and handing PDF.js the raw bytes via // `{ data: ... }` sidesteps the fetch entirely, so the cross-origin // worker has nothing to retrieve. const [fileBytes, setFileBytes] = useState(null); useEffect(() => { if (!file) { // eslint-disable-next-line react-hooks/set-state-in-effect -- clear preview bytes when caller drops the file setFileBytes(null); return; } let cancelled = false; void file.arrayBuffer().then((buf) => { if (!cancelled) setFileBytes(new Uint8Array(buf)); }); return () => { cancelled = true; }; }, [file]); // Persist the rep's progress to localStorage as they work. Debounced // at 500ms so a flurry of state updates (typing a long invitation // message, dragging a field across the page) doesn't hammer storage. // We DO NOT persist the File object itself - the rep has to re-pick // the PDF after a refresh. Everything else (title, signers, // placements, custom note) round-trips. The `step` is restored too // so the dialog reopens on the same screen the rep left. const draftDebounceRef = useRef | null>(null); useEffect(() => { if (draftDebounceRef.current) clearTimeout(draftDebounceRef.current); draftDebounceRef.current = setTimeout(() => { // Skip persistence in the pristine "no progress yet" state so // dismissing the dialog without touching anything doesn't leave // a phantom draft behind. const hasProgress = title.length > 0 || recipients.length > 0 || fields.length > 0 || invitationMessage.length > 0; if (!hasProgress) { clearDraft(draftScopeId, documentType); return; } const now = new Date().toISOString(); saveDraft(draftScopeId, documentType, { step, title, recipients, fields, invitationMessage, savedAt: now, }); setDraftSavedAt(now); }, 500); return () => { if (draftDebounceRef.current) clearTimeout(draftDebounceRef.current); }; }, [step, title, recipients, fields, invitationMessage, draftScopeId, documentType]); function discardDraft() { clearDraft(draftScopeId, documentType); setTitle(''); setRecipients([]); setFields([]); setInvitationMessage(''); setStep('select-file'); setDraftSavedAt(null); } const autoDetect = useMutation({ mutationFn: async (uploadedFile: File) => { const form = new FormData(); form.append('file', uploadedFile); // apiFetch JSON-encodes the body when set, so we go raw here. const res = await fetch('/api/v1/documents/auto-detect-fields', { method: 'POST', body: form, credentials: 'include', }); if (!res.ok) throw new Error('Auto-detect failed'); return (await res.json()) as { data: { fields: DetectedFieldResponse[] } }; }, onSuccess: (res) => { // Drop detected fields onto a temporary "unassigned" recipient // (index 0). Rep reassigns via the side panel. const placed: PlacedField[] = res.data.fields.map((f) => ({ id: `det-${Math.random().toString(36).slice(2, 10)}`, type: f.type, recipientIndex: 0, pageNumber: f.pageNumber, pageX: f.pageX, pageY: f.pageY, pageWidth: f.pageWidth, pageHeight: f.pageHeight, })); setFields((existing) => [...existing, ...placed]); if (placed.length > 0) { toast.success( `Auto-detect placed ${placed.length} field${placed.length === 1 ? '' : 's'}.`, ); } else { toast.info('No fields auto-detected - place them manually.'); } }, onError: () => { toast.info('Auto-detect skipped - place fields manually.'); }, }); const queryClient = useQueryClient(); const sendMutation = useMutation({ mutationFn: async () => { if (!file) throw new Error('No file selected'); const form = new FormData(); form.append('file', file); form.append('documentType', documentType); form.append('title', title || file.name.replace(/\.pdf$/i, '')); form.append('recipients', JSON.stringify(recipients)); if (invitationMessage.trim()) { form.append('invitationMessage', invitationMessage.trim()); } // Strip the client-side `id` from each placed field - the server // assigns its own ids on the documenso side. form.append( 'fields', JSON.stringify( fields.map((f) => ({ type: f.type, recipientIndex: f.recipientIndex, pageNumber: f.pageNumber, pageX: f.pageX, pageY: f.pageY, pageWidth: f.pageWidth, pageHeight: f.pageHeight, })), ), ); // Generic envelopes go to the cross-cutting endpoint; the // entity / folder context piggybacks on the form so the file // row lands under the right system folder. Interest-scoped // flows keep their dedicated route so the pipeline-stage // advance + doc-status flip side effects fire. if (interestId) { if (documentType === 'generic') { throw new Error('Generic documentType requires interestId=null'); } } else { if (entity) form.append('entity', JSON.stringify({ type: entity.type, id: entity.id })); if (folderId) form.append('folderId', folderId); } const endpoint = interestId ? `/api/v1/interests/${interestId}/upload-for-signing` : `/api/v1/upload-for-signing`; const res = await fetch(endpoint, { method: 'POST', body: form, credentials: 'include', }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string; requestId?: string; }; const err = new Error(body.error ?? 'Upload failed'); // Tack the requestId onto the Error so toastError can surface it. (err as Error & { requestId?: string }).requestId = body.requestId; throw err; } return res.json() as Promise<{ data: { documentId: string; signingUrls: Record }; }>; }, onSuccess: (res) => { toast.success( defaults?.data?.sendMode === 'auto' ? 'Document sent for signing - first signer has been invited.' : 'Document uploaded and ready to send. Use the Send button on the doc to email the first signer.', ); queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' }); queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'interest' }); queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'files' }); if (onCreated && res?.data?.documentId) { onCreated({ documentId: res.data.documentId }); } // Clear the draft on successful submission - the in-flight upload // is now an actual document; the localStorage shouldn't keep its // shadow around. clearDraft(draftScopeId, documentType); onClose(); }, onError: (err) => toastError(err, 'Upload failed'), }); // ─── Step renderers ───────────────────────────────────────────── return ( <>
Send {docLabel.toLowerCase()} for signing {step === 'select-file' && 'Upload the draft PDF to send via Documenso.'} {step === 'configure-recipients' && 'Confirm who needs to sign and in what order.'} {step === 'place-fields' && 'Place signing fields where each recipient needs to sign, date, or fill in. Click a palette button then click on the PDF to place a field.'}
{/* Draft-saved indicator + Discard button. Renders when there's a persisted draft so the rep knows progress is saved across dialog open / close cycles. Discard wipes the draft and resets to the file-picker step. The file itself isn't persisted (large blobs + browser quota), so on reopen the rep needs to re-pick the PDF - the rest of the state (title, signers, placements, custom note) survives. */} {draftSavedAt ? (
Draft saved
) : null}
{step === 'select-file' && ( { setFile(f); setTitle(f.name.replace(/\.pdf$/i, '')); // Seed recipients from the prefill snapshot when the rep // first lands a file - only if they haven't already // edited the list. This pattern keeps the prefill // synchronization in user-event handlers (no setState- // in-effect lint trip). if (recipients.length === 0 && prefillRecipients.length > 0) { setRecipients(prefillRecipients); } setStep('configure-recipients'); autoDetect.mutate(f); }} title={title} onTitleChange={setTitle} /> )} {step === 'configure-recipients' && ( )} {step === 'place-fields' && fileBytes && ( )}
{step === 'configure-recipients' && ( )} {step === 'place-fields' && ( )} {step !== 'place-fields' && ( )} {step === 'place-fields' && ( )}
); } function StepIndicator({ step }: { step: Step }) { const dots = [ { key: 'select-file', label: 'File' }, { key: 'configure-recipients', label: 'Recipients' }, { key: 'place-fields', label: 'Fields' }, ] as const; const activeIdx = dots.findIndex((d) => d.key === step); return (
{dots.map((d, i) => ( {d.label} {i < dots.length - 1 && } ))}
); } // ─── Step 1: file picker ────────────────────────────────────────── function FilePickerStep({ onFileSelected, title, onTitleChange, }: { onFileSelected: (file: File) => void; title: string; onTitleChange: (t: string) => void; }) { const [dragging, setDragging] = useState(false); const inputRef = useRef(null); function handleFile(f: File) { if (!f.name.toLowerCase().endsWith('.pdf') && f.type !== 'application/pdf') { toast.error('Only PDF files are accepted'); return; } onFileSelected(f); } return (
onTitleChange(e.target.value)} placeholder="e.g. Berth A-12 Sales Contract - John Smith" />
{ e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={(e) => { e.preventDefault(); setDragging(false); const f = e.dataTransfer.files[0]; if (f) handleFile(f); }} className={ 'rounded-lg border-2 border-dashed p-12 text-center transition-colors cursor-pointer ' + (dragging ? 'border-foreground bg-muted/40' : 'border-muted-foreground/30 hover:border-muted-foreground/60') } onClick={() => inputRef.current?.click()} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); inputRef.current?.click(); } }} >

Drop a PDF here, or click to browse

Max 50 MB.

{ const f = e.target.files?.[0]; if (f) handleFile(f); }} />
); } // ─── Step 2: recipient configurator ─────────────────────────────── function RecipientsStep({ recipients, onChange, title, onTitleChange, invitationMessage, onInvitationMessageChange, }: { recipients: Recipient[]; onChange: (next: Recipient[]) => void; title: string; onTitleChange: (t: string) => void; invitationMessage: string; onInvitationMessageChange: (next: string) => void; }) { function update(i: number, patch: Partial) { const next = [...recipients]; next[i] = { ...next[i]!, ...patch }; onChange(next); } function remove(i: number) { const next = recipients.filter((_, idx) => idx !== i); // Reflow signingOrder onChange(next.map((r, idx) => ({ ...r, signingOrder: idx + 1 }))); } function add() { onChange([ ...recipients, { name: '', email: '', role: 'SIGNER', signingOrder: recipients.length + 1, }, ]); } return (
onTitleChange(e.target.value)} />
{recipients.map((r, i) => (
#{r.signingOrder} update(i, { name: e.target.value })} /> update(i, { email: e.target.value })} />
))}