feat(documents): wire folder sidebar + breadcrumb + In-progress tab

Documents hub now opens with the folder tree on the left and a
breadcrumb on top. Folder selection is its own state — undefined =
"All", null = "Root only", string = specific folder. Filter pushes
through to /api/v1/documents via folderId query param.

Drops the "Signature-based only" pill — it defaulted to true and
silently hid informational documents, which confused new reps. With
folders the rep organises by location, not by signature-vs-not.

Adds an "In progress" hub tab covering status IN (draft, sent,
partially_signed) for the everyday "what's in flight" view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 12:12:53 +02:00
parent 4dd1fa4b24
commit 4556a03b8b
3 changed files with 144 additions and 119 deletions

View File

@@ -18,11 +18,14 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
import { documentsHubTabs, type DocumentsHubTab } from '@/lib/validators/documents'; import { documentsHubTabs, type DocumentsHubTab } from '@/lib/validators/documents';
import { FolderActionsMenu } from './folder-actions-menu';
import { FolderBreadcrumb } from './folder-breadcrumb';
import { FolderTreeSidebar } from './folder-tree-sidebar';
interface HubDoc { interface HubDoc {
id: string; id: string;
@@ -35,6 +38,7 @@ interface HubDoc {
interface HubCounts { interface HubCounts {
all: number; all: number;
in_progress: number;
eoi_queue: number; eoi_queue: number;
awaiting_them: number; awaiting_them: number;
awaiting_me: number; awaiting_me: number;
@@ -44,6 +48,7 @@ interface HubCounts {
const TAB_LABELS: Record<DocumentsHubTab, string> = { const TAB_LABELS: Record<DocumentsHubTab, string> = {
all: 'All', all: 'All',
in_progress: 'In progress',
eoi_queue: 'EOI queue', eoi_queue: 'EOI queue',
awaiting_them: 'Awaiting them', awaiting_them: 'Awaiting them',
awaiting_me: 'Awaiting me', awaiting_me: 'Awaiting me',
@@ -92,7 +97,9 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
const [tab, setTab] = useState<DocumentsHubTab>(initialTab); const [tab, setTab] = useState<DocumentsHubTab>(initialTab);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all'); const [typeFilter, setTypeFilter] = useState<string>('all');
const [signatureOnly, setSignatureOnly] = useState(true); // 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 [expandedDocId, setExpandedDocId] = useState<string | null>(null);
const queryParams = useMemo(() => { const queryParams = useMemo(() => {
@@ -100,9 +107,11 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
params.set('tab', tab); params.set('tab', tab);
if (search) params.set('search', search); if (search) params.set('search', search);
if (typeFilter && typeFilter !== 'all') params.set('documentType', typeFilter); if (typeFilter && typeFilter !== 'all') params.set('documentType', typeFilter);
if (signatureOnly) params.set('signatureOnly', 'true'); if (selectedFolderId !== undefined) {
params.set('folderId', selectedFolderId ?? '');
}
return params; return params;
}, [tab, search, typeFilter, signatureOnly]); }, [tab, search, typeFilter, selectedFolderId]);
const { data: documents, isLoading } = usePaginatedQuery<HubDoc>({ const { data: documents, isLoading } = usePaginatedQuery<HubDoc>({
queryKey: ['documents', 'hub', queryParams.toString()], queryKey: ['documents', 'hub', queryParams.toString()],
@@ -130,6 +139,7 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
const counts: HubCounts = countsResp?.data ?? { const counts: HubCounts = countsResp?.data ?? {
all: 0, all: 0,
in_progress: 0,
eoi_queue: 0, eoi_queue: 0,
awaiting_them: 0, awaiting_them: 0,
awaiting_me: 0, awaiting_me: 0,
@@ -208,119 +218,123 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
}; };
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col sm:flex-row h-full">
<PageHeader <FolderTreeSidebar
title="Documents" selectedFolderId={selectedFolderId}
description="Track signing status, chase pending signers, and audit completion." onSelect={setSelectedFolderId}
kpiLine={ footer={
<> <PermissionGate resource="documents" action="manage_folders">
<span> <FolderActionsMenu
<strong className="font-semibold text-foreground tabular-nums">{counts.all}</strong>{' '} selectedFolderId={selectedFolderId}
total onAfterDelete={() => setSelectedFolderId(undefined)}
</span> />
<span> </PermissionGate>
<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"
/> />
<div className="flex-1 min-w-0 p-4 space-y-4">
<FolderBreadcrumb selectedFolderId={selectedFolderId} onSelect={setSelectedFolderId} />
<Tabs value={tab} onValueChange={(v) => setTab(v as DocumentsHubTab)}> <PageHeader
<TabsList> title="Documents"
{documentsHubTabs.map((t) => ( description="Track signing status, chase pending signers, and audit completion."
<TabsTrigger key={t} value={t}> kpiLine={
{TAB_LABELS[t]} <>
{t !== 'all' && counts[t] > 0 ? ( <span>
<span className="ml-1.5 rounded-full bg-muted px-1.5 py-0.5 text-[0.65rem] text-muted-foreground"> <strong className="font-semibold text-foreground tabular-nums">{counts.all}</strong>{' '}
{counts[t]} total
</span> </span>
) : null} <span>
</TabsTrigger> <strong className="font-semibold text-foreground tabular-nums">
))} {counts.awaiting_them}
</TabsList> </strong>{' '}
</Tabs> awaiting signers
</span>
<div className="flex flex-wrap items-center gap-2"> <span>
<Input <strong className="font-semibold text-foreground tabular-nums">
placeholder="Search by title…" {counts.awaiting_me}
value={search} </strong>{' '}
onChange={(e) => setSearch(e.target.value)} awaiting you
className="max-w-xs h-9" </span>
/> </>
<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={ actions={
tab === 'all' ? ( <Button asChild>
<Button asChild> <Link href={`/${portSlug}/documents/new`}>
<Link href={`/${portSlug}/documents/new`}> <Plus className="mr-1.5 h-4 w-4" />
<Plus className="mr-1.5 h-4 w-4" /> New document
New document </Link>
</Link> </Button>
</Button>
) : null
} }
variant="gradient"
/> />
) : (
<ul className="rounded-md border bg-white shadow-xs">{documents.map(renderRow)}</ul> <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 h-9"
/>
<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>
</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> </div>
); );
} }

View File

@@ -62,6 +62,13 @@ function buildHubTabFilters(
if (!tab || tab === 'all') return filters; if (!tab || tab === 'all') return filters;
switch (tab) { switch (tab) {
case 'in_progress':
// All document types currently in-flight — the everyday "what's in flight" view.
filters.push(
inArray(documents.status, ['draft', 'sent', 'partially_signed']),
sql`${documents.status} != 'expired'`,
);
break;
case 'eoi_queue': case 'eoi_queue':
// EOI documents currently in-flight (drafted, sent, or partially signed). // EOI documents currently in-flight (drafted, sent, or partially signed).
// Used by the dedicated tab on the documents hub to triage EOI signing // Used by the dedicated tab on the documents hub to triage EOI signing
@@ -288,6 +295,7 @@ export async function listDealDocumentsForBerth(
export interface HubTabCounts { export interface HubTabCounts {
all: number; all: number;
in_progress: number;
eoi_queue: number; eoi_queue: number;
awaiting_them: number; awaiting_them: number;
awaiting_me: number; awaiting_me: number;
@@ -313,16 +321,18 @@ export async function getHubTabCounts(
return row?.count ?? 0; return row?.count ?? 0;
} }
const [all, eoi_queue, awaiting_them, awaiting_me, completed, expired] = await Promise.all([ const [all, in_progress, eoi_queue, awaiting_them, awaiting_me, completed, expired] =
tabCount('all'), await Promise.all([
tabCount('eoi_queue'), tabCount('all'),
tabCount('awaiting_them'), tabCount('in_progress'),
tabCount('awaiting_me'), tabCount('eoi_queue'),
tabCount('completed'), tabCount('awaiting_them'),
tabCount('expired'), tabCount('awaiting_me'),
]); tabCount('completed'),
tabCount('expired'),
]);
return { all, eoi_queue, awaiting_them, awaiting_me, completed, expired }; return { all, in_progress, eoi_queue, awaiting_them, awaiting_me, completed, expired };
} }
// ─── Get by ID ──────────────────────────────────────────────────────────────── // ─── Get by ID ────────────────────────────────────────────────────────────────

View File

@@ -72,6 +72,7 @@ export type CreateDocumentWizardInput = z.infer<typeof createDocumentWizardSchem
export const documentsHubTabs = [ export const documentsHubTabs = [
'all', 'all',
'in_progress',
'eoi_queue', 'eoi_queue',
'awaiting_them', 'awaiting_them',
'awaiting_me', 'awaiting_me',