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>
130 lines
4.3 KiB
TypeScript
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>
|
|
);
|
|
}
|