diff --git a/src/components/interests/external-eoi-upload-dialog.tsx b/src/components/interests/external-eoi-upload-dialog.tsx index ed980395..e8b78d76 100644 --- a/src/components/interests/external-eoi-upload-dialog.tsx +++ b/src/components/interests/external-eoi-upload-dialog.tsx @@ -81,14 +81,21 @@ export function ExternalEoiUploadDialog({ // unscannable in any list when a port has multiple deals closing on // the same day. Also drives auto-fill on signatory rows tagged // role=client. + // Cache shape MUST match the parent consumer of the same + // ['interests', interestId] key, which unwraps the envelope and caches the + // inner row. Caching the wrapped { data: ... } here would clobber the + // parent's cache and blank the detail page (the rep would see "Unknown + // Client" + an empty tab body the moment this dialog opened — UAT + // 2026-05-24). const { data: interestData } = useQuery<{ - data: { clientName: string | null; clientPrimaryEmail: string | null }; + clientName: string | null; + clientPrimaryEmail: string | null; }>({ queryKey: ['interests', interestId], queryFn: () => apiFetch<{ data: { clientName: string | null; clientPrimaryEmail: string | null } }>( `/api/v1/interests/${interestId}`, - ), + ).then((r) => r.data), enabled: open, staleTime: 60_000, }); @@ -99,11 +106,11 @@ export function ExternalEoiUploadDialog({ const signatories: SignatoryRow[] = useMemo(() => { if (signatoriesOverride !== null) return signatoriesOverride; if (prefillSignatories && prefillSignatories.length > 0) return prefillSignatories; - if (!interestData?.data) return []; + if (!interestData) return []; return [ { - name: interestData.data.clientName ?? '', - email: interestData.data.clientPrimaryEmail ?? '', + name: interestData.clientName ?? '', + email: interestData.clientPrimaryEmail ?? '', role: 'client' as const, }, ]; @@ -124,7 +131,7 @@ export function ExternalEoiUploadDialog({ .map((b) => b.mooringNumber) .filter((m): m is string => !!m); const berthLabel = moorings.length > 0 ? formatBerthRange(moorings) : null; - const clientName = interestData?.data?.clientName ?? null; + const clientName = interestData?.clientName ?? null; const parts = ['External EOI']; if (clientName) parts.push(clientName); if (berthLabel) parts.push(berthLabel); diff --git a/src/components/search/command-search.tsx b/src/components/search/command-search.tsx index e4b5b707..9e0b9024 100644 --- a/src/components/search/command-search.tsx +++ b/src/components/search/command-search.tsx @@ -318,7 +318,13 @@ export function CommandSearch() { role="listbox" aria-label="Search results" className={cn( - 'absolute top-[calc(100%+4px)] left-0 z-50 rounded-md border bg-popover shadow-lg overflow-hidden', + // Explicit white/popover background pair guards against subtle + // transparency artifacts UAT 2026-05-24 captured (rows on the + // Berths page bled through to the table behind even though + // bg-popover resolves opaque on paper). bg-white wins the + // tailwind-merge race so light mode is unambiguously solid; + // dark mode falls through to bg-popover via the dark: prefix. + 'absolute top-[calc(100%+4px)] left-0 z-50 rounded-md border bg-white dark:bg-popover shadow-lg overflow-hidden', // Desktop: anchored to the input width, capped on viewport 'w-full max-w-[min(720px,calc(100vw-2rem))]', // Mobile ( { + const ext = await import('@/lib/services/external-eoi.service'); + uploadExternallySignedEoi = ext.uploadExternallySignedEoi; + const svc = await import('@/lib/services/interests.service'); + createInterest = svc.createInterest; + const factories = await import('../helpers/factories'); + makePort = factories.makePort; + makeClient = factories.makeClient; + makeAuditMeta = factories.makeAuditMeta; +}); + +describe('uploadExternallySignedEoi — stage-advance gate', () => { + beforeEach(() => { + vi.doMock('@/lib/storage', async () => { + const real = await vi.importActual('@/lib/storage'); + return { + ...real, + getStorageBackend: vi.fn(async () => ({ + put: vi.fn(async () => undefined), + get: vi.fn(), + head: vi.fn(), + delete: vi.fn(async () => undefined), + listByPrefix: vi.fn(async () => []), + presignUpload: vi.fn(async () => ''), + presignDownload: vi.fn(async () => ''), + })), + }; + }); + }); + + afterEach(() => { + vi.doUnmock('@/lib/storage'); + }); + + async function makeInterest( + stage: + | 'enquiry' + | 'qualified' + | 'nurturing' + | 'eoi' + | 'reservation' + | 'deposit_paid' + | 'contract', + ) { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const meta = makeAuditMeta({ portId: port.id }); + + const created = await createInterest( + port.id, + { clientId: client.id, pipelineStage: 'enquiry', reminderEnabled: false, tagIds: [] }, + meta, + ); + + // Set stage directly via DB to bypass the yacht-required gate on + // changeInterestStage — this test focuses solely on the external-EOI + // advance behaviour given an arbitrary starting stage, not on the + // separate yacht-link gate that fronts manual stage transitions. + if (stage !== 'enquiry') { + await db.update(interests).set({ pipelineStage: stage }).where(eq(interests.id, created.id)); + } + + return { port, client, meta, interest: created }; + } + + async function uploadFakeEoi( + interestId: string, + portId: string, + meta: ReturnType, + ) { + return uploadExternallySignedEoi({ + interestId, + portId, + fileData: { + buffer: Buffer.from('%PDF-1.4\n%fake\n'), + originalName: 'signed.pdf', + mimeType: 'application/pdf', + size: 16, + }, + signedAt: new Date('2026-05-24T10:00:00Z'), + signatories: [{ name: 'Client', email: 'client@example.com', role: 'client' }], + meta, + }); + } + + it.each(['enquiry', 'qualified', 'nurturing'] as const)( + 'advances %s → eoi', + async (startStage) => { + const { port, meta, interest } = await makeInterest(startStage); + + const result = await uploadFakeEoi(interest.id, port.id, meta); + + expect(result.stageChanged).toBe(true); + expect(result.newStage).toBe('eoi'); + + const row = await db.query.interests.findFirst({ where: eq(interests.id, interest.id) }); + expect(row?.pipelineStage).toBe('eoi'); + expect(row?.eoiStatus).toBe('signed'); + expect(row?.eoiDocStatus).toBe('signed'); + expect(row?.dateEoiSigned).toBeTruthy(); + }, + ); + + it.each(['eoi', 'reservation', 'deposit_paid', 'contract'] as const)( + 'leaves %s untouched (metadata still writes)', + async (startStage) => { + const { port, meta, interest } = await makeInterest(startStage); + + const result = await uploadFakeEoi(interest.id, port.id, meta); + + expect(result.stageChanged).toBe(false); + expect(result.newStage).toBe(startStage); + + const row = await db.query.interests.findFirst({ where: eq(interests.id, interest.id) }); + expect(row?.pipelineStage).toBe(startStage); + expect(row?.eoiStatus).toBe('signed'); + expect(row?.eoiDocStatus).toBe('signed'); + expect(row?.dateEoiSigned).toBeTruthy(); + }, + ); +});