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 ? (
+
+ ) : null}
+
+ );
+ };
+
+ return (
+
+ );
+}
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({