feat(documents): rebuild hub — root view + entity-folder view

Three rendering modes for the main panel:
  - HubRootView (no folder selected): port-wide Signing + recent Files.
  - EntityFolderView (system-managed entity subfolder selected):
    AggregatedSection × 2 with owner-grouped subsections + per-row
    "view signing details" link on signed files (heuristic: filename
    starts with "signed-"; follow-up: surface signedFromDocumentId
    from the aggregated API).
  - FlatFolderListing (any other folder): existing search + chips + list.

Drops the signing-status tab strip (in_progress / awaiting_them / etc.)
— folders are the primary navigation now. hub-counts query removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 12:39:03 +02:00
parent 7f85128dc2
commit 631b5d7ed5
3 changed files with 340 additions and 197 deletions

View File

@@ -2,24 +2,24 @@
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 { cn } from '@/lib/utils';
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 { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
import { documentsHubTabs, type DocumentsHubTab } from '@/lib/validators/documents';
import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders';
import type { DocumentsHubTab } from '@/lib/validators/documents';
import { FolderActionsMenu } from './folder-actions-menu';
import { FolderBreadcrumb } from './folder-breadcrumb';
import { FolderTreeSidebar } from './folder-tree-sidebar';
import { HubRootView } from './hub-root-view';
import { EntityFolderView } from './entity-folder-view';
interface HubDoc {
id: string;
@@ -30,26 +30,6 @@ interface HubDoc {
signers?: Array<{ id: string; signerEmail: string; signerName: string; status: string }>;
}
interface HubCounts {
all: number;
in_progress: number;
eoi_queue: number;
awaiting_them: number;
awaiting_me: number;
completed: number;
expired: number;
}
const TAB_LABELS: Record<DocumentsHubTab, string> = {
all: 'All',
in_progress: 'In progress',
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',
@@ -87,45 +67,113 @@ interface DocumentsHubProps {
initialTab?: DocumentsHubTab;
}
export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps) {
const [tab, setTab] = useState<DocumentsHubTab>(initialTab);
function findInTree(nodes: FolderNode[], id: string): FolderNode | null {
for (const n of nodes) {
if (n.id === id) return n;
const found = findInTree(n.children, id);
if (found) return found;
}
return null;
}
export function DocumentsHub({ portSlug }: DocumentsHubProps) {
// undefined = "All documents" (no folder selected / hub root)
// null = root folder only
// string = specific folder id
const [selectedFolderId, setSelectedFolderId] = useState<string | null | undefined>(undefined);
const { data: tree = [] } = useDocumentFolders();
const selectedFolder =
typeof selectedFolderId === 'string' ? findInTree(tree, selectedFolderId) : null;
const isEntityFolder =
selectedFolder?.systemManaged === true &&
selectedFolder.entityType != null &&
selectedFolder.entityType !== 'root' &&
selectedFolder.entityId != null;
const handleFolderSelect = (id: string | null | undefined) => {
setSelectedFolderId(id);
};
return (
<div className="flex flex-col sm:flex-row h-full">
<FolderTreeSidebar
selectedFolderId={selectedFolderId}
onSelect={handleFolderSelect}
footer={
<PermissionGate resource="documents" action="manage_folders">
<FolderActionsMenu
selectedFolderId={selectedFolderId}
onAfterDelete={() => handleFolderSelect(undefined)}
/>
</PermissionGate>
}
/>
<div className="flex-1 min-w-0 p-4 space-y-4">
<FolderBreadcrumb selectedFolderId={selectedFolderId} onSelect={handleFolderSelect} />
<PageHeader
title="Documents"
description="Track signing status, chase pending signers, and audit completion."
actions={
<Button asChild>
<Link href={`/${portSlug}/documents/new`}>
<Plus className="mr-1.5 h-4 w-4" />
New document
</Link>
</Button>
}
variant="gradient"
/>
{selectedFolderId === undefined ? (
<HubRootView portSlug={portSlug} />
) : isEntityFolder ? (
<EntityFolderView
portSlug={portSlug}
entityType={selectedFolder!.entityType as 'client' | 'company' | 'yacht'}
entityId={selectedFolder!.entityId!}
/>
) : (
<FlatFolderListing portSlug={portSlug} folderId={selectedFolderId} />
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// FlatFolderListing — the original search + type-chip + document rows panel,
// now scoped to a specific folder (or null for root-only).
// ---------------------------------------------------------------------------
interface FlatFolderListingProps {
portSlug: string;
folderId: string | null;
}
function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
// undefined = "All documents" (no folder filter), null = root only,
// string = a specific folder id.
const [selectedFolderId, setSelectedFolderId] = useState<string | null | undefined>(undefined);
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) params.set('documentType', typeFilter);
if (selectedFolderId !== undefined) {
params.set('folderId', selectedFolderId ?? '');
}
// folderId null = root, string = specific folder
params.set('folderId', folderId ?? '');
return params;
}, [tab, search, typeFilter, selectedFolderId]);
}, [search, typeFilter, folderId]);
const { data: documents, isLoading } = usePaginatedQuery<HubDoc>({
queryKey: ['documents', 'hub', queryParams.toString()],
queryKey: ['documents', 'hub', 'folder', 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,
});
const { data: flagsResp } = useQuery<{ data: { showExpiredTab: boolean } }>({
queryKey: ['documents-feature-flags'],
queryFn: () => apiFetch('/api/v1/documents/feature-flags'),
staleTime: 5 * 60 * 1000,
});
const showExpiredTab = flagsResp?.data?.showExpiredTab ?? true;
useRealtimeInvalidation({
'document:created': [['documents']],
'document:updated': [['documents']],
@@ -138,16 +186,6 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
'document:signer:signed': [['documents']],
});
const counts: HubCounts = countsResp?.data ?? {
all: 0,
in_progress: 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;
@@ -218,51 +256,59 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
);
};
const handleFolderSelect = (id: string | null | undefined) => {
setSelectedFolderId(id);
setTypeFilter(undefined);
};
return (
<div className="flex flex-col sm:flex-row h-full">
<FolderTreeSidebar
selectedFolderId={selectedFolderId}
onSelect={handleFolderSelect}
footer={
<PermissionGate resource="documents" action="manage_folders">
<FolderActionsMenu
selectedFolderId={selectedFolderId}
onAfterDelete={() => handleFolderSelect(undefined)}
/>
</PermissionGate>
}
/>
<div className="flex-1 min-w-0 p-4 space-y-4">
<FolderBreadcrumb selectedFolderId={selectedFolderId} onSelect={handleFolderSelect} />
<>
<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 h-9"
/>
{(() => {
const seenTypes = Array.from(new Set(documents.map((d) => d.documentType))).sort();
if (seenTypes.length === 0) return null;
return (
<div className="flex flex-wrap gap-1.5">
<button
type="button"
className={cn(
'rounded-full border px-2.5 py-0.5 text-xs',
typeFilter === undefined ? 'bg-foreground text-background' : 'hover:bg-accent',
)}
onClick={() => setTypeFilter(undefined)}
>
All types
</button>
{seenTypes.map((t) => (
<button
type="button"
key={t}
className={cn(
'rounded-full border px-2.5 py-0.5 text-xs',
typeFilter === t ? 'bg-foreground text-background' : 'hover:bg-accent',
)}
onClick={() => setTypeFilter(t)}
>
{TYPE_LABELS[t] ?? t}
</button>
))}
</div>
);
})()}
</div>
<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>
</>
}
{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="No documents in this folder"
body="Create a document or move existing ones here."
actions={
<Button asChild>
<Link href={`/${portSlug}/documents/new`}>
@@ -271,102 +317,10 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
</Link>
</Button>
}
variant="gradient"
/>
<Tabs
value={tab}
onValueChange={(v) => {
setTab(v as DocumentsHubTab);
setTypeFilter(undefined);
}}
>
<TabsList>
{documentsHubTabs
.filter((t) => t !== 'expired' || showExpiredTab)
.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 h-9"
/>
{(() => {
const seenTypes = Array.from(new Set(documents.map((d) => d.documentType))).sort();
if (seenTypes.length === 0) return null;
return (
<div className="flex flex-wrap gap-1.5">
<button
type="button"
className={cn(
'rounded-full border px-2.5 py-0.5 text-xs',
typeFilter === undefined ? 'bg-foreground text-background' : 'hover:bg-accent',
)}
onClick={() => setTypeFilter(undefined)}
>
All types
</button>
{seenTypes.map((t) => (
<button
type="button"
key={t}
className={cn(
'rounded-full border px-2.5 py-0.5 text-xs',
typeFilter === t ? 'bg-foreground text-background' : 'hover:bg-accent',
)}
onClick={() => setTypeFilter(t)}
>
{TYPE_LABELS[t] ?? t}
</button>
))}
</div>
);
})()}
</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>
</div>
) : (
<ul className="rounded-md border bg-white shadow-xs">{documents.map(renderRow)}</ul>
)}
</>
);
}