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:
2026-05-18 17:09:19 +02:00
parent f938847ed9
commit ef0dc5abc4
18 changed files with 1532 additions and 204 deletions

View File

@@ -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>
);
}