Files
pn-new-crm/src/components/shared/custom-fields-section.tsx

324 lines
9.2 KiB
TypeScript
Raw Normal View History

'use client';
import { useRef, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
// ─── Types ────────────────────────────────────────────────────────────────────
interface CustomFieldDefinition {
id: string;
fieldName: string;
fieldLabel: string;
fieldType: 'text' | 'number' | 'date' | 'boolean' | 'select';
selectOptions: string[] | null;
isRequired: boolean;
sortOrder: number;
entityType: string;
}
interface CustomFieldValue {
id: string;
fieldId: string;
entityId: string;
value: unknown;
}
interface FieldEntry {
definition: CustomFieldDefinition;
value: CustomFieldValue | null;
}
interface CustomFieldsSectionProps {
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.
2026-05-07 21:45:42 +02:00
entityType: 'client' | 'interest' | 'berth' | 'yacht' | 'company';
entityId: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function CustomFieldsSection({ entityType, entityId }: CustomFieldsSectionProps) {
const [collapsed, setCollapsed] = useState(false);
const queryClient = useQueryClient();
// ── Data fetching ──────────────────────────────────────────────────────────
const { data: entries, isLoading } = useQuery<FieldEntry[]>({
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.
2026-05-07 21:45:42 +02:00
queryKey: ['custom-field-values', entityType, entityId],
queryFn: async () => {
const res = await apiFetch<{ data: FieldEntry[] }>(
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.
2026-05-07 21:45:42 +02:00
`/api/v1/custom-fields/${entityId}?entityType=${entityType}`,
);
return res.data;
},
enabled: !!entityId,
});
// Only show fields for this entity type
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.
2026-05-07 21:45:42 +02:00
const filteredEntries = entries?.filter((e) => e.definition.entityType === entityType) ?? [];
// ── Mutation ───────────────────────────────────────────────────────────────
const mutation = useMutation({
mutationFn: async (values: Array<{ fieldId: string; value: unknown }>) => {
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.
2026-05-07 21:45:42 +02:00
await apiFetch(`/api/v1/custom-fields/${entityId}?entityType=${entityType}`, {
method: 'PUT',
body: { values },
});
},
onSuccess: () => {
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.
2026-05-07 21:45:42 +02:00
void queryClient.invalidateQueries({
queryKey: ['custom-field-values', entityType, entityId],
});
},
});
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Custom Fields</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[1, 2].map((i) => (
<div key={i} className="h-8 animate-pulse rounded bg-muted" />
))}
</div>
</CardContent>
</Card>
);
}
return (
<Card>
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.
2026-05-07 21:45:42 +02:00
<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 ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</CardHeader>
{!collapsed && (
<CardContent>
{filteredEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">No custom fields configured.</p>
) : (
<div className="space-y-4">
{filteredEntries.map((entry) => (
<FieldControl
key={entry.definition.id}
entry={entry}
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.
2026-05-07 21:45:42 +02:00
onSave={(fieldId, value) => mutation.mutate([{ fieldId, value }])}
/>
))}
</div>
)}
</CardContent>
)}
</Card>
);
}
// ─── FieldControl ─────────────────────────────────────────────────────────────
interface FieldControlProps {
entry: FieldEntry;
onSave: (fieldId: string, value: unknown) => void;
}
function FieldControl({ entry, onSave }: FieldControlProps) {
const { definition, value: savedValue } = entry;
const initialValue = savedValue?.value ?? null;
// Debounce timer ref
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
function scheduleBlurSave(fieldId: string, val: unknown) {
// Immediate debounce cancel then save after 500ms idle
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
onSave(fieldId, val);
}, 500);
}
const label = (
<Label htmlFor={`cf-${definition.id}`} className="text-sm font-medium">
{definition.fieldLabel}
{definition.isRequired && (
<span className="ml-0.5 text-destructive" aria-label="required">
*
</span>
)}
</Label>
);
if (definition.fieldType === 'boolean') {
return (
<BooleanField
definition={definition}
initialValue={initialValue as boolean | null}
label={label}
onSave={onSave}
/>
);
}
if (definition.fieldType === 'select') {
return (
<SelectField
definition={definition}
initialValue={initialValue as string | null}
label={label}
onSave={onSave}
/>
);
}
// text / number / date
return (
<TextLikeField
definition={definition}
initialValue={initialValue}
label={label}
onScheduleSave={scheduleBlurSave}
/>
);
}
// ─── Sub-controls ──────────────────────────────────────────────────────────────
function TextLikeField({
definition,
initialValue,
label,
onScheduleSave,
}: {
definition: CustomFieldDefinition;
initialValue: unknown;
label: React.ReactNode;
onScheduleSave: (fieldId: string, val: unknown) => void;
}) {
const [localValue, setLocalValue] = useState(
initialValue !== null && initialValue !== undefined ? String(initialValue) : '',
);
const inputType =
definition.fieldType === 'number'
? 'number'
: definition.fieldType === 'date'
? 'date'
: 'text';
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const raw = e.target.value;
setLocalValue(raw);
let parsed: unknown = raw;
if (definition.fieldType === 'number') {
parsed = raw === '' ? null : parseFloat(raw);
} else if (raw === '') {
parsed = null;
}
onScheduleSave(definition.id, parsed);
}
return (
<div className="space-y-1.5">
{label}
<Input
id={`cf-${definition.id}`}
type={inputType}
value={localValue}
onChange={handleChange}
placeholder={definition.fieldLabel}
/>
</div>
);
}
function BooleanField({
definition,
initialValue,
label,
onSave,
}: {
definition: CustomFieldDefinition;
initialValue: boolean | null;
label: React.ReactNode;
onSave: (fieldId: string, val: unknown) => void;
}) {
const [checked, setChecked] = useState(initialValue ?? false);
function handleChange(val: boolean) {
setChecked(val);
onSave(definition.id, val);
}
return (
<div className="flex items-center justify-between gap-2">
{label}
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.
2026-05-07 21:45:42 +02:00
<Switch id={`cf-${definition.id}`} checked={checked} onCheckedChange={handleChange} />
</div>
);
}
function SelectField({
definition,
initialValue,
label,
onSave,
}: {
definition: CustomFieldDefinition;
initialValue: string | null;
label: React.ReactNode;
onSave: (fieldId: string, val: unknown) => void;
}) {
const options = definition.selectOptions ?? [];
const [selected, setSelected] = useState(initialValue ?? '');
function handleChange(val: string) {
setSelected(val);
onSave(definition.id, val === '__none__' ? null : val);
}
return (
<div className="space-y-1.5">
{label}
<Select value={selected || '__none__'} onValueChange={handleChange}>
<SelectTrigger id={`cf-${definition.id}`}>
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{!definition.isRequired && (
<SelectItem value="__none__">
<span className="text-muted-foreground">None</span>
</SelectItem>
)}
{options.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}