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) <noreply@anthropic.com>
This commit is contained in:
24
src/app/(dashboard)/[portSlug]/documents/[id]/page.tsx
Normal file
24
src/app/(dashboard)/[portSlug]/documents/[id]/page.tsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title="Document detail"
|
||||
description={`Document ${id} — full detail view ships in PR5 of the Phase A rollout.`}
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${portSlug}/documents`}>Back to documents</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
src/app/(dashboard)/[portSlug]/documents/files/page.tsx
Normal file
138
src/app/(dashboard)/[portSlug]/documents/files/page.tsx
Normal file
@@ -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<FileRow | null>(null);
|
||||
const [, setRenameFile] = useState<FileRow | null>(null);
|
||||
|
||||
const { data, isLoading } = usePaginatedQuery<FileRow & { storagePath: string }>({
|
||||
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 (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<PageHeader
|
||||
title="Documents"
|
||||
description="Store and manage port documents and attachments"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
|
||||
>
|
||||
{viewMode === 'grid' ? <List className="h-4 w-4" /> : <Grid className="h-4 w-4" />}
|
||||
</Button>
|
||||
<PermissionGate resource="files" action="upload">
|
||||
<Button size="sm" onClick={() => setShowUpload((v) => !v)}>
|
||||
<Upload className="mr-1.5 h-4 w-4" />
|
||||
Upload
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{showUpload && (
|
||||
<PermissionGate resource="files" action="upload">
|
||||
<FileUploadZone
|
||||
onUploadComplete={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
setShowUpload(false);
|
||||
}}
|
||||
/>
|
||||
</PermissionGate>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 gap-4 overflow-hidden">
|
||||
{/* Folder tree sidebar */}
|
||||
<aside className="w-48 shrink-0 overflow-y-auto rounded-lg border bg-card p-2">
|
||||
<p className="mb-1 px-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Folders
|
||||
</p>
|
||||
<FolderTree
|
||||
files={data}
|
||||
currentFolder={currentFolder}
|
||||
onFolderSelect={setCurrentFolder}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-y-auto rounded-lg border bg-card p-4">
|
||||
<FileGrid
|
||||
files={filesInFolder}
|
||||
onDownload={handleDownload}
|
||||
onPreview={setPreviewFile}
|
||||
onRename={setRenameFile}
|
||||
onDelete={handleDelete}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<FilePreviewDialog
|
||||
open={!!previewFile}
|
||||
onOpenChange={(open) => !open && setPreviewFile(null)}
|
||||
fileId={previewFile?.id}
|
||||
fileName={previewFile?.filename}
|
||||
mimeType={previewFile?.mimeType ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/app/(dashboard)/[portSlug]/documents/new/page.tsx
Normal file
24
src/app/(dashboard)/[portSlug]/documents/new/page.tsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title="New document"
|
||||
description="The create-document wizard ships in PR6 of the Phase A rollout."
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${portSlug}/documents`}>Back to documents</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<FileRow | null>(null);
|
||||
const [, setRenameFile] = useState<FileRow | null>(null);
|
||||
|
||||
const { data, isLoading } = usePaginatedQuery<FileRow & { storagePath: string }>({
|
||||
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
|
||||
interface PageProps {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<PageHeader
|
||||
title="Documents"
|
||||
description="Store and manage port documents and attachments"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
|
||||
>
|
||||
{viewMode === 'grid' ? (
|
||||
<List className="h-4 w-4" />
|
||||
) : (
|
||||
<Grid className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<PermissionGate resource="files" action="upload">
|
||||
<Button size="sm" onClick={() => setShowUpload((v) => !v)}>
|
||||
<Upload className="mr-1.5 h-4 w-4" />
|
||||
Upload
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{showUpload && (
|
||||
<PermissionGate resource="files" action="upload">
|
||||
<FileUploadZone
|
||||
onUploadComplete={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
setShowUpload(false);
|
||||
}}
|
||||
/>
|
||||
</PermissionGate>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 gap-4 overflow-hidden">
|
||||
{/* Folder tree sidebar */}
|
||||
<aside className="w-48 shrink-0 overflow-y-auto rounded-lg border bg-card p-2">
|
||||
<p className="mb-1 px-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Folders
|
||||
</p>
|
||||
<FolderTree
|
||||
files={data}
|
||||
currentFolder={currentFolder}
|
||||
onFolderSelect={setCurrentFolder}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-y-auto rounded-lg border bg-card p-4">
|
||||
<FileGrid
|
||||
files={filesInFolder}
|
||||
onDownload={handleDownload}
|
||||
onPreview={setPreviewFile}
|
||||
onRename={setRenameFile}
|
||||
onDelete={handleDelete}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<FilePreviewDialog
|
||||
open={!!previewFile}
|
||||
onOpenChange={(open) => !open && setPreviewFile(null)}
|
||||
fileId={previewFile?.id}
|
||||
fileName={previewFile?.filename}
|
||||
mimeType={previewFile?.mimeType ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
export default async function DocumentsPage({ params }: PageProps) {
|
||||
const { portSlug } = await params;
|
||||
return <DocumentsHub portSlug={portSlug} />;
|
||||
}
|
||||
|
||||
16
src/app/api/v1/documents/hub-counts/route.ts
Normal file
16
src/app/api/v1/documents/hub-counts/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
313
src/components/documents/documents-hub.tsx
Normal file
313
src/components/documents/documents-hub.tsx
Normal file
@@ -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<DocumentsHubTab, string> = {
|
||||
all: 'All',
|
||||
awaiting_them: 'Awaiting them',
|
||||
awaiting_me: 'Awaiting me',
|
||||
completed: 'Completed',
|
||||
expired: 'Expired',
|
||||
};
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
interface DocumentsHubProps {
|
||||
portSlug: string;
|
||||
}
|
||||
|
||||
export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
||||
const [tab, setTab] = useState<DocumentsHubTab>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [signatureOnly, setSignatureOnly] = useState(true);
|
||||
const [expandedDocId, setExpandedDocId] = useState<string | null>(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<HubDoc>({
|
||||
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 (
|
||||
<li
|
||||
key={doc.id}
|
||||
className="border-b last:border-b-0 transition-colors hover:bg-gradient-brand-soft/40"
|
||||
>
|
||||
<div className="grid grid-cols-[auto_1fr_auto_auto_auto_auto] items-center gap-3 px-4 py-3 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={expanded ? 'Collapse signers' : 'Expand signers'}
|
||||
onClick={() => setExpandedDocId(expanded ? null : doc.id)}
|
||||
className="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}
|
||||
</StatusPill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title="Documents"
|
||||
description="Track signing status, chase pending signers, and audit completion."
|
||||
kpiLine={
|
||||
<>
|
||||
<span>
|
||||
<strong className="font-semibold text-foreground tabular-nums">{counts.all}</strong>{' '}
|
||||
total
|
||||
</span>
|
||||
<span>
|
||||
<strong className="font-semibold text-foreground tabular-nums">
|
||||
{counts.awaiting_them}
|
||||
</strong>{' '}
|
||||
awaiting signers
|
||||
</span>
|
||||
<span>
|
||||
<strong className="font-semibold text-foreground tabular-nums">
|
||||
{counts.awaiting_me}
|
||||
</strong>{' '}
|
||||
awaiting you
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<Link href={`/${portSlug}/documents/new`}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New document
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as DocumentsHubTab)}>
|
||||
<TabsList>
|
||||
{documentsHubTabs.map((t) => (
|
||||
<TabsTrigger key={t} value={t}>
|
||||
{TAB_LABELS[t]}
|
||||
{t !== 'all' && counts[t] > 0 ? (
|
||||
<span className="ml-1.5 rounded-full bg-muted px-1.5 py-0.5 text-[0.65rem] text-muted-foreground">
|
||||
{counts[t]}
|
||||
</span>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search by title…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
{Object.entries(TYPE_LABELS).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSignatureOnly((v) => !v)}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1 text-xs transition-colors',
|
||||
signatureOnly
|
||||
? 'border-brand-200 bg-brand-50 text-brand-700'
|
||||
: 'border-slate-200 bg-white text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
Signature-based only
|
||||
</button>
|
||||
</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={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' ? (
|
||||
<Button asChild>
|
||||
<Link href={`/${portSlug}/documents/new`}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New document
|
||||
</Link>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="rounded-md border bg-white shadow-xs">{documents.map(renderRow)}</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof and>[] {
|
||||
const filters: ReturnType<typeof and>[] = [];
|
||||
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<typeof and>[] = [];
|
||||
|
||||
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<typeof buildListQuery>[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<HubTabCounts> {
|
||||
async function tabCount(tab: ListDocumentsInput['tab']): Promise<number> {
|
||||
const filters: ReturnType<typeof and>[] = [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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user