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>
This commit is contained in:
2026-05-25 03:40:37 +02:00
parent 41737fa950
commit 14ae41d0fa
40 changed files with 835 additions and 70 deletions

View File

@@ -51,9 +51,19 @@ interface Props {
onOpenChange: (next: boolean) => void;
interestId: string;
onSuccess?: () => void;
/** When supplied, used as the initial signatory seed (typically derived
* from the active Documenso EOI's signers). Falls through to the
* client-only seed when omitted or empty. */
prefillSignatories?: SignatoryRow[];
}
export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSuccess }: Props) {
export function ExternalEoiUploadDialog({
open,
onOpenChange,
interestId,
onSuccess,
prefillSignatories,
}: Props) {
const qc = useQueryClient();
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState('');
@@ -88,6 +98,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
// explicit override takes over.
const signatories: SignatoryRow[] = useMemo(() => {
if (signatoriesOverride !== null) return signatoriesOverride;
if (prefillSignatories && prefillSignatories.length > 0) return prefillSignatories;
if (!interestData?.data) return [];
return [
{
@@ -96,7 +107,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
role: 'client' as const,
},
];
}, [signatoriesOverride, interestData]);
}, [signatoriesOverride, prefillSignatories, interestData]);
const { data: berthsData } = useQuery<{ data: Array<{ mooringNumber: string | null }> }>({
queryKey: ['interests', interestId, 'berths'],
queryFn: () =>

View File

@@ -4,9 +4,7 @@ import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { FileSignature } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { DocumentList } from '@/components/documents/document-list';
import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog';
import { FileGrid, type FileRow } from '@/components/files/file-grid';
import { FileUploadZone } from '@/components/files/file-upload-zone';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
@@ -35,7 +33,6 @@ interface InterestData {
*/
export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) {
const queryClient = useQueryClient();
const [eoiDialogOpen, setEoiDialogOpen] = useState(false);
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
const { confirm, dialog: confirmDialog } = useConfirmation();
@@ -111,9 +108,6 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
<section className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">Signature documents</h3>
<Button size="sm" variant="outline" onClick={() => setEoiDialogOpen(true)}>
Generate EOI
</Button>
</div>
<DocumentList
@@ -126,12 +120,9 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">No documents yet</p>
<p className="text-xs text-muted-foreground">
Generate the EOI to send it for signing in one click.
Generate the EOI from the Overview tab to send it for signing in one click.
</p>
</div>
<Button size="sm" onClick={() => setEoiDialogOpen(true)}>
Generate EOI
</Button>
</div>
}
/>
@@ -202,13 +193,6 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
)}
</section>
<EoiGenerateDialog
interestId={interestId}
clientId={interest?.clientId ?? null}
open={eoiDialogOpen}
onOpenChange={setEoiDialogOpen}
/>
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(open) => !open && setPreviewFile(null)}

View File

@@ -126,6 +126,37 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
const activeDoc = useMemo(() => docs.find((d) => ACTIVE_STATUSES.has(d.status)) ?? null, [docs]);
const completedDocs = useMemo(() => docs.filter((d) => !ACTIVE_STATUSES.has(d.status)), [docs]);
// Pulled at the parent so we can thread the active EOI's signers into the
// ExternalEoiUploadDialog as a prefill seed. ActiveEoiCard hits the same
// query key — react-query dedupes the actual fetch.
const { data: activeSignersRes } = useQuery<{ data: DocumentSigner[] }>({
queryKey: ['documents', activeDoc?.id, 'signers'],
queryFn: () =>
apiFetch<{ data: DocumentSigner[] }>(`/api/v1/documents/${activeDoc!.id}/signers`),
enabled: !!activeDoc,
staleTime: 30_000,
});
const externalUploadPrefill = useMemo(() => {
const rows = activeSignersRes?.data ?? [];
if (rows.length === 0) return undefined;
const ROLE_MAP: Record<string, 'client' | 'developer' | 'rep' | 'witness' | 'cc'> = {
client: 'client',
developer: 'developer',
approver: 'developer',
rep: 'rep',
witness: 'witness',
cc: 'cc',
viewer: 'cc',
other: 'witness',
};
return rows.map((s) => ({
name: s.signerName,
email: s.signerEmail,
role: ROLE_MAP[(s.signerRole ?? '').toLowerCase()] ?? ('witness' as const),
}));
}, [activeSignersRes]);
return (
<div className="space-y-5">
{docsLoading ? (
@@ -201,6 +232,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
open={uploadSignedOpen}
onOpenChange={setUploadSignedOpen}
interestId={interestId}
prefillSignatories={externalUploadPrefill}
/>
{/* Phase 4 parity - same upload-PDF + place-fields wizard as

View File

@@ -47,6 +47,7 @@ import { YachtForm } from '@/components/yachts/yacht-form';
import { YachtPicker } from '@/components/yachts/yacht-picker';
import { apiFetch } from '@/lib/api/client';
import { useEntityOptions } from '@/hooks/use-entity-options';
import { formatBerthRange } from '@/lib/templates/berth-range';
import type { z } from 'zod';
import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests';
import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES, SOURCES } from '@/lib/constants';
@@ -438,11 +439,24 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
>
<span className="truncate">
{selectedBerthId
? `${selectedBerth?.label ?? interest?.berthMooringNumber ?? selectedBerthId}${
additionalBerthIds.length > 0
? ` + ${additionalBerthIds.length} more`
: ''
}`
? (() => {
const primaryLabel =
selectedBerth?.label ??
interest?.berthMooringNumber ??
selectedBerthId;
const additionalLabels = additionalBerthIds
.map((id) => berthOptions.find((b) => b.value === id)?.label)
.filter((label): label is string => Boolean(label));
const allLabels = [primaryLabel, ...additionalLabels];
const range = formatBerthRange(allLabels);
// Cap at 5 segments after range collapse so "A1-A3, B5, C2, D7, E4 +N more"
// doesn't overflow the trigger.
const segments = range ? range.split(', ') : [];
if (segments.length <= 5) return range || primaryLabel;
const head = segments.slice(0, 5).join(', ');
const overflow = segments.length - 5;
return `${head} +${overflow} more`;
})()
: 'Select berths…'}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
@@ -791,6 +805,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
open={createYachtOpen}
onOpenChange={setCreateYachtOpen}
initialOwner={{ type: 'client', id: selectedClientId }}
onCreated={(y) => setValue('yachtId', y.id, { shouldDirty: true })}
/>
)}
</Sheet>

View File

@@ -3,7 +3,7 @@
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Anchor, CheckCircle2, Circle, FileSignature, Send, Wallet } from 'lucide-react';
@@ -31,6 +31,7 @@ import { RemindersInline } from '@/components/reminders/reminders-inline';
import { BerthRecommenderPanel } from '@/components/interests/berth-recommender-panel';
import { LinkedBerthsList } from '@/components/interests/linked-berths-list';
import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog';
import { SigningProgress } from '@/components/documents/signing-progress';
// Shared parser for the interest's stringly-typed numeric columns (Drizzle
// returns Postgres numeric as string). Used by both the Overview milestone
@@ -627,6 +628,76 @@ function FutureMilestones({
);
}
/**
* Compact per-signer progress widget for the Overview tab's EOI milestone
* card. Mounts inside the milestone footer when the EOI is sent but not
* yet fully signed, so reps see who's signed at a glance without leaving
* Overview. Heavy lifting (resend, void, etc.) stays on the EOI tab — a
* "View EOI" link below the widget routes the rep there.
*/
function EoiMilestoneActiveProgress({
interestId,
portSlug,
}: {
interestId: string;
portSlug: string;
}) {
const { data: docsRes } = useQuery<{
data: Array<{ id: string; status: string }>;
}>({
queryKey: ['documents', { interestId, documentType: 'eoi' }],
queryFn: () =>
apiFetch<{ data: Array<{ id: string; status: string }> }>(
`/api/v1/documents?interestId=${interestId}&documentType=eoi`,
),
staleTime: 30_000,
});
const activeDoc = (docsRes?.data ?? []).find(
(d) => d.status === 'sent' || d.status === 'partially_signed' || d.status === 'signed',
);
const { data: signersRes } = useQuery<{
data: Array<{
id: string;
signerName: string;
signerEmail: string;
signerRole: string;
signingOrder: number;
status: string;
signedAt?: string | null;
invitedAt?: string | null;
openedAt?: string | null;
lastReminderSentAt?: string | null;
signingUrl?: string | null;
}>;
}>({
queryKey: ['documents', activeDoc?.id, 'signers'],
queryFn: () =>
apiFetch<{ data: Array<never> }>(`/api/v1/documents/${activeDoc!.id}/signers`) as never,
enabled: !!activeDoc,
staleTime: 30_000,
});
if (!activeDoc) return null;
const signers = signersRes?.data ?? [];
if (signers.length === 0) return null;
return (
<div className="space-y-2">
<SigningProgress documentId={activeDoc.id} signers={signers} />
<div className="flex justify-end">
<Button asChild type="button" size="sm" variant="ghost" className="h-7 text-xs">
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/interests/${interestId}?tab=eoi` as any}
>
View EOI
</Link>
</Button>
</div>
</div>
);
}
function OverviewTab({
interestId,
interest,
@@ -863,6 +934,8 @@ function OverviewTab({
</Link>
</Button>
</div>
) : eoiPhase === 'current' && interest.dateEoiSent && !interest.dateEoiSigned ? (
<EoiMilestoneActiveProgress interestId={interestId} portSlug={portSlug} />
) : null,
pastSummary: interest.dateEoiSigned ? (
`Signed ${formatDate(interest.dateEoiSigned)}`

View File

@@ -117,11 +117,12 @@ function formatDimensions(
};
const l = toNum(length);
const w = toNum(width);
const d = toNum(draft);
if (l !== null) parts.push(`${l.toFixed(1)}ft L`);
if (w !== null) parts.push(`${w.toFixed(1)}ft W`);
if (d !== null) parts.push(`${d.toFixed(1)}ft D`);
return parts.length > 0 ? parts.join(' · ') : null;
// Draft intentionally omitted from the inline row strip per UAT 2026-05-24
// — opaque to sales reps; still visible on the berth detail page.
void toNum(draft);
if (l !== null) parts.push(`${l.toFixed(1)} ft`);
if (w !== null) parts.push(`${w.toFixed(1)} ft`);
return parts.length > 0 ? parts.join(' × ') : null;
}
const SPECIFIC_CONSEQUENCE_ON =