2026-04-28 02:35:36 +02:00
|
|
|
'use client';
|
|
|
|
|
|
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>
2026-05-11 13:56:05 +02:00
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
2026-04-28 02:35:36 +02:00
|
|
|
import Link from 'next/link';
|
|
|
|
|
import { ChevronDown, ChevronRight, FileText, Plus } from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
2026-05-10 12:21:14 +02:00
|
|
|
import { cn } from '@/lib/utils';
|
2026-04-28 02:35:36 +02:00
|
|
|
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
|
|
|
|
import { EmptyState } from '@/components/ui/empty-state';
|
|
|
|
|
import { PageHeader } from '@/components/shared/page-header';
|
2026-05-10 12:12:53 +02:00
|
|
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
2026-04-28 02:35:36 +02:00
|
|
|
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
|
|
|
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
2026-05-11 12:39:03 +02:00
|
|
|
import { useDocumentFolders, type FolderNode } from '@/hooks/use-document-folders';
|
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>
2026-05-11 13:56:05 +02:00
|
|
|
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
2026-05-10 12:12:53 +02:00
|
|
|
import { FolderActionsMenu } from './folder-actions-menu';
|
|
|
|
|
import { FolderBreadcrumb } from './folder-breadcrumb';
|
|
|
|
|
import { FolderTreeSidebar } from './folder-tree-sidebar';
|
2026-05-11 12:39:03 +02:00
|
|
|
import { HubRootView } from './hub-root-view';
|
|
|
|
|
import { EntityFolderView } from './entity-folder-view';
|
2026-04-28 02:35:36 +02:00
|
|
|
|
|
|
|
|
interface HubDoc {
|
|
|
|
|
id: string;
|
|
|
|
|
documentType: string;
|
|
|
|
|
title: string;
|
|
|
|
|
status: string;
|
|
|
|
|
createdAt: string;
|
|
|
|
|
signers?: Array<{ id: string; signerEmail: string; signerName: string; status: string }>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
};
|
|
|
|
|
|
fix(ui): humanize enum labels, format dates, resolve actor names, loading skeleton
- Documents hub signer status now renders via a label map (`Pending`,
`Signed`, `Declined`, …) instead of the raw lowercase enum value.
- Invoice detail formats `dueDate` and `paymentDate` as `MMM d, yyyy`
via `date-fns` instead of leaking raw `2025-03-14` ISO strings, and
swaps the "Payment Method" free-text input for a `Select` of labelled
options (`Bank transfer`, `Credit card`, …) so we never store
`bank_transfer` from a hand-typed field again.
- Interest tabs `MilestoneSection` status badge uses a `humanizeStatus`
helper so values like `waiting_for_signatures` show as
`Waiting For Signatures` (correctly title-cased) instead of being a
lower-snake-case fragment inside an ALL-CAPS pill.
- `OUTCOME_BADGE` in the interest header now has a fall-through that
renders any unknown outcome as a closed-state badge, preventing a
closed interest from looking open just because its enum was added
upstream without a matching label entry.
- Interest timeline route joins the `user` table and returns
`userName` alongside `userId`; the client renders the resolved name
instead of a 36-char UUID. Falls back to `'a teammate'` if the user
row was deleted.
- Invoice "New / Step 3 — Review" replaces the truncated UUID display
with a server-resolved client/company name via a small `useQuery`,
so users can confirm they picked the right billing entity before
submitting.
- New `loading.tsx` for client detail renders a header / tab strip /
card skeleton during the server-component / initial-query window
that previously flashed empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:01:35 +02:00
|
|
|
const SIGNER_STATUS_LABELS: Record<string, string> = {
|
|
|
|
|
pending: 'Pending',
|
|
|
|
|
sent: 'Sent',
|
|
|
|
|
signed: 'Signed',
|
|
|
|
|
declined: 'Declined',
|
|
|
|
|
expired: 'Expired',
|
|
|
|
|
cancelled: 'Cancelled',
|
|
|
|
|
};
|
|
|
|
|
|
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>
2026-05-11 13:56:05 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 02:35:36 +02:00
|
|
|
interface DocumentsHubProps {
|
|
|
|
|
portSlug: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 12:39:03 +02:00
|
|
|
function findInTree(nodes: FolderNode[], id: string): FolderNode | null {
|
|
|
|
|
for (const n of nodes) {
|
|
|
|
|
if (n.id === id) return n;
|
|
|
|
|
const found = findInTree(n.children, id);
|
|
|
|
|
if (found) return found;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
|
|
|
|
// undefined = "All documents" (no folder selected / hub root)
|
|
|
|
|
// null = root folder only
|
|
|
|
|
// string = specific folder id
|
|
|
|
|
const [selectedFolderId, setSelectedFolderId] = useState<string | null | undefined>(undefined);
|
|
|
|
|
|
|
|
|
|
const { data: tree = [] } = useDocumentFolders();
|
|
|
|
|
|
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>
2026-05-11 13:56:05 +02:00
|
|
|
// 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]);
|
|
|
|
|
|
2026-05-11 12:39:03 +02:00
|
|
|
const selectedFolder =
|
|
|
|
|
typeof selectedFolderId === 'string' ? findInTree(tree, selectedFolderId) : null;
|
|
|
|
|
|
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>
2026-05-11 13:56:05 +02:00
|
|
|
const folderEntityType = selectedFolder?.entityType;
|
2026-05-11 12:39:03 +02:00
|
|
|
const isEntityFolder =
|
|
|
|
|
selectedFolder?.systemManaged === true &&
|
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>
2026-05-11 13:56:05 +02:00
|
|
|
folderEntityType != null &&
|
|
|
|
|
folderEntityType !== 'root' &&
|
|
|
|
|
selectedFolder.entityId != null &&
|
|
|
|
|
isEntityType(folderEntityType);
|
2026-05-11 12:39:03 +02:00
|
|
|
|
|
|
|
|
const handleFolderSelect = (id: string | null | undefined) => {
|
|
|
|
|
setSelectedFolderId(id);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col sm:flex-row h-full">
|
|
|
|
|
<FolderTreeSidebar
|
|
|
|
|
selectedFolderId={selectedFolderId}
|
|
|
|
|
onSelect={handleFolderSelect}
|
|
|
|
|
footer={
|
|
|
|
|
<PermissionGate resource="documents" action="manage_folders">
|
|
|
|
|
<FolderActionsMenu
|
|
|
|
|
selectedFolderId={selectedFolderId}
|
|
|
|
|
onAfterDelete={() => handleFolderSelect(undefined)}
|
|
|
|
|
/>
|
|
|
|
|
</PermissionGate>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex-1 min-w-0 p-4 space-y-4">
|
2026-05-11 15:08:53 +02:00
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
|
|
|
<FolderBreadcrumb selectedFolderId={selectedFolderId} onSelect={handleFolderSelect} />
|
|
|
|
|
{selectedFolderId !== undefined && (
|
|
|
|
|
<Button asChild size="sm">
|
2026-05-11 12:39:03 +02:00
|
|
|
<Link href={`/${portSlug}/documents/new`}>
|
|
|
|
|
<Plus className="mr-1.5 h-4 w-4" />
|
|
|
|
|
New document
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
2026-05-11 15:08:53 +02:00
|
|
|
)}
|
|
|
|
|
</div>
|
2026-05-11 12:39:03 +02:00
|
|
|
|
|
|
|
|
{selectedFolderId === undefined ? (
|
2026-05-11 15:08:53 +02:00
|
|
|
<>
|
|
|
|
|
<PageHeader
|
|
|
|
|
title="Documents"
|
|
|
|
|
description="Track signing status, chase pending signers, and audit completion."
|
|
|
|
|
actions={
|
|
|
|
|
<Button asChild>
|
|
|
|
|
<Link href={`/${portSlug}/documents/new`}>
|
|
|
|
|
<Plus className="mr-1.5 h-4 w-4" />
|
|
|
|
|
New document
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
}
|
|
|
|
|
variant="gradient"
|
|
|
|
|
/>
|
|
|
|
|
<HubRootView portSlug={portSlug} />
|
|
|
|
|
</>
|
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>
2026-05-11 13:56:05 +02:00
|
|
|
) : isEntityFolder && isEntityType(folderEntityType) ? (
|
2026-05-11 12:39:03 +02:00
|
|
|
<EntityFolderView
|
|
|
|
|
portSlug={portSlug}
|
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>
2026-05-11 13:56:05 +02:00
|
|
|
entityType={folderEntityType}
|
2026-05-11 12:39:03 +02:00
|
|
|
entityId={selectedFolder!.entityId!}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
2026-05-11 13:01:47 +02:00
|
|
|
<FlatFolderListing
|
|
|
|
|
key={selectedFolderId ?? 'root'}
|
|
|
|
|
portSlug={portSlug}
|
|
|
|
|
folderId={selectedFolderId}
|
|
|
|
|
/>
|
2026-05-11 12:39:03 +02:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// FlatFolderListing — the original search + type-chip + document rows panel,
|
|
|
|
|
// now scoped to a specific folder (or null for root-only).
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
interface FlatFolderListingProps {
|
|
|
|
|
portSlug: string;
|
|
|
|
|
folderId: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
|
2026-04-28 02:35:36 +02:00
|
|
|
const [search, setSearch] = useState('');
|
2026-05-10 12:21:14 +02:00
|
|
|
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
|
2026-04-28 02:35:36 +02:00
|
|
|
const [expandedDocId, setExpandedDocId] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const queryParams = useMemo(() => {
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
if (search) params.set('search', search);
|
2026-05-10 12:21:14 +02:00
|
|
|
if (typeFilter) params.set('documentType', typeFilter);
|
2026-05-11 12:39:03 +02:00
|
|
|
// folderId null = root, string = specific folder
|
|
|
|
|
params.set('folderId', folderId ?? '');
|
2026-04-28 02:35:36 +02:00
|
|
|
return params;
|
2026-05-11 12:39:03 +02:00
|
|
|
}, [search, typeFilter, folderId]);
|
2026-04-28 02:35:36 +02:00
|
|
|
|
|
|
|
|
const { data: documents, isLoading } = usePaginatedQuery<HubDoc>({
|
2026-05-11 12:39:03 +02:00
|
|
|
queryKey: ['documents', 'hub', 'folder', queryParams.toString()],
|
2026-04-28 02:35:36 +02:00
|
|
|
endpoint: `/api/v1/documents?${queryParams.toString()}`,
|
|
|
|
|
filterDefinitions: [],
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-05-11 13:56:05 +02:00
|
|
|
// Realtime invalidation is lifted to DocumentsHub so it survives mode
|
|
|
|
|
// switches (root / entity-folder / flat-folder). Don't re-subscribe here.
|
2026-04-28 02:35:36 +02:00
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
>
|
2026-04-28 12:10:21 +02:00
|
|
|
<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">
|
2026-04-28 02:35:36 +02:00
|
|
|
<button
|
|
|
|
|
type="button"
|
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>
2026-05-11 13:56:05 +02:00
|
|
|
aria-label={`${expanded ? 'Collapse' : 'Expand'} signers for ${doc.title}`}
|
|
|
|
|
aria-expanded={expanded}
|
2026-04-28 02:35:36 +02:00
|
|
|
onClick={() => setExpandedDocId(expanded ? null : doc.id)}
|
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>
2026-05-11 13:56:05 +02:00
|
|
|
className="flex min-h-[44px] min-w-[44px] items-center justify-center text-muted-foreground transition-transform"
|
2026-04-28 02:35:36 +02:00
|
|
|
>
|
|
|
|
|
{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">
|
2026-05-04 22:57:01 +02:00
|
|
|
{totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : '-'}
|
2026-04-28 02:35:36 +02:00
|
|
|
</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'}>
|
fix(ui): humanize enum labels, format dates, resolve actor names, loading skeleton
- Documents hub signer status now renders via a label map (`Pending`,
`Signed`, `Declined`, …) instead of the raw lowercase enum value.
- Invoice detail formats `dueDate` and `paymentDate` as `MMM d, yyyy`
via `date-fns` instead of leaking raw `2025-03-14` ISO strings, and
swaps the "Payment Method" free-text input for a `Select` of labelled
options (`Bank transfer`, `Credit card`, …) so we never store
`bank_transfer` from a hand-typed field again.
- Interest tabs `MilestoneSection` status badge uses a `humanizeStatus`
helper so values like `waiting_for_signatures` show as
`Waiting For Signatures` (correctly title-cased) instead of being a
lower-snake-case fragment inside an ALL-CAPS pill.
- `OUTCOME_BADGE` in the interest header now has a fall-through that
renders any unknown outcome as a closed-state badge, preventing a
closed interest from looking open just because its enum was added
upstream without a matching label entry.
- Interest timeline route joins the `user` table and returns
`userName` alongside `userId`; the client renders the resolved name
instead of a 36-char UUID. Falls back to `'a teammate'` if the user
row was deleted.
- Invoice "New / Step 3 — Review" replaces the truncated UUID display
with a server-resolved client/company name via a small `useQuery`,
so users can confirm they picked the right billing entity before
submitting.
- New `loading.tsx` for client detail renders a header / tab strip /
card skeleton during the server-component / initial-query window
that previously flashed empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:01:35 +02:00
|
|
|
{SIGNER_STATUS_LABELS[signer.status] ?? signer.status}
|
2026-04-28 02:35:36 +02:00
|
|
|
</StatusPill>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</li>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-11 12:39:03 +02:00
|
|
|
<>
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="Search by title..."
|
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>
2026-05-11 13:56:05 +02:00
|
|
|
aria-label="Search documents by title"
|
2026-05-11 12:39:03 +02:00
|
|
|
value={search}
|
|
|
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
|
|
|
className="max-w-xs h-9"
|
|
|
|
|
/>
|
|
|
|
|
{(() => {
|
|
|
|
|
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"
|
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>
2026-05-11 13:56:05 +02:00
|
|
|
aria-pressed={typeFilter === undefined}
|
2026-05-11 12:39:03 +02:00
|
|
|
className={cn(
|
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>
2026-05-11 13:56:05 +02:00
|
|
|
'inline-flex min-h-[44px] items-center rounded-full border px-3 py-2 text-xs',
|
2026-05-11 12:39:03 +02:00
|
|
|
typeFilter === undefined ? 'bg-foreground text-background' : 'hover:bg-accent',
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => setTypeFilter(undefined)}
|
|
|
|
|
>
|
|
|
|
|
All types
|
|
|
|
|
</button>
|
|
|
|
|
{seenTypes.map((t) => (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
key={t}
|
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>
2026-05-11 13:56:05 +02:00
|
|
|
aria-pressed={typeFilter === t}
|
2026-05-11 12:39:03 +02:00
|
|
|
className={cn(
|
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>
2026-05-11 13:56:05 +02:00
|
|
|
'inline-flex min-h-[44px] items-center rounded-full border px-3 py-2 text-xs',
|
2026-05-11 12:39:03 +02:00
|
|
|
typeFilter === t ? 'bg-foreground text-background' : 'hover:bg-accent',
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => setTypeFilter(t)}
|
|
|
|
|
>
|
|
|
|
|
{TYPE_LABELS[t] ?? t}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
|
|
|
|
</div>
|
2026-04-28 02:35:36 +02:00
|
|
|
|
2026-05-11 12:39:03 +02:00
|
|
|
{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="No documents in this folder"
|
|
|
|
|
body="Create a document or move existing ones here."
|
2026-04-28 02:35:36 +02:00
|
|
|
actions={
|
2026-05-10 12:12:53 +02:00
|
|
|
<Button asChild>
|
|
|
|
|
<Link href={`/${portSlug}/documents/new`}>
|
|
|
|
|
<Plus className="mr-1.5 h-4 w-4" />
|
|
|
|
|
New document
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
2026-04-28 02:35:36 +02:00
|
|
|
}
|
|
|
|
|
/>
|
2026-05-11 12:39:03 +02:00
|
|
|
) : (
|
|
|
|
|
<ul className="rounded-md border bg-white shadow-xs">{documents.map(renderRow)}</ul>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
2026-04-28 02:35:36 +02:00
|
|
|
);
|
|
|
|
|
}
|