feat(documents): hub page with tabs, filters, and live counts

Replaces /documents with the Phase A hub: tabs (All/Awaiting them/
Awaiting me/Completed/Expired) backed by per-tab counts via a new
hub-counts endpoint, signature-only chip, type filter, expandable
signer rows, and real-time invalidation across the eight document
socket events. listDocuments grew tab/watcher/signatureOnly/sent-window
filters; the legacy file browser moved to /documents/files where the
sidebar already linked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-28 02:35:36 +02:00
parent 398d6322f1
commit da7262f18f
9 changed files with 718 additions and 146 deletions

View File

@@ -0,0 +1,313 @@
'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;
awaiting_them: number;
awaiting_me: number;
completed: number;
expired: number;
}
const TAB_LABELS: Record<DocumentsHubTab, string> = {
all: 'All',
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;
}
export function DocumentsHub({ portSlug }: DocumentsHubProps) {
const [tab, setTab] = useState<DocumentsHubTab>('all');
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,
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="grid grid-cols-[auto_1fr_auto_auto_auto_auto] items-center gap-3 px-4 py-3 text-sm">
<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>
);
}