From 2d574172ec174c27e053f19f9fce29a803217623 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 16:50:58 +0200 Subject: [PATCH] =?UTF-8?q?fix(uat-batch-1):=20wave-1=20blocker=20bugs=20?= =?UTF-8?q?=E2=80=94=20supplemental=20gate,=20file=20FK,=20downloads,=20se?= =?UTF-8?q?arch=20dedup,=20notes=20stale,=20expense=20form,=20vocab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surgical fixes for the 7 UAT blockers that prevent productive forward testing. Each item has a corresponding entry in alpha-uat-master.md. - supplemental-info route relocated out of (portal) so it bypasses the isPortalDisabledGlobally() kill-switch. URL unchanged. - file upload service derives client_id/company_id/yacht_id from (entityType, entityId) when not explicitly passed, so interest-tab uploads no longer land with client_id=NULL and stay visible in the Attachments list. - triggerBlobDownload / triggerUrlDownload helpers in src/lib/utils attach the anchor to the DOM before click so Chromium honours the download attribute; 7 sites refactored, file-named downloads stop arriving as bare UUIDs. - search-nav-catalog dedupes by href at the result-collection layer so the same href can no longer surface twice in the command-K dropdown (kills the React duplicate-key warning); /admin/templates entries merged into a single richer-keyword variant. - NotesList gains a parentInvalidateKey prop, wired through all five callers (interest, client, yacht, company, residential client/ interest) so the Overview "Latest note" teaser refreshes when a note is added in the Notes tab. - expense-form-dialog: setValue('receiptFileIds') / setValue( 'noReceiptAcknowledged') on upload/clear/checkbox so the schema-level refine sees the field and Create stops silently no-op'ing on submit. - bulk-add-berths-wizard: side-pontoon dropdown now reads through useVocabulary('berth_side_pontoon_options') instead of a wrong local enum ('Port', 'Starboard', 'Bow', 'Stern') — wizard data now matches the rest of the platform + honours admin-editable per-port overrides. tsc clean. 1419/1419 vitest. lint clean on touched files. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../(dashboard)/[portSlug]/expenses/page.tsx | 8 +--- .../public/supplemental-info/[token]/page.tsx | 0 src/components/admin/backup-admin-panel.tsx | 6 +-- .../admin/bulk-add-berths-wizard.tsx | 11 ++++-- src/components/clients/client-files-tab.tsx | 6 +-- src/components/clients/client-tabs.tsx | 1 + .../companies/company-files-tab.tsx | 6 +-- src/components/companies/company-tabs.tsx | 1 + src/components/dashboard/chart-card.tsx | 17 +-------- .../expenses/expense-form-dialog.tsx | 10 +++++ .../interests/interest-documents-tab.tsx | 7 ++-- src/components/interests/interest-eoi-tab.tsx | 6 +-- src/components/interests/interest-tabs.tsx | 7 +++- .../residential/residential-client-tabs.tsx | 1 + .../residential/residential-interest-tabs.tsx | 1 + src/components/shared/notes-list.tsx | 30 ++++++++++++--- src/components/yachts/yacht-tabs.tsx | 8 +++- src/lib/services/files.ts | 18 +++++++-- src/lib/services/search-nav-catalog.ts | 32 +++++++++++----- src/lib/utils/download.ts | 37 +++++++++++++++++++ 20 files changed, 147 insertions(+), 66 deletions(-) rename src/app/{(portal) => }/public/supplemental-info/[token]/page.tsx (100%) create mode 100644 src/lib/utils/download.ts diff --git a/src/app/(dashboard)/[portSlug]/expenses/page.tsx b/src/app/(dashboard)/[portSlug]/expenses/page.tsx index 8a48b7cd..40088159 100644 --- a/src/app/(dashboard)/[portSlug]/expenses/page.tsx +++ b/src/app/(dashboard)/[portSlug]/expenses/page.tsx @@ -27,6 +27,7 @@ import { useCreateFromUrl } from '@/hooks/use-create-from-url'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { apiFetch } from '@/lib/api/client'; +import { triggerBlobDownload } from '@/lib/utils/download'; export default function ExpensesPage() { const params = useParams<{ portSlug: string }>(); @@ -91,12 +92,7 @@ export default function ExpensesPage() { }); if (!res.ok) return; const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `expenses.${type}`; - a.click(); - URL.revokeObjectURL(url); + triggerBlobDownload(blob, `expenses.${type}`); } const columns = getExpenseColumns({ diff --git a/src/app/(portal)/public/supplemental-info/[token]/page.tsx b/src/app/public/supplemental-info/[token]/page.tsx similarity index 100% rename from src/app/(portal)/public/supplemental-info/[token]/page.tsx rename to src/app/public/supplemental-info/[token]/page.tsx diff --git a/src/components/admin/backup-admin-panel.tsx b/src/components/admin/backup-admin-panel.tsx index aee8d433..f889abac 100644 --- a/src/components/admin/backup-admin-panel.tsx +++ b/src/components/admin/backup-admin-panel.tsx @@ -16,6 +16,7 @@ import { } from '@/components/ui/dialog'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; +import { triggerUrlDownload } from '@/lib/utils/download'; interface BackupJob { id: string; @@ -87,10 +88,7 @@ export function BackupAdminPanel() { async function download(id: string) { try { const res = await apiFetch<{ data: { url: string } }>(`/api/v1/admin/backup/${id}/download`); - const a = document.createElement('a'); - a.href = res.data.url; - a.download = `backup-${id}.dump`; - a.click(); + triggerUrlDownload(res.data.url, `backup-${id}.dump`); } catch (err) { toastError(err); } diff --git a/src/components/admin/bulk-add-berths-wizard.tsx b/src/components/admin/bulk-add-berths-wizard.tsx index a7bd1671..1b937115 100644 --- a/src/components/admin/bulk-add-berths-wizard.tsx +++ b/src/components/admin/bulk-add-berths-wizard.tsx @@ -35,12 +35,11 @@ import { } from '@/components/ui/select'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; +import { useVocabulary } from '@/hooks/use-vocabulary'; const DOCK_LETTERS = ['A', 'B', 'C', 'D', 'E'] as const; type DockLetter = (typeof DOCK_LETTERS)[number]; -const SIDE_PONTOON_OPTIONS = ['Port', 'Starboard', 'Bow', 'Stern', ''] as const; - interface RowDraft { mooringNumber: string; area: string; @@ -77,6 +76,10 @@ export function BulkAddBerthsWizard() { const params = useParams<{ portSlug: string }>(); const portSlug = params?.portSlug ?? ''; const router = useRouter(); + // Canonical, admin-editable side-pontoon vocabulary (per-port overrides + // honoured). Falls back to BERTH_SIDE_PONTOON_OPTIONS defaults when the + // /api/v1/vocabularies request hasn't resolved yet. + const sidePontoonOptions = useVocabulary('berth_side_pontoon_options'); const [step, setStep] = useState<'sequence' | 'edit'>('sequence'); @@ -261,7 +264,7 @@ export function BulkAddBerthsWizard() { (none) - {SIDE_PONTOON_OPTIONS.filter(Boolean).map((p) => ( + {sidePontoonOptions.filter(Boolean).map((p) => ( {p} @@ -331,7 +334,7 @@ export function BulkAddBerthsWizard() { - {SIDE_PONTOON_OPTIONS.filter(Boolean).map((p) => ( + {sidePontoonOptions.filter(Boolean).map((p) => ( {p} diff --git a/src/components/clients/client-files-tab.tsx b/src/components/clients/client-files-tab.tsx index 9083a696..8d453fb6 100644 --- a/src/components/clients/client-files-tab.tsx +++ b/src/components/clients/client-files-tab.tsx @@ -11,6 +11,7 @@ import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useConfirmation } from '@/hooks/use-confirmation'; import { apiFetch } from '@/lib/api/client'; +import { triggerUrlDownload } from '@/lib/utils/download'; import type { FileRow } from '@/components/files/file-grid'; interface ClientFilesTabProps { @@ -39,10 +40,7 @@ export function ClientFilesTab({ clientId }: ClientFilesTabProps) { const res = await apiFetch<{ data: { url: string; filename: string } }>( `/api/v1/files/${file.id}/download`, ); - const a = document.createElement('a'); - a.href = res.data.url; - a.download = res.data.filename; - a.click(); + triggerUrlDownload(res.data.url, res.data.filename); } catch { // silent } diff --git a/src/components/clients/client-tabs.tsx b/src/components/clients/client-tabs.tsx index 152f4b56..199f976b 100644 --- a/src/components/clients/client-tabs.tsx +++ b/src/components/clients/client-tabs.tsx @@ -288,6 +288,7 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt entityType="clients" entityId={clientId} currentUserId={currentUserId} + parentInvalidateKey={['clients', clientId]} /> ), }, diff --git a/src/components/companies/company-files-tab.tsx b/src/components/companies/company-files-tab.tsx index d9e89296..441f3ee5 100644 --- a/src/components/companies/company-files-tab.tsx +++ b/src/components/companies/company-files-tab.tsx @@ -11,6 +11,7 @@ import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useConfirmation } from '@/hooks/use-confirmation'; import { apiFetch } from '@/lib/api/client'; +import { triggerUrlDownload } from '@/lib/utils/download'; import type { FileRow } from '@/components/files/file-grid'; interface CompanyFilesTabProps { @@ -39,10 +40,7 @@ export function CompanyFilesTab({ companyId }: CompanyFilesTabProps) { const res = await apiFetch<{ data: { url: string; filename: string } }>( `/api/v1/files/${file.id}/download`, ); - const a = document.createElement('a'); - a.href = res.data.url; - a.download = res.data.filename; - a.click(); + triggerUrlDownload(res.data.url, res.data.filename); } catch { // silent } diff --git a/src/components/companies/company-tabs.tsx b/src/components/companies/company-tabs.tsx index 9e442aaf..17a0b8d3 100644 --- a/src/components/companies/company-tabs.tsx +++ b/src/components/companies/company-tabs.tsx @@ -229,6 +229,7 @@ export function getCompanyTabs({ entityType="companies" entityId={companyId} currentUserId={currentUserId} + parentInvalidateKey={['companies', companyId]} /> ), }, diff --git a/src/components/dashboard/chart-card.tsx b/src/components/dashboard/chart-card.tsx index b7042575..c2b5f934 100644 --- a/src/components/dashboard/chart-card.tsx +++ b/src/components/dashboard/chart-card.tsx @@ -12,6 +12,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; +import { triggerBlobDownload } from '@/lib/utils/download'; interface ChartCardProps { title: string; @@ -24,22 +25,6 @@ interface ChartCardProps { className?: string; } -/** - * Match the pattern used elsewhere in the codebase (see - * `src/app/(dashboard)/[portSlug]/expenses/page.tsx`, `client-files-tab.tsx`, - * `backup-admin-panel.tsx`). All four reduce to the same dead-simple shape - * and they all work — Chrome honours the `download` attribute and the - * file lands with the right name. - */ -function triggerBlobDownload(blob: Blob, filename: string) { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); -} - async function exportContainerAsPng(container: HTMLElement, filename: string) { const svg = container.querySelector('svg'); if (!svg) return; diff --git a/src/components/expenses/expense-form-dialog.tsx b/src/components/expenses/expense-form-dialog.tsx index ea1289c9..0c330184 100644 --- a/src/components/expenses/expense-form-dialog.tsx +++ b/src/components/expenses/expense-form-dialog.tsx @@ -88,6 +88,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi expenseDate: new Date(expense.expenseDate), paymentStatus: (expense.paymentStatus as CreateExpenseInput['paymentStatus']) ?? 'unpaid', tripLabel: expense.tripLabel ?? undefined, + noReceiptAcknowledged: Boolean(expense.noReceiptAcknowledged), }); setUploadedReceipt(null); setPreviewUrl(null); @@ -98,6 +99,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi currency: 'USD', paymentStatus: 'unpaid', expenseDate: new Date(), + noReceiptAcknowledged: false, }); setUploadedReceipt(null); setPreviewUrl(null); @@ -166,9 +168,15 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi const json = (await res.json()) as { data: { id: string; filename: string } }; setUploadedReceipt({ id: json.data.id, filename: json.data.filename }); setNoReceipt(false); + // Keep form state in sync so the schema-level refine that requires + // receiptFileIds.length > 0 || noReceiptAcknowledged === true sees + // a populated value at validation time. + setValue('receiptFileIds', [json.data.id], { shouldValidate: true }); + setValue('noReceiptAcknowledged', false, { shouldValidate: true }); } catch (err) { setUploadError(err instanceof Error ? err.message : 'Upload failed'); setUploadedReceipt(null); + setValue('receiptFileIds', undefined, { shouldValidate: true }); } finally { setIsUploading(false); } @@ -180,6 +188,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi setUploadedReceipt(null); setUploadError(null); if (fileInputRef.current) fileInputRef.current.value = ''; + setValue('receiptFileIds', undefined, { shouldValidate: true }); } function onSubmit(data: CreateExpenseInput) { @@ -403,6 +412,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi const next = checked === true; setNoReceipt(next); if (next) clearReceipt(); + setValue('noReceiptAcknowledged', next, { shouldValidate: true }); }} />