Files
pn-new-crm/src/components/documents/hub-root-view.tsx
Matt 14ae41d0fa feat(uat-b1): ship Wave A-E of Bucket 1 audit findings
Wave A (Interest+EOI form quick wins):
- Auto-select yacht after inline-create from interest form
- EOI generate dialog: "View EOI" action toast
- Interest form berth picker: formatBerthRange compact label
- Remove "Generate EOI" button from Documents tab (clean removal)
- Interest auto-assign: only sales_agent/sales_manager auto-claim
  ownership on create (explicit role check via user_port_roles join)
- LinkedBerthRowItem dims: drop "D" suffix + "L × W" format
- ExternalEoiUploadDialog: prefillSignatories prop threaded from
  active EOI signers
- EOI signature progress on Overview milestone card footer

Wave B (a11y + i18n sweeps):
- aria-live on supplemental-info error state
- text-[10px] -> text-xs in client-pipeline-summary
- Currency formatter: locale default removed (Intl uses runtime)
- en-US/en-GB hardcoded toLocaleString swept across 13 components

Wave C (Primary berth always in EOI bundle):
- Service guard strengthened on update path
- Migration 0083 backfills historical primary rows

Wave D (Onboarding super_admin discoverability):
- /api/v1/admin/onboarding/status endpoint + shared service
- Topbar OnboardingBanner (super_admin, session-dismissible)
- OnboardingTile dashboard widget (rail group, self-hides at 100%)
- Celebration toast + invalidate of shared status on last tick

Wave E (Branded post-completion email idempotency):
- Verified handleDocumentCompleted already owns the email fan-out
- Added regression test for the polling path + idempotency

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:40:37 +02:00

130 lines
4.3 KiB
TypeScript

'use client';
import { useState } from 'react';
import Link from 'next/link';
import { FileText, ClipboardSignature } from 'lucide-react';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
interface HubRootDoc {
id: string;
title: string;
documentType: string;
status: string;
createdAt: string;
}
interface HubRootFile {
id: string;
filename: string;
mimeType: string | null;
createdAt: string;
}
interface Props {
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) {
const [previewFile, setPreviewFile] = useState<{
id: string;
name: string;
mimeType: string | null;
} | null>(null);
const { data: workflows, isLoading: workflowsLoading } = usePaginatedQuery<HubRootDoc>({
queryKey: ['documents', 'hub-root', 'workflows'],
endpoint: '/api/v1/documents?tab=in_progress',
filterDefinitions: [],
});
const { data: filesData, isLoading: filesLoading } = usePaginatedQuery<HubRootFile>({
queryKey: ['files', 'hub-root'],
endpoint: '/api/v1/files?sort=createdAt&order=desc&limit=20',
filterDefinitions: [],
});
return (
<div className="space-y-4">
<section className="rounded-md border bg-white">
<h3 className="flex items-center gap-2 border-b px-3 py-2 text-sm font-semibold">
<ClipboardSignature className="h-4 w-4 text-muted-foreground" aria-hidden />
Signing in progress
</h3>
{workflowsLoading ? (
<div className="p-3 text-sm text-muted-foreground">Loading...</div>
) : workflows.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground">No workflows in flight.</div>
) : (
<ul className="divide-y">
{workflows.map((w) => (
<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="truncate hover:underline">
{w.title}
</Link>
<StatusPill status={STATUS_PILL_MAP[w.status] ?? 'pending'}>
{w.status.replace(/_/g, ' ')}
</StatusPill>
</li>
))}
</ul>
)}
</section>
<section className="rounded-md border bg-white">
<h3 className="flex items-center gap-2 border-b px-3 py-2 text-sm font-semibold">
<FileText className="h-4 w-4 text-muted-foreground" aria-hidden />
Recent files
</h3>
{filesLoading ? (
<div className="p-3 text-sm text-muted-foreground">Loading...</div>
) : filesData.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground">No files yet.</div>
) : (
<ul className="divide-y">
{filesData.map((f) => (
<li key={f.id} className="flex items-center justify-between px-3 py-2 text-sm">
<button
type="button"
className="truncate text-left hover:text-brand hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 rounded-sm"
onClick={() =>
setPreviewFile({ id: f.id, name: f.filename, mimeType: f.mimeType })
}
aria-label={`Preview ${f.filename}`}
>
{f.filename}
</button>
<span className="text-xs text-muted-foreground tabular-nums">
{new Date(f.createdAt).toLocaleDateString(undefined)}
</span>
</li>
))}
</ul>
)}
</section>
<FilePreviewDialog
open={Boolean(previewFile)}
onOpenChange={(open) => {
if (!open) setPreviewFile(null);
}}
fileId={previewFile?.id}
fileName={previewFile?.name}
mimeType={previewFile?.mimeType ?? undefined}
/>
</div>
);
}