From 4556a03b8ba0db40e6d2f459a65c823abe6f6e96 Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 10 May 2026 12:12:53 +0200 Subject: [PATCH] feat(documents): wire folder sidebar + breadcrumb + In-progress tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/documents/documents-hub.tsx | 234 +++++++++++---------- src/lib/services/documents.service.ts | 28 ++- src/lib/validators/documents.ts | 1 + 3 files changed, 144 insertions(+), 119 deletions(-) diff --git a/src/components/documents/documents-hub.tsx b/src/components/documents/documents-hub.tsx index 0d728af8..d2c39b08 100644 --- a/src/components/documents/documents-hub.tsx +++ b/src/components/documents/documents-hub.tsx @@ -18,11 +18,14 @@ 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 { cn } from '@/lib/utils'; 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 { id: string; @@ -35,6 +38,7 @@ interface HubDoc { interface HubCounts { all: number; + in_progress: number; eoi_queue: number; awaiting_them: number; awaiting_me: number; @@ -44,6 +48,7 @@ interface HubCounts { const TAB_LABELS: Record = { all: 'All', + in_progress: 'In progress', eoi_queue: 'EOI queue', awaiting_them: 'Awaiting them', awaiting_me: 'Awaiting me', @@ -92,7 +97,9 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps const [tab, setTab] = useState(initialTab); const [search, setSearch] = useState(''); const [typeFilter, setTypeFilter] = useState('all'); - const [signatureOnly, setSignatureOnly] = useState(true); + // undefined = "All documents" (no folder filter), null = root only, + // string = a specific folder id. + const [selectedFolderId, setSelectedFolderId] = useState(undefined); const [expandedDocId, setExpandedDocId] = useState(null); const queryParams = useMemo(() => { @@ -100,9 +107,11 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps params.set('tab', tab); if (search) params.set('search', search); if (typeFilter && typeFilter !== 'all') params.set('documentType', typeFilter); - if (signatureOnly) params.set('signatureOnly', 'true'); + if (selectedFolderId !== undefined) { + params.set('folderId', selectedFolderId ?? ''); + } return params; - }, [tab, search, typeFilter, signatureOnly]); + }, [tab, search, typeFilter, selectedFolderId]); const { data: documents, isLoading } = usePaginatedQuery({ queryKey: ['documents', 'hub', queryParams.toString()], @@ -130,6 +139,7 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps const counts: HubCounts = countsResp?.data ?? { all: 0, + in_progress: 0, eoi_queue: 0, awaiting_them: 0, awaiting_me: 0, @@ -208,119 +218,123 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps }; return ( -
- - - {counts.all}{' '} - total - - - - {counts.awaiting_them} - {' '} - awaiting signers - - - - {counts.awaiting_me} - {' '} - awaiting you - - +
+ + setSelectedFolderId(undefined)} + /> + } - actions={ - - } - variant="gradient" /> +
+ - setTab(v as DocumentsHubTab)}> - - {documentsHubTabs.map((t) => ( - - {TAB_LABELS[t]} - {t !== 'all' && counts[t] > 0 ? ( - - {counts[t]} - - ) : null} - - ))} - - - -
- setSearch(e.target.value)} - className="max-w-xs h-9" - /> - - -
- - {isLoading ? ( -
    - {[0, 1, 2, 3, 4].map((i) => ( -
  • - ))} -
- ) : documents.length === 0 ? ( - } - 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.' + + + {counts.all}{' '} + total + + + + {counts.awaiting_them} + {' '} + awaiting signers + + + + {counts.awaiting_me} + {' '} + awaiting you + + } actions={ - tab === 'all' ? ( - - ) : null + } + variant="gradient" /> - ) : ( -
    {documents.map(renderRow)}
- )} + + setTab(v as DocumentsHubTab)}> + + {documentsHubTabs.map((t) => ( + + {TAB_LABELS[t]} + {t !== 'all' && counts[t] > 0 ? ( + + {counts[t]} + + ) : null} + + ))} + + + +
+ setSearch(e.target.value)} + className="max-w-xs h-9" + /> + +
+ + {isLoading ? ( +
    + {[0, 1, 2, 3, 4].map((i) => ( +
  • + ))} +
+ ) : documents.length === 0 ? ( + } + 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' ? ( + + ) : null + } + /> + ) : ( +
    {documents.map(renderRow)}
+ )} +
); } diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index 45b2a786..8f3f173d 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -62,6 +62,13 @@ function buildHubTabFilters( if (!tab || tab === 'all') return filters; 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': // EOI documents currently in-flight (drafted, sent, or partially signed). // Used by the dedicated tab on the documents hub to triage EOI signing @@ -288,6 +295,7 @@ export async function listDealDocumentsForBerth( export interface HubTabCounts { all: number; + in_progress: number; eoi_queue: number; awaiting_them: number; awaiting_me: number; @@ -313,16 +321,18 @@ export async function getHubTabCounts( return row?.count ?? 0; } - const [all, eoi_queue, awaiting_them, awaiting_me, completed, expired] = await Promise.all([ - tabCount('all'), - tabCount('eoi_queue'), - tabCount('awaiting_them'), - tabCount('awaiting_me'), - tabCount('completed'), - tabCount('expired'), - ]); + const [all, in_progress, eoi_queue, awaiting_them, awaiting_me, completed, expired] = + await Promise.all([ + tabCount('all'), + tabCount('in_progress'), + tabCount('eoi_queue'), + tabCount('awaiting_them'), + 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 ──────────────────────────────────────────────────────────────── diff --git a/src/lib/validators/documents.ts b/src/lib/validators/documents.ts index 2c18741c..75695c0c 100644 --- a/src/lib/validators/documents.ts +++ b/src/lib/validators/documents.ts @@ -72,6 +72,7 @@ export type CreateDocumentWizardInput = z.infer