diff --git a/src/components/documents/documents-hub.tsx b/src/components/documents/documents-hub.tsx index 4bf7979c..f25adeb0 100644 --- a/src/components/documents/documents-hub.tsx +++ b/src/components/documents/documents-hub.tsx @@ -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 = { - 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 = { eoi: 'EOI', contract: 'Contract', @@ -87,45 +67,113 @@ interface DocumentsHubProps { initialTab?: DocumentsHubTab; } -export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps) { - const [tab, setTab] = useState(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(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 ( +
+ + handleFolderSelect(undefined)} + /> + + } + /> +
+ + + + + + New document + + + } + variant="gradient" + /> + + {selectedFolderId === undefined ? ( + + ) : isEntityFolder ? ( + + ) : ( + + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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(undefined); - // 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(() => { 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({ - 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 ( -
- - handleFolderSelect(undefined)} - /> - - } - /> -
- + <> +
+ 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 ( +
+ + {seenTypes.map((t) => ( + + ))} +
+ ); + })()} +
- - - {counts.all}{' '} - total - - - - {counts.awaiting_them} - {' '} - awaiting signers - - - - {counts.awaiting_me} - {' '} - awaiting you - - - } + {isLoading ? ( +
    + {[0, 1, 2, 3, 4].map((i) => ( +
  • + ))} +
+ ) : documents.length === 0 ? ( + } + title="No documents in this folder" + body="Create a document or move existing ones here." actions={ } - variant="gradient" /> - - { - setTab(v as DocumentsHubTab); - setTypeFilter(undefined); - }} - > - - {documentsHubTabs - .filter((t) => t !== 'expired' || showExpiredTab) - .map((t) => ( - - {TAB_LABELS[t]} - {t !== 'all' && counts[t] > 0 ? ( - - {counts[t]} - - ) : null} - - ))} - - - -
- 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 ( -
- - {seenTypes.map((t) => ( - - ))} -
- ); - })()} -
- - {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)}
- )} -
-
+ ) : ( +
    {documents.map(renderRow)}
+ )} + ); } diff --git a/src/components/documents/entity-folder-view.tsx b/src/components/documents/entity-folder-view.tsx new file mode 100644 index 00000000..9c1ab004 --- /dev/null +++ b/src/components/documents/entity-folder-view.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { ClipboardSignature, FileText, Eye } from 'lucide-react'; + +import { AggregatedSection } from './aggregated-section'; +import { SigningDetailsDialog } from './signing-details-dialog'; +import { useAggregatedFiles, useAggregatedWorkflows } from '@/hooks/use-aggregated-listing'; +import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; +import type { AggregatedWorkflow, AggregatedFile, AggregatedGroup } from '@/hooks/use-aggregated-listing'; + +interface Props { + portSlug: string; + entityType: 'client' | 'company' | 'yacht'; + entityId: string; +} + +function mapWorkflowStatus(status: string): StatusPillStatus { + const known: Record = { + draft: 'draft', + sent: 'sent', + partially_signed: 'partial', + completed: 'completed', + expired: 'expired', + cancelled: 'cancelled', + }; + return known[status] ?? 'pending'; +} + +export function EntityFolderView({ portSlug, entityType, entityId }: Props) { + const [detailsId, setDetailsId] = useState(null); + + // Hook data is the bare AggregatedGroup[] array (hooks unwrap the API envelope). + const { data: workflowGroups = [], isLoading: workflowsLoading } = useAggregatedWorkflows( + entityType, + entityId, + ); + const { data: fileGroups = [], isLoading: filesLoading } = useAggregatedFiles( + entityType, + entityId, + ); + + return ( +
+ } + groups={workflowGroups} + loading={workflowsLoading} + emptyMessage="No workflows in flight for this entity." + renderRow={(w: AggregatedWorkflow, _group: AggregatedGroup) => ( +
+ + {w.title} + + + {w.status.replace(/_/g, ' ')} + +
+ )} + /> + + } + groups={fileGroups} + loading={filesLoading} + emptyMessage="No files for this entity yet." + renderRow={(f: AggregatedFile, _group: AggregatedGroup) => { + // Heuristic v1: auto-deposit handler (Task 7) names signed files with "signed-" prefix. + // Follow-up: surface signedFromDocumentId from the aggregated API for a principled check. + const isSigned = f.filename?.startsWith('signed-'); + return ( +
+ {f.filename} +
+ {new Date(f.createdAt).toLocaleDateString('en-GB')} + {isSigned ? ( + + ) : null} +
+
+ ); + }} + /> + + !open && setDetailsId(null)} + /> +
+ ); +} diff --git a/src/components/documents/hub-root-view.tsx b/src/components/documents/hub-root-view.tsx new file mode 100644 index 00000000..762140b6 --- /dev/null +++ b/src/components/documents/hub-root-view.tsx @@ -0,0 +1,87 @@ +'use client'; + +import Link from 'next/link'; +import { FileText, ClipboardSignature } from 'lucide-react'; + +import { usePaginatedQuery } from '@/hooks/use-paginated-query'; + +interface HubRootDoc { + id: string; + title: string; + documentType: string; + status: string; + createdAt: string; +} + +interface HubRootFile { + id: string; + filename: string; + createdAt: string; +} + +interface Props { + portSlug: string; +} + +export function HubRootView({ portSlug }: Props) { + const { data: workflows, isLoading: workflowsLoading } = usePaginatedQuery({ + queryKey: ['documents', 'hub-root', 'workflows'], + endpoint: '/api/v1/documents?tab=in_progress', + filterDefinitions: [], + }); + const { data: filesData, isLoading: filesLoading } = usePaginatedQuery({ + queryKey: ['files', 'hub-root'], + endpoint: '/api/v1/files?sort=createdAt&order=desc&limit=20', + filterDefinitions: [], + }); + + return ( +
+
+

+ + Signing in progress +

+ {workflowsLoading ? ( +
Loading...
+ ) : workflows.length === 0 ? ( +
No workflows in flight.
+ ) : ( +
    + {workflows.map((w) => ( +
  • + + {w.title} + + {w.status} +
  • + ))} +
+ )} +
+ +
+

+ + Recent files +

+ {filesLoading ? ( +
Loading...
+ ) : filesData.length === 0 ? ( +
No files yet.
+ ) : ( +
    + {filesData.map((f) => ( +
  • + {f.filename} + + {new Date(f.createdAt).toLocaleDateString('en-GB')} + +
  • + ))} +
+ )} +
+
+ ); +}