Three independent strengthenings of the sales spine that the prior coherence
sweep made it possible to do cleanly.
1. EOI queue page
- Sidebar entry under Documents → "EOI queue".
- Route /[port]/documents/eoi renders DocumentsHub with the existing
eoi_queue tab pre-selected (filters in-flight EOIs only).
- .gitignore: tightened root-only `eoi/` ignore so the documents/eoi
route is no longer silently excluded.
2. Invoice ↔ deposit link
- invoices.interestId (FK, ON DELETE SET NULL) + invoices.kind
('general' | 'deposit'). Indexed on (port_id, interest_id).
- createInvoiceSchema requires interestId when kind === 'deposit';
the service validates the linked interest belongs to the same port
before insert.
- recordPayment auto-advances pipelineStage to deposit_10pct (via
advanceStageIfBehind) when a paid invoice is kind=deposit and has
an interestId. No-op if the interest is already further along.
- "Create deposit invoice" link added to the Deposit milestone on the
interest detail. Links to /invoices/new?interestId=…&kind=deposit;
the form prefills the billing entity from the linked interest's
client and shows a context banner.
3. Won / lost terminal outcomes
- interests.outcome ('won' | 'lost_other_marina' | 'lost_unqualified'
| 'lost_no_response' | 'cancelled') + outcomeReason text +
outcomeAt timestamp. Indexed on (port_id, outcome).
- setInterestOutcome / clearInterestOutcome services + POST/DELETE
/api/v1/interests/:id/outcome endpoints (gated by change_stage
permission). Setting an outcome moves the interest to `completed`
in the same write; clearing reopens to `in_communication` (or a
caller-specified stage).
- Mark Won / Mark Lost icon buttons on the interest detail header,
plus an outcome badge that replaces the stage pill once a terminal
outcome is set, plus a Reopen button.
- Funnel + dashboard math updated to exclude lost/cancelled outcomes
from active calculations (KPIs.activeInterests, pipelineValueUsd,
getPipelineCounts, computePipelineFunnel, getRevenueForecast).
The funnel now also returns a `lost` summary so callers can
surface leakage without polluting conversion percentages.
Schema changes shipped via 0019_lazy_vampiro.sql; applied to dev DB
manually via psql because drizzle-kit push hits a pre-existing zod
parsing issue on the companies index. Dev server may need a restart
to flush prepared-statement caches.
tsc clean. vitest 832/832 pass. ESLint clean on every file touched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
318 lines
10 KiB
TypeScript
318 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { ChevronDown, ChevronRight, FileText, Plus } from 'lucide-react';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
|
import { EmptyState } from '@/components/ui/empty-state';
|
|
import { PageHeader } from '@/components/shared/page-header';
|
|
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { cn } from '@/lib/utils';
|
|
import { documentsHubTabs, type DocumentsHubTab } from '@/lib/validators/documents';
|
|
|
|
interface HubDoc {
|
|
id: string;
|
|
documentType: string;
|
|
title: string;
|
|
status: string;
|
|
createdAt: string;
|
|
signers?: Array<{ id: string; signerEmail: string; signerName: string; status: string }>;
|
|
}
|
|
|
|
interface HubCounts {
|
|
all: number;
|
|
eoi_queue: number;
|
|
awaiting_them: number;
|
|
awaiting_me: number;
|
|
completed: number;
|
|
expired: number;
|
|
}
|
|
|
|
const TAB_LABELS: Record<DocumentsHubTab, string> = {
|
|
all: 'All',
|
|
eoi_queue: 'EOI queue',
|
|
awaiting_them: 'Awaiting them',
|
|
awaiting_me: 'Awaiting me',
|
|
completed: 'Completed',
|
|
expired: 'Expired',
|
|
};
|
|
|
|
const TYPE_LABELS: Record<string, string> = {
|
|
eoi: 'EOI',
|
|
contract: 'Contract',
|
|
nda: 'NDA',
|
|
reservation_agreement: 'Reservation Agreement',
|
|
welcome_letter: 'Welcome Letter',
|
|
handover_checklist: 'Handover',
|
|
acknowledgment: 'Acknowledgment',
|
|
correspondence: 'Correspondence',
|
|
other: 'Other',
|
|
};
|
|
|
|
const STATUS_PILL_MAP: Record<string, StatusPillStatus> = {
|
|
draft: 'draft',
|
|
sent: 'sent',
|
|
partially_signed: 'partial',
|
|
completed: 'completed',
|
|
signed: 'signed',
|
|
expired: 'expired',
|
|
cancelled: 'cancelled',
|
|
rejected: 'rejected',
|
|
};
|
|
|
|
interface DocumentsHubProps {
|
|
portSlug: string;
|
|
initialTab?: DocumentsHubTab;
|
|
}
|
|
|
|
export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps) {
|
|
const [tab, setTab] = useState<DocumentsHubTab>(initialTab);
|
|
const [search, setSearch] = useState('');
|
|
const [typeFilter, setTypeFilter] = useState<string>('all');
|
|
const [signatureOnly, setSignatureOnly] = useState(true);
|
|
const [expandedDocId, setExpandedDocId] = useState<string | null>(null);
|
|
|
|
const queryParams = useMemo(() => {
|
|
const params = new URLSearchParams();
|
|
params.set('tab', tab);
|
|
if (search) params.set('search', search);
|
|
if (typeFilter && typeFilter !== 'all') params.set('documentType', typeFilter);
|
|
if (signatureOnly) params.set('signatureOnly', 'true');
|
|
return params;
|
|
}, [tab, search, typeFilter, signatureOnly]);
|
|
|
|
const { data: documents, isLoading } = usePaginatedQuery<HubDoc>({
|
|
queryKey: ['documents', 'hub', queryParams.toString()],
|
|
endpoint: `/api/v1/documents?${queryParams.toString()}`,
|
|
filterDefinitions: [],
|
|
});
|
|
|
|
const { data: countsResp } = useQuery<{ data: HubCounts }>({
|
|
queryKey: ['documents', 'hub-counts'],
|
|
queryFn: () => apiFetch<{ data: HubCounts }>('/api/v1/documents/hub-counts'),
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
useRealtimeInvalidation({
|
|
'document:created': [['documents']],
|
|
'document:updated': [['documents']],
|
|
'document:deleted': [['documents']],
|
|
'document:sent': [['documents']],
|
|
'document:completed': [['documents']],
|
|
'document:expired': [['documents']],
|
|
'document:cancelled': [['documents']],
|
|
'document:rejected': [['documents']],
|
|
'document:signer:signed': [['documents']],
|
|
});
|
|
|
|
const counts: HubCounts = countsResp?.data ?? {
|
|
all: 0,
|
|
eoi_queue: 0,
|
|
awaiting_them: 0,
|
|
awaiting_me: 0,
|
|
completed: 0,
|
|
expired: 0,
|
|
};
|
|
|
|
const renderRow = (doc: HubDoc) => {
|
|
const expanded = expandedDocId === doc.id;
|
|
const totalSigners = doc.signers?.length ?? 0;
|
|
const signedCount = doc.signers?.filter((s) => s.status === 'signed').length ?? 0;
|
|
const pillStatus = STATUS_PILL_MAP[doc.status] ?? 'pending';
|
|
|
|
const isNonSignature = [
|
|
'welcome_letter',
|
|
'handover_checklist',
|
|
'acknowledgment',
|
|
'correspondence',
|
|
].includes(doc.documentType);
|
|
|
|
return (
|
|
<li
|
|
key={doc.id}
|
|
className="border-b last:border-b-0 transition-colors hover:bg-gradient-brand-soft/40"
|
|
>
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 text-sm sm:grid sm:grid-cols-[auto_1fr_auto_auto_auto_auto] sm:gap-3">
|
|
<button
|
|
type="button"
|
|
aria-label={expanded ? 'Collapse signers' : 'Expand signers'}
|
|
onClick={() => setExpandedDocId(expanded ? null : doc.id)}
|
|
className="text-muted-foreground transition-transform"
|
|
>
|
|
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
</button>
|
|
<Link
|
|
href={`/${portSlug}/documents/${doc.id}`}
|
|
className="min-w-0 truncate font-medium text-foreground hover:text-brand"
|
|
>
|
|
{doc.title}
|
|
</Link>
|
|
<span className="text-xs text-muted-foreground">
|
|
{TYPE_LABELS[doc.documentType] ?? doc.documentType}
|
|
</span>
|
|
<StatusPill
|
|
status={isNonSignature && doc.status === 'sent' ? 'delivered' : pillStatus}
|
|
withDot
|
|
>
|
|
{isNonSignature && doc.status === 'sent' ? 'Delivered' : doc.status.replace(/_/g, ' ')}
|
|
</StatusPill>
|
|
<span className="text-xs tabular-nums text-muted-foreground">
|
|
{totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : '—'}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{new Date(doc.createdAt).toLocaleDateString('en-GB')}
|
|
</span>
|
|
</div>
|
|
{expanded && doc.signers && doc.signers.length > 0 ? (
|
|
<div className="border-t bg-muted/30 px-12 py-2">
|
|
<ul className="space-y-1">
|
|
{doc.signers.map((signer) => (
|
|
<li key={signer.id} className="flex items-center justify-between gap-2 text-xs">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<span className="font-medium text-foreground">{signer.signerName}</span>
|
|
<span className="truncate text-muted-foreground">{signer.signerEmail}</span>
|
|
</div>
|
|
<StatusPill status={STATUS_PILL_MAP[signer.status] ?? 'pending'}>
|
|
{signer.status}
|
|
</StatusPill>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
</li>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<PageHeader
|
|
title="Documents"
|
|
description="Track signing status, chase pending signers, and audit completion."
|
|
kpiLine={
|
|
<>
|
|
<span>
|
|
<strong className="font-semibold text-foreground tabular-nums">{counts.all}</strong>{' '}
|
|
total
|
|
</span>
|
|
<span>
|
|
<strong className="font-semibold text-foreground tabular-nums">
|
|
{counts.awaiting_them}
|
|
</strong>{' '}
|
|
awaiting signers
|
|
</span>
|
|
<span>
|
|
<strong className="font-semibold text-foreground tabular-nums">
|
|
{counts.awaiting_me}
|
|
</strong>{' '}
|
|
awaiting you
|
|
</span>
|
|
</>
|
|
}
|
|
actions={
|
|
<Button asChild>
|
|
<Link href={`/${portSlug}/documents/new`}>
|
|
<Plus className="mr-1.5 h-4 w-4" />
|
|
New document
|
|
</Link>
|
|
</Button>
|
|
}
|
|
variant="gradient"
|
|
/>
|
|
|
|
<Tabs value={tab} onValueChange={(v) => setTab(v as DocumentsHubTab)}>
|
|
<TabsList>
|
|
{documentsHubTabs.map((t) => (
|
|
<TabsTrigger key={t} value={t}>
|
|
{TAB_LABELS[t]}
|
|
{t !== 'all' && counts[t] > 0 ? (
|
|
<span className="ml-1.5 rounded-full bg-muted px-1.5 py-0.5 text-[0.65rem] text-muted-foreground">
|
|
{counts[t]}
|
|
</span>
|
|
) : null}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Input
|
|
placeholder="Search by title…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="max-w-xs"
|
|
/>
|
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
|
<SelectTrigger className="w-44">
|
|
<SelectValue placeholder="Type" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All types</SelectItem>
|
|
{Object.entries(TYPE_LABELS).map(([k, v]) => (
|
|
<SelectItem key={k} value={k}>
|
|
{v}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSignatureOnly((v) => !v)}
|
|
className={cn(
|
|
'rounded-full border px-3 py-1 text-xs transition-colors',
|
|
signatureOnly
|
|
? 'border-brand-200 bg-brand-50 text-brand-700'
|
|
: 'border-slate-200 bg-white text-muted-foreground',
|
|
)}
|
|
>
|
|
Signature-based only
|
|
</button>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<ul className="rounded-md border bg-white">
|
|
{[0, 1, 2, 3, 4].map((i) => (
|
|
<li key={i} className="h-12 animate-pulse border-b last:border-b-0 bg-muted/40" />
|
|
))}
|
|
</ul>
|
|
) : documents.length === 0 ? (
|
|
<EmptyState
|
|
icon={<FileText className="h-7 w-7" />}
|
|
title={tab === 'all' ? 'No documents yet' : 'No documents match this view'}
|
|
body={
|
|
tab === 'all'
|
|
? 'Create your first document to track signing across signers and watchers.'
|
|
: 'Try a different tab or clear filters.'
|
|
}
|
|
actions={
|
|
tab === 'all' ? (
|
|
<Button asChild>
|
|
<Link href={`/${portSlug}/documents/new`}>
|
|
<Plus className="mr-1.5 h-4 w-4" />
|
|
New document
|
|
</Link>
|
|
</Button>
|
|
) : null
|
|
}
|
|
/>
|
|
) : (
|
|
<ul className="rounded-md border bg-white shadow-xs">{documents.map(renderRow)}</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|