feat(post-audit): finish Phase 3 / 4 / 5 / 7 — remaining work
Phase 3 — EOI overrides (now ☑):
- Address override field with the same per-component input UX as the
canonical address form (line1/line2/city/state/postal + ISO
subdivision + CountryCombobox). Two-checkbox intent semantics
identical to email/phone — useOnlyForThisEoi writes only to
documents.override_client_address_* columns; setAsDefault promotes
to the canonical client_addresses primary inside the override
transaction; neither flag inserts a non-primary address row for
future reuse. eoi-context route now returns available.addresses so
the dialog can render the picker over existing rows.
- yachts.source_document_id backfill — yachts spawned via EOI run
BEFORE generateAndSign creates the document row, so source_document_id
stayed NULL. Mirrored the bounded-recent backfill pattern from
contacts into persistDocumentOverrides for both client_addresses and
yachts (every row inserted in the last 60s with NULL source_document_id
and the right source flag gets attributed).
- Audit-log filter chips for the new verbs — eoi_field_override,
promote_to_primary, eoi_spawn_yacht now appear in /admin/audit
dropdown + get human labels in the card view.
Phase 4 — reminders inline section (now ☑):
- New <RemindersInline> shared component shows the 3-5 most recent
open reminders for an entity. Mounted on Overview tab of yacht /
client / interest detail. Empty state hints at the header button
rather than duplicating it.
Phase 5 — email tone (now ☑ across all 8 templates):
- admin-email-change, crm-invite, inquiry-sales-notification,
residential-inquiry — voice + sign-off match the 4 shipped earlier
("Dear X", "With warm regards, The {portName} Team", sentence-case
subjects). Snapshot tests deferred — they'd need a 2nd-port fixture
set up to catch port-name leaks; templates are correct in review.
Phase 7 — PDF editor (now ☑):
- 7.1 polish: unsaved-changes guard (beforeunload + "Unsaved changes"
badge), ResizeObserver-driven responsive PDF width, required-tokens-
unplaced indicator reading template.mergeFields.
- 7.2 drag-to-move with on-page clamping.
- 7.2 four-corner resize handles with min-size enforcement.
- 7.2 right-click context delete via onContextMenu.
- 7.2 multi-page navigation + per-page marker filter.
- 7.2 live preview endpoint POST /api/v1/document-templates/[id]/preview
runs the in-app pdf-lib fill against the supplied interest, uploads
to a transient previews/ key, returns a 15-min presigned URL.
- 7.2 new-PDF upload POST /api/v1/document-templates/[id]/source-pdf
takes multipart FormData, magic-byte verifies %PDF-, parses page
count via pdf-lib, swaps documentTemplates.sourceFileId. Editor
warns when the new page count truncates the prior set.
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:
@@ -94,6 +94,16 @@ interface EoiContextResponse {
|
||||
channel: 'phone' | 'whatsapp';
|
||||
source: string;
|
||||
}>;
|
||||
addresses: Array<{
|
||||
id: string;
|
||||
streetAddress: string | null;
|
||||
city: string | null;
|
||||
subdivisionIso: string | null;
|
||||
postalCode: string | null;
|
||||
countryIso: string | null;
|
||||
isPrimary: boolean;
|
||||
source: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -114,6 +124,24 @@ interface FieldOverrideState {
|
||||
setAsDefault: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3 follow-up — address override state. Treated as one logical
|
||||
* field with one pair of checkboxes (intent flags apply to the whole
|
||||
* address rather than per-component).
|
||||
*/
|
||||
interface AddressOverrideState {
|
||||
line1: string;
|
||||
line2: string;
|
||||
city: string;
|
||||
subdivisionIso: string;
|
||||
postalCode: string;
|
||||
countryIso: string | null;
|
||||
/** Existing client_addresses.id when the rep picked one; null = fresh. */
|
||||
addressId: string | null;
|
||||
useOnlyForThisEoi: boolean;
|
||||
setAsDefault: boolean;
|
||||
}
|
||||
|
||||
interface EoiGenerateDialogProps {
|
||||
interestId: string;
|
||||
/** Used to wire the "Edit on client" deep-link inside the dialog. */
|
||||
@@ -155,6 +183,7 @@ export function EoiGenerateDialog({
|
||||
const [emailOverride, setEmailOverride] = useState<FieldOverrideState | null>(null);
|
||||
const [phoneOverride, setPhoneOverride] = useState<FieldOverrideState | null>(null);
|
||||
const [yachtNameOverride, setYachtNameOverride] = useState<FieldOverrideState | null>(null);
|
||||
const [addressOverride, setAddressOverride] = useState<AddressOverrideState | null>(null);
|
||||
// Phase 3c — yacht spawn flow.
|
||||
const [yachtSpawnOpen, setYachtSpawnOpen] = useState(false);
|
||||
|
||||
@@ -309,24 +338,9 @@ export function EoiGenerateDialog({
|
||||
placeholder: 'Full legal name',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'address',
|
||||
// Mirrors the rendered EOI Address field exactly so the rep sees
|
||||
// what's going to appear on the document.
|
||||
label: 'Address',
|
||||
value: ctx.client.address
|
||||
? [
|
||||
ctx.client.address.street,
|
||||
ctx.client.address.city,
|
||||
ctx.client.address.subdivision,
|
||||
ctx.client.address.postalCode,
|
||||
ctx.client.address.countryIso,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: null,
|
||||
present: !!ctx.client.address,
|
||||
},
|
||||
// Address moved out to <OverridableAddressField> below so it can
|
||||
// surface the per-component combobox + 2 checkboxes alongside
|
||||
// the canonical preview.
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -365,8 +379,18 @@ export function EoiGenerateDialog({
|
||||
: [];
|
||||
|
||||
const emailPresent = ctx ? !!(emailOverride?.value ?? ctx.client.primaryEmail) : false;
|
||||
// Address is now required-via-override-field; either the canonical
|
||||
// address exists, OR the rep has typed line1+country in the override.
|
||||
const addressPresent = ctx
|
||||
? !!(addressOverride && addressOverride.line1 && addressOverride.countryIso) ||
|
||||
!!ctx.client.address
|
||||
: false;
|
||||
const requiredMet =
|
||||
!!ctx && required.length > 0 && required.every((r) => r.present) && emailPresent;
|
||||
!!ctx &&
|
||||
required.length > 0 &&
|
||||
required.every((r) => r.present) &&
|
||||
emailPresent &&
|
||||
addressPresent;
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!requiredMet) return;
|
||||
@@ -387,12 +411,31 @@ export function EoiGenerateDialog({
|
||||
...(s.contactId ? { contactId: s.contactId } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const addressPayload =
|
||||
addressOverride && addressOverride.line1 && addressOverride.countryIso
|
||||
? {
|
||||
line1: addressOverride.line1,
|
||||
line2: addressOverride.line2 || undefined,
|
||||
city: addressOverride.city || undefined,
|
||||
subdivisionIso: addressOverride.subdivisionIso || undefined,
|
||||
postalCode: addressOverride.postalCode || undefined,
|
||||
countryIso: addressOverride.countryIso,
|
||||
useOnlyForThisEoi: addressOverride.useOnlyForThisEoi,
|
||||
setAsDefault: addressOverride.setAsDefault,
|
||||
...(addressOverride.addressId ? { addressId: addressOverride.addressId } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const overrides = {
|
||||
clientEmail: overridePayload(emailOverride),
|
||||
clientPhone: overridePayload(phoneOverride),
|
||||
yachtName: overridePayload(yachtNameOverride),
|
||||
clientAddress: addressPayload,
|
||||
};
|
||||
const hasAnyOverride = overrides.clientEmail || overrides.clientPhone || overrides.yachtName;
|
||||
const hasAnyOverride =
|
||||
overrides.clientEmail ||
|
||||
overrides.clientPhone ||
|
||||
overrides.yachtName ||
|
||||
overrides.clientAddress;
|
||||
|
||||
await apiFetch(url, {
|
||||
method: 'POST',
|
||||
@@ -497,6 +540,16 @@ export function EoiGenerateDialog({
|
||||
onChange={setEmailOverride}
|
||||
missing={!emailPresent}
|
||||
/>
|
||||
<OverridableAddressField
|
||||
canonical={ctx.client.address}
|
||||
canonicalAddressId={
|
||||
ctx.available.addresses.find((a) => a.isPrimary)?.id ?? null
|
||||
}
|
||||
options={ctx.available.addresses}
|
||||
override={addressOverride}
|
||||
onChange={setAddressOverride}
|
||||
missing={!addressPresent}
|
||||
/>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="space-y-1 border-t pt-2">
|
||||
@@ -1098,3 +1151,276 @@ function OverridableContactField({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3 follow-up — address override row. Treats the address as one
|
||||
* logical field with one pair of checkboxes (master-plan decision:
|
||||
* reps think about addresses all-or-nothing). The per-component input
|
||||
* UX mirrors the canonical address form (separate fields per
|
||||
* line/city/state/postal/country + CountryCombobox) so reps don't
|
||||
* relearn an input pattern.
|
||||
*/
|
||||
function OverridableAddressField({
|
||||
canonical,
|
||||
canonicalAddressId,
|
||||
options,
|
||||
override,
|
||||
onChange,
|
||||
missing,
|
||||
}: {
|
||||
canonical: {
|
||||
street: string;
|
||||
city: string;
|
||||
subdivision: string;
|
||||
postalCode: string;
|
||||
countryIso: string;
|
||||
} | null;
|
||||
canonicalAddressId: string | null;
|
||||
options: Array<{
|
||||
id: string;
|
||||
streetAddress: string | null;
|
||||
city: string | null;
|
||||
subdivisionIso: string | null;
|
||||
postalCode: string | null;
|
||||
countryIso: string | null;
|
||||
isPrimary: boolean;
|
||||
}>;
|
||||
override: AddressOverrideState | null;
|
||||
onChange: (next: AddressOverrideState | null) => void;
|
||||
missing?: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const canonicalSummary = canonical
|
||||
? [
|
||||
canonical.street,
|
||||
canonical.city,
|
||||
canonical.subdivision,
|
||||
canonical.postalCode,
|
||||
canonical.countryIso,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: null;
|
||||
|
||||
const effectiveSummary = override
|
||||
? [
|
||||
override.line1,
|
||||
override.line2,
|
||||
override.city,
|
||||
override.subdivisionIso,
|
||||
override.postalCode,
|
||||
override.countryIso,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: canonicalSummary;
|
||||
|
||||
const selectValue = override?.addressId ?? (override ? '__manual__' : '__canonical__');
|
||||
|
||||
const fillFromOption = (id: string) => {
|
||||
const picked = options.find((o) => o.id === id);
|
||||
if (!picked) return;
|
||||
onChange({
|
||||
line1: picked.streetAddress ?? '',
|
||||
line2: '',
|
||||
city: picked.city ?? '',
|
||||
subdivisionIso: picked.subdivisionIso ?? '',
|
||||
postalCode: picked.postalCode ?? '',
|
||||
countryIso: picked.countryIso,
|
||||
addressId: picked.id,
|
||||
useOnlyForThisEoi: override?.useOnlyForThisEoi ?? false,
|
||||
setAsDefault: override?.setAsDefault ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
const updateOverride = (patch: Partial<AddressOverrideState>) => {
|
||||
onChange({
|
||||
line1: '',
|
||||
line2: '',
|
||||
city: '',
|
||||
subdivisionIso: '',
|
||||
postalCode: '',
|
||||
countryIso: null,
|
||||
addressId: null,
|
||||
useOnlyForThisEoi: false,
|
||||
setAsDefault: false,
|
||||
...(override ?? {}),
|
||||
...patch,
|
||||
});
|
||||
};
|
||||
|
||||
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">Address</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
'flex-1 wrap-break-word inline-flex items-center gap-2',
|
||||
missing
|
||||
? 'text-rose-700 font-medium'
|
||||
: effectiveSummary
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground italic',
|
||||
)}
|
||||
>
|
||||
<span className="flex-1">
|
||||
{effectiveSummary ?? (missing ? 'Missing — required' : 'Not set')}
|
||||
{override ? (
|
||||
<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 ? 'Edit override' : 'Override'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpanded(false);
|
||||
onChange(null);
|
||||
}}
|
||||
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);
|
||||
return;
|
||||
}
|
||||
if (v === '__manual__') {
|
||||
updateOverride({ addressId: null });
|
||||
return;
|
||||
}
|
||||
fillFromOption(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__canonical__">
|
||||
Use canonical address
|
||||
{canonicalAddressId ? '' : ''}
|
||||
</SelectItem>
|
||||
{options
|
||||
.filter((o) => !o.isPrimary)
|
||||
.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{[o.streetAddress, o.city, o.countryIso].filter(Boolean).join(', ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__manual__">+ Type a new address…</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={override?.line1 ?? ''}
|
||||
placeholder="Street address"
|
||||
onChange={(e) => updateOverride({ line1: e.target.value, addressId: null })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={override?.line2 ?? ''}
|
||||
placeholder="Address line 2 (optional)"
|
||||
onChange={(e) => updateOverride({ line2: e.target.value, addressId: null })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
value={override?.city ?? ''}
|
||||
placeholder="City"
|
||||
onChange={(e) => updateOverride({ city: e.target.value, addressId: null })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={override?.postalCode ?? ''}
|
||||
placeholder="Postal code"
|
||||
onChange={(e) => updateOverride({ postalCode: e.target.value, addressId: null })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
value={override?.subdivisionIso ?? ''}
|
||||
placeholder="ISO subdivision (e.g. US-CA)"
|
||||
onChange={(e) =>
|
||||
updateOverride({ subdivisionIso: e.target.value, addressId: null })
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<CountryCombobox
|
||||
value={override?.countryIso ?? null}
|
||||
onChange={(iso) => updateOverride({ countryIso: iso ?? null, addressId: null })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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?.line1 || !override?.countryIso}
|
||||
onChange={(e) =>
|
||||
updateOverride({
|
||||
useOnlyForThisEoi: e.target.checked,
|
||||
setAsDefault: e.target.checked ? false : (override?.setAsDefault ?? false),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
Use only for this EOI
|
||||
<span className="block text-[10px]">
|
||||
Records the deviation on this document; canonical address 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?.line1 || !override?.countryIso}
|
||||
onChange={(e) =>
|
||||
updateOverride({
|
||||
setAsDefault: e.target.checked,
|
||||
useOnlyForThisEoi: e.target.checked
|
||||
? false
|
||||
: (override?.useOnlyForThisEoi ?? false),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
Set as default for future docs
|
||||
<span className="block text-[10px]">
|
||||
Promotes this address to the canonical primary on save.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user