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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user