merge: PR4 — documents hub page (Phase A)
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';
|
interface PageProps {
|
||||||
import { Grid, List, Upload } from 'lucide-react';
|
params: Promise<{ portSlug: string }>;
|
||||||
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) => {
|
export default async function DocumentsPage({ params }: PageProps) {
|
||||||
if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return;
|
const { portSlug } = await params;
|
||||||
try {
|
return <DocumentsHub portSlug={portSlug} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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) => {
|
withPermission('documents', 'view', async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
const query = parseQuery(req, listDocumentsSchema);
|
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 { page, limit } = query;
|
||||||
const totalPages = Math.ceil(result.total / limit);
|
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 { db } from '@/lib/db';
|
||||||
import {
|
import {
|
||||||
@@ -43,15 +43,143 @@ interface AuditMeta {
|
|||||||
|
|
||||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function listDocuments(portId: string, query: ListDocumentsInput) {
|
import { documentWatchers as documentWatchersTable } from '@/lib/db/schema/documents';
|
||||||
const { page, limit, sort, order, search, interestId, clientId, documentType, status } = query;
|
|
||||||
|
|
||||||
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 (interestId) filters.push(eq(documents.interestId, interestId));
|
||||||
if (clientId) filters.push(eq(documents.clientId, clientId));
|
if (clientId) filters.push(eq(documents.clientId, clientId));
|
||||||
if (documentType) filters.push(eq(documents.documentType, documentType));
|
if (documentType) filters.push(eq(documents.documentType, documentType));
|
||||||
if (status) filters.push(eq(documents.status, status));
|
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 =
|
const sortColumn =
|
||||||
sort === 'title'
|
sort === 'title'
|
||||||
@@ -70,13 +198,52 @@ export async function listDocuments(portId: string, query: ListDocumentsInput) {
|
|||||||
updatedAtColumn: documents.updatedAt,
|
updatedAtColumn: documents.updatedAt,
|
||||||
searchColumns: [documents.title],
|
searchColumns: [documents.title],
|
||||||
searchTerm: search,
|
searchTerm: search,
|
||||||
filters,
|
filters: filters.filter(Boolean) as Parameters<typeof buildListQuery>[0]['filters'],
|
||||||
sort: sort ? { column: sortColumn, direction: order } : undefined,
|
sort: sort ? { column: sortColumn, direction: order } : undefined,
|
||||||
page,
|
page,
|
||||||
pageSize: limit,
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Get by ID ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getDocumentById(id: string, portId: string) {
|
export async function getDocumentById(id: string, portId: string) {
|
||||||
|
|||||||
@@ -17,11 +17,31 @@ export const updateDocumentSchema = z.object({
|
|||||||
status: z.enum(DOCUMENT_STATUSES).optional(),
|
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({
|
export const listDocumentsSchema = baseListQuerySchema.extend({
|
||||||
interestId: z.string().optional(),
|
interestId: z.string().optional(),
|
||||||
clientId: z.string().optional(),
|
clientId: z.string().optional(),
|
||||||
documentType: z.string().optional(),
|
documentType: z.string().optional(),
|
||||||
status: 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({
|
export const uploadSignedSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user