feat(documents): dynamic type-filter chips + move-to-folder row action

Type-filter chip cloud sourced from the documentTypes seen in the
current result set, replacing the static dropdown over the whole
DOCUMENT_TYPES enum. New "Move to folder…" entry on the per-row
action menu (gated on documents.manage_folders) opens the
MoveToFolderDialog Combobox.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 12:21:14 +02:00
parent 4556a03b8b
commit 433ab3bf75
2 changed files with 109 additions and 63 deletions

View File

@@ -1,5 +1,7 @@
'use client';
import { useState } from 'react';
import { FolderInput } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { Badge } from '@/components/ui/badge';
@@ -10,8 +12,10 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { apiFetch } from '@/lib/api/client';
import { MoveToFolderDialog } from './move-to-folder-dialog';
interface DocumentRow {
id: string;
@@ -19,6 +23,7 @@ interface DocumentRow {
title: string;
status: string;
createdAt: string;
folderId: string | null;
signers?: Array<{ status: string }>;
}
@@ -47,6 +52,73 @@ const TYPE_LABELS: Record<string, string> = {
other: 'Other',
};
interface DocRowProps {
doc: DocumentRow;
onDelete: (doc: DocumentRow) => void;
onSend: (doc: DocumentRow) => void;
}
function DocRow({ doc, onDelete, onSend }: DocRowProps) {
const [moveOpen, setMoveOpen] = useState(false);
const signerProgress = (() => {
if (!doc.signers) return '-';
const signed = doc.signers.filter((s) => s.status === 'signed').length;
return `${signed}/${doc.signers.length} signed`;
})();
return (
<>
<tr className="border-b last:border-0 hover:bg-muted/20">
<td className="px-4 py-3">
<Badge variant="outline">{TYPE_LABELS[doc.documentType] ?? doc.documentType}</Badge>
</td>
<td className="px-4 py-3 font-medium">{doc.title}</td>
<td className="px-4 py-3">
<Badge variant={STATUS_COLORS[doc.status] ?? 'default'}>{doc.status}</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">{signerProgress}</td>
<td className="px-4 py-3 text-muted-foreground">
{new Date(doc.createdAt).toLocaleDateString('en-GB')}
</td>
<td className="px-4 py-3 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
&hellip;
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{doc.status === 'draft' && (
<DropdownMenuItem onClick={() => onSend(doc)}>Send for Signing</DropdownMenuItem>
)}
<PermissionGate resource="documents" action="manage_folders">
<DropdownMenuItem onSelect={() => setMoveOpen(true)}>
<FolderInput className="mr-2 h-4 w-4" />
Move to folder
</DropdownMenuItem>
</PermissionGate>
<DropdownMenuItem
onClick={() => onDelete(doc)}
className="text-destructive focus:text-destructive"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
<MoveToFolderDialog
documentId={doc.id}
documentTitle={doc.title}
currentFolderId={doc.folderId ?? null}
open={moveOpen}
onOpenChange={setMoveOpen}
/>
</>
);
}
export function DocumentList({ interestId, clientId, emptyState }: DocumentListProps) {
const queryClient = useQueryClient();
@@ -79,12 +151,6 @@ export function DocumentList({ interestId, clientId, emptyState }: DocumentListP
}
};
const getSignerProgress = (doc: DocumentRow) => {
if (!doc.signers) return '-';
const signed = doc.signers.filter((s) => s.status === 'signed').length;
return `${signed}/${doc.signers.length} signed`;
};
if (isLoading) {
return (
<div className="py-8 text-center text-sm text-muted-foreground">Loading documents...</div>
@@ -111,41 +177,7 @@ export function DocumentList({ interestId, clientId, emptyState }: DocumentListP
</thead>
<tbody>
{data.map((doc) => (
<tr key={doc.id} className="border-b last:border-0 hover:bg-muted/20">
<td className="px-4 py-3">
<Badge variant="outline">{TYPE_LABELS[doc.documentType] ?? doc.documentType}</Badge>
</td>
<td className="px-4 py-3 font-medium">{doc.title}</td>
<td className="px-4 py-3">
<Badge variant={STATUS_COLORS[doc.status] ?? 'default'}>{doc.status}</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">{getSignerProgress(doc)}</td>
<td className="px-4 py-3 text-muted-foreground">
{new Date(doc.createdAt).toLocaleDateString('en-GB')}
</td>
<td className="px-4 py-3 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
&hellip;
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{doc.status === 'draft' && (
<DropdownMenuItem onClick={() => handleSend(doc)}>
Send for Signing
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handleDelete(doc)}
className="text-destructive focus:text-destructive"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
<DocRow key={doc.id} doc={doc} onDelete={handleDelete} onSend={handleSend} />
))}
</tbody>
</table>

View File

@@ -7,13 +7,7 @@ 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 { cn } from '@/lib/utils';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { EmptyState } from '@/components/ui/empty-state';
@@ -96,7 +90,7 @@ interface DocumentsHubProps {
export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps) {
const [tab, setTab] = useState<DocumentsHubTab>(initialTab);
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
// undefined = "All documents" (no folder filter), null = root only,
// string = a specific folder id.
const [selectedFolderId, setSelectedFolderId] = useState<string | null | undefined>(undefined);
@@ -106,7 +100,7 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
const params = new URLSearchParams();
params.set('tab', tab);
if (search) params.set('search', search);
if (typeFilter && typeFilter !== 'all') params.set('documentType', typeFilter);
if (typeFilter) params.set('documentType', typeFilter);
if (selectedFolderId !== undefined) {
params.set('folderId', selectedFolderId ?? '');
}
@@ -290,19 +284,39 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
onChange={(e) => setSearch(e.target.value)}
className="max-w-xs h-9"
/>
<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>
{(() => {
const seenTypes = Array.from(
new Set(documents.map((d) => d.documentType)),
).sort();
if (seenTypes.length === 0) return null;
return (
<div className="flex flex-wrap gap-1.5">
<button
type="button"
className={cn(
'rounded-full border px-2.5 py-0.5 text-xs',
typeFilter === undefined ? 'bg-foreground text-background' : 'hover:bg-accent',
)}
onClick={() => setTypeFilter(undefined)}
>
All types
</button>
{seenTypes.map((t) => (
<button
type="button"
key={t}
className={cn(
'rounded-full border px-2.5 py-0.5 text-xs',
typeFilter === t ? 'bg-foreground text-background' : 'hover:bg-accent',
)}
onClick={() => setTypeFilter(t)}
>
{t}
</button>
))}
</div>
);
})()}
</div>
{isLoading ? (