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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user