feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker

Phase 3b — EOI dialog field overrides:
- New EoiOverridesInput shape (clientEmail / clientPhone / yachtName)
  threaded through generate-and-sign validator + both pathways
  (in-app pdf-lib fill, Documenso template generate).
- src/lib/services/eoi-overrides.service.ts applies side-effects in one
  transaction: useOnlyForThisEoi writes documents.override_* and stops;
  setAsDefault demotes the prior primary + promotes (existing contactId)
  or inserts + promotes (fresh value); neither flag inserts a non-primary
  client_contacts row for future dropdown reuse.
- Document override columns persisted post-insert, with a 1-minute
  source_document_id backfill on freshly inserted contact rows.
- eoi-context route returns available.{emails, phones} so the dialog
  can render combobox options.
- <OverridableContactField> in eoi-generate-dialog.tsx renders the
  combobox + manual input + 2 checkboxes per field with mutually
  exclusive intent semantics.

Phase 3c — yacht spawn from EOI dialog:
- YachtForm gains createExtras + onCreated callbacks; the EOI dialog
  opens it as a nested Sheet pre-filled with the linked client as owner.
  On save the new yacht is stamped source='eoi-generated' and the
  interest is PATCHed with the new yachtId so the EOI context reflows.

Phase 3d — promote-to-primary + audit + [EOI] badge:
- POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary
  (transactional demote+promote via promoteContactToPrimary).
- src/lib/audit.ts AuditAction type adds eoi_field_override,
  promote_to_primary, eoi_spawn_yacht (DB column is free-text).
- ContactsEditor surfaces an [EOI] badge on non-primary rows where
  source='eoi-custom-input'.

Phase 4 — worker + TOD picker:
- processOverdueReminders refactored to UPDATE...RETURNING with a
  fired_at IS NULL gate so parallel workers can't double-fire. Uses
  the idx_reminders_due_unfired partial index from migration 0072.
- /settings gets a "Default reminder time" time-of-day picker; the
  value lands in user_profiles.preferences.digestTimeOfDay (validated
  HH:MM at the route). <ReminderForm> seeds its dueAt from this
  preference via a React-Query me-prefs fetch.

Phase 6 hardening:
- IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste
  of Google Workspace's 16-char App Password formatted as
  "abcd efgh ijkl mnop" still authenticates. Workspace activation
  procedure documented in MASTER-PLAN §Phase 6 (was previously written
  to CLAUDE.md, which was bloat — moved to the plan).

Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 16:18:03 +02:00
parent 503207ef68
commit eaab14943b
20 changed files with 1119 additions and 92 deletions

View File

@@ -31,6 +31,10 @@ interface Contact {
valueCountry?: string | null;
label?: string | null;
isPrimary: boolean;
/** Phase 3d — origin tag surfaced as an [EOI] badge when an EOI
* spawned this contact. */
source?: string | null;
sourceDocumentId?: string | null;
}
const CHANNEL_OPTIONS = [
@@ -254,6 +258,19 @@ function ContactRow({
/>
</div>
{contact.source === 'eoi-custom-input' && !contact.isPrimary ? (
<span
className="inline-flex items-center rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-800"
title={
contact.sourceDocumentId
? 'Spawned from an EOI — open the source document for details.'
: 'Spawned from an EOI override.'
}
>
EOI
</span>
) : null}
<button
type="button"
onClick={togglePrimary}

View File

@@ -28,6 +28,7 @@ import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui-store';
import { Input } from '@/components/ui/input';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { YachtForm } from '@/components/yachts/yacht-form';
import { toastError } from '@/lib/api/toast-error';
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
@@ -82,9 +83,37 @@ interface EoiContextResponse {
} | null;
eoiBerthRange: string;
port: { name: string };
/** Phase 3b — every contact row the dialog renders in its
* override comboboxes. Populated by the eoi-context route. */
available: {
emails: Array<{ id: string; value: string; isPrimary: boolean; source: string }>;
phones: Array<{
id: string;
value: string;
isPrimary: boolean;
channel: 'phone' | 'whatsapp';
source: string;
}>;
};
};
}
/**
* Phase 3b — per-field override state captured by the dialog. Sent
* verbatim on the generate-and-sign POST and translated server-side
* into the documents.override_* columns + (optionally) client_contacts
* mutations.
*/
interface FieldOverrideState {
/** When null, no override is active for this field. */
value: string | null;
/** The client_contacts.id the value came from (when picked from the
* combobox). Null = fresh typed value. */
contactId: string | null;
useOnlyForThisEoi: boolean;
setAsDefault: boolean;
}
interface EoiGenerateDialogProps {
interestId: string;
/** Used to wire the "Edit on client" deep-link inside the dialog. */
@@ -122,6 +151,12 @@ export function EoiGenerateDialog({
// (drives off the yacht's `lengthUnit` column). Stored as state so the
// rep can flip ft↔m before generating without losing the underlying data.
const [dimensionUnit, setDimensionUnit] = useState<'ft' | 'm' | null>(null);
// Phase 3b — per-field override state. null entries = no override.
const [emailOverride, setEmailOverride] = useState<FieldOverrideState | null>(null);
const [phoneOverride, setPhoneOverride] = useState<FieldOverrideState | null>(null);
const [yachtNameOverride, setYachtNameOverride] = useState<FieldOverrideState | null>(null);
// Phase 3c — yacht spawn flow.
const [yachtSpawnOpen, setYachtSpawnOpen] = useState(false);
// Resolved EOI context — the actual values the document will be
// auto-filled with. Loaded only while the dialog is open so we don't
@@ -254,14 +289,14 @@ export function EoiGenerateDialog({
await apiFetch(`/api/v1/clients/${ctx.client.id}`, { method: 'PATCH', body });
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] });
}
async function patchYacht(body: Record<string, unknown>) {
if (!ctx?.yacht) return;
await apiFetch(`/api/v1/yachts/${ctx.yacht.id}`, { method: 'PATCH', body });
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] });
}
// Required for the EOI's top paragraph (Section 2). Without these
// the document is unsignable, so generation is blocked.
//
// Email is rendered separately below with the Phase 3b override
// controls (combobox + 2 checkboxes), so it's omitted from the row
// array here — but its required-met status still gates `requiredMet`
// via `emailPresent` below.
const required = ctx
? [
{
@@ -274,12 +309,6 @@ export function EoiGenerateDialog({
placeholder: 'Full legal name',
},
},
{
key: 'email',
label: 'Email address',
value: ctx.client.primaryEmail ?? null,
present: !!ctx.client.primaryEmail,
},
{
key: 'address',
// Mirrors the rendered EOI Address field exactly so the rep sees
@@ -313,19 +342,10 @@ export function EoiGenerateDialog({
: [];
// Optional — Section 3 of the EOI. Generation proceeds without them.
// Yacht-name + phone are rendered separately below with Phase 3b
// override controls; the remainder show as straight previews.
const optional = ctx
? [
{
key: 'yacht',
label: 'Yacht name',
value: ctx.yacht?.name ?? null,
edit: ctx.yacht
? {
onSave: async (next: string | null) => await patchYacht({ name: next ?? '' }),
placeholder: 'Yacht name',
}
: undefined,
},
{
key: 'dimensions',
label: `Dimensions (L × W × D, ${effectiveDimensionUnit})`,
@@ -341,15 +361,12 @@ export function EoiGenerateDialog({
label: 'Berth bundle range',
value: ctx.eoiBerthRange || null,
},
{
key: 'phone',
label: 'Phone',
value: ctx.client.primaryPhone ?? null,
},
]
: [];
const requiredMet = required.length > 0 && required.every((r) => r.present);
const emailPresent = ctx ? !!(emailOverride?.value ?? ctx.client.primaryEmail) : false;
const requiredMet =
!!ctx && required.length > 0 && required.every((r) => r.present) && emailPresent;
async function handleGenerate() {
if (!requiredMet) return;
@@ -358,6 +375,25 @@ export function EoiGenerateDialog({
try {
const isDocumenso = selectedTemplate === DOCUMENSO_TEMPLATE_VALUE;
const url = `/api/v1/document-templates/${encodeURIComponent(selectedTemplate)}/generate-and-sign`;
// Phase 3b — pack the per-field overrides the rep selected. Each
// is null when untouched; the server validator accepts an absent
// entry and falls back to the canonical record.
const overridePayload = (s: FieldOverrideState | null) =>
s && s.value !== null
? {
value: s.value,
useOnlyForThisEoi: s.useOnlyForThisEoi,
setAsDefault: s.setAsDefault,
...(s.contactId ? { contactId: s.contactId } : {}),
}
: undefined;
const overrides = {
clientEmail: overridePayload(emailOverride),
clientPhone: overridePayload(phoneOverride),
yachtName: overridePayload(yachtNameOverride),
};
const hasAnyOverride = overrides.clientEmail || overrides.clientPhone || overrides.yachtName;
await apiFetch(url, {
method: 'POST',
body: {
@@ -370,6 +406,7 @@ export function EoiGenerateDialog({
// EOI's Length/Width/Draft formValues. Defaults server-side to
// the yacht's own `lengthUnit` column when unspecified.
dimensionUnit: effectiveDimensionUnit,
...(hasAnyOverride ? { overrides } : {}),
},
});
// Bounce every cache that surfaces the interest's EOI state so the
@@ -451,6 +488,15 @@ export function EoiGenerateDialog({
edit={row.edit}
/>
))}
<OverridableContactField
label="Email address"
canonicalValue={ctx.client.primaryEmail ?? null}
canonicalContactId={ctx.available.emails.find((e) => e.isPrimary)?.id ?? null}
options={ctx.available.emails}
override={emailOverride}
onChange={setEmailOverride}
missing={!emailPresent}
/>
</dl>
</div>
<div className="space-y-1 border-t pt-2">
@@ -490,9 +536,39 @@ export function EoiGenerateDialog({
) : null}
</div>
<dl className="space-y-1.5">
<div className="flex items-start gap-2">
<div className="flex-1">
<OverridableContactField
label="Yacht name"
canonicalValue={ctx.yacht?.name ?? null}
canonicalContactId={null}
options={[]}
override={yachtNameOverride}
onChange={setYachtNameOverride}
/>
</div>
{clientId ? (
<button
type="button"
onClick={() => setYachtSpawnOpen(true)}
className="mt-0.5 shrink-0 text-[11px] text-primary hover:underline"
title="Create a new yacht linked to this client"
>
+ New yacht
</button>
) : null}
</div>
{optional.map((row) => (
<PreviewRow key={row.key} label={row.label} value={row.value} edit={row.edit} />
<PreviewRow key={row.key} label={row.label} value={row.value} />
))}
<OverridableContactField
label="Phone"
canonicalValue={ctx.client.primaryPhone ?? null}
canonicalContactId={ctx.available.phones.find((p) => p.isPrimary)?.id ?? null}
options={ctx.available.phones}
override={phoneOverride}
onChange={setPhoneOverride}
/>
</dl>
</div>
{portSlug && clientId && (
@@ -674,6 +750,33 @@ export function EoiGenerateDialog({
</Button>
</SheetFooter>
</SheetContent>
{/* Phase 3c — nested yacht-spawn Sheet. Pre-selects the linked
client as owner so the rep only types the yacht-specific
fields. After save, PATCH the interest with the new yachtId so
the EOI's yacht block populates without a manual re-link. */}
{clientId ? (
<YachtForm
open={yachtSpawnOpen}
onOpenChange={setYachtSpawnOpen}
initialOwner={{ type: 'client', id: clientId }}
createExtras={{ source: 'eoi-generated' }}
onCreated={async (created) => {
try {
await apiFetch(`/api/v1/interests/${interestId}`, {
method: 'PATCH',
body: { yachtId: created.id },
});
await queryClient.invalidateQueries({
queryKey: ['interests', interestId, 'eoi-context'],
});
await queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
} catch (err) {
toastError(err);
}
}}
/>
) : null}
</Sheet>
);
}
@@ -781,3 +884,217 @@ function PreviewRow({
</div>
);
}
/**
* Phase 3b — overridable row for a contact channel (email/phone) or a
* single-value field (yacht name). Renders as a plain text row showing
* the canonical value, with a small "Override" affordance that expands
* into a Select (over `options`) + Input (for fresh values) + the two
* intent checkboxes.
*
* State is owned by the parent dialog so cancellation collapses cleanly
* back to canonical without round-tripping through the row.
*/
function OverridableContactField({
label,
canonicalValue,
canonicalContactId,
options,
override,
onChange,
missing,
}: {
label: string;
canonicalValue: string | null;
/** id of the row that holds `canonicalValue` in `options`. Used to
* pre-select the matching Select item when the user opens override
* mode without changing anything. */
canonicalContactId: string | null;
/** Picker options. For yacht-name pass [] — only the manual text path
* is available. */
options: Array<{ id: string; value: string; isPrimary: boolean }>;
override: FieldOverrideState | null;
onChange: (next: FieldOverrideState | null) => void;
missing?: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const [manualValue, setManualValue] = useState('');
// Synthetic combobox value: either an existing contact id, "__manual__"
// for free-text entry, or "__canonical__" for "use the canonical primary".
const selectValue =
override?.contactId ??
(override?.value != null && override?.contactId == null ? '__manual__' : '__canonical__');
// Resolve the displayed effective value for the read-only header (when
// collapsed): show the override if active, otherwise canonical.
const effective = override?.value ?? canonicalValue ?? null;
const collapseAndClear = () => {
setExpanded(false);
setManualValue('');
onChange(null);
};
return (
<div className="space-y-1.5">
<div className="flex items-baseline gap-2 text-sm">
<dt className="w-32 shrink-0 text-xs text-muted-foreground">{label}</dt>
<dd
className={cn(
'flex-1 wrap-break-word inline-flex items-center gap-2',
missing
? 'text-rose-700 font-medium'
: effective
? 'text-foreground'
: 'text-muted-foreground italic',
)}
>
<span className="flex-1">
{effective ?? (missing ? 'Missing — required' : 'Not set')}
{override?.value != null ? (
<span className="ml-1 inline-flex items-center rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-800">
[EOI]
</span>
) : null}
</span>
{!expanded ? (
<button
type="button"
onClick={() => setExpanded(true)}
className="text-[11px] text-primary hover:underline"
>
{override?.value != null ? 'Edit override' : 'Override'}
</button>
) : (
<button
type="button"
onClick={collapseAndClear}
className="text-[11px] text-muted-foreground hover:underline"
>
Clear & close
</button>
)}
</dd>
</div>
{expanded ? (
<div className="ml-32 space-y-2 rounded-md border bg-background/60 p-2">
{options.length > 0 ? (
<Select
value={selectValue}
onValueChange={(v) => {
if (v === '__canonical__') {
onChange(null);
setManualValue('');
return;
}
if (v === '__manual__') {
onChange({
value: manualValue.trim() || null,
contactId: null,
useOnlyForThisEoi: override?.useOnlyForThisEoi ?? false,
setAsDefault: override?.setAsDefault ?? false,
});
return;
}
const picked = options.find((o) => o.id === v);
if (!picked) return;
onChange({
value: picked.value,
contactId: picked.id,
useOnlyForThisEoi: override?.useOnlyForThisEoi ?? false,
setAsDefault: override?.setAsDefault ?? false,
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__canonical__">
Use canonical {label.toLowerCase()}
{canonicalContactId && options.find((o) => o.id === canonicalContactId)
? ` (${canonicalValue ?? ''})`
: ''}
</SelectItem>
{options
.filter((o) => !o.isPrimary)
.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.value}
</SelectItem>
))}
<SelectItem value="__manual__">+ Type a new value</SelectItem>
</SelectContent>
</Select>
) : null}
{selectValue === '__manual__' || options.length === 0 ? (
<Input
value={manualValue || override?.value || ''}
placeholder={`New ${label.toLowerCase()}`}
onChange={(e) => {
setManualValue(e.target.value);
onChange({
value: e.target.value.trim() || null,
contactId: null,
useOnlyForThisEoi: override?.useOnlyForThisEoi ?? false,
setAsDefault: override?.setAsDefault ?? false,
});
}}
className="h-8 text-xs"
/>
) : null}
<div className="space-y-1">
<label className="flex items-start gap-2 text-[11px] text-muted-foreground cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={override?.useOnlyForThisEoi ?? false}
disabled={!override || !override.value}
onChange={(e) =>
override &&
onChange({
...override,
useOnlyForThisEoi: e.target.checked,
// Mutually exclusive intent — both true at once doesn't
// make sense (per-doc vs. promote-to-canonical).
setAsDefault: e.target.checked ? false : override.setAsDefault,
})
}
/>
<span>
Use only for this EOI
<span className="block text-[10px]">
Records the deviation on this document; canonical record untouched.
</span>
</span>
</label>
<label className="flex items-start gap-2 text-[11px] text-muted-foreground cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={override?.setAsDefault ?? false}
disabled={!override || !override.value}
onChange={(e) =>
override &&
onChange({
...override,
setAsDefault: e.target.checked,
useOnlyForThisEoi: e.target.checked ? false : override.useOnlyForThisEoi,
})
}
/>
<span>
Set as default for future docs
<span className="block text-[10px]">
Promotes this value to the canonical primary on save.
</span>
</span>
</label>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -85,6 +85,20 @@ function ReminderFormBody({
onSuccess,
}: ReminderFormProps) {
const isEdit = !!reminder;
// Phase 4 — load the rep's preferred default-reminder time (HH:MM)
// BEFORE seeding the dueAt state. React Query's cache keeps this
// available synchronously on subsequent dialog opens (staleTime 60s)
// so the initial value is the rep's preference, not the historical
// 09:00 fallback. Enabled only on create-mode opens — edit mode
// already has the existing dueAt to seed from.
const meQuery = useQuery<{ data: { preferences?: { digestTimeOfDay?: string } } }>({
queryKey: ['me', 'preferences'],
queryFn: () => apiFetch('/api/v1/me'),
enabled: open && !reminder,
staleTime: 60_000,
});
const userTodPref = meQuery.data?.data.preferences?.digestTimeOfDay ?? null;
// Tomorrow 9am default for new-reminder dueAt.
//
// <input type="datetime-local"> takes/produces LOCAL wall-clock time
@@ -97,9 +111,20 @@ function ReminderFormBody({
const defaultDueAt = useMemo(() => {
const t = new Date();
t.setDate(t.getDate() + 1);
t.setHours(9, 0, 0, 0);
// Honour the rep's user_profiles.preferences.digestTimeOfDay when
// set ("HH:MM"). Falls back to 09:00 — historical default.
let h = 9;
let m = 0;
if (userTodPref && /^\d{2}:\d{2}$/.test(userTodPref)) {
const [hh = '09', mm = '00'] = userTodPref.split(':');
const parsedH = Number.parseInt(hh, 10);
const parsedM = Number.parseInt(mm, 10);
if (Number.isFinite(parsedH) && parsedH >= 0 && parsedH <= 23) h = parsedH;
if (Number.isFinite(parsedM) && parsedM >= 0 && parsedM <= 59) m = parsedM;
}
t.setHours(h, m, 0, 0);
return toLocalDatetimeLocal(t);
}, []);
}, [userTodPref]);
const [title, setTitle] = useState(reminder?.title ?? '');
const [note, setNote] = useState(reminder?.note ?? '');
const [dueAt, setDueAt] = useState(

View File

@@ -23,7 +23,7 @@ import type { CountryCode } from '@/lib/i18n/countries';
interface MeResponse {
user?: { name: string; email: string };
preferences?: { country?: string; timezone?: string };
preferences?: { country?: string; timezone?: string; digestTimeOfDay?: string };
profile?: {
avatarFileId?: string | null;
firstName?: string | null;
@@ -45,6 +45,10 @@ export function UserSettings() {
const [originalEmail, setOriginalEmail] = useState('');
const [country, setCountry] = useState<string | null>(null);
const [timezone, setTimezone] = useState<string | null>(null);
/** Phase 4 — default reminder firing time-of-day (HH:MM). Drives the
* `<ReminderForm>` "Due Date & Time" default when the rep creates a
* reminder without explicitly picking a time. */
const [digestTimeOfDay, setDigestTimeOfDay] = useState<string>('09:00');
const [saving, setSaving] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [resetMsg, setResetMsg] = useState<string | null>(null);
@@ -84,6 +88,7 @@ export function UserSettings() {
// saved yet — first-time users land on a sensible default rather
// than an empty picker. Doesn't overwrite an explicit choice.
setTimezone(res.data.preferences?.timezone ?? detectedTz ?? null);
setDigestTimeOfDay(res.data.preferences?.digestTimeOfDay ?? '09:00');
const fid = res.data.profile?.avatarFileId ?? null;
setAvatarFileId(fid);
setAvatarUrl(fid ? `/api/v1/files/${fid}/preview` : null);
@@ -145,6 +150,7 @@ export function UserSettings() {
preferences: {
country: country ?? undefined,
timezone: timezone ?? undefined,
digestTimeOfDay: digestTimeOfDay || undefined,
},
},
});
@@ -339,6 +345,20 @@ export function UserSettings() {
</WarningCallout>
)}
</div>
<div className="space-y-2">
<Label htmlFor="settings-digest-tod">Default reminder time</Label>
<Input
id="settings-digest-tod"
type="time"
value={digestTimeOfDay}
onChange={(e) => setDigestTimeOfDay(e.target.value)}
className="w-32"
/>
<p className="text-xs text-muted-foreground">
When you create a reminder without picking a time, this is the time-of-day it
defaults to. Per-reminder overrides still win.
</p>
</div>
<div className="flex items-center gap-3">
<Button onClick={saveProfile} disabled={saving === 'profile'}>
<Save className="mr-1.5 h-4 w-4" />

View File

@@ -58,11 +58,35 @@ interface YachtFormProps {
* owner-history workflow is the right surface for ownership changes).
*/
initialOwner?: { type: 'client' | 'company'; id: string };
/**
* Phase 3c — extra fields baked into the create POST. Lets the EOI
* spawn flow stamp the new yacht with `source='eoi-generated'` (and
* optionally `sourceDocumentId` when the calling document is already
* persisted) without exposing those fields in the form UI.
*/
createExtras?: Partial<{
source: 'manual' | 'imported' | 'eoi-generated';
sourceDocumentId: string;
}>;
/**
* Phase 3c — called with the new yacht's id/name after a successful
* create POST, BEFORE the dialog closes. The EOI spawn flow uses this
* to PATCH the interest's yachtId so the EOI re-fetches with the
* just-spawned yacht in scope.
*/
onCreated?: (yacht: { id: string; name: string }) => void | Promise<void>;
}
type YachtStatus = 'active' | 'retired' | 'sold_away';
export function YachtForm({ open, onOpenChange, yacht, initialOwner }: YachtFormProps) {
export function YachtForm({
open,
onOpenChange,
yacht,
initialOwner,
createExtras,
onCreated,
}: YachtFormProps) {
const queryClient = useQueryClient();
const isEdit = !!yacht;
const [formError, setFormError] = useState<string | null>(null);
@@ -135,12 +159,19 @@ export function YachtForm({ open, onOpenChange, yacht, initialOwner }: YachtForm
method: 'PATCH',
body: rest,
});
} else {
await apiFetch('/api/v1/yachts', { method: 'POST', body: data });
return null;
}
const created = await apiFetch<{ data: { id: string; name: string } }>('/api/v1/yachts', {
method: 'POST',
body: { ...data, ...(createExtras ?? {}) },
});
return created.data;
},
onSuccess: () => {
onSuccess: async (created) => {
queryClient.invalidateQueries({ queryKey: ['yachts'] });
if (created && onCreated) {
await onCreated(created);
}
onOpenChange(false);
},
onError: (err: Error) => {