fix(audit): backlog sweep — partial archived indexes, custom-fields per-entity gate, polish
Wave through the 2026-05-07 backlog of small/concrete audit-final-deferred
items (deferring the Documenso Phases 2-7 build and items needing design
decisions or live external instances).
DB schema:
- Migration 0046 converts 5 composite (port_id, archived_at) indexes to
partial WHERE archived_at IS NULL — clients, interests, yachts, and
both residential tables. Smaller, faster planner choice for the
dominant list-query shape.
Multi-tenant isolation:
- document_sends now verifies recipient.interestId belongs to the port
before landing on the audit row (the surrounding clientId check was
already port-scoped; interestId pollution was the gap).
Routes / API:
- /api/v1/custom-fields/[entityId] requires entityType query param and
gates on the matching resource permission (clients/interests/berths/
yachts/companies). Fixes the cross-resource gap where a user with
clients.view could read company custom-field values.
- Admin user list trash button wrapped in PermissionGate (edit was
already gated; remove was not).
Service polish:
- berth-recommender accepts string-shaped JSONB booleans
('true'/'false') so admin UIs that wrap values as strings don't
silently fall through to defaults.
- expense-pdf renderReceiptHeader anchors all text positions to a
captured baseY rather than reading mutating doc.y after rect+stroke.
Headers no longer drift on the first receipt page after a soft page
break.
- berth-pdf apply: collect non-finite numeric coercion drops + warn-log
them so partial silent drops are observable (was invisible because
the no-fields-supplied check only fires when ALL drop).
- Storage cache fingerprint comment documenting the encrypted-secret
invariant + the explicit invalidation hook.
UI polish:
- invoice-detail typed: replaced two `any` casts with a proper
InvoiceDetailData / LineItem / LinkedExpense interface set.
- YachtForm now accepts initialOwner prop. Wired through:
- client-yachts-tab passes { type: 'client', id: clientId }
- interest-form passes { type: 'client', id: selectedClientId }
- Interest-form yacht picker now includes company-owned yachts where
the selected client is a member (fetches client.companies and feeds
YachtPicker an array filter). Plus an inline "Add new" button that
opens YachtForm pre-bound to the client.
- YachtPicker accepts ownerFilter as single OR array for "match any"
semantics.
BACKLOG.md updated with what landed vs what's still deferred (and why
each deferred item is genuinely larger than this push warrants).
Tests: 1185/1185 vitest, tsc clean.
This commit is contained in:
@@ -118,19 +118,25 @@ export function UserList() {
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
<ConfirmationDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
}
|
||||
title="Remove User"
|
||||
description={`Remove "${row.original.displayName}" from this port? They will lose access but their account remains.`}
|
||||
confirmLabel="Remove"
|
||||
onConfirm={() => handleRemoveUser(row.original.userId)}
|
||||
loading={deletingId === row.original.userId}
|
||||
/>
|
||||
<PermissionGate resource="admin" action="manage_users">
|
||||
<ConfirmationDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
}
|
||||
title="Remove User"
|
||||
description={`Remove "${row.original.displayName}" from this port? They will lose access but their account remains.`}
|
||||
confirmLabel="Remove"
|
||||
onConfirm={() => handleRemoveUser(row.original.userId)}
|
||||
loading={deletingId === row.original.userId}
|
||||
/>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
|
||||
@@ -31,7 +31,7 @@ interface ClientYachtsTabProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export function ClientYachtsTab({ clientId: _clientId, yachts }: ClientYachtsTabProps) {
|
||||
export function ClientYachtsTab({ clientId, yachts }: ClientYachtsTabProps) {
|
||||
const routeParams = useParams<{ portSlug: string }>();
|
||||
const portSlug = routeParams?.portSlug ?? '';
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
@@ -89,13 +89,13 @@ export function ClientYachtsTab({ clientId: _clientId, yachts }: ClientYachtsTab
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
TODO: YachtForm (Task 5.2) does not yet accept a preset owner prop.
|
||||
When opened here, the user must manually pick this client in the owner
|
||||
picker. Wire an `initialOwner` prop into YachtForm in a follow-up so
|
||||
we can pre-select `{ type: 'client', id: clientId }`.
|
||||
*/}
|
||||
{createOpen && <YachtForm open={createOpen} onOpenChange={setCreateOpen} />}
|
||||
{createOpen && (
|
||||
<YachtForm
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
initialOwner={{ type: 'client', id: clientId }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, ChevronsUpDown, Check } from 'lucide-react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, ChevronsUpDown, Check, Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { TagPicker } from '@/components/shared/tag-picker';
|
||||
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';
|
||||
@@ -100,6 +101,25 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
const selectedClientId = watch('clientId');
|
||||
const selectedBerthId = watch('berthId');
|
||||
const selectedYachtId = watch('yachtId');
|
||||
const [createYachtOpen, setCreateYachtOpen] = useState(false);
|
||||
|
||||
// Fetch the selected client's company memberships so the YachtPicker can
|
||||
// include yachts owned by companies the client belongs to (e.g. a
|
||||
// managing-director client whose yachts are titled to the company).
|
||||
const { data: clientDetail } = useQuery<{
|
||||
data: { companies?: Array<{ company: { id: string } }> };
|
||||
}>({
|
||||
queryKey: ['client-detail-for-interest-form', selectedClientId],
|
||||
queryFn: () => apiFetch(`/api/v1/clients/${selectedClientId}`),
|
||||
enabled: !!selectedClientId,
|
||||
});
|
||||
const memberCompanyIds: string[] = clientDetail?.data.companies?.map((m) => m.company.id) ?? [];
|
||||
const yachtOwnerFilter = selectedClientId
|
||||
? [
|
||||
{ type: 'client' as const, id: selectedClientId },
|
||||
...memberCompanyIds.map((id) => ({ type: 'company' as const, id })),
|
||||
]
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
options: clientOptions,
|
||||
@@ -313,21 +333,38 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Yacht</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Yacht</Label>
|
||||
{selectedClientId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setCreateYachtOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add new
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<YachtPicker
|
||||
value={selectedYachtId ?? null}
|
||||
onChange={(id) => setValue('yachtId', id ?? undefined)}
|
||||
ownerFilter={
|
||||
selectedClientId ? { type: 'client', id: selectedClientId } : undefined
|
||||
}
|
||||
ownerFilter={yachtOwnerFilter}
|
||||
disabled={!selectedClientId}
|
||||
placeholder={selectedClientId ? 'Select yacht...' : 'Select a client first'}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required before the interest can leave the "Open" stage.
|
||||
{memberCompanyIds.length > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
Includes yachts from {memberCompanyIds.length}{' '}
|
||||
{memberCompanyIds.length === 1 ? 'member company' : 'member companies'}.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{/* TODO: also include company-owned yachts where client is a member - requires autocomplete owner=any|company filter */}
|
||||
{/* TODO: add "Add new yacht" inline shortcut (requires YachtForm integration) */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -501,6 +538,13 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
{createYachtOpen && selectedClientId && (
|
||||
<YachtForm
|
||||
open={createYachtOpen}
|
||||
onOpenChange={setCreateYachtOpen}
|
||||
initialOwner={{ type: 'client', id: selectedClientId }}
|
||||
/>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,15 +75,53 @@ interface InvoiceDetailProps {
|
||||
invoiceId: string;
|
||||
}
|
||||
|
||||
interface InvoiceLineItem {
|
||||
id: string;
|
||||
description: string;
|
||||
quantity: number | string;
|
||||
unitPrice: number | string;
|
||||
total: number | string;
|
||||
}
|
||||
|
||||
interface InvoiceLinkedExpense {
|
||||
id: string;
|
||||
establishmentName: string | null;
|
||||
category: string | null;
|
||||
expenseDate: string;
|
||||
amount: number | string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface InvoiceDetailData {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
status: string;
|
||||
clientName: string;
|
||||
currency: string;
|
||||
total: number | string;
|
||||
subtotal: number | string;
|
||||
discountAmount: number | string;
|
||||
discountPct: number | string;
|
||||
feeAmount: number | string;
|
||||
feePct: number | string;
|
||||
dueDate: string | null;
|
||||
paymentTerms: string | null;
|
||||
notes: string | null;
|
||||
pdfFileId: string | null;
|
||||
paymentDate: string | null;
|
||||
paymentMethod: string | null;
|
||||
paymentReference: string | null;
|
||||
lineItems?: InvoiceLineItem[];
|
||||
linkedExpenses?: InvoiceLinkedExpense[];
|
||||
}
|
||||
|
||||
export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [tab, setTab] = useState('overview');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data, isLoading, error } = useQuery<{ data: any }>({
|
||||
const { data, isLoading, error } = useQuery<{ data: InvoiceDetailData }>({
|
||||
queryKey: ['invoices', invoiceId],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryFn: () => apiFetch<{ data: any }>(`/api/v1/invoices/${invoiceId}`),
|
||||
queryFn: () => apiFetch<{ data: InvoiceDetailData }>(`/api/v1/invoices/${invoiceId}`),
|
||||
});
|
||||
|
||||
const { setChrome } = useMobileChrome();
|
||||
@@ -233,8 +271,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
||||
<span className="col-span-2 text-right">Unit Price</span>
|
||||
<span className="col-span-2 text-right">Total</span>
|
||||
</div>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{(invoice.lineItems as any[]).map((li) => (
|
||||
{invoice.lineItems.map((li) => (
|
||||
<div key={li.id} className="grid grid-cols-12 gap-2 text-sm">
|
||||
<span className="col-span-6">{li.description}</span>
|
||||
<span className="col-span-2 text-right tabular-nums">{li.quantity}</span>
|
||||
@@ -299,8 +336,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
||||
<TabsContent value="expenses" className="pt-4">
|
||||
{invoice.linkedExpenses && invoice.linkedExpenses.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{(invoice.linkedExpenses as any[]).map((exp) => (
|
||||
{invoice.linkedExpenses.map((exp) => (
|
||||
<div
|
||||
key={exp.id}
|
||||
className="flex items-center justify-between p-3 border rounded-md text-sm"
|
||||
|
||||
@@ -43,7 +43,7 @@ interface FieldEntry {
|
||||
}
|
||||
|
||||
interface CustomFieldsSectionProps {
|
||||
entityType: 'client' | 'interest' | 'berth';
|
||||
entityType: 'client' | 'interest' | 'berth' | 'yacht' | 'company';
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
@@ -56,10 +56,10 @@ export function CustomFieldsSection({ entityType, entityId }: CustomFieldsSectio
|
||||
// ── Data fetching ──────────────────────────────────────────────────────────
|
||||
|
||||
const { data: entries, isLoading } = useQuery<FieldEntry[]>({
|
||||
queryKey: ['custom-field-values', entityId],
|
||||
queryKey: ['custom-field-values', entityType, entityId],
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch<{ data: FieldEntry[] }>(
|
||||
`/api/v1/custom-fields/${entityId}`,
|
||||
`/api/v1/custom-fields/${entityId}?entityType=${entityType}`,
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
@@ -67,20 +67,21 @@ export function CustomFieldsSection({ entityType, entityId }: CustomFieldsSectio
|
||||
});
|
||||
|
||||
// Only show fields for this entity type
|
||||
const filteredEntries =
|
||||
entries?.filter((e) => e.definition.entityType === entityType) ?? [];
|
||||
const filteredEntries = entries?.filter((e) => e.definition.entityType === entityType) ?? [];
|
||||
|
||||
// ── Mutation ───────────────────────────────────────────────────────────────
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: Array<{ fieldId: string; value: unknown }>) => {
|
||||
await apiFetch(`/api/v1/custom-fields/${entityId}`, {
|
||||
await apiFetch(`/api/v1/custom-fields/${entityId}?entityType=${entityType}`, {
|
||||
method: 'PUT',
|
||||
body: { values },
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['custom-field-values', entityId] });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['custom-field-values', entityType, entityId],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -103,10 +104,7 @@ export function CustomFieldsSection({ entityType, entityId }: CustomFieldsSectio
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
>
|
||||
<CardHeader className="cursor-pointer select-none" onClick={() => setCollapsed((c) => !c)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Custom Fields</CardTitle>
|
||||
{collapsed ? (
|
||||
@@ -127,9 +125,7 @@ export function CustomFieldsSection({ entityType, entityId }: CustomFieldsSectio
|
||||
<FieldControl
|
||||
key={entry.definition.id}
|
||||
entry={entry}
|
||||
onSave={(fieldId, value) =>
|
||||
mutation.mutate([{ fieldId, value }])
|
||||
}
|
||||
onSave={(fieldId, value) => mutation.mutate([{ fieldId, value }])}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -278,11 +274,7 @@ function BooleanField({
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{label}
|
||||
<Switch
|
||||
id={`cf-${definition.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={handleChange}
|
||||
/>
|
||||
<Switch id={`cf-${definition.id}`} checked={checked} onCheckedChange={handleChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,11 +49,18 @@ interface YachtFormProps {
|
||||
status?: string | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
/**
|
||||
* In create mode, pre-select the owner so a user opening this form from
|
||||
* a client/company detail page doesn't have to manually re-pick the
|
||||
* entity they're already on. Ignored in edit mode (the existing
|
||||
* owner-history workflow is the right surface for ownership changes).
|
||||
*/
|
||||
initialOwner?: { type: 'client' | 'company'; id: string };
|
||||
}
|
||||
|
||||
type YachtStatus = 'active' | 'retired' | 'sold_away';
|
||||
|
||||
export function YachtForm({ open, onOpenChange, yacht }: YachtFormProps) {
|
||||
export function YachtForm({ open, onOpenChange, yacht, initialOwner }: YachtFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!yacht;
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
@@ -109,10 +116,11 @@ export function YachtForm({ open, onOpenChange, yacht }: YachtFormProps) {
|
||||
name: '',
|
||||
status: 'active',
|
||||
tagIds: [],
|
||||
...(initialOwner ? { owner: initialOwner } : {}),
|
||||
});
|
||||
}
|
||||
setFormError(null);
|
||||
}, [yacht, open, reset]);
|
||||
}, [yacht, open, reset, initialOwner]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: CreateYachtInput) => {
|
||||
|
||||
@@ -27,11 +27,17 @@ interface YachtOption {
|
||||
currentOwnerId?: string;
|
||||
}
|
||||
|
||||
type OwnerFilter = { type: 'client' | 'company'; id: string };
|
||||
|
||||
interface YachtPickerProps {
|
||||
value: string | null;
|
||||
onChange: (yachtId: string | null) => void;
|
||||
/** Optional filter to only show yachts owned by the given client or company. */
|
||||
ownerFilter?: { type: 'client' | 'company'; id: string };
|
||||
/**
|
||||
* Optional filter. Single owner = strict match. Array = match ANY of the
|
||||
* supplied owners (used by the interest-form to include yachts owned by
|
||||
* the client AND by companies the client is a member of).
|
||||
*/
|
||||
ownerFilter?: OwnerFilter | OwnerFilter[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
@@ -54,11 +60,17 @@ export function YachtPicker({
|
||||
});
|
||||
|
||||
const rawOptions = data?.data ?? [];
|
||||
const options = ownerFilter
|
||||
? rawOptions.filter(
|
||||
(y) => y.currentOwnerType === ownerFilter.type && y.currentOwnerId === ownerFilter.id,
|
||||
)
|
||||
: rawOptions;
|
||||
const filterList: OwnerFilter[] | null = ownerFilter
|
||||
? Array.isArray(ownerFilter)
|
||||
? ownerFilter
|
||||
: [ownerFilter]
|
||||
: null;
|
||||
const options =
|
||||
filterList && filterList.length > 0
|
||||
? rawOptions.filter((y) =>
|
||||
filterList.some((f) => y.currentOwnerType === f.type && y.currentOwnerId === f.id),
|
||||
)
|
||||
: rawOptions;
|
||||
|
||||
const selectedLabel = (() => {
|
||||
if (!value) return placeholder;
|
||||
|
||||
Reference in New Issue
Block a user