From da7262f18f15ca66087cdeef7079ab6aa9547227 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Tue, 28 Apr 2026 02:35:36 +0200 Subject: [PATCH] feat(documents): hub page with tabs, filters, and live counts Replaces /documents with the Phase A hub: tabs (All/Awaiting them/ Awaiting me/Completed/Expired) backed by per-tab counts via a new hub-counts endpoint, signature-only chip, type filter, expandable signer rows, and real-time invalidation across the eight document socket events. listDocuments grew tab/watcher/signatureOnly/sent-window filters; the legacy file browser moved to /documents/files where the sidebar already linked. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[portSlug]/documents/[id]/page.tsx | 24 ++ .../[portSlug]/documents/files/page.tsx | 138 ++++++++ .../[portSlug]/documents/new/page.tsx | 24 ++ .../(dashboard)/[portSlug]/documents/page.tsx | 148 +-------- src/app/api/v1/documents/hub-counts/route.ts | 16 + src/app/api/v1/documents/route.ts | 4 +- src/components/documents/documents-hub.tsx | 313 ++++++++++++++++++ src/lib/services/documents.service.ts | 177 +++++++++- src/lib/validators/documents.ts | 20 ++ 9 files changed, 718 insertions(+), 146 deletions(-) create mode 100644 src/app/(dashboard)/[portSlug]/documents/[id]/page.tsx create mode 100644 src/app/(dashboard)/[portSlug]/documents/files/page.tsx create mode 100644 src/app/(dashboard)/[portSlug]/documents/new/page.tsx create mode 100644 src/app/api/v1/documents/hub-counts/route.ts create mode 100644 src/components/documents/documents-hub.tsx diff --git a/src/app/(dashboard)/[portSlug]/documents/[id]/page.tsx b/src/app/(dashboard)/[portSlug]/documents/[id]/page.tsx new file mode 100644 index 0000000..1e56405 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/documents/[id]/page.tsx @@ -0,0 +1,24 @@ +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { PageHeader } from '@/components/shared/page-header'; + +interface PageProps { + params: Promise<{ portSlug: string; id: string }>; +} + +export default async function DocumentDetailPage({ params }: PageProps) { + const { portSlug, id } = await params; + return ( +
+ + Back to documents + + } + /> +
+ ); +} diff --git a/src/app/(dashboard)/[portSlug]/documents/files/page.tsx b/src/app/(dashboard)/[portSlug]/documents/files/page.tsx new file mode 100644 index 0000000..182e8df --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/documents/files/page.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useState } from 'react'; +import { Grid, List, Upload } from 'lucide-react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { Button } from '@/components/ui/button'; +import { PageHeader } from '@/components/shared/page-header'; +import { PermissionGate } from '@/components/shared/permission-gate'; +import { FileGrid } from '@/components/files/file-grid'; +import { FolderTree } from '@/components/files/folder-tree'; +import { FileUploadZone } from '@/components/files/file-upload-zone'; +import { FilePreviewDialog } from '@/components/files/file-preview-dialog'; +import { usePaginatedQuery } from '@/hooks/use-paginated-query'; +import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; +import { useFileBrowserStore } from '@/stores/file-browser-store'; +import { apiFetch } from '@/lib/api/client'; +import type { FileRow } from '@/components/files/file-grid'; + +export default function DocumentsPage() { + const queryClient = useQueryClient(); + + const { viewMode, setViewMode, currentFolder, setCurrentFolder } = useFileBrowserStore(); + const [showUpload, setShowUpload] = useState(false); + const [previewFile, setPreviewFile] = useState(null); + const [, setRenameFile] = useState(null); + + const { data, isLoading } = usePaginatedQuery({ + queryKey: ['files'], + endpoint: '/api/v1/files', + filterDefinitions: [], + }); + + useRealtimeInvalidation({ + 'file:uploaded': [['files']], + 'file:updated': [['files']], + 'file:deleted': [['files']], + }); + + const filesInFolder = currentFolder + ? data.filter((f) => f.storagePath?.includes(currentFolder)) + : data; + + const handleDownload = async (file: FileRow) => { + try { + const res = await apiFetch<{ data: { url: string; filename: string } }>( + `/api/v1/files/${file.id}/download`, + ); + const a = document.createElement('a'); + a.href = res.data.url; + a.download = res.data.filename; + a.click(); + } catch { + // silent + } + }; + + const handleDelete = async (file: FileRow) => { + if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return; + try { + await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' }); + queryClient.invalidateQueries({ queryKey: ['files'] }); + } catch { + // silent + } + }; + + return ( +
+ + + + + +
+ } + /> + + {showUpload && ( + + { + queryClient.invalidateQueries({ queryKey: ['files'] }); + setShowUpload(false); + }} + /> + + )} + +
+ {/* Folder tree sidebar */} + + + {/* Main content */} +
+ +
+
+ + !open && setPreviewFile(null)} + fileId={previewFile?.id} + fileName={previewFile?.filename} + mimeType={previewFile?.mimeType ?? undefined} + /> + + ); +} diff --git a/src/app/(dashboard)/[portSlug]/documents/new/page.tsx b/src/app/(dashboard)/[portSlug]/documents/new/page.tsx new file mode 100644 index 0000000..7dff734 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/documents/new/page.tsx @@ -0,0 +1,24 @@ +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { PageHeader } from '@/components/shared/page-header'; + +interface PageProps { + params: Promise<{ portSlug: string }>; +} + +export default async function NewDocumentPage({ params }: PageProps) { + const { portSlug } = await params; + return ( +
+ + Back to documents + + } + /> +
+ ); +} diff --git a/src/app/(dashboard)/[portSlug]/documents/page.tsx b/src/app/(dashboard)/[portSlug]/documents/page.tsx index 5ea27b8..6ee2e60 100644 --- a/src/app/(dashboard)/[portSlug]/documents/page.tsx +++ b/src/app/(dashboard)/[portSlug]/documents/page.tsx @@ -1,142 +1,10 @@ -'use client'; +import { DocumentsHub } from '@/components/documents/documents-hub'; -import { useState } from 'react'; -import { Grid, List, Upload } from 'lucide-react'; -import { useQueryClient } from '@tanstack/react-query'; - -import { Button } from '@/components/ui/button'; -import { PageHeader } from '@/components/shared/page-header'; -import { PermissionGate } from '@/components/shared/permission-gate'; -import { FileGrid } from '@/components/files/file-grid'; -import { FolderTree } from '@/components/files/folder-tree'; -import { FileUploadZone } from '@/components/files/file-upload-zone'; -import { FilePreviewDialog } from '@/components/files/file-preview-dialog'; -import { usePaginatedQuery } from '@/hooks/use-paginated-query'; -import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; -import { useFileBrowserStore } from '@/stores/file-browser-store'; -import { apiFetch } from '@/lib/api/client'; -import type { FileRow } from '@/components/files/file-grid'; - -export default function DocumentsPage() { - const queryClient = useQueryClient(); - - const { viewMode, setViewMode, currentFolder, setCurrentFolder } = useFileBrowserStore(); - const [showUpload, setShowUpload] = useState(false); - const [previewFile, setPreviewFile] = useState(null); - const [, setRenameFile] = useState(null); - - const { data, isLoading } = usePaginatedQuery({ - queryKey: ['files'], - endpoint: '/api/v1/files', - filterDefinitions: [], - }); - - useRealtimeInvalidation({ - 'file:uploaded': [['files']], - 'file:updated': [['files']], - 'file:deleted': [['files']], - }); - - const filesInFolder = currentFolder - ? data.filter((f) => f.storagePath?.includes(currentFolder)) - : data; - - const handleDownload = async (file: FileRow) => { - try { - const res = await apiFetch<{ data: { url: string; filename: string } }>( - `/api/v1/files/${file.id}/download`, - ); - const a = document.createElement('a'); - a.href = res.data.url; - a.download = res.data.filename; - a.click(); - } catch { - // silent - } - }; - - const handleDelete = async (file: FileRow) => { - if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return; - try { - await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' }); - queryClient.invalidateQueries({ queryKey: ['files'] }); - } catch { - // silent - } - }; - - return ( -
- - - - - -
- } - /> - - {showUpload && ( - - { - queryClient.invalidateQueries({ queryKey: ['files'] }); - setShowUpload(false); - }} - /> - - )} - -
- {/* Folder tree sidebar */} - - - {/* Main content */} -
- -
-
- - !open && setPreviewFile(null)} - fileId={previewFile?.id} - fileName={previewFile?.filename} - mimeType={previewFile?.mimeType ?? undefined} - /> - - ); +interface PageProps { + params: Promise<{ portSlug: string }>; +} + +export default async function DocumentsPage({ params }: PageProps) { + const { portSlug } = await params; + return ; } diff --git a/src/app/api/v1/documents/hub-counts/route.ts b/src/app/api/v1/documents/hub-counts/route.ts new file mode 100644 index 0000000..74b1942 --- /dev/null +++ b/src/app/api/v1/documents/hub-counts/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { getHubTabCounts } from '@/lib/services/documents.service'; + +export const GET = withAuth( + withPermission('documents', 'view', async (_req, ctx) => { + try { + const counts = await getHubTabCounts(ctx.portId, ctx.user.email); + return NextResponse.json({ data: counts }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/documents/route.ts b/src/app/api/v1/documents/route.ts index 1c52eac..f737923 100644 --- a/src/app/api/v1/documents/route.ts +++ b/src/app/api/v1/documents/route.ts @@ -10,7 +10,9 @@ export const GET = withAuth( withPermission('documents', 'view', async (req, ctx) => { try { const query = parseQuery(req, listDocumentsSchema); - const result = await listDocuments(ctx.portId, query); + const result = await listDocuments(ctx.portId, query, { + currentUserEmail: ctx.user.email, + }); const { page, limit } = query; const totalPages = Math.ceil(result.total / limit); diff --git a/src/components/documents/documents-hub.tsx b/src/components/documents/documents-hub.tsx new file mode 100644 index 0000000..9fd0914 --- /dev/null +++ b/src/components/documents/documents-hub.tsx @@ -0,0 +1,313 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import Link from 'next/link'; +import { useQuery } from '@tanstack/react-query'; +import { ChevronDown, ChevronRight, FileText, Plus } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; +import { EmptyState } from '@/components/ui/empty-state'; +import { PageHeader } from '@/components/shared/page-header'; +import { usePaginatedQuery } from '@/hooks/use-paginated-query'; +import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; +import { apiFetch } from '@/lib/api/client'; +import { cn } from '@/lib/utils'; +import { documentsHubTabs, type DocumentsHubTab } from '@/lib/validators/documents'; + +interface HubDoc { + id: string; + documentType: string; + title: string; + status: string; + createdAt: string; + signers?: Array<{ id: string; signerEmail: string; signerName: string; status: string }>; +} + +interface HubCounts { + all: number; + awaiting_them: number; + awaiting_me: number; + completed: number; + expired: number; +} + +const TAB_LABELS: Record = { + all: 'All', + awaiting_them: 'Awaiting them', + awaiting_me: 'Awaiting me', + completed: 'Completed', + expired: 'Expired', +}; + +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', +}; + +interface DocumentsHubProps { + portSlug: string; +} + +export function DocumentsHub({ portSlug }: DocumentsHubProps) { + const [tab, setTab] = useState('all'); + const [search, setSearch] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const [signatureOnly, setSignatureOnly] = useState(true); + const [expandedDocId, setExpandedDocId] = useState(null); + + const queryParams = useMemo(() => { + const params = new URLSearchParams(); + params.set('tab', tab); + if (search) params.set('search', search); + if (typeFilter && typeFilter !== 'all') params.set('documentType', typeFilter); + if (signatureOnly) params.set('signatureOnly', 'true'); + return params; + }, [tab, search, typeFilter, signatureOnly]); + + const { data: documents, isLoading } = usePaginatedQuery({ + queryKey: ['documents', 'hub', queryParams.toString()], + endpoint: `/api/v1/documents?${queryParams.toString()}`, + filterDefinitions: [], + }); + + const { data: countsResp } = useQuery<{ data: HubCounts }>({ + queryKey: ['documents', 'hub-counts'], + queryFn: () => apiFetch<{ data: HubCounts }>('/api/v1/documents/hub-counts'), + staleTime: 30_000, + }); + + 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']], + }); + + const counts: HubCounts = countsResp?.data ?? { + all: 0, + awaiting_them: 0, + awaiting_me: 0, + completed: 0, + expired: 0, + }; + + 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} + +
    • + ))} +
    +
    + ) : null} +
  • + ); + }; + + return ( +
    + + + {counts.all}{' '} + total + + + + {counts.awaiting_them} + {' '} + awaiting signers + + + + {counts.awaiting_me} + {' '} + awaiting you + + + } + actions={ + + } + variant="gradient" + /> + + setTab(v as DocumentsHubTab)}> + + {documentsHubTabs.map((t) => ( + + {TAB_LABELS[t]} + {t !== 'all' && counts[t] > 0 ? ( + + {counts[t]} + + ) : null} + + ))} + + + +
    + setSearch(e.target.value)} + className="max-w-xs" + /> + + +
    + + {isLoading ? ( +
      + {[0, 1, 2, 3, 4].map((i) => ( +
    • + ))} +
    + ) : documents.length === 0 ? ( + } + 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' ? ( + + ) : null + } + /> + ) : ( +
      {documents.map(renderRow)}
    + )} +
    + ); +} diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index 513f1a7..43e2bb0 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -1,4 +1,4 @@ -import { and, eq } from 'drizzle-orm'; +import { and, count, eq, gte, inArray, lt, lte, ne, sql, exists } from 'drizzle-orm'; import { db } from '@/lib/db'; import { @@ -43,15 +43,143 @@ interface AuditMeta { // ─── List ───────────────────────────────────────────────────────────────────── -export async function listDocuments(portId: string, query: ListDocumentsInput) { - const { page, limit, sort, order, search, interestId, clientId, documentType, status } = query; +import { documentWatchers as documentWatchersTable } from '@/lib/db/schema/documents'; - const filters = []; +const NON_SIGNATURE_TYPES = [ + 'welcome_letter', + 'handover_checklist', + 'acknowledgment', + 'correspondence', +]; + +function buildHubTabFilters( + tab: ListDocumentsInput['tab'], + currentUserEmail: string | undefined, +): ReturnType[] { + const filters: ReturnType[] = []; + if (!tab || tab === 'all') return filters; + + switch (tab) { + case 'awaiting_them': + // "awaiting them" = pending signers other than the current user. + // Without a known caller email we cannot make that distinction, so + // short-circuit to empty rather than silently widen the result set. + if (!currentUserEmail) { + filters.push(sql`1 = 0`); + break; + } + filters.push(inArray(documents.status, ['sent', 'partially_signed'])); + filters.push( + exists( + db + .select({ x: sql`1` }) + .from(documentSigners) + .where( + and( + eq(documentSigners.documentId, documents.id), + eq(documentSigners.status, 'pending'), + ne(documentSigners.signerEmail, currentUserEmail), + ), + ), + ), + ); + break; + case 'awaiting_me': + if (!currentUserEmail) { + // Without a current-user email there is no concept of "awaiting me" + filters.push(sql`1 = 0`); + break; + } + filters.push( + exists( + db + .select({ x: sql`1` }) + .from(documentSigners) + .where( + and( + eq(documentSigners.documentId, documents.id), + eq(documentSigners.status, 'pending'), + eq(documentSigners.signerEmail, currentUserEmail), + ), + ), + ), + ); + break; + case 'completed': + filters.push(inArray(documents.status, ['completed', 'signed'])); + break; + case 'expired': + // Either explicitly expired, or in-flight past their expiry date. + // (Documents schema doesn't yet have an `expires_at` column, so for + // now this is just status='expired' — extend when expiry lands.) + filters.push(eq(documents.status, 'expired')); + break; + } + return filters; +} + +export interface ListDocumentsExtra { + /** Email of the calling user — used by hub tab filtering for "awaiting me". */ + currentUserEmail?: string; +} + +export async function listDocuments( + portId: string, + query: ListDocumentsInput, + extra: ListDocumentsExtra = {}, +) { + const { + page, + limit, + sort, + order, + search, + interestId, + clientId, + documentType, + status, + tab, + watcherUserId, + signatureOnly, + sentSince, + sentUntil, + } = query; + + const filters: ReturnType[] = []; if (interestId) filters.push(eq(documents.interestId, interestId)); if (clientId) filters.push(eq(documents.clientId, clientId)); if (documentType) filters.push(eq(documents.documentType, documentType)); if (status) filters.push(eq(documents.status, status)); + if (sentSince) filters.push(gte(documents.createdAt, new Date(sentSince))); + if (sentUntil) filters.push(lte(documents.createdAt, new Date(sentUntil))); + if (signatureOnly === true) { + filters.push( + sql`${documents.documentType} NOT IN ('welcome_letter','handover_checklist','acknowledgment','correspondence')`, + ); + } else if (signatureOnly === false) { + // Pass-through, no extra filter needed. + } + if (watcherUserId) { + filters.push( + exists( + db + .select({ x: sql`1` }) + .from(documentWatchersTable) + .where( + and( + eq(documentWatchersTable.documentId, documents.id), + eq(documentWatchersTable.userId, watcherUserId), + ), + ), + ), + ); + } + + filters.push(...buildHubTabFilters(tab, extra.currentUserEmail)); + + void NON_SIGNATURE_TYPES; + void lt; const sortColumn = sort === 'title' @@ -70,13 +198,52 @@ export async function listDocuments(portId: string, query: ListDocumentsInput) { updatedAtColumn: documents.updatedAt, searchColumns: [documents.title], searchTerm: search, - filters, + filters: filters.filter(Boolean) as Parameters[0]['filters'], sort: sort ? { column: sortColumn, direction: order } : undefined, page, pageSize: limit, }); } +// ─── Hub tab counts ─────────────────────────────────────────────────────────── + +export interface HubTabCounts { + all: number; + awaiting_them: number; + awaiting_me: number; + completed: number; + expired: number; +} + +/** + * Compute hub tab counts in a single roundtrip per tab. Uses + * idx_docs_status_port for cheap aggregation. + */ +export async function getHubTabCounts( + portId: string, + currentUserEmail: string | undefined, +): Promise { + async function tabCount(tab: ListDocumentsInput['tab']): Promise { + const filters: ReturnType[] = [eq(documents.portId, portId)]; + filters.push(...buildHubTabFilters(tab, currentUserEmail)); + const [row] = await db + .select({ count: count() }) + .from(documents) + .where(and(...filters)); + return row?.count ?? 0; + } + + const [all, awaiting_them, awaiting_me, completed, expired] = await Promise.all([ + tabCount('all'), + tabCount('awaiting_them'), + tabCount('awaiting_me'), + tabCount('completed'), + tabCount('expired'), + ]); + + return { all, awaiting_them, awaiting_me, completed, expired }; +} + // ─── Get by ID ──────────────────────────────────────────────────────────────── export async function getDocumentById(id: string, portId: string) { diff --git a/src/lib/validators/documents.ts b/src/lib/validators/documents.ts index 71791aa..7248510 100644 --- a/src/lib/validators/documents.ts +++ b/src/lib/validators/documents.ts @@ -17,11 +17,31 @@ export const updateDocumentSchema = z.object({ status: z.enum(DOCUMENT_STATUSES).optional(), }); +export const documentsHubTabs = [ + 'all', + 'awaiting_them', + 'awaiting_me', + 'completed', + 'expired', +] as const; +export type DocumentsHubTab = (typeof documentsHubTabs)[number]; + export const listDocumentsSchema = baseListQuerySchema.extend({ interestId: z.string().optional(), clientId: z.string().optional(), documentType: z.string().optional(), status: z.string().optional(), + /** Hub tab filter — applies tab-specific status / signer-membership constraints. */ + tab: z.enum(documentsHubTabs).optional(), + /** Restrict to docs being watched by this user id. */ + watcherUserId: z.string().optional(), + /** When true, only docs intended for signing (default true on hub). */ + signatureOnly: z + .enum(['true', 'false']) + .optional() + .transform((v) => (v === undefined ? undefined : v === 'true')), + sentSince: z.string().datetime().optional(), + sentUntil: z.string().datetime().optional(), }); export const uploadSignedSchema = z.object({