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

@@ -64,6 +64,9 @@ function actionVerb(action: string): string {
merge: 'Merged',
revert: 'Reverted',
viewed: 'Viewed',
eoi_field_override: 'EOI field override',
promote_to_primary: 'Contact promoted',
eoi_spawn_yacht: 'EOI spawn yacht',
};
return map[action] ?? action.charAt(0).toUpperCase() + action.slice(1);
}

View File

@@ -471,6 +471,9 @@ export function AuditLogList() {
<SelectItem value="outcome_cleared">Outcome cleared</SelectItem>
<SelectItem value="branding.logo.uploaded">Logo uploaded</SelectItem>
<SelectItem value="branding.logo.archived">Logo archived</SelectItem>
<SelectItem value="eoi_field_override">EOI field override</SelectItem>
<SelectItem value="promote_to_primary">Contact promoted</SelectItem>
<SelectItem value="eoi_spawn_yacht">EOI spawn yacht</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -1,9 +1,9 @@
'use client';
import { useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Document, Page, pdfjs } from 'react-pdf';
import { Loader2, Save, Trash2, X } from 'lucide-react';
import { ChevronLeft, ChevronRight, Eye, Loader2, Save, Trash2, Upload, X } from 'lucide-react';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
@@ -36,6 +36,10 @@ interface TemplateData {
templateFormat: string;
sourceFileId: string | null;
overlayPositions: FieldMap | null;
/** Tokens marked as required for the EOI flow — see
* STANDARD_EOI_MERGE_FIELDS in lib/templates/merge-fields. The editor
* surfaces a checklist of which required tokens are still unplaced. */
mergeFields?: string[] | null;
}
interface PendingMarker {
@@ -44,19 +48,41 @@ interface PendingMarker {
page: number;
}
type DragKind = 'move' | 'resize-nw' | 'resize-ne' | 'resize-sw' | 'resize-se';
interface DragState {
index: number;
kind: DragKind;
startMarkerX: number;
startMarkerY: number;
startMarkerW: number;
startMarkerH: number;
startClientX: number;
startClientY: number;
containerW: number;
containerH: number;
}
const TOKEN_OPTIONS = Array.from(VALID_MERGE_TOKENS).sort();
const DEFAULT_MARKER_W = 0.18;
const DEFAULT_MARKER_H = 0.04;
const MIN_MARKER_DIM = 0.02;
/**
* Phase 7.1 — page-1 PDF marker editor. Click anywhere on the rendered
* PDF to drop a marker, pick which merge token it represents, save.
* Phase 7.1 + 7.2 — PDF marker editor.
*
* Scope intentionally narrow:
* - Page 1 only (multi-page page-picker is a 7.2 ticket).
* - Add + delete markers; drag-to-move + corner-resize defer to 7.2.
* - Coordinates stored as percent of page width/height so a future
* page-size swap (A4 ↔ Letter) doesn't shift placements.
* - Click anywhere to drop a marker (page-aware).
* - Drag markers to move; corner handles to resize.
* - Right-click for context-menu delete.
* - Multi-page navigation via page picker.
* - "Required tokens unplaced" checklist surfaces missing fields.
* - Unsaved-changes guard (beforeunload + visual diff indicator).
* - Responsive PDF width tracks the container via ResizeObserver.
* - Live preview pane renders the AcroForm fill against a chosen
* interest via POST /api/v1/document-templates/[id]/preview.
* - "Replace PDF" reuses the existing template-templates source-file
* upload route while preserving the field map (warn on page-count
* change).
*/
export function TemplateEditor({ templateId }: { templateId: string }) {
const { data: template, isLoading } = useQuery<{ data: TemplateData }>({
@@ -73,10 +99,9 @@ export function TemplateEditor({ templateId }: { templateId: string }) {
);
}
// Inner body keyed by templateId so a route change re-mounts and
// the markers useState initializer re-reads the server payload.
// Avoids the in-render setState pattern (React anti-pattern) that
// a single component would otherwise need to seed from the query.
// Inner body keyed by templateId so a route change re-mounts and the
// markers useState initializer re-reads the server payload, avoiding
// an in-render setState pattern to seed from the query.
return <TemplateEditorBody key={templateId} templateId={templateId} template={template.data} />;
}
@@ -89,22 +114,73 @@ function TemplateEditorBody({
}) {
const qc = useQueryClient();
const [markers, setMarkers] = useState<FieldMap>(template.overlayPositions ?? []);
// Track the "last saved" baseline as state (not a ref) so the dirty
// check re-runs on save. React Compiler forbids reading refs during
// render, and the isDirty memo needs a stable baseline that updates
// exactly when we commit a save.
const [savedMarkers, setSavedMarkers] = useState<FieldMap>(template.overlayPositions ?? []);
const [pending, setPending] = useState<PendingMarker | null>(null);
const [pendingToken, setPendingToken] = useState<string>('');
const [saving, setSaving] = useState(false);
const [savedMsg, setSavedMsg] = useState<string | null>(null);
const [pageNumber, setPageNumber] = useState(1);
const [numPages, setNumPages] = useState<number | null>(null);
const [pageWidth, setPageWidth] = useState(680);
const [dragState, setDragState] = useState<DragState | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewInterestId, setPreviewInterestId] = useState<string>('');
const pageContainerRef = useRef<HTMLDivElement | null>(null);
const outerColumnRef = useRef<HTMLDivElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const pdfUrl = template.sourceFileId ? `/api/v1/files/${template.sourceFileId}/preview` : null;
// ─── Unsaved changes detection ────────────────────────────────────────────
const isDirty = useMemo(
() => JSON.stringify(markers) !== JSON.stringify(savedMarkers),
[markers, savedMarkers],
);
useEffect(() => {
if (!isDirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
// Modern browsers ignore the returnValue string and show their own
// generic "you have unsaved changes" prompt — setting it still
// triggers the prompt, just without our wording.
e.returnValue = '';
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [isDirty]);
// ─── Responsive PDF width ─────────────────────────────────────────────────
useEffect(() => {
const el = outerColumnRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width ?? 680;
// Subtract Card padding/border so the PDF doesn't overshoot the
// container. 32 = px-4 (16) × 2.
setPageWidth(Math.max(360, Math.floor(w - 32)));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// ─── Click-to-place (only when not dragging) ──────────────────────────────
function handlePageClick(e: React.MouseEvent<HTMLDivElement>) {
if (dragState) return; // drag in progress; ignore click
const container = pageContainerRef.current;
if (!container) return;
// Ignore clicks bubbling from a marker (handled by its own onClick).
const target = e.target as HTMLElement;
if (target.dataset.markerHandle === 'true') return;
const rect = container.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
if (x < 0 || x > 1 || y < 0 || y > 1) return;
setPending({ x, y, page: 1 });
setPending({ x, y, page: pageNumber });
setPendingToken(TOKEN_OPTIONS[0] ?? '');
}
@@ -132,6 +208,98 @@ function TemplateEditorBody({
setMarkers((m) => m.filter((_, i) => i !== index));
}
// ─── Drag + resize ────────────────────────────────────────────────────────
const onMarkerMouseDown = useCallback(
(index: number, kind: DragKind) => (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
e.preventDefault();
const container = pageContainerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const m = markers[index];
if (!m) return;
setDragState({
index,
kind,
startMarkerX: m.x,
startMarkerY: m.y,
startMarkerW: m.w ?? DEFAULT_MARKER_W,
startMarkerH: m.h ?? DEFAULT_MARKER_H,
startClientX: e.clientX,
startClientY: e.clientY,
containerW: rect.width,
containerH: rect.height,
});
},
[markers],
);
useEffect(() => {
if (!dragState) return;
function onMove(e: MouseEvent) {
if (!dragState) return;
const dxPct = (e.clientX - dragState.startClientX) / dragState.containerW;
const dyPct = (e.clientY - dragState.startClientY) / dragState.containerH;
setMarkers((current) => {
const next = current.slice();
const m = next[dragState.index];
if (!m) return current;
const w = m.w ?? DEFAULT_MARKER_W;
const h = m.h ?? DEFAULT_MARKER_H;
if (dragState.kind === 'move') {
// Clamp so the box stays fully on-page.
next[dragState.index] = {
...m,
x: clamp(dragState.startMarkerX + dxPct, 0, 1 - w),
y: clamp(dragState.startMarkerY + dyPct, 0, 1 - h),
};
} else {
// Resize from a specific corner; the opposing corner stays
// pinned, so x/y/w/h all change for NW (drag the top-left
// corner: x and y increase by the delta, w and h decrease by it).
let nx = dragState.startMarkerX;
let ny = dragState.startMarkerY;
let nw = dragState.startMarkerW;
let nh = dragState.startMarkerH;
if (dragState.kind === 'resize-nw') {
nx = dragState.startMarkerX + dxPct;
ny = dragState.startMarkerY + dyPct;
nw = dragState.startMarkerW - dxPct;
nh = dragState.startMarkerH - dyPct;
} else if (dragState.kind === 'resize-ne') {
ny = dragState.startMarkerY + dyPct;
nw = dragState.startMarkerW + dxPct;
nh = dragState.startMarkerH - dyPct;
} else if (dragState.kind === 'resize-sw') {
nx = dragState.startMarkerX + dxPct;
nw = dragState.startMarkerW - dxPct;
nh = dragState.startMarkerH + dyPct;
} else if (dragState.kind === 'resize-se') {
nw = dragState.startMarkerW + dxPct;
nh = dragState.startMarkerH + dyPct;
}
// Enforce min size + clamp so the box stays on-page.
nw = Math.max(MIN_MARKER_DIM, Math.min(1, nw));
nh = Math.max(MIN_MARKER_DIM, Math.min(1, nh));
nx = clamp(nx, 0, 1 - nw);
ny = clamp(ny, 0, 1 - nh);
next[dragState.index] = { ...m, x: nx, y: ny, w: nw, h: nh };
}
return next;
});
}
function onUp() {
setDragState(null);
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [dragState]);
// ─── Save ────────────────────────────────────────────────────────────────
async function save() {
setSaving(true);
setSavedMsg(null);
@@ -140,6 +308,7 @@ function TemplateEditorBody({
method: 'PATCH',
body: { overlayPositions: markers },
});
setSavedMarkers(markers);
await qc.invalidateQueries({ queryKey: ['document-template', templateId] });
setSavedMsg('Markers saved.');
} catch (err) {
@@ -149,7 +318,66 @@ function TemplateEditorBody({
}
}
const visibleMarkers = useMemo(() => markers.filter((m) => m.page === 1), [markers]);
// ─── Preview ─────────────────────────────────────────────────────────────
async function generatePreview() {
if (!previewInterestId) {
toastError(new Error('Pick an interest to preview against.'));
return;
}
setPreviewLoading(true);
try {
const res = await apiFetch<{ data: { previewUrl: string } }>(
`/api/v1/document-templates/${templateId}/preview`,
{ method: 'POST', body: { interestId: previewInterestId } },
);
setPreviewUrl(res.data.previewUrl);
} catch (err) {
toastError(err);
} finally {
setPreviewLoading(false);
}
}
// ─── New-PDF upload (replace source) ─────────────────────────────────────
async function onReplacePdf(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(`/api/v1/document-templates/${templateId}/source-pdf`, {
method: 'POST',
body: fd,
});
if (!res.ok) throw new Error((await res.text()) || 'Upload failed');
const json = (await res.json()) as { data?: { pageCount?: number } };
const newPageCount = json.data?.pageCount;
if (newPageCount && numPages && newPageCount < numPages) {
toastError(
new Error(
`New PDF has ${newPageCount} pages; the previous source had ${numPages}. Markers on pages ${newPageCount + 1}${numPages} are now orphaned and won't render.`,
),
);
}
await qc.invalidateQueries({ queryKey: ['document-template', templateId] });
} catch (err) {
toastError(err);
} finally {
if (fileInputRef.current) fileInputRef.current.value = '';
}
}
// ─── Derived state ────────────────────────────────────────────────────────
const visibleMarkers = useMemo(
() =>
markers
.map((m, i) => ({ marker: m, index: i }))
.filter((row) => row.marker.page === pageNumber),
[markers, pageNumber],
);
const placedTokens = useMemo(() => new Set(markers.map((m) => m.token)), [markers]);
const requiredTokens = template.mergeFields ?? [];
const unplacedRequired = requiredTokens.filter((t) => !placedTokens.has(t));
if (!pdfUrl) {
return (
@@ -160,8 +388,19 @@ function TemplateEditorBody({
/>
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
This template has no source PDF attached. Upload one from the template list before
opening the editor.
This template has no source PDF attached. Upload one to get started.
<div className="mt-4">
<Button onClick={() => fileInputRef.current?.click()}>
<Upload className="mr-1.5 h-4 w-4" /> Upload PDF
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/pdf"
className="hidden"
onChange={onReplacePdf}
/>
</div>
</CardContent>
</Card>
</div>
@@ -172,77 +411,119 @@ function TemplateEditorBody({
<div className="space-y-4">
<PageHeader
title={`Edit "${template.name}"`}
description="Click anywhere on the page to drop a merge-field marker. Phase 1: page 1, click-to-place. Drag/resize/preview lands later."
description="Click to drop markers · drag to move · corner handles to resize · right-click to delete."
actions={
<div className="flex items-center gap-2">
{isDirty ? (
<span className="text-xs text-amber-700 font-medium">Unsaved changes</span>
) : null}
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
<Upload className="mr-1.5 h-3.5 w-3.5" /> Replace PDF
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/pdf"
className="hidden"
onChange={onReplacePdf}
/>
<Button onClick={save} disabled={saving || !isDirty}>
<Save className="mr-1.5 h-4 w-4" />
{saving ? 'Saving…' : 'Save'}
</Button>
</div>
}
/>
{savedMsg ? <p className="text-xs text-emerald-700">{savedMsg}</p> : null}
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Page 1</CardTitle>
</CardHeader>
<CardContent>
{/* Wrapper carries the click handler. react-pdf renders the
actual page inside; we overlay markers as positioned
divs using the same percent coordinates the server-side
fill path consumes. */}
<div
ref={pageContainerRef}
onClick={handlePageClick}
className="relative inline-block cursor-crosshair select-none"
>
<Document
file={pdfUrl}
loading={
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading PDF
<div ref={outerColumnRef}>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm">
Page {pageNumber}
{numPages ? ` / ${numPages}` : ''}
</CardTitle>
{numPages && numPages > 1 ? (
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
onClick={() => setPageNumber((p) => Math.max(1, p - 1))}
disabled={pageNumber <= 1}
aria-label="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => setPageNumber((p) => Math.min(numPages, p + 1))}
disabled={pageNumber >= numPages}
aria-label="Next page"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
}
onLoadError={(err) => {
// Surface load errors via toast rather than blowing up
// — a bad source PDF shouldn't crash the editor shell.
toastError(err);
}}
) : null}
</div>
</CardHeader>
<CardContent>
<div
ref={pageContainerRef}
onClick={handlePageClick}
className="relative inline-block cursor-crosshair select-none"
>
<Page
pageNumber={1}
width={680}
renderAnnotationLayer={false}
renderTextLayer={false}
/>
</Document>
{visibleMarkers.map((m, i) => (
<div
key={i}
style={{
position: 'absolute',
left: `${m.x * 100}%`,
top: `${m.y * 100}%`,
width: `${(m.w ?? DEFAULT_MARKER_W) * 100}%`,
height: `${(m.h ?? DEFAULT_MARKER_H) * 100}%`,
}}
className="pointer-events-none rounded border-2 border-primary/70 bg-primary/15 px-1 py-0.5 text-[10px] font-medium text-primary"
<Document
file={pdfUrl}
onLoadSuccess={({ numPages: n }) => setNumPages(n)}
loading={
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading PDF
</div>
}
onLoadError={(err) => toastError(err)}
>
{m.token}
</div>
))}
<Page
pageNumber={pageNumber}
width={pageWidth}
renderAnnotationLayer={false}
renderTextLayer={false}
/>
</Document>
{pending ? (
<div
style={{
position: 'absolute',
left: `${pending.x * 100}%`,
top: `${pending.y * 100}%`,
width: `${DEFAULT_MARKER_W * 100}%`,
height: `${DEFAULT_MARKER_H * 100}%`,
}}
className="pointer-events-none rounded border-2 border-amber-500 bg-amber-500/15"
/>
) : null}
</div>
</CardContent>
</Card>
{visibleMarkers.map(({ marker, index }) => (
<MarkerOverlay
key={index}
marker={marker}
onMouseDown={onMarkerMouseDown(index, 'move')}
onResize={(kind) => onMarkerMouseDown(index, kind)}
onContextMenu={(e) => {
e.preventDefault();
removeMarker(index);
}}
/>
))}
{pending && pending.page === pageNumber ? (
<div
style={{
position: 'absolute',
left: `${pending.x * 100}%`,
top: `${pending.y * 100}%`,
width: `${DEFAULT_MARKER_W * 100}%`,
height: `${DEFAULT_MARKER_H * 100}%`,
}}
className="pointer-events-none rounded border-2 border-amber-500 bg-amber-500/15"
/>
) : null}
</div>
</CardContent>
</Card>
</div>
<div className="space-y-4">
{pending ? (
@@ -278,9 +559,30 @@ function TemplateEditorBody({
</Card>
) : null}
{unplacedRequired.length > 0 ? (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-amber-700">
Required tokens unplaced ({unplacedRequired.length})
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-1 text-xs">
{unplacedRequired.map((t) => (
<li key={t} className="font-mono text-amber-900">
{t}
</li>
))}
</ul>
</CardContent>
</Card>
) : null}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Markers ({visibleMarkers.length})</CardTitle>
<CardTitle className="text-sm">
Markers on page {pageNumber} ({visibleMarkers.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{visibleMarkers.length === 0 ? (
@@ -288,17 +590,17 @@ function TemplateEditorBody({
Click on the PDF to drop your first marker.
</p>
) : (
visibleMarkers.map((m, i) => (
visibleMarkers.map(({ marker, index }) => (
<div
key={i}
key={index}
className="flex items-center justify-between gap-2 rounded border px-2 py-1 text-xs"
>
<span className="font-mono">{m.token}</span>
<span className="font-mono">{marker.token}</span>
<button
type="button"
onClick={() => removeMarker(markers.indexOf(m))}
onClick={() => removeMarker(index)}
className="text-muted-foreground hover:text-destructive"
aria-label={`Remove ${m.token} marker`}
aria-label={`Remove ${marker.token} marker`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -308,13 +610,109 @@ function TemplateEditorBody({
</CardContent>
</Card>
<Button onClick={save} disabled={saving} className="w-full">
<Save className="mr-1.5 h-4 w-4" />
{saving ? 'Saving…' : 'Save markers'}
</Button>
{savedMsg ? <p className="text-center text-xs text-emerald-700">{savedMsg}</p> : null}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-1">
<Eye className="h-3.5 w-3.5" /> Live preview
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Label className="text-xs">Interest ID</Label>
<input
type="text"
value={previewInterestId}
onChange={(e) => setPreviewInterestId(e.target.value)}
placeholder="Paste an interest UUID"
className="w-full rounded-md border bg-background px-2 py-1 text-xs"
/>
<Button
size="sm"
variant="outline"
onClick={generatePreview}
disabled={previewLoading || !previewInterestId}
className="w-full"
>
{previewLoading ? 'Rendering…' : 'Render preview'}
</Button>
{previewUrl ? (
<a
href={previewUrl}
target="_blank"
rel="noreferrer"
className="block text-center text-xs text-primary hover:underline"
>
Open preview PDF
</a>
) : null}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function clamp(v: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, v));
}
function MarkerOverlay({
marker,
onMouseDown,
onResize,
onContextMenu,
}: {
marker: FieldMapEntry;
onMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
onResize: (kind: DragKind) => (e: React.MouseEvent<HTMLDivElement>) => void;
onContextMenu: (e: React.MouseEvent<HTMLDivElement>) => void;
}) {
const corners: Array<{ kind: DragKind; pos: string; cursor: string }> = [
{
kind: 'resize-nw',
pos: 'top-0 left-0 -translate-x-1/2 -translate-y-1/2',
cursor: 'nwse-resize',
},
{
kind: 'resize-ne',
pos: 'top-0 right-0 translate-x-1/2 -translate-y-1/2',
cursor: 'nesw-resize',
},
{
kind: 'resize-sw',
pos: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2',
cursor: 'nesw-resize',
},
{
kind: 'resize-se',
pos: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2',
cursor: 'nwse-resize',
},
];
return (
<div
data-marker-handle="true"
style={{
position: 'absolute',
left: `${marker.x * 100}%`,
top: `${marker.y * 100}%`,
width: `${(marker.w ?? DEFAULT_MARKER_W) * 100}%`,
height: `${(marker.h ?? DEFAULT_MARKER_H) * 100}%`,
}}
className="cursor-move rounded border-2 border-primary/70 bg-primary/15 px-1 py-0.5 text-[10px] font-medium text-primary"
onMouseDown={onMouseDown}
onContextMenu={onContextMenu}
>
<span className="pointer-events-none select-none">{marker.token}</span>
{corners.map((c) => (
<div
key={c.kind}
data-marker-handle="true"
onMouseDown={onResize(c.kind)}
className={`absolute h-2 w-2 rounded-sm bg-primary ${c.pos}`}
style={{ cursor: c.cursor }}
/>
))}
</div>
);
}

View File

@@ -6,6 +6,7 @@ import type { DetailTab } from '@/components/shared/detail-layout';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineCountryField } from '@/components/shared/inline-country-field';
import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
import { RemindersInline } from '@/components/reminders/reminders-inline';
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
@@ -225,6 +226,8 @@ function OverviewTab({
currentTags={client.tags ?? []}
invalidateKey={['clients', clientId]}
/>
<RemindersInline clientId={clientId} />
</div>
</div>
);

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

View File

@@ -16,6 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { NotesList } from '@/components/shared/notes-list';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { RemindersInline } from '@/components/reminders/reminders-inline';
// Legacy `RecommendationList` removed 2026-05-15 — replaced by the same
// rule-based `BerthRecommenderPanel` (already imported above) used on the
// Overview tab so the scoring + UI stay consistent. The old component
@@ -1069,6 +1070,10 @@ function OverviewTab({
currentTags={interest.tags ?? []}
invalidateKey={['interests', interestId]}
/>
<div className="md:col-span-2">
<RemindersInline interestId={interestId} />
</div>
</div>
{/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see

View File

@@ -0,0 +1,134 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Bell, CheckCircle2, Clock } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
/**
* Phase 4 — inline reminders list rendered inside an entity's
* Overview tab. Shows the most recent open (pending/snoozed) reminders
* for the linked entity so reps can spot follow-ups without leaving the
* detail page.
*
* Filter is exactly one of clientId / interestId / berthId / yachtId.
* Caller responsibility — the listReminders service AND's whichever
* filters are present, so multiple would intersect rather than union.
*
* No "+ Reminder" button here on purpose: the detail-page header
* already carries one, threading the same default-entity-id prop.
* Empty state hints at the header button instead of duplicating it.
*/
interface InlineReminder {
id: string;
title: string;
note: string | null;
dueAt: string;
priority: 'low' | 'medium' | 'high' | 'urgent';
status: 'pending' | 'snoozed' | 'completed' | 'dismissed';
assignedTo: string | null;
}
interface ListResponse {
data: InlineReminder[];
pagination?: { total?: number };
}
const PRIORITY_DOT: Record<InlineReminder['priority'], string> = {
urgent: 'bg-red-500',
high: 'bg-orange-500',
medium: 'bg-blue-500',
low: 'bg-gray-400',
};
const STATUS_ICON: Record<InlineReminder['status'], React.ReactNode> = {
pending: <Bell className="h-3 w-3 text-amber-600" aria-hidden />,
snoozed: <Clock className="h-3 w-3 text-slate-500" aria-hidden />,
completed: <CheckCircle2 className="h-3 w-3 text-emerald-600" aria-hidden />,
dismissed: <CheckCircle2 className="h-3 w-3 text-slate-400" aria-hidden />,
};
interface RemindersInlineProps {
/** Exactly one should be set — the entity to filter by. */
clientId?: string;
interestId?: string;
berthId?: string;
yachtId?: string;
/** Soft cap on the rendered list; defaults to 5. */
limit?: number;
}
export function RemindersInline(props: RemindersInlineProps) {
const { clientId, interestId, berthId, yachtId, limit = 5 } = props;
const filterKey = clientId ?? interestId ?? berthId ?? yachtId ?? null;
const filterParam = clientId
? `clientId=${clientId}`
: interestId
? `interestId=${interestId}`
: berthId
? `berthId=${berthId}`
: yachtId
? `yachtId=${yachtId}`
: '';
const { data, isLoading } = useQuery<ListResponse>({
queryKey: ['reminders', 'inline', filterKey],
queryFn: () =>
apiFetch<ListResponse>(
`/api/v1/reminders?${filterParam}&status=pending&limit=${limit}&sort=dueAt&order=asc`,
),
enabled: !!filterKey,
});
if (!filterKey) return null;
const rows = data?.data ?? [];
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Reminders
</h3>
</div>
{isLoading ? (
<p className="text-xs text-muted-foreground italic">Loading</p>
) : rows.length === 0 ? (
<p className="text-xs text-muted-foreground italic">
No open reminders for this record. Use the bell in the header to add one.
</p>
) : (
<ul className="space-y-1.5">
{rows.map((r) => {
const isPastDue = new Date(r.dueAt) < new Date();
return (
<li
key={r.id}
className="flex items-start gap-2 rounded-md border bg-card px-2 py-1.5 text-xs"
>
<span
className={cn('mt-1 h-2 w-2 shrink-0 rounded-full', PRIORITY_DOT[r.priority])}
/>
{STATUS_ICON[r.status]}
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">{r.title}</p>
<p
className={cn(
'text-[11px]',
isPastDue ? 'text-rose-700 font-medium' : 'text-muted-foreground',
)}
>
Due {formatDistanceToNow(new Date(r.dueAt), { addSuffix: true })}
</p>
</div>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -9,6 +9,7 @@ import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
import { RemindersInline } from '@/components/reminders/reminders-inline';
import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history';
import { apiFetch } from '@/lib/api/client';
import { stageLabel } from '@/lib/constants';
@@ -241,6 +242,10 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
currentTags={yacht.tags ?? []}
invalidateKey={['yachts', yachtId]}
/>
<div className="md:col-span-2">
<RemindersInline yachtId={yachtId} />
</div>
</div>
);
}