417 lines
15 KiB
TypeScript
417 lines
15 KiB
TypeScript
|
|
'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<DocumentRow['status'], string> = {
|
||
|
|
draft: 'Draft',
|
||
|
|
sent: 'Awaiting signatures',
|
||
|
|
partially_signed: 'Partially signed',
|
||
|
|
completed: 'Signed',
|
||
|
|
expired: 'Expired',
|
||
|
|
cancelled: 'Cancelled',
|
||
|
|
};
|
||
|
|
|
||
|
|
const STATUS_TONES: Record<DocumentRow['status'], string> = {
|
||
|
|
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<DocumentRow['status']>(['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 (
|
||
|
|
<div className="space-y-5">
|
||
|
|
{docsLoading ? (
|
||
|
|
<Skeleton className="h-44 w-full rounded-lg" />
|
||
|
|
) : activeDoc ? (
|
||
|
|
<ActiveContractCard
|
||
|
|
doc={activeDoc}
|
||
|
|
portSlug={portSlug ?? null}
|
||
|
|
onUploadSigned={() => setUploadSignedOpen(true)}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<EmptyContractState
|
||
|
|
onUploadSigned={() => setUploadSignedOpen(true)}
|
||
|
|
onUploadForSigning={() => setUploadForSigningOpen(true)}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{completedDocs.length > 0 && (
|
||
|
|
<section className="rounded-lg border bg-background">
|
||
|
|
<header className="flex items-center justify-between border-b px-4 py-2.5">
|
||
|
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||
|
|
Contract history
|
||
|
|
</h3>
|
||
|
|
<span className="text-xs text-muted-foreground">
|
||
|
|
{completedDocs.length} {completedDocs.length === 1 ? 'document' : 'documents'}
|
||
|
|
</span>
|
||
|
|
</header>
|
||
|
|
<ul className="divide-y">
|
||
|
|
{completedDocs.map((d) => (
|
||
|
|
<li key={d.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
|
||
|
|
<StatusBadge status={d.status} />
|
||
|
|
<span className="flex-1 truncate font-medium">{d.title}</span>
|
||
|
|
<span className="text-xs text-muted-foreground">
|
||
|
|
{new Date(d.createdAt).toLocaleDateString()}
|
||
|
|
</span>
|
||
|
|
{portSlug && (
|
||
|
|
<Link
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
href={`/${portSlug}/documents/${d.id}` as any}
|
||
|
|
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
||
|
|
>
|
||
|
|
Open
|
||
|
|
<ExternalLink className="size-3" />
|
||
|
|
</Link>
|
||
|
|
)}
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</section>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 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 && (
|
||
|
|
<ExternalEoiUploadDialog
|
||
|
|
open={uploadSignedOpen}
|
||
|
|
onOpenChange={setUploadSignedOpen}
|
||
|
|
interestId={interestId}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 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 && (
|
||
|
|
<ComingSoonDialog
|
||
|
|
open={uploadForSigningOpen}
|
||
|
|
onOpenChange={setUploadForSigningOpen}
|
||
|
|
title="Send contract for signing"
|
||
|
|
body="Upload-and-send-via-Documenso for contracts is being built. For now, draft the contract externally, get it signed via paper or another tool, then upload the signed copy here."
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 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 (
|
||
|
|
<section className="rounded-xl border bg-gradient-brand-soft p-5 shadow-xs">
|
||
|
|
<header className="flex items-start justify-between gap-3">
|
||
|
|
<div className="min-w-0 flex-1 space-y-1">
|
||
|
|
<div className="flex items-center gap-2 flex-wrap">
|
||
|
|
<FileSignature className="size-4 text-foreground" />
|
||
|
|
<h2 className="truncate text-base font-semibold text-foreground">{doc.title}</h2>
|
||
|
|
<StatusBadge status={doc.status} />
|
||
|
|
</div>
|
||
|
|
<p className="text-xs text-muted-foreground">
|
||
|
|
Created {new Date(doc.createdAt).toLocaleDateString()} ·{' '}
|
||
|
|
{totalCount > 0 ? `${signedCount} of ${totalCount} signed` : 'No signers loaded'}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<div className="flex shrink-0 items-center gap-1">
|
||
|
|
{portSlug && (
|
||
|
|
<Button asChild variant="outline" size="sm" className="gap-1.5 [&_svg]:size-3.5">
|
||
|
|
<Link
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
href={`/${portSlug}/documents/${doc.id}` as any}
|
||
|
|
>
|
||
|
|
Open
|
||
|
|
<ExternalLink />
|
||
|
|
</Link>
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
{!allSigned && (
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
disabled={remindAllMutation.isPending}
|
||
|
|
onClick={() => remindAllMutation.mutate()}
|
||
|
|
className="gap-1.5 [&_svg]:size-3.5"
|
||
|
|
>
|
||
|
|
{remindAllMutation.isPending ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||
|
|
Remind all
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<div className="mt-4 rounded-lg border bg-background p-4">
|
||
|
|
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||
|
|
Signing progress
|
||
|
|
</h3>
|
||
|
|
{signersLoading ? (
|
||
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
|
|
<Loader2 className="size-3.5 animate-spin" /> Loading signers…
|
||
|
|
</div>
|
||
|
|
) : signers.length === 0 ? (
|
||
|
|
<p className="text-sm text-muted-foreground italic">
|
||
|
|
Documenso hasn't reported signers yet — check back in a moment.
|
||
|
|
</p>
|
||
|
|
) : (
|
||
|
|
<SigningProgress documentId={doc.id} signers={signers} />
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<footer className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
||
|
|
<p className="flex items-center gap-1.5">
|
||
|
|
<AlertTriangle className="size-3 text-amber-600" />
|
||
|
|
Reminders are rate-limited (max once per 7 days per signer).
|
||
|
|
</p>
|
||
|
|
<div className="flex items-center gap-1">
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={onUploadSigned}
|
||
|
|
className="h-7 gap-1.5 text-xs [&_svg]:size-3"
|
||
|
|
>
|
||
|
|
<Upload />
|
||
|
|
Upload paper-signed copy
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
disabled={cancelMutation.isPending}
|
||
|
|
onClick={() => {
|
||
|
|
if (window.confirm('Cancel this contract? Signers will no longer be able to sign.')) {
|
||
|
|
cancelMutation.mutate();
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
|
||
|
|
>
|
||
|
|
<XCircle />
|
||
|
|
Cancel contract
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</footer>
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Empty state ─────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function EmptyContractState({
|
||
|
|
onUploadSigned,
|
||
|
|
onUploadForSigning,
|
||
|
|
}: {
|
||
|
|
onUploadSigned: () => void;
|
||
|
|
onUploadForSigning: () => void;
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<section className="rounded-xl border border-dashed bg-muted/20 p-8 text-center">
|
||
|
|
<div className="mx-auto flex size-14 items-center justify-center rounded-full bg-background text-muted-foreground">
|
||
|
|
<FileSignature className="size-6" />
|
||
|
|
</div>
|
||
|
|
<h2 className="mt-4 text-base font-semibold text-foreground">
|
||
|
|
No contract in flight for this interest
|
||
|
|
</h2>
|
||
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
||
|
|
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.
|
||
|
|
</p>
|
||
|
|
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
|
||
|
|
<Button onClick={onUploadForSigning} size="sm" className="gap-1.5">
|
||
|
|
<FileSignature className="size-4" />
|
||
|
|
Upload draft for signing
|
||
|
|
</Button>
|
||
|
|
<Button onClick={onUploadSigned} variant="outline" size="sm" className="gap-1.5">
|
||
|
|
<Upload className="size-4" />
|
||
|
|
Upload paper-signed copy
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function StatusBadge({ status }: { status: DocumentRow['status'] }) {
|
||
|
|
return (
|
||
|
|
<Badge
|
||
|
|
variant="outline"
|
||
|
|
className={cn(
|
||
|
|
'border-transparent text-[10px] font-semibold uppercase tracking-wide',
|
||
|
|
STATUS_TONES[status],
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{status === 'completed' && <CheckCircle2 className="mr-1 size-3" />}
|
||
|
|
{STATUS_LABELS[status]}
|
||
|
|
</Badge>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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 (
|
||
|
|
<div
|
||
|
|
role="dialog"
|
||
|
|
aria-modal="true"
|
||
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||
|
|
onClick={() => onOpenChange(false)}
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
className="max-w-md rounded-lg border bg-background p-6 shadow-lg"
|
||
|
|
onClick={(e) => e.stopPropagation()}
|
||
|
|
>
|
||
|
|
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||
|
|
<p className="mt-2 text-sm text-muted-foreground">{body}</p>
|
||
|
|
<div className="mt-4 flex justify-end">
|
||
|
|
<Button onClick={() => onOpenChange(false)} size="sm" variant="outline">
|
||
|
|
Got it
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|