fix(uat-batch-1): wave-1 blocker bugs — supplemental gate, file FK, downloads, search dedup, notes stale, expense form, vocab
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) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ import { useCreateFromUrl } from '@/hooks/use-create-from-url';
|
|||||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { triggerBlobDownload } from '@/lib/utils/download';
|
||||||
|
|
||||||
export default function ExpensesPage() {
|
export default function ExpensesPage() {
|
||||||
const params = useParams<{ portSlug: string }>();
|
const params = useParams<{ portSlug: string }>();
|
||||||
@@ -91,12 +92,7 @@ export default function ExpensesPage() {
|
|||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
triggerBlobDownload(blob, `expenses.${type}`);
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `expenses.${type}`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = getExpenseColumns({
|
const columns = getExpenseColumns({
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
import { triggerUrlDownload } from '@/lib/utils/download';
|
||||||
|
|
||||||
interface BackupJob {
|
interface BackupJob {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -87,10 +88,7 @@ export function BackupAdminPanel() {
|
|||||||
async function download(id: string) {
|
async function download(id: string) {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ data: { url: string } }>(`/api/v1/admin/backup/${id}/download`);
|
const res = await apiFetch<{ data: { url: string } }>(`/api/v1/admin/backup/${id}/download`);
|
||||||
const a = document.createElement('a');
|
triggerUrlDownload(res.data.url, `backup-${id}.dump`);
|
||||||
a.href = res.data.url;
|
|
||||||
a.download = `backup-${id}.dump`;
|
|
||||||
a.click();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastError(err);
|
toastError(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,12 +35,11 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
import { useVocabulary } from '@/hooks/use-vocabulary';
|
||||||
|
|
||||||
const DOCK_LETTERS = ['A', 'B', 'C', 'D', 'E'] as const;
|
const DOCK_LETTERS = ['A', 'B', 'C', 'D', 'E'] as const;
|
||||||
type DockLetter = (typeof DOCK_LETTERS)[number];
|
type DockLetter = (typeof DOCK_LETTERS)[number];
|
||||||
|
|
||||||
const SIDE_PONTOON_OPTIONS = ['Port', 'Starboard', 'Bow', 'Stern', ''] as const;
|
|
||||||
|
|
||||||
interface RowDraft {
|
interface RowDraft {
|
||||||
mooringNumber: string;
|
mooringNumber: string;
|
||||||
area: string;
|
area: string;
|
||||||
@@ -77,6 +76,10 @@ export function BulkAddBerthsWizard() {
|
|||||||
const params = useParams<{ portSlug: string }>();
|
const params = useParams<{ portSlug: string }>();
|
||||||
const portSlug = params?.portSlug ?? '';
|
const portSlug = params?.portSlug ?? '';
|
||||||
const router = useRouter();
|
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');
|
const [step, setStep] = useState<'sequence' | 'edit'>('sequence');
|
||||||
|
|
||||||
@@ -261,7 +264,7 @@ export function BulkAddBerthsWizard() {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__none__">(none)</SelectItem>
|
<SelectItem value="__none__">(none)</SelectItem>
|
||||||
{SIDE_PONTOON_OPTIONS.filter(Boolean).map((p) => (
|
{sidePontoonOptions.filter(Boolean).map((p) => (
|
||||||
<SelectItem key={p} value={p}>
|
<SelectItem key={p} value={p}>
|
||||||
{p}
|
{p}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -331,7 +334,7 @@ export function BulkAddBerthsWizard() {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__none__">—</SelectItem>
|
<SelectItem value="__none__">—</SelectItem>
|
||||||
{SIDE_PONTOON_OPTIONS.filter(Boolean).map((p) => (
|
{sidePontoonOptions.filter(Boolean).map((p) => (
|
||||||
<SelectItem key={p} value={p}>
|
<SelectItem key={p} value={p}>
|
||||||
{p}
|
{p}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
|||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { useConfirmation } from '@/hooks/use-confirmation';
|
import { useConfirmation } from '@/hooks/use-confirmation';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { triggerUrlDownload } from '@/lib/utils/download';
|
||||||
import type { FileRow } from '@/components/files/file-grid';
|
import type { FileRow } from '@/components/files/file-grid';
|
||||||
|
|
||||||
interface ClientFilesTabProps {
|
interface ClientFilesTabProps {
|
||||||
@@ -39,10 +40,7 @@ export function ClientFilesTab({ clientId }: ClientFilesTabProps) {
|
|||||||
const res = await apiFetch<{ data: { url: string; filename: string } }>(
|
const res = await apiFetch<{ data: { url: string; filename: string } }>(
|
||||||
`/api/v1/files/${file.id}/download`,
|
`/api/v1/files/${file.id}/download`,
|
||||||
);
|
);
|
||||||
const a = document.createElement('a');
|
triggerUrlDownload(res.data.url, res.data.filename);
|
||||||
a.href = res.data.url;
|
|
||||||
a.download = res.data.filename;
|
|
||||||
a.click();
|
|
||||||
} catch {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
|||||||
entityType="clients"
|
entityType="clients"
|
||||||
entityId={clientId}
|
entityId={clientId}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
|
parentInvalidateKey={['clients', clientId]}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
|||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { useConfirmation } from '@/hooks/use-confirmation';
|
import { useConfirmation } from '@/hooks/use-confirmation';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { triggerUrlDownload } from '@/lib/utils/download';
|
||||||
import type { FileRow } from '@/components/files/file-grid';
|
import type { FileRow } from '@/components/files/file-grid';
|
||||||
|
|
||||||
interface CompanyFilesTabProps {
|
interface CompanyFilesTabProps {
|
||||||
@@ -39,10 +40,7 @@ export function CompanyFilesTab({ companyId }: CompanyFilesTabProps) {
|
|||||||
const res = await apiFetch<{ data: { url: string; filename: string } }>(
|
const res = await apiFetch<{ data: { url: string; filename: string } }>(
|
||||||
`/api/v1/files/${file.id}/download`,
|
`/api/v1/files/${file.id}/download`,
|
||||||
);
|
);
|
||||||
const a = document.createElement('a');
|
triggerUrlDownload(res.data.url, res.data.filename);
|
||||||
a.href = res.data.url;
|
|
||||||
a.download = res.data.filename;
|
|
||||||
a.click();
|
|
||||||
} catch {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ export function getCompanyTabs({
|
|||||||
entityType="companies"
|
entityType="companies"
|
||||||
entityId={companyId}
|
entityId={companyId}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
|
parentInvalidateKey={['companies', companyId]}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { triggerBlobDownload } from '@/lib/utils/download';
|
||||||
|
|
||||||
interface ChartCardProps {
|
interface ChartCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -24,22 +25,6 @@ interface ChartCardProps {
|
|||||||
className?: string;
|
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) {
|
async function exportContainerAsPng(container: HTMLElement, filename: string) {
|
||||||
const svg = container.querySelector('svg');
|
const svg = container.querySelector('svg');
|
||||||
if (!svg) return;
|
if (!svg) return;
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
|
|||||||
expenseDate: new Date(expense.expenseDate),
|
expenseDate: new Date(expense.expenseDate),
|
||||||
paymentStatus: (expense.paymentStatus as CreateExpenseInput['paymentStatus']) ?? 'unpaid',
|
paymentStatus: (expense.paymentStatus as CreateExpenseInput['paymentStatus']) ?? 'unpaid',
|
||||||
tripLabel: expense.tripLabel ?? undefined,
|
tripLabel: expense.tripLabel ?? undefined,
|
||||||
|
noReceiptAcknowledged: Boolean(expense.noReceiptAcknowledged),
|
||||||
});
|
});
|
||||||
setUploadedReceipt(null);
|
setUploadedReceipt(null);
|
||||||
setPreviewUrl(null);
|
setPreviewUrl(null);
|
||||||
@@ -98,6 +99,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
|
|||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
paymentStatus: 'unpaid',
|
paymentStatus: 'unpaid',
|
||||||
expenseDate: new Date(),
|
expenseDate: new Date(),
|
||||||
|
noReceiptAcknowledged: false,
|
||||||
});
|
});
|
||||||
setUploadedReceipt(null);
|
setUploadedReceipt(null);
|
||||||
setPreviewUrl(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 } };
|
const json = (await res.json()) as { data: { id: string; filename: string } };
|
||||||
setUploadedReceipt({ id: json.data.id, filename: json.data.filename });
|
setUploadedReceipt({ id: json.data.id, filename: json.data.filename });
|
||||||
setNoReceipt(false);
|
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) {
|
} catch (err) {
|
||||||
setUploadError(err instanceof Error ? err.message : 'Upload failed');
|
setUploadError(err instanceof Error ? err.message : 'Upload failed');
|
||||||
setUploadedReceipt(null);
|
setUploadedReceipt(null);
|
||||||
|
setValue('receiptFileIds', undefined, { shouldValidate: true });
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
}
|
}
|
||||||
@@ -180,6 +188,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
|
|||||||
setUploadedReceipt(null);
|
setUploadedReceipt(null);
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
setValue('receiptFileIds', undefined, { shouldValidate: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSubmit(data: CreateExpenseInput) {
|
function onSubmit(data: CreateExpenseInput) {
|
||||||
@@ -403,6 +412,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
|
|||||||
const next = checked === true;
|
const next = checked === true;
|
||||||
setNoReceipt(next);
|
setNoReceipt(next);
|
||||||
if (next) clearReceipt();
|
if (next) clearReceipt();
|
||||||
|
setValue('noReceiptAcknowledged', next, { shouldValidate: true });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="noReceipt" className="text-sm font-normal leading-tight">
|
<Label htmlFor="noReceipt" className="text-sm font-normal leading-tight">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
|||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { useConfirmation } from '@/hooks/use-confirmation';
|
import { useConfirmation } from '@/hooks/use-confirmation';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { triggerUrlDownload } from '@/lib/utils/download';
|
||||||
|
|
||||||
interface InterestDocumentsTabProps {
|
interface InterestDocumentsTabProps {
|
||||||
interestId: string;
|
interestId: string;
|
||||||
@@ -69,10 +70,7 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
|
|||||||
const res = await apiFetch<{ data: { url: string; filename: string } }>(
|
const res = await apiFetch<{ data: { url: string; filename: string } }>(
|
||||||
`/api/v1/files/${file.id}/download`,
|
`/api/v1/files/${file.id}/download`,
|
||||||
);
|
);
|
||||||
const a = document.createElement('a');
|
triggerUrlDownload(res.data.url, res.data.filename);
|
||||||
a.href = res.data.url;
|
|
||||||
a.download = res.data.filename;
|
|
||||||
a.click();
|
|
||||||
} catch {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
}
|
}
|
||||||
@@ -141,6 +139,7 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
|
|||||||
<FileUploadZone
|
<FileUploadZone
|
||||||
entityType="client"
|
entityType="client"
|
||||||
entityId={interest.clientId}
|
entityId={interest.clientId}
|
||||||
|
clientId={interest.clientId}
|
||||||
onUploadComplete={() => {
|
onUploadComplete={() => {
|
||||||
queryClient.invalidateQueries({ queryKey: filesQueryKey });
|
queryClient.invalidateQueries({ queryKey: filesQueryKey });
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
type DocumentStatus,
|
type DocumentStatus,
|
||||||
} from '@/lib/labels/document-status';
|
} from '@/lib/labels/document-status';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { triggerUrlDownload } from '@/lib/utils/download';
|
||||||
import { useUIStore } from '@/stores/ui-store';
|
import { useUIStore } from '@/stores/ui-store';
|
||||||
|
|
||||||
interface InterestEoiTabProps {
|
interface InterestEoiTabProps {
|
||||||
@@ -594,10 +595,7 @@ function SignedPdfActions({ fileId }: { fileId: string }) {
|
|||||||
if (mode === 'view') {
|
if (mode === 'view') {
|
||||||
window.open(res.data.url, '_blank', 'noopener,noreferrer');
|
window.open(res.data.url, '_blank', 'noopener,noreferrer');
|
||||||
} else {
|
} else {
|
||||||
const a = document.createElement('a');
|
triggerUrlDownload(res.data.url, res.data.filename);
|
||||||
a.href = res.data.url;
|
|
||||||
a.download = res.data.filename;
|
|
||||||
a.click();
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastError(err, 'Failed to fetch signed PDF');
|
toastError(err, 'Failed to fetch signed PDF');
|
||||||
|
|||||||
@@ -1208,7 +1208,12 @@ export function getInterestTabs({
|
|||||||
id: 'notes',
|
id: 'notes',
|
||||||
label: 'Notes',
|
label: 'Notes',
|
||||||
content: (
|
content: (
|
||||||
<NotesList entityType="interests" entityId={interestId} currentUserId={currentUserId} />
|
<NotesList
|
||||||
|
entityType="interests"
|
||||||
|
entityId={interestId}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
parentInvalidateKey={['interests', interestId]}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export function getResidentialClientTabs({
|
|||||||
entityType="residential_clients"
|
entityType="residential_clients"
|
||||||
entityId={clientId}
|
entityId={clientId}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
|
parentInvalidateKey={['residential-client', clientId]}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export function getResidentialInterestTabs({
|
|||||||
entityType="residential_interests"
|
entityType="residential_interests"
|
||||||
entityId={interestId}
|
entityId={interestId}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
|
parentInvalidateKey={['residential-interest', interestId]}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient, type QueryKey } from '@tanstack/react-query';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { Lock, Pencil, Trash2, Send, Loader2 } from 'lucide-react';
|
import { Lock, Pencil, Trash2, Send, Loader2 } from 'lucide-react';
|
||||||
import { useAutoAnimate } from '@formkit/auto-animate/react';
|
import { useAutoAnimate } from '@formkit/auto-animate/react';
|
||||||
@@ -100,6 +100,14 @@ interface NotesListProps {
|
|||||||
* residential_clients}. Ignored for interests / residential_interests.
|
* residential_clients}. Ignored for interests / residential_interests.
|
||||||
*/
|
*/
|
||||||
aggregate?: boolean;
|
aggregate?: boolean;
|
||||||
|
/**
|
||||||
|
* Optional parent-entity query key to invalidate alongside the notes
|
||||||
|
* query on create/update/delete. The parent entity detail typically
|
||||||
|
* hydrates a `recentNote` / `notesCount` teaser that goes stale after
|
||||||
|
* a note mutation; passing the detail's query key here keeps it in
|
||||||
|
* sync without a hard refresh.
|
||||||
|
*/
|
||||||
|
parentInvalidateKey?: QueryKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOTE_EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
const NOTE_EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
@@ -126,8 +134,20 @@ function sortByGroup(notes: Note[]): Note[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotesList({ entityType, entityId, currentUserId, aggregate }: NotesListProps) {
|
export function NotesList({
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
currentUserId,
|
||||||
|
aggregate,
|
||||||
|
parentInvalidateKey,
|
||||||
|
}: NotesListProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const invalidateAll = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey });
|
||||||
|
if (parentInvalidateKey) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: parentInvalidateKey });
|
||||||
|
}
|
||||||
|
};
|
||||||
const [newNote, setNewNote] = useState('');
|
const [newNote, setNewNote] = useState('');
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editContent, setEditContent] = useState('');
|
const [editContent, setEditContent] = useState('');
|
||||||
@@ -164,7 +184,7 @@ export function NotesList({ entityType, entityId, currentUserId, aggregate }: No
|
|||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (content: string) => apiFetch(baseEndpoint, { method: 'POST', body: { content } }),
|
mutationFn: (content: string) => apiFetch(baseEndpoint, { method: 'POST', body: { content } }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey });
|
invalidateAll();
|
||||||
setNewNote('');
|
setNewNote('');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -173,14 +193,14 @@ export function NotesList({ entityType, entityId, currentUserId, aggregate }: No
|
|||||||
mutationFn: ({ noteId, content }: { noteId: string; content: string }) =>
|
mutationFn: ({ noteId, content }: { noteId: string; content: string }) =>
|
||||||
apiFetch(`${baseEndpoint}/${noteId}`, { method: 'PATCH', body: { content } }),
|
apiFetch(`${baseEndpoint}/${noteId}`, { method: 'PATCH', body: { content } }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey });
|
invalidateAll();
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (noteId: string) => apiFetch(`${baseEndpoint}/${noteId}`, { method: 'DELETE' }),
|
mutationFn: (noteId: string) => apiFetch(`${baseEndpoint}/${noteId}`, { method: 'DELETE' }),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
onSuccess: () => invalidateAll(),
|
||||||
});
|
});
|
||||||
|
|
||||||
function canEdit(note: Note): boolean {
|
function canEdit(note: Note): boolean {
|
||||||
|
|||||||
@@ -348,7 +348,13 @@ export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions
|
|||||||
id: 'notes',
|
id: 'notes',
|
||||||
label: 'Notes',
|
label: 'Notes',
|
||||||
content: (
|
content: (
|
||||||
<NotesList entityType="yachts" entityId={yachtId} currentUserId={currentUserId} aggregate />
|
<NotesList
|
||||||
|
entityType="yachts"
|
||||||
|
entityId={yachtId}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
aggregate
|
||||||
|
parentInvalidateKey={['yachts', yachtId]}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -94,13 +94,25 @@ export async function uploadFile(
|
|||||||
sizeBytes: normalizedSize,
|
sizeBytes: normalizedSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Derive the entity FK from (entityType, entityId) when the caller
|
||||||
|
// didn't pass it explicitly. Without this, an interest-tab upload that
|
||||||
|
// sets `entityType='client'` + `entityId=<UUID>` lands with
|
||||||
|
// `client_id=NULL` — the Attachments list filters on `clientId` and
|
||||||
|
// the file vanishes from the interest's Documents tab.
|
||||||
|
const derivedClientId =
|
||||||
|
data.clientId ?? (data.entityType === 'client' ? (data.entityId ?? null) : null);
|
||||||
|
const derivedCompanyId =
|
||||||
|
data.companyId ?? (data.entityType === 'company' ? (data.entityId ?? null) : null);
|
||||||
|
const derivedYachtId =
|
||||||
|
data.yachtId ?? (data.entityType === 'yacht' ? (data.entityId ?? null) : null);
|
||||||
|
|
||||||
// E8: auto-set entity FK from system-managed folder when the rep uploads
|
// E8: auto-set entity FK from system-managed folder when the rep uploads
|
||||||
// directly into a client/company/yacht folder. No-op for non-system folders.
|
// directly into a client/company/yacht folder. No-op for non-system folders.
|
||||||
const enrichedValues = await applyEntityFkFromFolder(portId, {
|
const enrichedValues = await applyEntityFkFromFolder(portId, {
|
||||||
portId,
|
portId,
|
||||||
clientId: data.clientId ?? null,
|
clientId: derivedClientId,
|
||||||
yachtId: data.yachtId ?? null,
|
yachtId: derivedYachtId,
|
||||||
companyId: data.companyId ?? null,
|
companyId: derivedCompanyId,
|
||||||
folderId: data.folderId ?? null,
|
folderId: data.folderId ?? null,
|
||||||
filename: sanitizedFilename,
|
filename: sanitizedFilename,
|
||||||
originalName: sanitizedOriginal,
|
originalName: sanitizedOriginal,
|
||||||
|
|||||||
@@ -89,7 +89,15 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
|
|||||||
href: '/:portSlug/admin/templates',
|
href: '/:portSlug/admin/templates',
|
||||||
label: 'Document templates',
|
label: 'Document templates',
|
||||||
category: 'settings',
|
category: 'settings',
|
||||||
keywords: ['eoi', 'documenso', 'pdf templates', 'template merge fields'],
|
keywords: [
|
||||||
|
'eoi',
|
||||||
|
'documenso',
|
||||||
|
'pdf templates',
|
||||||
|
'email templates',
|
||||||
|
'template merge fields',
|
||||||
|
'merge fields',
|
||||||
|
'eoi template',
|
||||||
|
],
|
||||||
requires: 'admin.manage_settings',
|
requires: 'admin.manage_settings',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -271,13 +279,6 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
|
|||||||
keywords: ['form templates', 'inquiry', 'intake', 'public form'],
|
keywords: ['form templates', 'inquiry', 'intake', 'public form'],
|
||||||
requires: 'admin.manage_forms',
|
requires: 'admin.manage_forms',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: '/:portSlug/admin/templates',
|
|
||||||
label: 'Document templates',
|
|
||||||
category: 'admin',
|
|
||||||
keywords: ['pdf templates', 'email templates', 'merge fields', 'eoi template'],
|
|
||||||
requires: 'admin.manage_settings',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: '/:portSlug/admin/email-templates',
|
href: '/:portSlug/admin/email-templates',
|
||||||
label: 'Email templates',
|
label: 'Email templates',
|
||||||
@@ -425,7 +426,7 @@ export function searchNavCatalog(
|
|||||||
if (q.length === 0) return [];
|
if (q.length === 0) return [];
|
||||||
|
|
||||||
const limit = opts.limit ?? 5;
|
const limit = opts.limit ?? 5;
|
||||||
const out: Array<NavCatalogEntry & { score: number }> = [];
|
const byHref = new Map<string, NavCatalogEntry & { score: number }>();
|
||||||
|
|
||||||
for (const entry of NAV_CATALOG) {
|
for (const entry of NAV_CATALOG) {
|
||||||
if (entry.superAdminOnly && !opts.isSuperAdmin) continue;
|
if (entry.superAdminOnly && !opts.isSuperAdmin) continue;
|
||||||
@@ -434,9 +435,20 @@ export function searchNavCatalog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const score = scoreEntry(q, entry);
|
const score = scoreEntry(q, entry);
|
||||||
if (score > 0) out.push({ ...entry, score });
|
if (score === 0) continue;
|
||||||
|
|
||||||
|
// Some hrefs intentionally appear in multiple catalog categories
|
||||||
|
// (e.g. /admin/templates lives under both 'settings' and 'admin').
|
||||||
|
// Keep the highest-scoring variant so the dropdown never renders
|
||||||
|
// two rows with the same `id` (href) — React would otherwise warn
|
||||||
|
// about duplicate keys.
|
||||||
|
const existing = byHref.get(entry.href);
|
||||||
|
if (!existing || score > existing.score) {
|
||||||
|
byHref.set(entry.href, { ...entry, score });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const out = Array.from(byHref.values());
|
||||||
out.sort((a, b) => b.score - a.score);
|
out.sort((a, b) => b.score - a.score);
|
||||||
return out.slice(0, limit);
|
return out.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|||||||
37
src/lib/utils/download.ts
Normal file
37
src/lib/utils/download.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Trigger a browser download for a Blob with the given filename.
|
||||||
|
*
|
||||||
|
* The naive pattern (`<a download={name}>` synthesized, click()'d, then
|
||||||
|
* URL.revokeObjectURL'd) silently drops the `download` attribute on
|
||||||
|
* Chromium-based browsers when the anchor isn't attached to the DOM.
|
||||||
|
* The browser then names the file after the blob URL (a UUID) with no
|
||||||
|
* extension. Appending to body, clicking, removing — in that order —
|
||||||
|
* keeps the attribute honoured everywhere.
|
||||||
|
*
|
||||||
|
* `URL.revokeObjectURL` runs on the next tick so the browser has a
|
||||||
|
* chance to read the URL before it's released; revoking synchronously
|
||||||
|
* can race with the download start in Safari.
|
||||||
|
*/
|
||||||
|
export function triggerBlobDownload(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
triggerUrlDownload(url, filename);
|
||||||
|
// Defer revoke to next microtask so the navigation can latch the URL.
|
||||||
|
queueMicrotask(() => URL.revokeObjectURL(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a browser download from an already-resolved URL (e.g. a
|
||||||
|
* presigned S3 / MinIO URL). Same DOM-attached pattern as
|
||||||
|
* triggerBlobDownload — without it Chromium drops the `download`
|
||||||
|
* attribute and the file lands with the URL's last path segment as
|
||||||
|
* the filename (a UUID, no extension).
|
||||||
|
*/
|
||||||
|
export function triggerUrlDownload(url: string, filename: string): void {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.rel = 'noopener';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user