diff --git a/src/components/documents/aggregated-section.tsx b/src/components/documents/aggregated-section.tsx index 17dbf735..21de9582 100644 --- a/src/components/documents/aggregated-section.tsx +++ b/src/components/documents/aggregated-section.tsx @@ -2,16 +2,35 @@ import { Loader2 } from 'lucide-react'; -import type { AggregatedGroup } from '@/hooks/use-aggregated-listing'; +import { Button } from '@/components/ui/button'; +import type { + AggregatedFile, + AggregatedGroup, + AggregatedWorkflow, +} from '@/hooks/use-aggregated-listing'; -interface AggregatedSectionProps { +/** + * Discriminated-union of the two item shapes the aggregated projection + * surfaces (files / workflows). Keeps `renderRow` strictly typed so callers + * don't have to widen to `unknown` or recast inside the row renderer. + */ +type AggregatedItemKind = + | { kind: 'files'; items: AggregatedFile[] } + | { kind: 'workflows'; items: AggregatedWorkflow[] }; + +type ItemOfKind = Extract< + AggregatedItemKind, + { kind: K } +>['items'][number]; + +interface AggregatedSectionProps { title: string; icon?: React.ReactNode; - groups: AggregatedGroup[]; - renderRow: (item: T, group: AggregatedGroup) => React.ReactNode; + groups: AggregatedGroup>[]; + renderRow: (item: ItemOfKind, group: AggregatedGroup>) => React.ReactNode; emptyMessage?: string; loading?: boolean; - onShowAll?: (group: AggregatedGroup) => void; + onShowAll?: (group: AggregatedGroup>) => void; } /** @@ -20,7 +39,7 @@ interface AggregatedSectionProps { * link drills into the source-scoped flat list. Hidden when groups is * empty. */ -export function AggregatedSection({ +export function AggregatedSection({ title, icon, groups, @@ -28,7 +47,7 @@ export function AggregatedSection({ emptyMessage = 'Nothing here yet.', loading, onShowAll, -}: AggregatedSectionProps) { +}: AggregatedSectionProps) { const total = groups.reduce((sum, g) => sum + g.total, 0); if (loading) { @@ -65,7 +84,7 @@ export function AggregatedSection({
{groups.map((g) => ( - key={`${g.source}-${g.label}`} group={g} renderRow={renderRow} @@ -77,16 +96,19 @@ export function AggregatedSection({ ); } -function GroupBlock({ +function GroupBlock({ group, renderRow, onShowAll, }: { - group: AggregatedGroup; - renderRow: (item: T, group: AggregatedGroup) => React.ReactNode; - onShowAll?: (group: AggregatedGroup) => void; + group: AggregatedGroup>; + renderRow: (item: ItemOfKind, group: AggregatedGroup>) => React.ReactNode; + onShowAll?: (group: AggregatedGroup>) => void; }) { - const items = (group.files ?? group.workflows ?? []) as T[]; + // The server always sets exactly one of `files` / `workflows` per group; + // unify them into a single list for rendering. The discriminated-union + // generic on `AggregatedSection` keeps the row type correct upstream. + const items = ((group.files ?? group.workflows ?? []) as unknown) as ItemOfKind[]; return (
@@ -99,13 +121,14 @@ function GroupBlock({ ))} {group.total > items.length ? ( - + ) : null}
); diff --git a/src/components/documents/documents-hub.tsx b/src/components/documents/documents-hub.tsx index ff148ffd..bfa1302d 100644 --- a/src/components/documents/documents-hub.tsx +++ b/src/components/documents/documents-hub.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import { ChevronDown, ChevronRight, FileText, Plus } from 'lucide-react'; @@ -14,6 +14,7 @@ import { PermissionGate } from '@/components/shared/permission-gate'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders'; +import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { FolderActionsMenu } from './folder-actions-menu'; import { FolderBreadcrumb } from './folder-breadcrumb'; import { FolderTreeSidebar } from './folder-tree-sidebar'; @@ -61,6 +62,15 @@ const SIGNER_STATUS_LABELS: Record = { cancelled: 'Cancelled', }; +// Runtime guard so we don't cast `entityType` from the FolderNode shape; if a +// future system folder shape leaks an unexpected entity type the UI falls +// back to FlatFolderListing instead of crashing on a bad route. +const ENTITY_TYPES = new Set(['client', 'company', 'yacht'] as const); +type EntityType = 'client' | 'company' | 'yacht'; +function isEntityType(v: unknown): v is EntityType { + return typeof v === 'string' && ENTITY_TYPES.has(v as EntityType); +} + interface DocumentsHubProps { portSlug: string; } @@ -82,14 +92,45 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) { const { data: tree = [] } = useDocumentFolders(); + // Realtime invalidation covers ALL three render modes (HubRootView, + // EntityFolderView, FlatFolderListing) so navigating between modes + // doesn't tear down the subscription. The hook-level eventKeysSig + // dedup means the inline literal is safe across re-renders. + useRealtimeInvalidation({ + 'document:created': [['documents']], + 'document:updated': [['documents']], + 'document:deleted': [['documents']], + 'document:sent': [['documents']], + 'document:completed': [['documents'], ['files']], + 'document:expired': [['documents']], + 'document:cancelled': [['documents']], + 'document:rejected': [['documents']], + 'document:signer:signed': [['documents']], + 'file:created': [['files']], + 'file:updated': [['files']], + 'file:deleted': [['files']], + 'folder:created': [['document-folders']], + 'folder:updated': [['document-folders']], + 'folder:deleted': [['document-folders']], + 'folder:moved': [['document-folders']], + }); + + const { setChrome } = useMobileChrome(); + useEffect(() => { + setChrome({ title: 'Documents' }); + return () => setChrome({ title: null }); + }, [setChrome]); + const selectedFolder = typeof selectedFolderId === 'string' ? findInTree(tree, selectedFolderId) : null; + const folderEntityType = selectedFolder?.entityType; const isEntityFolder = selectedFolder?.systemManaged === true && - selectedFolder.entityType != null && - selectedFolder.entityType !== 'root' && - selectedFolder.entityId != null; + folderEntityType != null && + folderEntityType !== 'root' && + selectedFolder.entityId != null && + isEntityType(folderEntityType); const handleFolderSelect = (id: string | null | undefined) => { setSelectedFolderId(id); @@ -128,10 +169,10 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) { {selectedFolderId === undefined ? ( - ) : isEntityFolder ? ( + ) : isEntityFolder && isEntityType(folderEntityType) ? ( ) : ( @@ -176,17 +217,8 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) { filterDefinitions: [], }); - useRealtimeInvalidation({ - 'document:created': [['documents']], - 'document:updated': [['documents']], - 'document:deleted': [['documents']], - 'document:sent': [['documents']], - 'document:completed': [['documents']], - 'document:expired': [['documents']], - 'document:cancelled': [['documents']], - 'document:rejected': [['documents']], - 'document:signer:signed': [['documents']], - }); + // Realtime invalidation is lifted to DocumentsHub so it survives mode + // switches (root / entity-folder / flat-folder). Don't re-subscribe here. const renderRow = (doc: HubDoc) => { const expanded = expandedDocId === doc.id; @@ -209,9 +241,10 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
@@ -263,6 +296,7 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
setSearch(e.target.value)} className="max-w-xs h-9" @@ -274,8 +308,9 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
+ View signing details + ) : null}
diff --git a/src/components/documents/folder-actions-menu.tsx b/src/components/documents/folder-actions-menu.tsx index 2eb0b7dd..cfc4be29 100644 --- a/src/components/documents/folder-actions-menu.tsx +++ b/src/components/documents/folder-actions-menu.tsx @@ -79,7 +79,7 @@ export function FolderActionsMenu({ selectedFolderId, onAfterDelete }: FolderAct <> - diff --git a/src/components/documents/folder-breadcrumb.tsx b/src/components/documents/folder-breadcrumb.tsx index c74a619f..0fa24b80 100644 --- a/src/components/documents/folder-breadcrumb.tsx +++ b/src/components/documents/folder-breadcrumb.tsx @@ -35,12 +35,12 @@ export function FolderBreadcrumb({ selectedFolderId, onSelect }: FolderBreadcrum return (
diff --git a/src/components/documents/hub-root-view.tsx b/src/components/documents/hub-root-view.tsx index 762140b6..fb1ca963 100644 --- a/src/components/documents/hub-root-view.tsx +++ b/src/components/documents/hub-root-view.tsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import { FileText, ClipboardSignature } from 'lucide-react'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; +import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; interface HubRootDoc { id: string; @@ -23,6 +24,17 @@ interface Props { portSlug: string; } +const STATUS_PILL_MAP: Record = { + draft: 'draft', + sent: 'sent', + partially_signed: 'partial', + completed: 'completed', + signed: 'signed', + expired: 'expired', + cancelled: 'cancelled', + rejected: 'rejected', +}; + export function HubRootView({ portSlug }: Props) { const { data: workflows, isLoading: workflowsLoading } = usePaginatedQuery({ queryKey: ['documents', 'hub-root', 'workflows'], @@ -49,11 +61,13 @@ export function HubRootView({ portSlug }: Props) { ) : (
    {workflows.map((w) => ( -
  • - +
  • + {w.title} - {w.status} + + {w.status.replace(/_/g, ' ')} +
  • ))}
diff --git a/src/components/documents/signing-details-dialog.tsx b/src/components/documents/signing-details-dialog.tsx index 923b7775..b6b6d35e 100644 --- a/src/components/documents/signing-details-dialog.tsx +++ b/src/components/documents/signing-details-dialog.tsx @@ -59,13 +59,13 @@ export function SigningDetailsDialog({ documentId, open, onOpenChange }: Props) Signing details - Audit trail for this signed document — signers and timeline. + Audit trail for this signed document: signers and timeline. {isLoading || !data ? (
- Loading… + Loading...
) : (
@@ -93,9 +93,9 @@ export function SigningDetailsDialog({ documentId, open, onOpenChange }: Props) key={s.id} className="flex items-center justify-between gap-2 px-3 py-2 text-xs" > -
- {s.signerName} - {s.signerEmail} +
+ {s.signerName} + {s.signerEmail}
{s.signedAt ? (