'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import { useQueryClient } from '@tanstack/react-query'; import { ChevronDown, ChevronRight, FileText, Plus, Upload } 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 { useUIStore } from '@/stores/ui-store'; 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'; import { NewDocumentMenu } from './new-document-menu'; 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 ? ( <> } 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)}
    )} ); } // --------------------------------------------------------------------------- // FolderDropZone — wraps the main content panel and accepts file drops onto // the currently-viewed folder. Files dropped here upload with folder_id + // entity FKs set so they land where the rep expects. // // Renders an overlay while a drag is in progress; the underlying content // stays interactive. Multiple files at once are supported (Promise.all // inside the upload loop). Errors surface inline; the toast layer picks // them up via React Query / fetch error responses. // --------------------------------------------------------------------------- interface FolderDropZoneProps { folderId: string | null; entityType?: 'client' | 'company' | 'yacht'; entityId?: string; children: React.ReactNode; } function FolderDropZone({ folderId, entityType, entityId, children }: FolderDropZoneProps) { const [dragActive, setDragActive] = useState(false); const [uploading, setUploading] = useState(false); const dragCounter = useMemo(() => ({ count: 0 }), []); const queryClient = useQueryClient(); const portId = useUIStore((s) => s.currentPortId); const onDragEnter = useCallback( (e: React.DragEvent) => { // Only react to drags that carry files. Avoids fighting with // text-selection / element drags inside the listing. if (!Array.from(e.dataTransfer.types).includes('Files')) return; e.preventDefault(); dragCounter.count += 1; if (dragCounter.count === 1) setDragActive(true); }, [dragCounter], ); const onDragLeave = useCallback( (e: React.DragEvent) => { if (!Array.from(e.dataTransfer.types).includes('Files')) return; dragCounter.count -= 1; if (dragCounter.count <= 0) { dragCounter.count = 0; setDragActive(false); } }, [dragCounter], ); const onDragOver = useCallback((e: React.DragEvent) => { if (!Array.from(e.dataTransfer.types).includes('Files')) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }, []); const onDrop = useCallback( async (e: React.DragEvent) => { if (!Array.from(e.dataTransfer.types).includes('Files')) return; e.preventDefault(); dragCounter.count = 0; setDragActive(false); const files = Array.from(e.dataTransfer.files); if (files.length === 0) return; setUploading(true); try { await Promise.all( files.map(async (file) => { const fd = new FormData(); fd.append('file', file); fd.append('filename', file.name); if (folderId) fd.append('folderId', folderId); if (entityType) fd.append('entityType', entityType); if (entityId) fd.append('entityId', entityId); if (entityType === 'client' && entityId) fd.append('clientId', entityId); if (entityType === 'company' && entityId) fd.append('companyId', entityId); if (entityType === 'yacht' && entityId) fd.append('yachtId', entityId); const headers = new Headers(); if (portId) headers.set('X-Port-Id', portId); await fetch('/api/v1/files/upload', { method: 'POST', headers, credentials: 'include', body: fd, }); }), ); queryClient.invalidateQueries({ queryKey: ['files'] }); queryClient.invalidateQueries({ queryKey: ['documents'] }); } finally { setUploading(false); } }, [dragCounter, folderId, entityType, entityId, portId, queryClient], ); return (
    {children} {(dragActive || uploading) && (
    {uploading ? 'Uploading…' : 'Drop to upload to this folder'}
    )}
    ); }