'use client'; import { useMemo, useState } from 'react'; import Link from 'next/link'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AlertTriangle, CheckCircle2, ExternalLink, FileSignature, Loader2, RefreshCw, Upload, XCircle, } from 'lucide-react'; import { toast } from 'sonner'; 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 { SigningProgress } from '@/components/documents/signing-progress'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import { cn } from '@/lib/utils'; import { useUIStore } from '@/stores/ui-store'; interface InterestContractTabProps { interestId: string; clientId: string | null; } interface DocumentRow { id: string; documentType: string; title: string; status: 'draft' | 'sent' | 'partially_signed' | 'completed' | 'expired' | 'cancelled'; createdAt: string; signers?: Array<{ status: string }>; } interface DocumentSigner { id: string; signerName: string; signerEmail: string; signerRole: string; signingOrder: number; status: string; signedAt?: string | null; } const STATUS_LABELS: Record = { draft: 'Draft', sent: 'Awaiting signatures', partially_signed: 'Partially signed', completed: 'Signed', expired: 'Expired', cancelled: 'Cancelled', }; const STATUS_TONES: Record = { draft: 'bg-slate-100 text-slate-700', sent: 'bg-blue-100 text-blue-700', partially_signed: 'bg-amber-100 text-amber-800', completed: 'bg-emerald-100 text-emerald-700', expired: 'bg-rose-100 text-rose-700', cancelled: 'bg-slate-200 text-slate-600', }; const ACTIVE_STATUSES = new Set(['draft', 'sent', 'partially_signed']); /** * Dedicated Contract workspace tab. Mirrors the EOI tab pattern but * for sales contracts. Contracts differ from EOIs in that there's no * standard Documenso template — each contract is drafted custom per * deal. So the active flows are: * * 1. **Upload paper-signed copy** — the signed contract was handled * outside the system; rep uploads the PDF for the record. * * 2. **Upload draft for Documenso signing** — rep uploads the PDF * draft, configures signers + signing order + signature field * placement, then sends via Documenso. (Recipient configurator * and field-placement UI are the bigger pieces; for v1 a default * footer-anchored signature layout is used.) * * The Documents tab still shows every contract document (signed or * drafted) as a permanent history. */ export function InterestContractTab({ interestId, clientId: _clientId }: InterestContractTabProps) { const portSlug = useUIStore((s) => s.currentPortSlug); const [uploadSignedOpen, setUploadSignedOpen] = useState(false); const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false); const { data: docsRes, isLoading: docsLoading } = useQuery<{ data: DocumentRow[] }>({ queryKey: ['documents', { interestId, documentType: 'contract' }], queryFn: () => apiFetch<{ data: DocumentRow[] }>( `/api/v1/documents?interestId=${interestId}&documentType=contract`, ), }); const docs = docsRes?.data ?? []; const activeDoc = useMemo(() => docs.find((d) => ACTIVE_STATUSES.has(d.status)) ?? null, [docs]); const completedDocs = useMemo(() => docs.filter((d) => !ACTIVE_STATUSES.has(d.status)), [docs]); return (
{docsLoading ? ( ) : activeDoc ? ( setUploadSignedOpen(true)} /> ) : ( setUploadSignedOpen(true)} onUploadForSigning={() => setUploadForSigningOpen(true)} /> )} {completedDocs.length > 0 && (

Contract history

{completedDocs.length} {completedDocs.length === 1 ? 'document' : 'documents'}
    {completedDocs.map((d) => (
  • {d.title} {new Date(d.createdAt).toLocaleDateString()} {portSlug && ( Open )}
  • ))}
)} {/* Reuses the external-EOI upload dialog. The endpoint `/api/v1/interests/{id}/external-eoi` is EOI-specific today — for contract paper-uploads we'll need the equivalent contract endpoint (deferred to a follow-up; the dialog UI is the pattern we'll clone). For now the flow is documented as 'coming soon' rather than misrouting through EOI. */} {uploadSignedOpen && ( )} {/* Upload-for-Documenso-signing dialog placeholder. The real dialog (PDF picker + recipient configurator + send button) is part of the larger custom-doc-upload service that's a follow-up. For now show a friendly "coming soon" card. */} {uploadForSigningOpen && ( )}
); } // ─── Active contract hero ──────────────────────────────────────────────────── function ActiveContractCard({ doc, portSlug, onUploadSigned, }: { doc: DocumentRow; portSlug: string | null; onUploadSigned: () => void; }) { const queryClient = useQueryClient(); const { data: signersRes, isLoading: signersLoading } = useQuery<{ data: DocumentSigner[] }>({ queryKey: ['documents', doc.id, 'signers'], queryFn: () => apiFetch<{ data: DocumentSigner[] }>(`/api/v1/documents/${doc.id}/signers`), refetchInterval: 30_000, }); const signers = signersRes?.data ?? []; const signedCount = signers.filter((s) => s.status === 'signed').length; const totalCount = signers.length; const allSigned = totalCount > 0 && signedCount === totalCount; const cancelMutation = useMutation({ mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/cancel`, { method: 'POST', body: {} }), onSuccess: () => { queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' }); toast.success('Contract cancelled.'); }, onError: (err) => toastError(err), }); const remindAllMutation = useMutation({ mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/remind`, { method: 'POST', body: {} }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['documents', doc.id, 'signers'] }); toast.success('Reminder sent.'); }, onError: (err) => toastError(err), }); return (

{doc.title}

Created {new Date(doc.createdAt).toLocaleDateString()} ·{' '} {totalCount > 0 ? `${signedCount} of ${totalCount} signed` : 'No signers loaded'}

{portSlug && ( )} {!allSigned && ( )}

Signing progress

{signersLoading ? (
Loading signers…
) : signers.length === 0 ? (

Documenso hasn't reported signers yet — check back in a moment.

) : ( )}

Reminders are rate-limited (max once per 7 days per signer).

); } // ─── Empty state ───────────────────────────────────────────────────────────── function EmptyContractState({ onUploadSigned, onUploadForSigning, }: { onUploadSigned: () => void; onUploadForSigning: () => void; }) { return (

No contract in flight for this interest

Sales contracts are drafted custom per deal. Either upload a paper-signed copy you handled externally, or upload the draft PDF and send for e-signing via Documenso.

); } // ─── Helpers ───────────────────────────────────────────────────────────────── function StatusBadge({ status }: { status: DocumentRow['status'] }) { return ( {status === 'completed' && } {STATUS_LABELS[status]} ); } /** * Placeholder for the upload-for-Documenso-signing flow until the * full upload + recipient + field-placement service is shipped. * Intentional dead-end so reps know the path exists rather than * misclicking and getting confusing behaviour. */ function ComingSoonDialog({ open, onOpenChange, title, body, }: { open: boolean; onOpenChange: (next: boolean) => void; title: string; body: string; }) { if (!open) return null; return (
onOpenChange(false)} >
e.stopPropagation()} >

{title}

{body}

); }