'use client'; import { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; 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 { 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 { 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'; import { HubRootView } from './hub-root-view'; import { EntityFolderView } from './entity-folder-view'; interface HubDoc { id: string; documentType: string; title: string; status: string; createdAt: string; signers?: Array<{ id: string; signerEmail: string; signerName: string; status: string }>; } const TYPE_LABELS: Record = { eoi: 'EOI', contract: 'Contract', nda: 'NDA', reservation_agreement: 'Reservation Agreement', welcome_letter: 'Welcome Letter', handover_checklist: 'Handover', acknowledgment: 'Acknowledgment', correspondence: 'Correspondence', other: 'Other', }; const STATUS_PILL_MAP: Record = { draft: 'draft', sent: 'sent', partially_signed: 'partial', completed: 'completed', signed: 'signed', expired: 'expired', cancelled: 'cancelled', rejected: 'rejected', }; const SIGNER_STATUS_LABELS: Record = { pending: 'Pending', sent: 'Sent', signed: 'Signed', declined: 'Declined', expired: 'Expired', 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; } 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(); // 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 && folderEntityType != null && folderEntityType !== 'root' && selectedFolder.entityId != null && isEntityType(folderEntityType); const handleFolderSelect = (id: string | null | undefined) => { setSelectedFolderId(id); }; return (
handleFolderSelect(undefined)} /> } />
{selectedFolderId !== undefined && ( )}
{selectedFolderId === undefined ? ( <> New document } variant="gradient" /> ) : isEntityFolder && isEntityType(folderEntityType) ? ( ) : ( )}
); } // --------------------------------------------------------------------------- // 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); const [expandedDocId, setExpandedDocId] = useState(null); const queryParams = useMemo(() => { const params = new URLSearchParams(); if (search) params.set('search', search); if (typeFilter) params.set('documentType', typeFilter); // folderId null = root, string = specific folder params.set('folderId', folderId ?? ''); return params; }, [search, typeFilter, folderId]); const { data: documents, isLoading } = usePaginatedQuery({ queryKey: ['documents', 'hub', 'folder', queryParams.toString()], endpoint: `/api/v1/documents?${queryParams.toString()}`, filterDefinitions: [], }); // 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; const totalSigners = doc.signers?.length ?? 0; const signedCount = doc.signers?.filter((s) => s.status === 'signed').length ?? 0; const pillStatus = STATUS_PILL_MAP[doc.status] ?? 'pending'; const isNonSignature = [ 'welcome_letter', 'handover_checklist', 'acknowledgment', 'correspondence', ].includes(doc.documentType); return (
  • {doc.title} {TYPE_LABELS[doc.documentType] ?? doc.documentType} {isNonSignature && doc.status === 'sent' ? 'Delivered' : doc.status.replace(/_/g, ' ')} {totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : '-'} {new Date(doc.createdAt).toLocaleDateString('en-GB')}
    {expanded && doc.signers && doc.signers.length > 0 ? (
      {doc.signers.map((signer) => (
    • {signer.signerName} {signer.signerEmail}
      {SIGNER_STATUS_LABELS[signer.status] ?? signer.status}
    • ))}
    ) : null}
  • ); }; return ( <>
    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="No documents in this folder" body="Create a document or move existing ones here." actions={ } /> ) : (
      {documents.map(renderRow)}
    )} ); }