fix(documents-ui): a11y, mobile, realtime lift, type-safety, UI polish

- A2: lift useRealtimeInvalidation to DocumentsHub (covers all 3 render modes)
- B1-B4: aria-label, aria-pressed, aria-expanded, Lock SVG aria-hidden
- C1-C7: Sheet wrap for mobile sidebar, border axis fix, 44x44 tap targets
- Mobile Important: useMobileChrome title, FolderActionsMenu icon size, breadcrumb tap targets, signer email truncate
- Type-safety: ENTITY_TYPES guard, AggregatedSection discriminated union
- UI/UX: em-dash to colon in SigningDetailsDialog, Loading state normalize, StatusPill on HubRootView, view signing details as Button

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 13:56:05 +02:00
parent c761b4b911
commit b5ebed9c36
8 changed files with 212 additions and 74 deletions

View File

@@ -2,16 +2,35 @@
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import type { AggregatedGroup } from '@/hooks/use-aggregated-listing'; import { Button } from '@/components/ui/button';
import type {
AggregatedFile,
AggregatedGroup,
AggregatedWorkflow,
} from '@/hooks/use-aggregated-listing';
interface AggregatedSectionProps<T> { /**
* Discriminated-union of the two item shapes the aggregated projection
* surfaces (files / workflows). Keeps `renderRow` strictly typed so callers
* don't have to widen to `unknown` or recast inside the row renderer.
*/
type AggregatedItemKind =
| { kind: 'files'; items: AggregatedFile[] }
| { kind: 'workflows'; items: AggregatedWorkflow[] };
type ItemOfKind<K extends AggregatedItemKind['kind']> = Extract<
AggregatedItemKind,
{ kind: K }
>['items'][number];
interface AggregatedSectionProps<K extends AggregatedItemKind['kind']> {
title: string; title: string;
icon?: React.ReactNode; icon?: React.ReactNode;
groups: AggregatedGroup<T>[]; groups: AggregatedGroup<ItemOfKind<K>>[];
renderRow: (item: T, group: AggregatedGroup<T>) => React.ReactNode; renderRow: (item: ItemOfKind<K>, group: AggregatedGroup<ItemOfKind<K>>) => React.ReactNode;
emptyMessage?: string; emptyMessage?: string;
loading?: boolean; loading?: boolean;
onShowAll?: (group: AggregatedGroup<T>) => void; onShowAll?: (group: AggregatedGroup<ItemOfKind<K>>) => void;
} }
/** /**
@@ -20,7 +39,7 @@ interface AggregatedSectionProps<T> {
* link drills into the source-scoped flat list. Hidden when groups is * link drills into the source-scoped flat list. Hidden when groups is
* empty. * empty.
*/ */
export function AggregatedSection<T>({ export function AggregatedSection<K extends AggregatedItemKind['kind']>({
title, title,
icon, icon,
groups, groups,
@@ -28,7 +47,7 @@ export function AggregatedSection<T>({
emptyMessage = 'Nothing here yet.', emptyMessage = 'Nothing here yet.',
loading, loading,
onShowAll, onShowAll,
}: AggregatedSectionProps<T>) { }: AggregatedSectionProps<K>) {
const total = groups.reduce((sum, g) => sum + g.total, 0); const total = groups.reduce((sum, g) => sum + g.total, 0);
if (loading) { if (loading) {
@@ -65,7 +84,7 @@ export function AggregatedSection<T>({
</h3> </h3>
<div className="divide-y"> <div className="divide-y">
{groups.map((g) => ( {groups.map((g) => (
<GroupBlock <GroupBlock<K>
key={`${g.source}-${g.label}`} key={`${g.source}-${g.label}`}
group={g} group={g}
renderRow={renderRow} renderRow={renderRow}
@@ -77,16 +96,19 @@ export function AggregatedSection<T>({
); );
} }
function GroupBlock<T>({ function GroupBlock<K extends AggregatedItemKind['kind']>({
group, group,
renderRow, renderRow,
onShowAll, onShowAll,
}: { }: {
group: AggregatedGroup<T>; group: AggregatedGroup<ItemOfKind<K>>;
renderRow: (item: T, group: AggregatedGroup<T>) => React.ReactNode; renderRow: (item: ItemOfKind<K>, group: AggregatedGroup<ItemOfKind<K>>) => React.ReactNode;
onShowAll?: (group: AggregatedGroup<T>) => void; onShowAll?: (group: AggregatedGroup<ItemOfKind<K>>) => void;
}) { }) {
const items = (group.files ?? group.workflows ?? []) as T[]; // The server always sets exactly one of `files` / `workflows` per group;
// unify them into a single list for rendering. The discriminated-union
// generic on `AggregatedSection` keeps the row type correct upstream.
const items = ((group.files ?? group.workflows ?? []) as unknown) as ItemOfKind<K>[];
return ( return (
<div className="px-3 py-2"> <div className="px-3 py-2">
<header className="mb-1 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground"> <header className="mb-1 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
@@ -99,13 +121,14 @@ function GroupBlock<T>({
))} ))}
</ul> </ul>
{group.total > items.length ? ( {group.total > items.length ? (
<button <Button
type="button" variant="ghost"
className="mt-1 text-xs text-brand hover:underline" size="sm"
className="mt-1 min-h-[44px] px-2 text-xs text-brand hover:underline"
onClick={() => onShowAll?.(group)} onClick={() => onShowAll?.(group)}
> >
Show all ({group.total}) Show all ({group.total})
</button> </Button>
) : null} ) : null}
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ChevronDown, ChevronRight, FileText, Plus } from 'lucide-react'; import { ChevronDown, ChevronRight, FileText, Plus } from 'lucide-react';
@@ -14,6 +14,7 @@ import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders'; import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { FolderActionsMenu } from './folder-actions-menu'; import { FolderActionsMenu } from './folder-actions-menu';
import { FolderBreadcrumb } from './folder-breadcrumb'; import { FolderBreadcrumb } from './folder-breadcrumb';
import { FolderTreeSidebar } from './folder-tree-sidebar'; import { FolderTreeSidebar } from './folder-tree-sidebar';
@@ -61,6 +62,15 @@ const SIGNER_STATUS_LABELS: Record<string, string> = {
cancelled: 'Cancelled', cancelled: 'Cancelled',
}; };
// Runtime guard so we don't cast `entityType` from the FolderNode shape; if a
// future system folder shape leaks an unexpected entity type the UI falls
// back to FlatFolderListing instead of crashing on a bad route.
const ENTITY_TYPES = new Set(['client', 'company', 'yacht'] as const);
type EntityType = 'client' | 'company' | 'yacht';
function isEntityType(v: unknown): v is EntityType {
return typeof v === 'string' && ENTITY_TYPES.has(v as EntityType);
}
interface DocumentsHubProps { interface DocumentsHubProps {
portSlug: string; portSlug: string;
} }
@@ -82,14 +92,45 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
const { data: tree = [] } = useDocumentFolders(); const { data: tree = [] } = useDocumentFolders();
// Realtime invalidation covers ALL three render modes (HubRootView,
// EntityFolderView, FlatFolderListing) so navigating between modes
// doesn't tear down the subscription. The hook-level eventKeysSig
// dedup means the inline literal is safe across re-renders.
useRealtimeInvalidation({
'document:created': [['documents']],
'document:updated': [['documents']],
'document:deleted': [['documents']],
'document:sent': [['documents']],
'document:completed': [['documents'], ['files']],
'document:expired': [['documents']],
'document:cancelled': [['documents']],
'document:rejected': [['documents']],
'document:signer:signed': [['documents']],
'file:created': [['files']],
'file:updated': [['files']],
'file:deleted': [['files']],
'folder:created': [['document-folders']],
'folder:updated': [['document-folders']],
'folder:deleted': [['document-folders']],
'folder:moved': [['document-folders']],
});
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'Documents' });
return () => setChrome({ title: null });
}, [setChrome]);
const selectedFolder = const selectedFolder =
typeof selectedFolderId === 'string' ? findInTree(tree, selectedFolderId) : null; typeof selectedFolderId === 'string' ? findInTree(tree, selectedFolderId) : null;
const folderEntityType = selectedFolder?.entityType;
const isEntityFolder = const isEntityFolder =
selectedFolder?.systemManaged === true && selectedFolder?.systemManaged === true &&
selectedFolder.entityType != null && folderEntityType != null &&
selectedFolder.entityType !== 'root' && folderEntityType !== 'root' &&
selectedFolder.entityId != null; selectedFolder.entityId != null &&
isEntityType(folderEntityType);
const handleFolderSelect = (id: string | null | undefined) => { const handleFolderSelect = (id: string | null | undefined) => {
setSelectedFolderId(id); setSelectedFolderId(id);
@@ -128,10 +169,10 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
{selectedFolderId === undefined ? ( {selectedFolderId === undefined ? (
<HubRootView portSlug={portSlug} /> <HubRootView portSlug={portSlug} />
) : isEntityFolder ? ( ) : isEntityFolder && isEntityType(folderEntityType) ? (
<EntityFolderView <EntityFolderView
portSlug={portSlug} portSlug={portSlug}
entityType={selectedFolder!.entityType as 'client' | 'company' | 'yacht'} entityType={folderEntityType}
entityId={selectedFolder!.entityId!} entityId={selectedFolder!.entityId!}
/> />
) : ( ) : (
@@ -176,17 +217,8 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
filterDefinitions: [], filterDefinitions: [],
}); });
useRealtimeInvalidation({ // Realtime invalidation is lifted to DocumentsHub so it survives mode
'document:created': [['documents']], // switches (root / entity-folder / flat-folder). Don't re-subscribe here.
'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 renderRow = (doc: HubDoc) => { const renderRow = (doc: HubDoc) => {
const expanded = expandedDocId === doc.id; const expanded = expandedDocId === doc.id;
@@ -209,9 +241,10 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 text-sm sm:grid sm:grid-cols-[auto_1fr_auto_auto_auto_auto] sm:gap-3"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 text-sm sm:grid sm:grid-cols-[auto_1fr_auto_auto_auto_auto] sm:gap-3">
<button <button
type="button" type="button"
aria-label={expanded ? 'Collapse signers' : 'Expand signers'} aria-label={`${expanded ? 'Collapse' : 'Expand'} signers for ${doc.title}`}
aria-expanded={expanded}
onClick={() => setExpandedDocId(expanded ? null : doc.id)} onClick={() => setExpandedDocId(expanded ? null : doc.id)}
className="text-muted-foreground transition-transform" className="flex min-h-[44px] min-w-[44px] items-center justify-center text-muted-foreground transition-transform"
> >
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />} {expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button> </button>
@@ -263,6 +296,7 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Input <Input
placeholder="Search by title..." placeholder="Search by title..."
aria-label="Search documents by title"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="max-w-xs h-9" className="max-w-xs h-9"
@@ -274,8 +308,9 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<button <button
type="button" type="button"
aria-pressed={typeFilter === undefined}
className={cn( className={cn(
'rounded-full border px-2.5 py-0.5 text-xs', 'inline-flex min-h-[44px] items-center rounded-full border px-3 py-2 text-xs',
typeFilter === undefined ? 'bg-foreground text-background' : 'hover:bg-accent', typeFilter === undefined ? 'bg-foreground text-background' : 'hover:bg-accent',
)} )}
onClick={() => setTypeFilter(undefined)} onClick={() => setTypeFilter(undefined)}
@@ -286,8 +321,9 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
<button <button
type="button" type="button"
key={t} key={t}
aria-pressed={typeFilter === t}
className={cn( className={cn(
'rounded-full border px-2.5 py-0.5 text-xs', 'inline-flex min-h-[44px] items-center rounded-full border px-3 py-2 text-xs',
typeFilter === t ? 'bg-foreground text-background' : 'hover:bg-accent', typeFilter === t ? 'bg-foreground text-background' : 'hover:bg-accent',
)} )}
onClick={() => setTypeFilter(t)} onClick={() => setTypeFilter(t)}

View File

@@ -4,14 +4,15 @@ import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ClipboardSignature, FileText, Eye } from 'lucide-react'; import { ClipboardSignature, FileText, Eye } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AggregatedSection } from './aggregated-section'; import { AggregatedSection } from './aggregated-section';
import { SigningDetailsDialog } from './signing-details-dialog'; import { SigningDetailsDialog } from './signing-details-dialog';
import { useAggregatedFiles, useAggregatedWorkflows } from '@/hooks/use-aggregated-listing'; import { useAggregatedFiles, useAggregatedWorkflows } from '@/hooks/use-aggregated-listing';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import type { import type {
AggregatedWorkflow,
AggregatedFile, AggregatedFile,
AggregatedGroup, AggregatedGroup,
AggregatedWorkflow,
} from '@/hooks/use-aggregated-listing'; } from '@/hooks/use-aggregated-listing';
interface Props { interface Props {
@@ -47,7 +48,7 @@ export function EntityFolderView({ portSlug, entityType, entityId }: Props) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<AggregatedSection <AggregatedSection<'workflows'>
title="Signing in progress" title="Signing in progress"
icon={<ClipboardSignature className="h-4 w-4 text-muted-foreground" />} icon={<ClipboardSignature className="h-4 w-4 text-muted-foreground" />}
groups={workflowGroups} groups={workflowGroups}
@@ -65,7 +66,7 @@ export function EntityFolderView({ portSlug, entityType, entityId }: Props) {
)} )}
/> />
<AggregatedSection <AggregatedSection<'files'>
title="Files" title="Files"
icon={<FileText className="h-4 w-4 text-muted-foreground" />} icon={<FileText className="h-4 w-4 text-muted-foreground" />}
groups={fileGroups} groups={fileGroups}
@@ -79,14 +80,15 @@ export function EntityFolderView({ portSlug, entityType, entityId }: Props) {
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums"> <div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
<span>{new Date(f.createdAt).toLocaleDateString('en-GB')}</span> <span>{new Date(f.createdAt).toLocaleDateString('en-GB')}</span>
{signedFromDocumentId ? ( {signedFromDocumentId ? (
<button <Button
type="button" variant="ghost"
className="flex items-center gap-1 text-brand hover:underline" size="sm"
className="min-h-[44px] gap-1 px-2 text-xs text-brand"
onClick={() => setDetailsId(signedFromDocumentId)} onClick={() => setDetailsId(signedFromDocumentId)}
> >
<Eye className="h-3 w-3" /> <Eye className="h-3 w-3" />
view signing details View signing details
</button> </Button>
) : null} ) : null}
</div> </div>
</div> </div>

View File

@@ -79,7 +79,7 @@ export function FolderActionsMenu({ selectedFolderId, onAfterDelete }: FolderAct
<> <>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7"> <Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Folder actions</span> <span className="sr-only">Folder actions</span>
</Button> </Button>

View File

@@ -35,12 +35,12 @@ export function FolderBreadcrumb({ selectedFolderId, onSelect }: FolderBreadcrum
return ( return (
<nav <nav
aria-label="Folder breadcrumb" aria-label="Folder breadcrumb"
className="flex items-center gap-1 text-sm text-muted-foreground" className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground"
> >
<button <button
type="button" type="button"
onClick={() => onSelect(undefined)} onClick={() => onSelect(undefined)}
className="flex items-center gap-1 hover:text-foreground" className="flex min-h-[44px] items-center gap-1 py-2 hover:text-foreground"
> >
<Home className="h-3.5 w-3.5" /> <Home className="h-3.5 w-3.5" />
<span>All</span> <span>All</span>
@@ -64,7 +64,7 @@ export function FolderBreadcrumb({ selectedFolderId, onSelect }: FolderBreadcrum
<button <button
type="button" type="button"
onClick={() => onSelect(node.id)} onClick={() => onSelect(node.id)}
className="hover:text-foreground" className="flex min-h-[44px] items-center py-2 hover:text-foreground"
> >
{node.name} {node.name}
</button> </button>

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { ChevronRight, Folder, FolderOpen, Inbox, Lock } from 'lucide-react'; import { ChevronRight, Folder, FolderOpen, FolderTree, Inbox, Lock } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders'; import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders';
@@ -24,15 +25,73 @@ interface FolderTreeSidebarProps {
* *
* Designed for unlimited depth — only the top level renders by default * Designed for unlimited depth — only the top level renders by default
* so deep trees don't blow out the page; reps drill in by expanding. * so deep trees don't blow out the page; reps drill in by expanding.
*
* On mobile (< sm) the sidebar collapses into a Sheet drawer triggered by
* a "Show folders" button so the main listing isn't pushed below a
* full-width folder stack.
*/ */
export function FolderTreeSidebar({ selectedFolderId, onSelect, footer }: FolderTreeSidebarProps) { export function FolderTreeSidebar({ selectedFolderId, onSelect, footer }: FolderTreeSidebarProps) {
const [mobileOpen, setMobileOpen] = useState(false);
const handleMobileSelect = (id: string | null | undefined) => {
onSelect(id);
setMobileOpen(false);
};
return (
<>
{/* Mobile-only trigger that opens the drawer; hidden at sm+. */}
<div className="sm:hidden px-3 pt-3">
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="sm" className="min-h-[44px]">
<FolderTree className="mr-2 h-4 w-4" />
Show folders
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-3/4 max-w-xs p-0">
<SheetHeader className="border-b px-3 py-3">
<SheetTitle className="text-sm">Folders</SheetTitle>
</SheetHeader>
<div className="p-2 overflow-y-auto">
<TreeBody
selectedFolderId={selectedFolderId}
onSelect={handleMobileSelect}
footer={footer}
/>
</div>
</SheetContent>
</Sheet>
</div>
{/* Desktop sidebar: hidden on mobile (the Sheet trigger replaces it). */}
<aside className="hidden sm:block w-60 shrink-0 border-b sm:border-b-0 sm:border-r bg-muted/40 p-2">
<div className="mb-2 px-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Folders
</div>
<TreeBody
selectedFolderId={selectedFolderId}
onSelect={onSelect}
footer={footer}
/>
</aside>
</>
);
}
function TreeBody({
selectedFolderId,
onSelect,
footer,
}: {
selectedFolderId: string | null | undefined;
onSelect: (folderId: string | null | undefined) => void;
footer?: React.ReactNode;
}) {
const { data: tree = [], isLoading, isError } = useDocumentFolders(); const { data: tree = [], isLoading, isError } = useDocumentFolders();
return ( return (
<aside className="w-full sm:w-60 shrink-0 border-r bg-muted/40 p-2"> <>
<div className="mb-2 px-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Folders
</div>
<div className="space-y-0.5"> <div className="space-y-0.5">
<PseudoRow <PseudoRow
label="All documents" label="All documents"
@@ -49,7 +108,7 @@ export function FolderTreeSidebar({ selectedFolderId, onSelect, footer }: Folder
</div> </div>
<div className="mt-3 space-y-0.5"> <div className="mt-3 space-y-0.5">
{isLoading ? ( {isLoading ? (
<p className="px-2 text-xs text-muted-foreground">Loading</p> <p className="px-2 text-xs text-muted-foreground">Loading...</p>
) : isError ? ( ) : isError ? (
<p className="px-2 text-xs text-destructive">Failed to load folders.</p> <p className="px-2 text-xs text-destructive">Failed to load folders.</p>
) : tree.length === 0 ? ( ) : tree.length === 0 ? (
@@ -67,7 +126,7 @@ export function FolderTreeSidebar({ selectedFolderId, onSelect, footer }: Folder
)} )}
</div> </div>
{footer ? <div className="mt-4 border-t pt-3">{footer}</div> : null} {footer ? <div className="mt-4 border-t pt-3">{footer}</div> : null}
</aside> </>
); );
} }
@@ -86,7 +145,10 @@ function PseudoRow({
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className={cn('w-full justify-start font-normal', active && 'bg-accent text-foreground')} className={cn(
'w-full min-h-[44px] justify-start font-normal',
active && 'bg-accent text-foreground',
)}
onClick={onClick} onClick={onClick}
> >
<Icon className="mr-2 h-4 w-4" /> <Icon className="mr-2 h-4 w-4" />
@@ -121,12 +183,13 @@ function FolderRow({
> >
<button <button
type="button" type="button"
aria-label={open ? 'Collapse' : 'Expand'} aria-label={`${open ? 'Collapse' : 'Expand'} ${node.name}`}
aria-expanded={hasChildren ? open : undefined}
aria-hidden={!hasChildren} aria-hidden={!hasChildren}
tabIndex={hasChildren ? 0 : -1} tabIndex={hasChildren ? 0 : -1}
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
className={cn( className={cn(
'flex h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground', 'flex min-h-[44px] min-w-[44px] items-center justify-center text-muted-foreground hover:text-foreground',
!hasChildren && 'invisible', !hasChildren && 'invisible',
)} )}
> >
@@ -136,7 +199,7 @@ function FolderRow({
type="button" type="button"
onClick={() => onSelect(node.id)} onClick={() => onSelect(node.id)}
className={cn( className={cn(
'flex flex-1 items-center gap-1.5 truncate text-left', 'flex min-h-[44px] flex-1 items-center gap-1.5 truncate py-2 text-left',
node.archivedAt != null && 'text-muted-foreground', node.archivedAt != null && 'text-muted-foreground',
)} )}
> >
@@ -145,12 +208,12 @@ function FolderRow({
) : ( ) : (
<Folder className="h-4 w-4 shrink-0" /> <Folder className="h-4 w-4 shrink-0" />
)} )}
<span className="truncate">{node.name}</span> <span className="truncate">
{node.name}
{node.systemManaged ? <span className="sr-only"> (system folder)</span> : null}
</span>
{node.systemManaged ? ( {node.systemManaged ? (
<Lock <Lock className="ml-1 h-3 w-3 shrink-0 text-muted-foreground" aria-hidden="true" />
className="ml-1 h-3 w-3 shrink-0 text-muted-foreground"
aria-label="System folder"
/>
) : null} ) : null}
</button> </button>
</div> </div>

View File

@@ -4,6 +4,7 @@ import Link from 'next/link';
import { FileText, ClipboardSignature } from 'lucide-react'; import { FileText, ClipboardSignature } from 'lucide-react';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
interface HubRootDoc { interface HubRootDoc {
id: string; id: string;
@@ -23,6 +24,17 @@ interface Props {
portSlug: string; portSlug: string;
} }
const STATUS_PILL_MAP: Record<string, StatusPillStatus> = {
draft: 'draft',
sent: 'sent',
partially_signed: 'partial',
completed: 'completed',
signed: 'signed',
expired: 'expired',
cancelled: 'cancelled',
rejected: 'rejected',
};
export function HubRootView({ portSlug }: Props) { export function HubRootView({ portSlug }: Props) {
const { data: workflows, isLoading: workflowsLoading } = usePaginatedQuery<HubRootDoc>({ const { data: workflows, isLoading: workflowsLoading } = usePaginatedQuery<HubRootDoc>({
queryKey: ['documents', 'hub-root', 'workflows'], queryKey: ['documents', 'hub-root', 'workflows'],
@@ -49,11 +61,13 @@ export function HubRootView({ portSlug }: Props) {
) : ( ) : (
<ul className="divide-y"> <ul className="divide-y">
{workflows.map((w) => ( {workflows.map((w) => (
<li key={w.id} className="px-3 py-2 text-sm"> <li key={w.id} className="flex items-center justify-between gap-2 px-3 py-2 text-sm">
<Link href={`/${portSlug}/documents/${w.id}`} className="hover:underline"> <Link href={`/${portSlug}/documents/${w.id}`} className="truncate hover:underline">
{w.title} {w.title}
</Link> </Link>
<span className="ml-2 text-xs text-muted-foreground">{w.status}</span> <StatusPill status={STATUS_PILL_MAP[w.status] ?? 'pending'}>
{w.status.replace(/_/g, ' ')}
</StatusPill>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -59,13 +59,13 @@ export function SigningDetailsDialog({ documentId, open, onOpenChange }: Props)
<DialogHeader> <DialogHeader>
<DialogTitle>Signing details</DialogTitle> <DialogTitle>Signing details</DialogTitle>
<DialogDescription> <DialogDescription>
Audit trail for this signed document signers and timeline. Audit trail for this signed document: signers and timeline.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{isLoading || !data ? ( {isLoading || !data ? (
<div className="flex items-center justify-center py-12 text-muted-foreground"> <div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading Loading...
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
@@ -93,9 +93,9 @@ export function SigningDetailsDialog({ documentId, open, onOpenChange }: Props)
key={s.id} key={s.id}
className="flex items-center justify-between gap-2 px-3 py-2 text-xs" className="flex items-center justify-between gap-2 px-3 py-2 text-xs"
> >
<div className="min-w-0"> <div className="flex min-w-0 flex-1 items-center gap-2">
<span className="font-medium">{s.signerName}</span> <span className="truncate font-medium">{s.signerName}</span>
<span className="ml-2 text-muted-foreground">{s.signerEmail}</span> <span className="truncate text-muted-foreground">{s.signerEmail}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{s.signedAt ? ( {s.signedAt ? (