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

@@ -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>