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
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
interface PageProps {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user