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:
@@ -2,24 +2,24 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { ChevronDown, ChevronRight, FileText, Plus } from 'lucide-react';
|
import { ChevronDown, ChevronRight, FileText, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
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 { 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 { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders';
|
||||||
import { documentsHubTabs, type DocumentsHubTab } from '@/lib/validators/documents';
|
import type { DocumentsHubTab } from '@/lib/validators/documents';
|
||||||
import { FolderActionsMenu } from './folder-actions-menu';
|
import { FolderActionsMenu } from './folder-actions-menu';
|
||||||
import { FolderBreadcrumb } from './folder-breadcrumb';
|
import { FolderBreadcrumb } from './folder-breadcrumb';
|
||||||
import { FolderTreeSidebar } from './folder-tree-sidebar';
|
import { FolderTreeSidebar } from './folder-tree-sidebar';
|
||||||
|
import { HubRootView } from './hub-root-view';
|
||||||
|
import { EntityFolderView } from './entity-folder-view';
|
||||||
|
|
||||||
interface HubDoc {
|
interface HubDoc {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,26 +30,6 @@ interface HubDoc {
|
|||||||
signers?: Array<{ id: string; signerEmail: string; signerName: string; status: string }>;
|
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> = {
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
eoi: 'EOI',
|
eoi: 'EOI',
|
||||||
contract: 'Contract',
|
contract: 'Contract',
|
||||||
@@ -87,45 +67,113 @@ interface DocumentsHubProps {
|
|||||||
initialTab?: DocumentsHubTab;
|
initialTab?: DocumentsHubTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps) {
|
function findInTree(nodes: FolderNode[], id: string): FolderNode | null {
|
||||||
const [tab, setTab] = useState<DocumentsHubTab>(initialTab);
|
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 [search, setSearch] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
|
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 [expandedDocId, setExpandedDocId] = useState<string | null>(null);
|
||||||
|
|
||||||
const queryParams = useMemo(() => {
|
const queryParams = useMemo(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('tab', tab);
|
|
||||||
if (search) params.set('search', search);
|
if (search) params.set('search', search);
|
||||||
if (typeFilter) params.set('documentType', typeFilter);
|
if (typeFilter) params.set('documentType', typeFilter);
|
||||||
if (selectedFolderId !== undefined) {
|
// folderId null = root, string = specific folder
|
||||||
params.set('folderId', selectedFolderId ?? '');
|
params.set('folderId', folderId ?? '');
|
||||||
}
|
|
||||||
return params;
|
return params;
|
||||||
}, [tab, search, typeFilter, selectedFolderId]);
|
}, [search, typeFilter, folderId]);
|
||||||
|
|
||||||
const { data: documents, isLoading } = usePaginatedQuery<HubDoc>({
|
const { data: documents, isLoading } = usePaginatedQuery<HubDoc>({
|
||||||
queryKey: ['documents', 'hub', queryParams.toString()],
|
queryKey: ['documents', 'hub', 'folder', queryParams.toString()],
|
||||||
endpoint: `/api/v1/documents?${queryParams.toString()}`,
|
endpoint: `/api/v1/documents?${queryParams.toString()}`,
|
||||||
filterDefinitions: [],
|
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({
|
useRealtimeInvalidation({
|
||||||
'document:created': [['documents']],
|
'document:created': [['documents']],
|
||||||
'document:updated': [['documents']],
|
'document:updated': [['documents']],
|
||||||
@@ -138,16 +186,6 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
|
|||||||
'document:signer:signed': [['documents']],
|
'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 renderRow = (doc: HubDoc) => {
|
||||||
const expanded = expandedDocId === doc.id;
|
const expanded = expandedDocId === doc.id;
|
||||||
const totalSigners = doc.signers?.length ?? 0;
|
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 (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row h-full">
|
<>
|
||||||
<FolderTreeSidebar
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
selectedFolderId={selectedFolderId}
|
<Input
|
||||||
onSelect={handleFolderSelect}
|
placeholder="Search by title..."
|
||||||
footer={
|
value={search}
|
||||||
<PermissionGate resource="documents" action="manage_folders">
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
<FolderActionsMenu
|
className="max-w-xs h-9"
|
||||||
selectedFolderId={selectedFolderId}
|
/>
|
||||||
onAfterDelete={() => handleFolderSelect(undefined)}
|
{(() => {
|
||||||
/>
|
const seenTypes = Array.from(new Set(documents.map((d) => d.documentType))).sort();
|
||||||
</PermissionGate>
|
if (seenTypes.length === 0) return null;
|
||||||
}
|
return (
|
||||||
/>
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<div className="flex-1 min-w-0 p-4 space-y-4">
|
<button
|
||||||
<FolderBreadcrumb selectedFolderId={selectedFolderId} onSelect={handleFolderSelect} />
|
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
|
{isLoading ? (
|
||||||
title="Documents"
|
<ul className="rounded-md border bg-white">
|
||||||
description="Track signing status, chase pending signers, and audit completion."
|
{[0, 1, 2, 3, 4].map((i) => (
|
||||||
kpiLine={
|
<li key={i} className="h-12 animate-pulse border-b last:border-b-0 bg-muted/40" />
|
||||||
<>
|
))}
|
||||||
<span>
|
</ul>
|
||||||
<strong className="font-semibold text-foreground tabular-nums">{counts.all}</strong>{' '}
|
) : documents.length === 0 ? (
|
||||||
total
|
<EmptyState
|
||||||
</span>
|
icon={<FileText className="h-7 w-7" />}
|
||||||
<span>
|
title="No documents in this folder"
|
||||||
<strong className="font-semibold text-foreground tabular-nums">
|
body="Create a document or move existing ones here."
|
||||||
{counts.awaiting_them}
|
|
||||||
</strong>{' '}
|
|
||||||
awaiting signers
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<strong className="font-semibold text-foreground tabular-nums">
|
|
||||||
{counts.awaiting_me}
|
|
||||||
</strong>{' '}
|
|
||||||
awaiting you
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
actions={
|
actions={
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href={`/${portSlug}/documents/new`}>
|
<Link href={`/${portSlug}/documents/new`}>
|
||||||
@@ -271,102 +317,10 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
variant="gradient"
|
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
<Tabs
|
<ul className="rounded-md border bg-white shadow-xs">{documents.map(renderRow)}</ul>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
102
src/components/documents/entity-folder-view.tsx
Normal file
102
src/components/documents/entity-folder-view.tsx
Normal file
@@ -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<string, StatusPillStatus> = {
|
||||||
|
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<string | null>(null);
|
||||||
|
|
||||||
|
// Hook data is the bare AggregatedGroup<T>[] array (hooks unwrap the API envelope).
|
||||||
|
const { data: workflowGroups = [], isLoading: workflowsLoading } = useAggregatedWorkflows(
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
);
|
||||||
|
const { data: fileGroups = [], isLoading: filesLoading } = useAggregatedFiles(
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<AggregatedSection
|
||||||
|
title="Signing in progress"
|
||||||
|
icon={<ClipboardSignature className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
groups={workflowGroups}
|
||||||
|
loading={workflowsLoading}
|
||||||
|
emptyMessage="No workflows in flight for this entity."
|
||||||
|
renderRow={(w: AggregatedWorkflow, _group: AggregatedGroup<AggregatedWorkflow>) => (
|
||||||
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
|
<Link href={`/${portSlug}/documents/${w.id}`} className="truncate hover:underline">
|
||||||
|
{w.title}
|
||||||
|
</Link>
|
||||||
|
<StatusPill status={mapWorkflowStatus(w.status)}>
|
||||||
|
{w.status.replace(/_/g, ' ')}
|
||||||
|
</StatusPill>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AggregatedSection
|
||||||
|
title="Files"
|
||||||
|
icon={<FileText className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
groups={fileGroups}
|
||||||
|
loading={filesLoading}
|
||||||
|
emptyMessage="No files for this entity yet."
|
||||||
|
renderRow={(f: AggregatedFile, _group: AggregatedGroup<AggregatedFile>) => {
|
||||||
|
// 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 (
|
||||||
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
|
<span className="truncate">{f.filename}</span>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
|
||||||
|
<span>{new Date(f.createdAt).toLocaleDateString('en-GB')}</span>
|
||||||
|
{isSigned ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 text-brand hover:underline"
|
||||||
|
onClick={() => setDetailsId(f.id)}
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
view signing details
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SigningDetailsDialog
|
||||||
|
documentId={detailsId}
|
||||||
|
open={Boolean(detailsId)}
|
||||||
|
onOpenChange={(open) => !open && setDetailsId(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/components/documents/hub-root-view.tsx
Normal file
87
src/components/documents/hub-root-view.tsx
Normal file
@@ -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<HubRootDoc>({
|
||||||
|
queryKey: ['documents', 'hub-root', 'workflows'],
|
||||||
|
endpoint: '/api/v1/documents?tab=in_progress',
|
||||||
|
filterDefinitions: [],
|
||||||
|
});
|
||||||
|
const { data: filesData, isLoading: filesLoading } = usePaginatedQuery<HubRootFile>({
|
||||||
|
queryKey: ['files', 'hub-root'],
|
||||||
|
endpoint: '/api/v1/files?sort=createdAt&order=desc&limit=20',
|
||||||
|
filterDefinitions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<section className="rounded-md border bg-white">
|
||||||
|
<h3 className="flex items-center gap-2 border-b px-3 py-2 text-sm font-semibold">
|
||||||
|
<ClipboardSignature className="h-4 w-4 text-muted-foreground" />
|
||||||
|
Signing in progress
|
||||||
|
</h3>
|
||||||
|
{workflowsLoading ? (
|
||||||
|
<div className="p-3 text-sm text-muted-foreground">Loading...</div>
|
||||||
|
) : workflows.length === 0 ? (
|
||||||
|
<div className="p-3 text-sm text-muted-foreground">No workflows in flight.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{workflows.map((w) => (
|
||||||
|
<li key={w.id} className="px-3 py-2 text-sm">
|
||||||
|
<Link href={`/${portSlug}/documents/${w.id}`} className="hover:underline">
|
||||||
|
{w.title}
|
||||||
|
</Link>
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">{w.status}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-md border bg-white">
|
||||||
|
<h3 className="flex items-center gap-2 border-b px-3 py-2 text-sm font-semibold">
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
Recent files
|
||||||
|
</h3>
|
||||||
|
{filesLoading ? (
|
||||||
|
<div className="p-3 text-sm text-muted-foreground">Loading...</div>
|
||||||
|
) : filesData.length === 0 ? (
|
||||||
|
<div className="p-3 text-sm text-muted-foreground">No files yet.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{filesData.map((f) => (
|
||||||
|
<li key={f.id} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||||
|
<span className="truncate">{f.filename}</span>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{new Date(f.createdAt).toLocaleDateString('en-GB')}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user