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:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user