Files
pn-new-crm/src/components/documents/documents-hub.tsx
Matt 1bdc856589 feat(documents-hub): NewDocumentMenu dropdown + FolderDropZone drag-drop
Replaces the bare "+ New document" Button on the documents hub with a
NewDocumentMenu dropdown so reps explicitly pick between:
- "Upload file" → opens a Dialog with FileUploadZone scoped to the
  current folder + entity context. No signing flow attached.
- "Generate document for signing" → navigates to /documents/new wizard.

Avoids the prior ambiguity where reps clicked "+ New document" intending
to attach a file and were dropped into the Documenso signer wizard.

Also adds FolderDropZone wrapping FlatFolderListing and EntityFolderView.
Dragging files from the OS over the current folder shows a drop overlay;
drop fires N parallel uploads carrying the folder + entity context.
Mirrors the per-entity Files tab UX but works in-place on the hub.

Both surfaces hit /api/v1/files/upload with folderId + entityType/Id +
the legacy clientId/companyId/yachtId FKs so files land on the right
entity AND inside the correct folder.

Also includes the in-flight prettier reformat from lint-staged on a
few previously-touched files (create-document-wizard, file-upload-zone,
admin/documenso/page) and adds the standalone prod-readiness audit
report to docs/superpowers/audits/ for permanent reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:59:34 +02:00

499 lines
18 KiB
TypeScript

'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<string, string> = {
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<string, StatusPillStatus> = {
draft: 'draft',
sent: 'sent',
partially_signed: 'partial',
completed: 'completed',
signed: 'signed',
expired: 'expired',
cancelled: 'cancelled',
rejected: 'rejected',
};
const SIGNER_STATUS_LABELS: Record<string, string> = {
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<string | null | undefined>(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 (
<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">
<div className="flex items-center justify-between gap-3">
<FolderBreadcrumb selectedFolderId={selectedFolderId} onSelect={handleFolderSelect} />
{selectedFolderId !== undefined && (
<NewDocumentMenu
portSlug={portSlug}
folderId={selectedFolderId}
entityType={
isEntityFolder && isEntityType(folderEntityType) ? folderEntityType : undefined
}
entityId={isEntityFolder ? (selectedFolder!.entityId ?? undefined) : undefined}
size="sm"
/>
)}
</div>
{selectedFolderId === undefined ? (
<>
<PageHeader
title="Documents"
description="Track signing status, chase pending signers, and audit completion."
actions={<NewDocumentMenu portSlug={portSlug} />}
variant="gradient"
/>
<HubRootView portSlug={portSlug} />
</>
) : isEntityFolder && isEntityType(folderEntityType) ? (
<FolderDropZone
folderId={selectedFolderId}
entityType={folderEntityType}
entityId={selectedFolder!.entityId!}
>
<EntityFolderView
portSlug={portSlug}
entityType={folderEntityType}
entityId={selectedFolder!.entityId!}
/>
</FolderDropZone>
) : (
<FolderDropZone folderId={selectedFolderId}>
<FlatFolderListing
key={selectedFolderId ?? 'root'}
portSlug={portSlug}
folderId={selectedFolderId}
/>
</FolderDropZone>
)}
</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 [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
const [expandedDocId, setExpandedDocId] = useState<string | null>(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<HubDoc>({
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 (
<li
key={doc.id}
className="border-b last:border-b-0 transition-colors hover:bg-gradient-brand-soft/40"
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 text-sm sm:grid sm:grid-cols-[auto_1fr_auto_auto_auto_auto] sm:gap-3">
<button
type="button"
aria-label={`${expanded ? 'Collapse' : 'Expand'} signers for ${doc.title}`}
aria-expanded={expanded}
onClick={() => setExpandedDocId(expanded ? null : doc.id)}
className="flex min-h-[44px] min-w-[44px] items-center justify-center text-muted-foreground transition-transform"
>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
<Link
href={`/${portSlug}/documents/${doc.id}`}
className="min-w-0 truncate font-medium text-foreground hover:text-brand"
>
{doc.title}
</Link>
<span className="text-xs text-muted-foreground">
{TYPE_LABELS[doc.documentType] ?? doc.documentType}
</span>
<StatusPill
status={isNonSignature && doc.status === 'sent' ? 'delivered' : pillStatus}
withDot
>
{isNonSignature && doc.status === 'sent' ? 'Delivered' : doc.status.replace(/_/g, ' ')}
</StatusPill>
<span className="text-xs tabular-nums text-muted-foreground">
{totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : '-'}
</span>
<span className="text-xs text-muted-foreground">
{new Date(doc.createdAt).toLocaleDateString('en-GB')}
</span>
</div>
{expanded && doc.signers && doc.signers.length > 0 ? (
<div className="border-t bg-muted/30 px-12 py-2">
<ul className="space-y-1">
{doc.signers.map((signer) => (
<li key={signer.id} className="flex items-center justify-between gap-2 text-xs">
<div className="flex min-w-0 items-center gap-2">
<span className="font-medium text-foreground">{signer.signerName}</span>
<span className="truncate text-muted-foreground">{signer.signerEmail}</span>
</div>
<StatusPill status={STATUS_PILL_MAP[signer.status] ?? 'pending'}>
{SIGNER_STATUS_LABELS[signer.status] ?? signer.status}
</StatusPill>
</li>
))}
</ul>
</div>
) : null}
</li>
);
};
return (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by title..."
aria-label="Search documents 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"
aria-pressed={typeFilter === undefined}
className={cn(
'inline-flex min-h-[44px] items-center rounded-full border px-3 py-2 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}
aria-pressed={typeFilter === t}
className={cn(
'inline-flex min-h-[44px] items-center rounded-full border px-3 py-2 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="No documents in this folder"
body="Create a document or move existing ones here."
actions={
<Button asChild>
<Link href={`/${portSlug}/documents/new`}>
<Plus className="mr-1.5 h-4 w-4" />
New document
</Link>
</Button>
}
/>
) : (
<ul className="rounded-md border bg-white shadow-xs">{documents.map(renderRow)}</ul>
)}
</>
);
}
// ---------------------------------------------------------------------------
// 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<HTMLDivElement>) => {
// 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (!Array.from(e.dataTransfer.types).includes('Files')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}, []);
const onDrop = useCallback(
async (e: React.DragEvent<HTMLDivElement>) => {
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 (
<div
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
className="relative"
>
{children}
{(dragActive || uploading) && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md border-2 border-dashed border-primary bg-primary/5 backdrop-blur-[1px] z-10">
<div className="flex flex-col items-center gap-2 text-sm font-medium text-primary">
<Upload className="h-8 w-8" />
{uploading ? 'Uploading…' : 'Drop to upload to this folder'}
</div>
</div>
)}
</div>
);
}