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

@@ -905,16 +905,15 @@ Deferred:
- ☑ Per-entity `[+ Reminder]` buttons on yacht / client / interest detail - ☑ Per-entity `[+ Reminder]` buttons on yacht / client / interest detail
headers threading defaultYachtId / defaultClientId / defaultInterestId headers threading defaultYachtId / defaultClientId / defaultInterestId
- ☐ Per-entity reminders LIST inline on detail pages (button exists; section TBD) - ☐ Per-entity reminders LIST inline on detail pages (button exists; section TBD)
- Phase 5 — Email-copy refactor (df1594d + 2026-05-18 PM) - Phase 5 — Email-copy refactor (df1594d + 2026-05-18 PM x3)
- ☑ Per-port background URL — closes the last hard-coded portnimara.com asset - ☑ Per-port background URL — closes the last hard-coded portnimara.com asset
- ☑ 4/8 templates rewritten with luxury-port voice (portal-auth activation - ☑ All 8 templates rewritten with luxury-port voice: portal-auth (activation +
- reset, inquiry-client-confirmation, notification-digest, document-signing reset), inquiry-client-confirmation, notification-digest, document-signing,
sign-offs). Voice captured from old-CRM Nuxt repo `server/utils/ admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry.
signature-notifications.ts` ("Dear X", "With warm regards, The Voice: "Dear X", "With warm regards, The {portName} Team", subjects in
{portName} Team"). sentence case ("Thank you for…" not "Thank You For…").
- ☐ Remaining 4 templates: admin-email-change, crm-invite, - ☐ Snapshot tests per template at port-nimara + 2nd test port (defer — would
inquiry-sales-notification, residential-inquiry need a 2nd-port fixture set up; templates work in code review)
- ☐ Snapshot tests per template at port-nimara + 2nd test port
- ◐ Phase 6 — IMAP bounce-to-interest linking (9f57868 + session 2026-05-18 PM) - ◐ Phase 6 — IMAP bounce-to-interest linking (9f57868 + session 2026-05-18 PM)
- ☑ Schema migration 0074: bounce_status/reason/detected_at on document_sends - ☑ Schema migration 0074: bounce_status/reason/detected_at on document_sends
- ☑ Parser library `src/lib/email/bounce-parser.ts` (RFC 3464 + Outlook + OOO) - ☑ Parser library `src/lib/email/bounce-parser.ts` (RFC 3464 + Outlook + OOO)
@@ -939,17 +938,27 @@ signature-notifications.ts` ("Dear X", "With warm regards, The
envelope sender's mailbox (the SMTP user account), so pointing the envelope sender's mailbox (the SMTP user account), so pointing the
poller at that single mailbox catches every automated-email bounce poller at that single mailbox catches every automated-email bounce
in one place. in one place.
- Phase 7 — PDF template editor (9f57868 + 2026-05-18 PM) - Phase 7 — PDF template editor (9f57868 + 2026-05-18 PM x3)
- ☑ FieldMap type definitions + Zod validators + page-count cross-validator - ☑ FieldMap type definitions + Zod validators + page-count cross-validator
- ☑ 7.1 scaffold — `/admin/templates/[id]/editor/page.tsx` + client-side - ☑ 7.1 scaffold — `/admin/templates/[id]/editor/page.tsx` + client-side
`<TemplateEditor>` with react-pdf, click-to-place markers, token picker `<TemplateEditor>` with react-pdf, click-to-place markers, token picker
from `VALID_MERGE_TOKENS`, save via PATCH to overlayPositions. Page 1 from `VALID_MERGE_TOKENS`, save via PATCH to overlayPositions.
only; add + delete markers supported. - ☑ 7.1 polish — unsaved-changes guard (beforeunload + visual "Unsaved
- ☐ 7.1 polish: unsaved-changes guard, responsive PDF width, changes" badge), responsive PDF width via ResizeObserver, required
"required tokens unplaced" indicator tokens unplaced indicator that reads `template.mergeFields`.
- 7.2 Edit + preview (~1-2 weeks): drag/resize, live preview pane with - 7.2 drag-to-move with on-page clamping; 4 corner resize handles
sample interest data, multi-page navigation, new-PDF upload (replace (NW/NE/SW/SE) with min-size + on-page clamping.
source while preserving field map) - ☑ 7.2 multi-page navigation (page picker + per-page marker filter).
- ☑ 7.2 right-click context delete (onContextMenu → preventDefault →
setMarkers filter).
- ☑ 7.2 live preview endpoint — `POST /api/v1/document-templates/[id]/preview`
accepts {interestId}, runs the same in-app pdf-lib fill, uploads to a
transient `previews/` storage key, returns a 15-minute presigned URL.
- ☑ 7.2 new-PDF upload — `POST /api/v1/document-templates/[id]/source-pdf`
accepts multipart FormData, magic-byte verifies %PDF-, parses page count
via pdf-lib, swaps `documentTemplates.sourceFileId` to the new files row.
Editor warns when new page count truncates the prior set so reps know
their markers on now-orphaned pages won't render.
--- ---

View File

@@ -0,0 +1,80 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { and, eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { documentTemplates } from '@/lib/db/schema/documents';
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
import { generateEoiPdfFromTemplate } from '@/lib/pdf/fill-eoi-form';
import { buildEoiContext } from '@/lib/services/eoi-context';
import { getStorageBackend, presignDownloadUrl } from '@/lib/storage';
import { buildStoragePath } from '@/lib/minio';
const previewBodySchema = z.object({
interestId: z.string().uuid(),
dimensionUnit: z.enum(['ft', 'm']).optional(),
});
/**
* Phase 7.2 — live preview endpoint for the PDF editor.
*
* Generates a transient EOI PDF against the supplied interest using the
* template's current source PDF + overlay markers, uploads it to a
* scratch storage key, and returns a 15-minute presigned download URL.
*
* The blob is intentionally not linked to a `files` row — preview PDFs
* are throwaway. The storage backend's lifecycle policy (TTL on
* `previews/` prefix) cleans them up; in dev the filesystem backend
* just accumulates them, which is acceptable for the editor workflow.
*/
export const POST = withAuth(
withPermission('documents', 'create', async (req, ctx, params) => {
try {
const body = await parseBody(req, previewBodySchema);
const template = await db.query.documentTemplates.findFirst({
where: and(eq(documentTemplates.id, params.id!), eq(documentTemplates.portId, ctx.portId)),
});
if (!template) throw new NotFoundError('Template');
if (template.templateType !== 'eoi') {
// Live preview is currently EOI-only — that's where the
// editor's overlay-positions flow into rendering. Other
// template types are deferred (no in-app fill yet).
throw new ValidationError(
`Live preview is only available for EOI templates (got "${template.templateType}").`,
);
}
const eoiContext = await buildEoiContext(body.interestId, ctx.portId);
const pdfBytes = await generateEoiPdfFromTemplate(eoiContext, {
dimensionUnit: body.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft',
});
const previewKey = buildStoragePath(
ctx.portSlug,
'previews',
'document-templates',
template.id,
`${crypto.randomUUID()}.pdf`,
);
const backend = await getStorageBackend();
const buffer = Buffer.from(pdfBytes);
await backend.put(previewKey, buffer, {
contentType: 'application/pdf',
sizeBytes: buffer.length,
});
const previewUrl = await presignDownloadUrl(
previewKey,
900,
`${template.name}.preview.pdf`,
ctx.portSlug,
);
return NextResponse.json({ data: { previewUrl } });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,114 @@
import { NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { PDFDocument } from 'pdf-lib';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { documentTemplates, files } from '@/lib/db/schema/documents';
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
import { buildStoragePath } from '@/lib/minio';
import { getStorageBackend } from '@/lib/storage';
import { env } from '@/lib/env';
import { createAuditLog } from '@/lib/audit';
const MAX_PDF_BYTES = 10 * 1024 * 1024;
const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]); // "%PDF-"
/**
* Phase 7.2 — replace the template's source PDF while preserving the
* field map. The existing `overlay_positions` is kept exactly as-is;
* the client warns when the new page count truncates the previous set
* (markers on now-orphaned pages are invisible at render time).
*
* Magic-byte (`%PDF-`) verified server-side so a non-PDF file (with a
* spoofed extension) can't sneak into the storage backend.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
try {
const template = await db.query.documentTemplates.findFirst({
where: and(eq(documentTemplates.id, params.id!), eq(documentTemplates.portId, ctx.portId)),
});
if (!template) throw new NotFoundError('Template');
const form = await req.formData();
const file = form.get('file');
if (!file || typeof file === 'string') {
throw new ValidationError('Missing "file" field');
}
const arrayBuf = await file.arrayBuffer();
if (arrayBuf.byteLength > MAX_PDF_BYTES) {
throw new ValidationError(
`PDF exceeds the ${Math.floor(MAX_PDF_BYTES / 1024 / 1024)} MB cap`,
);
}
const buf = Buffer.from(arrayBuf);
if (buf.length < PDF_MAGIC.length || !buf.subarray(0, PDF_MAGIC.length).equals(PDF_MAGIC)) {
throw new ValidationError('Uploaded file does not look like a PDF (missing %PDF- header)');
}
// Resolve the page count so the client can surface a warning if
// the new PDF truncates the prior page set + orphaned markers
// exist. pdf-lib parses fully into memory but that's cheap for a
// sub-10MB editor source.
const pdfDoc = await PDFDocument.load(buf);
const pageCount = pdfDoc.getPageCount();
const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(
ctx.portSlug,
'document-templates',
template.id,
fileId,
'pdf',
);
const backend = await getStorageBackend();
await backend.put(storagePath, buf, {
contentType: 'application/pdf',
sizeBytes: buf.length,
});
const [fileRecord] = await db
.insert(files)
.values({
portId: ctx.portId,
filename: `${template.name.toLowerCase().replace(/\s+/g, '-')}.pdf`,
originalName: file.name || `${template.name}.pdf`,
mimeType: 'application/pdf',
sizeBytes: String(buf.length),
storagePath,
storageBucket: env.MINIO_BUCKET,
category: 'eoi',
uploadedBy: ctx.userId,
})
.returning();
await db
.update(documentTemplates)
.set({ sourceFileId: fileRecord!.id, updatedAt: new Date() })
.where(eq(documentTemplates.id, template.id));
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'update',
entityType: 'documentTemplate',
entityId: template.id,
metadata: {
action: 'replace_source_pdf',
fileId: fileRecord!.id,
pageCount,
sizeBytes: buf.length,
},
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({
data: { sourceFileId: fileRecord!.id, pageCount },
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -4,7 +4,7 @@ import { and, desc, eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers'; import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests'; import { interests } from '@/lib/db/schema/interests';
import { clientContacts } from '@/lib/db/schema/clients'; import { clientAddresses, clientContacts } from '@/lib/db/schema/clients';
import { errorResponse, NotFoundError } from '@/lib/errors'; import { errorResponse, NotFoundError } from '@/lib/errors';
import { buildEoiContext } from '@/lib/services/eoi-context'; import { buildEoiContext } from '@/lib/services/eoi-context';
@@ -45,6 +45,21 @@ export const GET = withAuth(
.where(eq(clientContacts.clientId, interest.clientId)) .where(eq(clientContacts.clientId, interest.clientId))
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt)); .orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt));
const addressRows = await db
.select({
id: clientAddresses.id,
streetAddress: clientAddresses.streetAddress,
city: clientAddresses.city,
subdivisionIso: clientAddresses.subdivisionIso,
postalCode: clientAddresses.postalCode,
countryIso: clientAddresses.countryIso,
isPrimary: clientAddresses.isPrimary,
source: clientAddresses.source,
})
.from(clientAddresses)
.where(eq(clientAddresses.clientId, interest.clientId))
.orderBy(desc(clientAddresses.isPrimary), desc(clientAddresses.updatedAt));
const available = { const available = {
emails: contactRows emails: contactRows
.filter((c) => c.channel === 'email') .filter((c) => c.channel === 'email')
@@ -58,6 +73,7 @@ export const GET = withAuth(
channel: c.channel, channel: c.channel,
source: c.source, source: c.source,
})), })),
addresses: addressRows,
}; };
return NextResponse.json({ data: { ...context, available } }); return NextResponse.json({ data: { ...context, available } });

View File

@@ -64,6 +64,9 @@ function actionVerb(action: string): string {
merge: 'Merged', merge: 'Merged',
revert: 'Reverted', revert: 'Reverted',
viewed: 'Viewed', 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); 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="outcome_cleared">Outcome cleared</SelectItem>
<SelectItem value="branding.logo.uploaded">Logo uploaded</SelectItem> <SelectItem value="branding.logo.uploaded">Logo uploaded</SelectItem>
<SelectItem value="branding.logo.archived">Logo archived</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> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -1,9 +1,9 @@
'use client'; 'use client';
import { useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Document, Page, pdfjs } from 'react-pdf'; 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/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css'; import 'react-pdf/dist/Page/TextLayer.css';
@@ -36,6 +36,10 @@ interface TemplateData {
templateFormat: string; templateFormat: string;
sourceFileId: string | null; sourceFileId: string | null;
overlayPositions: FieldMap | 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 { interface PendingMarker {
@@ -44,19 +48,41 @@ interface PendingMarker {
page: number; 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 TOKEN_OPTIONS = Array.from(VALID_MERGE_TOKENS).sort();
const DEFAULT_MARKER_W = 0.18; const DEFAULT_MARKER_W = 0.18;
const DEFAULT_MARKER_H = 0.04; 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 * Phase 7.1 + 7.2 — PDF marker editor.
* PDF to drop a marker, pick which merge token it represents, save.
* *
* Scope intentionally narrow: * - Click anywhere to drop a marker (page-aware).
* - Page 1 only (multi-page page-picker is a 7.2 ticket). * - Drag markers to move; corner handles to resize.
* - Add + delete markers; drag-to-move + corner-resize defer to 7.2. * - Right-click for context-menu delete.
* - Coordinates stored as percent of page width/height so a future * - Multi-page navigation via page picker.
* page-size swap (A4 ↔ Letter) doesn't shift placements. * - "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 }) { export function TemplateEditor({ templateId }: { templateId: string }) {
const { data: template, isLoading } = useQuery<{ data: TemplateData }>({ 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 // Inner body keyed by templateId so a route change re-mounts and the
// the markers useState initializer re-reads the server payload. // markers useState initializer re-reads the server payload, avoiding
// Avoids the in-render setState pattern (React anti-pattern) that // an in-render setState pattern to seed from the query.
// a single component would otherwise need to seed from the query.
return <TemplateEditorBody key={templateId} templateId={templateId} template={template.data} />; return <TemplateEditorBody key={templateId} templateId={templateId} template={template.data} />;
} }
@@ -89,22 +114,73 @@ function TemplateEditorBody({
}) { }) {
const qc = useQueryClient(); const qc = useQueryClient();
const [markers, setMarkers] = useState<FieldMap>(template.overlayPositions ?? []); 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 [pending, setPending] = useState<PendingMarker | null>(null);
const [pendingToken, setPendingToken] = useState<string>(''); const [pendingToken, setPendingToken] = useState<string>('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [savedMsg, setSavedMsg] = useState<string | null>(null); 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 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; 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>) { function handlePageClick(e: React.MouseEvent<HTMLDivElement>) {
if (dragState) return; // drag in progress; ignore click
const container = pageContainerRef.current; const container = pageContainerRef.current;
if (!container) return; 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 rect = container.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width; const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height; const y = (e.clientY - rect.top) / rect.height;
if (x < 0 || x > 1 || y < 0 || y > 1) return; if (x < 0 || x > 1 || y < 0 || y > 1) return;
setPending({ x, y, page: 1 }); setPending({ x, y, page: pageNumber });
setPendingToken(TOKEN_OPTIONS[0] ?? ''); setPendingToken(TOKEN_OPTIONS[0] ?? '');
} }
@@ -132,6 +208,98 @@ function TemplateEditorBody({
setMarkers((m) => m.filter((_, i) => i !== index)); 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() { async function save() {
setSaving(true); setSaving(true);
setSavedMsg(null); setSavedMsg(null);
@@ -140,6 +308,7 @@ function TemplateEditorBody({
method: 'PATCH', method: 'PATCH',
body: { overlayPositions: markers }, body: { overlayPositions: markers },
}); });
setSavedMarkers(markers);
await qc.invalidateQueries({ queryKey: ['document-template', templateId] }); await qc.invalidateQueries({ queryKey: ['document-template', templateId] });
setSavedMsg('Markers saved.'); setSavedMsg('Markers saved.');
} catch (err) { } 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) { if (!pdfUrl) {
return ( return (
@@ -160,8 +388,19 @@ function TemplateEditorBody({
/> />
<Card> <Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground"> <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 This template has no source PDF attached. Upload one to get started.
opening the editor. <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> </CardContent>
</Card> </Card>
</div> </div>
@@ -172,77 +411,119 @@ function TemplateEditorBody({
<div className="space-y-4"> <div className="space-y-4">
<PageHeader <PageHeader
title={`Edit "${template.name}"`} 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]"> <div className="grid gap-4 lg:grid-cols-[1fr_320px]">
<Card> <div ref={outerColumnRef}>
<CardHeader className="pb-2"> <Card>
<CardTitle className="text-sm">Page 1</CardTitle> <CardHeader className="pb-2">
</CardHeader> <div className="flex items-center justify-between gap-2">
<CardContent> <CardTitle className="text-sm">
{/* Wrapper carries the click handler. react-pdf renders the Page {pageNumber}
actual page inside; we overlay markers as positioned {numPages ? ` / ${numPages}` : ''}
divs using the same percent coordinates the server-side </CardTitle>
fill path consumes. */} {numPages && numPages > 1 ? (
<div <div className="flex items-center gap-1">
ref={pageContainerRef} <Button
onClick={handlePageClick} size="icon"
className="relative inline-block cursor-crosshair select-none" variant="ghost"
> onClick={() => setPageNumber((p) => Math.max(1, p - 1))}
<Document disabled={pageNumber <= 1}
file={pdfUrl} aria-label="Previous page"
loading={ >
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground"> <ChevronLeft className="h-4 w-4" />
<Loader2 className="h-4 w-4 animate-spin" /> </Button>
Loading PDF <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> </div>
} ) : null}
onLoadError={(err) => { </div>
// Surface load errors via toast rather than blowing up </CardHeader>
// — a bad source PDF shouldn't crash the editor shell. <CardContent>
toastError(err); <div
}} ref={pageContainerRef}
onClick={handlePageClick}
className="relative inline-block cursor-crosshair select-none"
> >
<Page <Document
pageNumber={1} file={pdfUrl}
width={680} onLoadSuccess={({ numPages: n }) => setNumPages(n)}
renderAnnotationLayer={false} loading={
renderTextLayer={false} <div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
/> <Loader2 className="h-4 w-4 animate-spin" />
</Document> Loading PDF
</div>
{visibleMarkers.map((m, i) => ( }
<div onLoadError={(err) => toastError(err)}
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"
> >
{m.token} <Page
</div> pageNumber={pageNumber}
))} width={pageWidth}
renderAnnotationLayer={false}
renderTextLayer={false}
/>
</Document>
{pending ? ( {visibleMarkers.map(({ marker, index }) => (
<div <MarkerOverlay
style={{ key={index}
position: 'absolute', marker={marker}
left: `${pending.x * 100}%`, onMouseDown={onMarkerMouseDown(index, 'move')}
top: `${pending.y * 100}%`, onResize={(kind) => onMarkerMouseDown(index, kind)}
width: `${DEFAULT_MARKER_W * 100}%`, onContextMenu={(e) => {
height: `${DEFAULT_MARKER_H * 100}%`, e.preventDefault();
}} removeMarker(index);
className="pointer-events-none rounded border-2 border-amber-500 bg-amber-500/15" }}
/> />
) : null} ))}
</div>
</CardContent> {pending && pending.page === pageNumber ? (
</Card> <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"> <div className="space-y-4">
{pending ? ( {pending ? (
@@ -278,9 +559,30 @@ function TemplateEditorBody({
</Card> </Card>
) : null} ) : 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> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm">Markers ({visibleMarkers.length})</CardTitle> <CardTitle className="text-sm">
Markers on page {pageNumber} ({visibleMarkers.length})
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
{visibleMarkers.length === 0 ? ( {visibleMarkers.length === 0 ? (
@@ -288,17 +590,17 @@ function TemplateEditorBody({
Click on the PDF to drop your first marker. Click on the PDF to drop your first marker.
</p> </p>
) : ( ) : (
visibleMarkers.map((m, i) => ( visibleMarkers.map(({ marker, index }) => (
<div <div
key={i} key={index}
className="flex items-center justify-between gap-2 rounded border px-2 py-1 text-xs" 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 <button
type="button" type="button"
onClick={() => removeMarker(markers.indexOf(m))} onClick={() => removeMarker(index)}
className="text-muted-foreground hover:text-destructive" 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" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
@@ -308,13 +610,109 @@ function TemplateEditorBody({
</CardContent> </CardContent>
</Card> </Card>
<Button onClick={save} disabled={saving} className="w-full"> <Card>
<Save className="mr-1.5 h-4 w-4" /> <CardHeader className="pb-2">
{saving ? 'Saving…' : 'Save markers'} <CardTitle className="text-sm flex items-center gap-1">
</Button> <Eye className="h-3.5 w-3.5" /> Live preview
{savedMsg ? <p className="text-center text-xs text-emerald-700">{savedMsg}</p> : null} </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> </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 { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineCountryField } from '@/components/shared/inline-country-field'; import { InlineCountryField } from '@/components/shared/inline-country-field';
import { InlineTimezoneField } from '@/components/shared/inline-timezone-field'; import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
import { RemindersInline } from '@/components/reminders/reminders-inline';
import { primaryTimezoneFor } from '@/lib/i18n/timezones'; import { primaryTimezoneFor } from '@/lib/i18n/timezones';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
@@ -225,6 +226,8 @@ function OverviewTab({
currentTags={client.tags ?? []} currentTags={client.tags ?? []}
invalidateKey={['clients', clientId]} invalidateKey={['clients', clientId]}
/> />
<RemindersInline clientId={clientId} />
</div> </div>
</div> </div>
); );

View File

@@ -94,6 +94,16 @@ interface EoiContextResponse {
channel: 'phone' | 'whatsapp'; channel: 'phone' | 'whatsapp';
source: string; 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; 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 { interface EoiGenerateDialogProps {
interestId: string; interestId: string;
/** Used to wire the "Edit on client" deep-link inside the dialog. */ /** 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 [emailOverride, setEmailOverride] = useState<FieldOverrideState | null>(null);
const [phoneOverride, setPhoneOverride] = useState<FieldOverrideState | null>(null); const [phoneOverride, setPhoneOverride] = useState<FieldOverrideState | null>(null);
const [yachtNameOverride, setYachtNameOverride] = useState<FieldOverrideState | null>(null); const [yachtNameOverride, setYachtNameOverride] = useState<FieldOverrideState | null>(null);
const [addressOverride, setAddressOverride] = useState<AddressOverrideState | null>(null);
// Phase 3c — yacht spawn flow. // Phase 3c — yacht spawn flow.
const [yachtSpawnOpen, setYachtSpawnOpen] = useState(false); const [yachtSpawnOpen, setYachtSpawnOpen] = useState(false);
@@ -309,24 +338,9 @@ export function EoiGenerateDialog({
placeholder: 'Full legal name', placeholder: 'Full legal name',
}, },
}, },
{ // Address moved out to <OverridableAddressField> below so it can
key: 'address', // surface the per-component combobox + 2 checkboxes alongside
// Mirrors the rendered EOI Address field exactly so the rep sees // the canonical preview.
// 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,
},
] ]
: []; : [];
@@ -365,8 +379,18 @@ export function EoiGenerateDialog({
: []; : [];
const emailPresent = ctx ? !!(emailOverride?.value ?? ctx.client.primaryEmail) : false; 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 = 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() { async function handleGenerate() {
if (!requiredMet) return; if (!requiredMet) return;
@@ -387,12 +411,31 @@ export function EoiGenerateDialog({
...(s.contactId ? { contactId: s.contactId } : {}), ...(s.contactId ? { contactId: s.contactId } : {}),
} }
: undefined; : 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 = { const overrides = {
clientEmail: overridePayload(emailOverride), clientEmail: overridePayload(emailOverride),
clientPhone: overridePayload(phoneOverride), clientPhone: overridePayload(phoneOverride),
yachtName: overridePayload(yachtNameOverride), 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, { await apiFetch(url, {
method: 'POST', method: 'POST',
@@ -497,6 +540,16 @@ export function EoiGenerateDialog({
onChange={setEmailOverride} onChange={setEmailOverride}
missing={!emailPresent} 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> </dl>
</div> </div>
<div className="space-y-1 border-t pt-2"> <div className="space-y-1 border-t pt-2">
@@ -1098,3 +1151,276 @@ function OverridableContactField({
</div> </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 { NotesList } from '@/components/shared/notes-list';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; 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 // Legacy `RecommendationList` removed 2026-05-15 — replaced by the same
// rule-based `BerthRecommenderPanel` (already imported above) used on the // rule-based `BerthRecommenderPanel` (already imported above) used on the
// Overview tab so the scoring + UI stay consistent. The old component // Overview tab so the scoring + UI stay consistent. The old component
@@ -1069,6 +1070,10 @@ function OverviewTab({
currentTags={interest.tags ?? []} currentTags={interest.tags ?? []}
invalidateKey={['interests', interestId]} invalidateKey={['interests', interestId]}
/> />
<div className="md:col-span-2">
<RemindersInline interestId={interestId} />
</div>
</div> </div>
{/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see {/* 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 { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed'; import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list'; 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 { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { stageLabel } from '@/lib/constants'; import { stageLabel } from '@/lib/constants';
@@ -241,6 +242,10 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
currentTags={yacht.tags ?? []} currentTags={yacht.tags ?? []}
invalidateKey={['yachts', yachtId]} invalidateKey={['yachts', yachtId]}
/> />
<div className="md:col-span-2">
<RemindersInline yachtId={yachtId} />
</div>
</div> </div>
); );
} }

View File

@@ -27,19 +27,19 @@ function AdminEmailChangeBody({
loginUrl, loginUrl,
accent, accent,
}: AdminEmailChangeData & { portName: string; accent: string }) { }: AdminEmailChangeData & { portName: string; accent: string }) {
const greeting = recipientName ? `Hello ${recipientName},` : 'Hello,'; const greeting = recipientName ? `Dear ${recipientName},` : 'Hello,';
const adminLine = changedByDisplayName const adminLine = changedByDisplayName
? `${changedByDisplayName} (an administrator)` ? `${changedByDisplayName} (an administrator)`
: 'an administrator'; : 'an administrator';
return ( return (
<> <>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}> <Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
Your sign-in email was changed Your sign-in email has changed
</Text> </Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text> <Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}> <Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
{adminLine} just updated the email address linked to your {portName} account. From now on, We&apos;re writing to let you know that {adminLine} has updated the email address linked to
please sign in with the new address below: your {portName} account. Going forward, please sign in with the address below:
</Text> </Text>
<Text style={{ margin: '20px 0', textAlign: 'center', fontSize: '16px' }}> <Text style={{ margin: '20px 0', textAlign: 'center', fontSize: '16px' }}>
<strong>{newEmail}</strong> <strong>{newEmail}</strong>
@@ -65,13 +65,13 @@ function AdminEmailChangeBody({
) : null} ) : null}
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '20px 0 0' }} /> <Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '20px 0 0' }} />
<Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', padding: '15px 0 0' }}> <Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', padding: '15px 0 0' }}>
If you weren&apos;t expecting this change, contact your administrator immediately. Your old If this change wasn&apos;t expected, please contact your administrator straight away. The
address (the one this message was sent to) can no longer be used to sign in. previous address (where this message was delivered) is no longer accepted for sign-in.
</Text> </Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}> <Text style={{ fontSize: '16px', marginTop: '30px' }}>
Thanks, With warm regards,
<br /> <br />
<strong>{portName}</strong> <strong>The {portName} Team</strong>
</Text> </Text>
</> </>
); );
@@ -84,7 +84,7 @@ export async function adminEmailChangeEmail(
const portName = data.portName ?? 'Port Nimara'; const portName = data.portName ?? 'Port Nimara';
const subject = overrides?.subject?.trim() const subject = overrides?.subject?.trim()
? overrides.subject ? overrides.subject
: `An administrator updated your ${portName} sign-in email`; : `Your ${portName} sign-in email has been updated`;
const accent = brandingPrimaryColor(overrides?.branding); const accent = brandingPrimaryColor(overrides?.branding);
const body = await render( const body = await render(
@@ -95,14 +95,17 @@ export async function adminEmailChangeEmail(
); );
const text = [ const text = [
`Your sign-in email was changed`, `Your sign-in email has changed`,
'', '',
`${data.changedByDisplayName ?? 'An administrator'} updated the email linked to your ${portName} account.`, `${data.changedByDisplayName ?? 'An administrator'} has updated the email address linked to your ${portName} account.`,
`From now on, sign in with: ${data.newEmail}`, `Going forward, please sign in with: ${data.newEmail}`,
'', '',
data.loginUrl ? `Sign in: ${data.loginUrl}` : '', data.loginUrl ? `Sign in: ${data.loginUrl}` : '',
'', '',
`If you weren't expecting this change, contact your administrator immediately.`, `If this change wasn't expected, please contact your administrator straight away.`,
'',
`With warm regards,`,
`The ${portName} Team`,
] ]
.filter(Boolean) .filter(Boolean)
.join('\n'); .join('\n');

View File

@@ -33,7 +33,7 @@ function InviteBody({
role: string; role: string;
accent: string; accent: string;
}) { }) {
const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome,'; const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome aboard,';
return ( return (
<> <>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}> <Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
@@ -41,8 +41,9 @@ function InviteBody({
</Text> </Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text> <Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}> <Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
You&apos;ve been invited to the {portName} CRM as a {role}. Click the button below to set You&apos;ve been invited to join the {portName} CRM as a {role}. Use the button below to set
your password and activate your account. The link expires in {ttlHours} hours. your password and activate your account at your convenience the link will remain valid for{' '}
{ttlHours} hours.
</Text> </Text>
<div style={{ textAlign: 'center', margin: '30px 0' }}> <div style={{ textAlign: 'center', margin: '30px 0' }}>
<Button <Button
@@ -73,9 +74,9 @@ function InviteBody({
</Link> </Link>
</Text> </Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}> <Text style={{ fontSize: '16px', marginTop: '30px' }}>
Thank you, With warm regards,
<br /> <br />
<strong>{portName} CRM</strong> <strong>The {portName} Team</strong>
</Text> </Text>
</> </>
); );
@@ -107,13 +108,13 @@ export async function crmInviteEmail(
const text = [ const text = [
`Welcome to the ${portName} CRM`, `Welcome to the ${portName} CRM`,
'', '',
`You've been invited as a ${role}.`, `You've been invited to join the ${portName} CRM as a ${role}.`,
`Set up your account: ${data.link}`, `Set up your account: ${data.link}`,
'', '',
`The link expires in ${data.ttlHours} hours.`, `The link will remain valid for ${data.ttlHours} hours.`,
'', '',
`Thank you,`, `With warm regards,`,
`${portName} CRM`, `The ${portName} Team`,
].join('\n'); ].join('\n');
return { return {

View File

@@ -37,10 +37,10 @@ function SalesNotificationBody({
const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const; const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const;
return ( return (
<> <>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear Administrator,</Text> <Text style={{ marginBottom: '10px', fontSize: '16px' }}>Hello,</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}> <Text style={{ marginBottom: '10px', fontSize: '16px' }}>
{fullName} has expressed their interest in <strong>{portName}</strong>. Here are their A new enquiry has come in for <strong>{portName}</strong>. {fullName} has asked us to be in
details: touch full details below:
</Text> </Text>
<Text style={detailStyle}> <Text style={detailStyle}>
<strong>Name:</strong> {fullName} <strong>Name:</strong> {fullName}
@@ -55,17 +55,13 @@ function SalesNotificationBody({
<strong>Berths Selected:</strong> {mooringDisplay} <strong>Berths Selected:</strong> {mooringDisplay}
</Text> </Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}> <Text style={{ marginBottom: '10px', fontSize: '16px' }}>
Please visit the{' '} Open the{' '}
<Link href={safeUrl(crmUrl)} style={{ color: accent, textDecoration: 'underline' }}> <Link href={safeUrl(crmUrl)} style={{ color: accent, textDecoration: 'underline' }}>
{portName} CRM {portName} CRM
</Link>{' '} </Link>{' '}
to view more information. to follow up.
</Text>
<Text style={{ fontSize: '16px' }}>
Thank you,
<br />
{portName} CRM
</Text> </Text>
<Text style={{ fontSize: '16px' }}> {portName} CRM</Text>
</> </>
); );
} }
@@ -76,7 +72,9 @@ export async function inquirySalesNotification(
) { ) {
const portName = data.portName ?? 'Port Nimara'; const portName = data.portName ?? 'Port Nimara';
const mooringDisplay = data.mooringNumber || 'None'; const mooringDisplay = data.mooringNumber || 'None';
const subject = overrides?.subject?.trim() ? overrides.subject : `New Interest - ${portName}`; const subject = overrides?.subject?.trim()
? overrides.subject
: `New enquiry — ${portName}${data.mooringNumber ? ` (Berth ${data.mooringNumber})` : ''}`;
const accent = brandingPrimaryColor(overrides?.branding); const accent = brandingPrimaryColor(overrides?.branding);
const body = await render( const body = await render(
@@ -93,19 +91,18 @@ export async function inquirySalesNotification(
); );
const text = [ const text = [
'Dear Administrator,', 'Hello,',
'', '',
`${data.fullName} has expressed their interest in ${portName}. Here are their details:`, `A new enquiry has come in for ${portName}. ${data.fullName} has asked us to be in touch — full details below:`,
'', '',
`Name: ${data.fullName}`, `Name: ${data.fullName}`,
`Email: ${data.email}`, `Email: ${data.email}`,
`Telephone: ${data.phone}`, `Telephone: ${data.phone}`,
`Berths Selected: ${mooringDisplay}`, `Berths Selected: ${mooringDisplay}`,
'', '',
`Please visit the ${portName} CRM (${data.crmUrl}) to view more information.`, `Open the ${portName} CRM (${data.crmUrl}) to follow up.`,
'', '',
'Thank you', `${portName} CRM`,
`${portName} CRM`,
].join('\n'); ].join('\n');
return { return {

View File

@@ -34,11 +34,12 @@ function ClientConfirmationBody({
Dear {firstName}, Dear {firstName},
</Text> </Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}> <Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
Thank you for expressing interest in {portName} residences. Our residential sales team has Thank you for your interest in the residences at {portName}. Our residential sales team has
received your inquiry and will reach out to you shortly with more information. received your enquiry, and a member of the team will be in touch shortly with the details
you&apos;ve requested.
</Text> </Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}> <Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
If you have any questions in the meantime, please reach us at{' '} Should anything come to mind in the meantime, please don&apos;t hesitate to write to us at{' '}
<Link <Link
href={safeUrl(`mailto:${contactEmail}`)} href={safeUrl(`mailto:${contactEmail}`)}
style={{ color: accent, textDecoration: 'underline' }} style={{ color: accent, textDecoration: 'underline' }}
@@ -48,7 +49,7 @@ function ClientConfirmationBody({
. .
</Text> </Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}> <Text style={{ fontSize: '16px', marginTop: '30px' }}>
Best regards, With warm regards,
<br /> <br />
<strong>The {portName} Residential Team</strong> <strong>The {portName} Residential Team</strong>
</Text> </Text>
@@ -63,7 +64,7 @@ export async function residentialClientConfirmation(
const portName = data.portName ?? 'Port Nimara'; const portName = data.portName ?? 'Port Nimara';
const subject = overrides?.subject?.trim() const subject = overrides?.subject?.trim()
? overrides.subject ? overrides.subject
: `Thank You for Your Interest - ${portName} Residences`; : `Thank you for your interest in ${portName} Residences`;
const accent = brandingPrimaryColor(overrides?.branding); const accent = brandingPrimaryColor(overrides?.branding);
const body = await render( const body = await render(
<ClientConfirmationBody <ClientConfirmationBody
@@ -183,7 +184,7 @@ export async function residentialSalesAlert(
const portName = data.portName ?? 'Port Nimara'; const portName = data.portName ?? 'Port Nimara';
const subject = overrides?.subject?.trim() const subject = overrides?.subject?.trim()
? overrides.subject ? overrides.subject
: `New Residential Inquiry - ${data.fullName}`; : `New residential enquiry ${data.fullName}`;
const accent = brandingPrimaryColor(overrides?.branding); const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, { const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
pretty: false, pretty: false,

View File

@@ -35,7 +35,7 @@
import { and, eq, sql } from 'drizzle-orm'; import { and, eq, sql } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { clients, clientContacts } from '@/lib/db/schema/clients'; import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
import { documents } from '@/lib/db/schema/documents'; import { documents } from '@/lib/db/schema/documents';
import { interests } from '@/lib/db/schema/interests'; import { interests } from '@/lib/db/schema/interests';
import { yachts } from '@/lib/db/schema/yachts'; import { yachts } from '@/lib/db/schema/yachts';
@@ -50,10 +50,25 @@ export interface FieldOverrideInput {
contactId?: string | null; contactId?: string | null;
} }
export interface AddressOverrideInput {
line1?: string;
line2?: string;
city?: string;
subdivisionIso?: string;
postalCode?: string;
countryIso?: string;
useOnlyForThisEoi: boolean;
setAsDefault: boolean;
/** Existing client_addresses.id when the rep picked one from a list;
* null = fresh values typed in the dialog. */
addressId?: string | null;
}
export interface EoiOverridesInput { export interface EoiOverridesInput {
clientEmail?: FieldOverrideInput; clientEmail?: FieldOverrideInput;
clientPhone?: FieldOverrideInput; clientPhone?: FieldOverrideInput;
yachtName?: FieldOverrideInput; yachtName?: FieldOverrideInput;
clientAddress?: AddressOverrideInput;
} }
export interface AppliedOverrides { export interface AppliedOverrides {
@@ -62,6 +77,14 @@ export interface AppliedOverrides {
clientEmail?: string; clientEmail?: string;
clientPhone?: string; clientPhone?: string;
yachtName?: string; yachtName?: string;
clientAddress?: {
line1: string;
line2: string;
city: string;
subdivisionIso: string;
postalCode: string;
countryIso: string;
};
}; };
/** Columns to write to `documents.override_*` after the doc row exists. /** Columns to write to `documents.override_*` after the doc row exists.
* Empty when every override either ran `setAsDefault` (canonical * Empty when every override either ran `setAsDefault` (canonical
@@ -70,6 +93,12 @@ export interface AppliedOverrides {
overrideClientEmail: string; overrideClientEmail: string;
overrideClientPhone: string; overrideClientPhone: string;
overrideYachtName: string; overrideYachtName: string;
overrideClientAddressLine1: string;
overrideClientAddressLine2: string;
overrideClientCity: string;
overrideClientState: string;
overrideClientPostalCode: string;
overrideClientCountry: string;
}>; }>;
} }
@@ -230,30 +259,155 @@ export async function applyEoiOverridesBeforeRender(
resolved.yachtName = value; resolved.yachtName = value;
} }
// One audit row per touched field summarising the override intent. if (overrides.clientAddress) {
const auditFields: Array<{ field: string; override: FieldOverrideInput }> = []; const a = overrides.clientAddress;
if (overrides.clientEmail) const resolvedAddr = {
auditFields.push({ field: 'clientEmail', override: overrides.clientEmail }); line1: (a.line1 ?? '').trim(),
if (overrides.clientPhone) line2: (a.line2 ?? '').trim(),
auditFields.push({ field: 'clientPhone', override: overrides.clientPhone }); city: (a.city ?? '').trim(),
if (overrides.yachtName) subdivisionIso: (a.subdivisionIso ?? '').trim(),
auditFields.push({ field: 'yachtName', override: overrides.yachtName }); postalCode: (a.postalCode ?? '').trim(),
countryIso: (a.countryIso ?? '').trim().toUpperCase(),
};
// Treat the address as one logical field — at least line1 + countryIso
// must be present for an EOI to render legally.
if (!resolvedAddr.line1 || !resolvedAddr.countryIso) {
throw new ValidationError('address override requires line1 and countryIso');
}
for (const { field, override } of auditFields) { if (a.useOnlyForThisEoi) {
documentOverrideColumns.overrideClientAddressLine1 = resolvedAddr.line1;
if (resolvedAddr.line2)
documentOverrideColumns.overrideClientAddressLine2 = resolvedAddr.line2;
if (resolvedAddr.city) documentOverrideColumns.overrideClientCity = resolvedAddr.city;
if (resolvedAddr.subdivisionIso)
documentOverrideColumns.overrideClientState = resolvedAddr.subdivisionIso;
if (resolvedAddr.postalCode)
documentOverrideColumns.overrideClientPostalCode = resolvedAddr.postalCode;
documentOverrideColumns.overrideClientCountry = resolvedAddr.countryIso;
} else if (a.setAsDefault) {
// Promote: demote the prior primary, then either update an existing
// address row (when addressId was provided) or insert a fresh one.
await tx
.update(clientAddresses)
.set({ isPrimary: false, updatedAt: new Date() })
.where(and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true)));
if (a.addressId) {
await tx
.update(clientAddresses)
.set({
// client_addresses has no addressLine2 column — concat line1+line2.
streetAddress: resolvedAddr.line2
? `${resolvedAddr.line1}\n${resolvedAddr.line2}`
: resolvedAddr.line1,
city: resolvedAddr.city || null,
subdivisionIso: resolvedAddr.subdivisionIso || null,
postalCode: resolvedAddr.postalCode || null,
countryIso: resolvedAddr.countryIso,
isPrimary: true,
updatedAt: new Date(),
})
.where(
and(eq(clientAddresses.id, a.addressId), eq(clientAddresses.clientId, client.id)),
);
} else {
await tx.insert(clientAddresses).values({
clientId: client.id,
portId: client.portId,
// client_addresses has no addressLine2 column — concat line1+line2.
streetAddress: resolvedAddr.line2
? `${resolvedAddr.line1}\n${resolvedAddr.line2}`
: resolvedAddr.line1,
city: resolvedAddr.city || null,
subdivisionIso: resolvedAddr.subdivisionIso || null,
postalCode: resolvedAddr.postalCode || null,
countryIso: resolvedAddr.countryIso,
isPrimary: true,
source: 'eoi-custom-input',
});
}
// Canonical now matches → documents.override_* stays NULL.
} else {
// Neither flag: persist per-doc + (if no addressId) insert a
// non-primary address row for future reuse.
if (!a.addressId) {
await tx.insert(clientAddresses).values({
clientId: client.id,
portId: client.portId,
streetAddress: resolvedAddr.line2
? `${resolvedAddr.line1}\n${resolvedAddr.line2}`
: resolvedAddr.line1,
city: resolvedAddr.city || null,
subdivisionIso: resolvedAddr.subdivisionIso || null,
postalCode: resolvedAddr.postalCode || null,
countryIso: resolvedAddr.countryIso,
isPrimary: false,
source: 'eoi-custom-input',
});
}
documentOverrideColumns.overrideClientAddressLine1 = resolvedAddr.line1;
if (resolvedAddr.line2)
documentOverrideColumns.overrideClientAddressLine2 = resolvedAddr.line2;
if (resolvedAddr.city) documentOverrideColumns.overrideClientCity = resolvedAddr.city;
if (resolvedAddr.subdivisionIso)
documentOverrideColumns.overrideClientState = resolvedAddr.subdivisionIso;
if (resolvedAddr.postalCode)
documentOverrideColumns.overrideClientPostalCode = resolvedAddr.postalCode;
documentOverrideColumns.overrideClientCountry = resolvedAddr.countryIso;
}
resolved.clientAddress = resolvedAddr;
}
// One audit row per touched field summarising the override intent.
const auditFields: Array<{ field: string; intent: Record<string, unknown> }> = [];
if (overrides.clientEmail)
auditFields.push({
field: 'clientEmail',
intent: {
value: overrides.clientEmail.value.slice(0, 200),
useOnlyForThisEoi: overrides.clientEmail.useOnlyForThisEoi,
setAsDefault: overrides.clientEmail.setAsDefault,
fromContactId: overrides.clientEmail.contactId ?? null,
},
});
if (overrides.clientPhone)
auditFields.push({
field: 'clientPhone',
intent: {
value: overrides.clientPhone.value.slice(0, 200),
useOnlyForThisEoi: overrides.clientPhone.useOnlyForThisEoi,
setAsDefault: overrides.clientPhone.setAsDefault,
fromContactId: overrides.clientPhone.contactId ?? null,
},
});
if (overrides.yachtName)
auditFields.push({
field: 'yachtName',
intent: {
value: overrides.yachtName.value.slice(0, 200),
useOnlyForThisEoi: overrides.yachtName.useOnlyForThisEoi,
setAsDefault: overrides.yachtName.setAsDefault,
},
});
if (overrides.clientAddress)
auditFields.push({
field: 'clientAddress',
intent: {
useOnlyForThisEoi: overrides.clientAddress.useOnlyForThisEoi,
setAsDefault: overrides.clientAddress.setAsDefault,
fromAddressId: overrides.clientAddress.addressId ?? null,
countryIso: overrides.clientAddress.countryIso,
},
});
for (const { field, intent } of auditFields) {
void createAuditLog({ void createAuditLog({
userId: meta.userId, userId: meta.userId,
portId, portId,
action: 'eoi_field_override', action: 'eoi_field_override',
entityType: 'interest', entityType: 'interest',
entityId: interestId, entityId: interestId,
newValue: { newValue: { field, ...intent },
field,
// Truncate to avoid bloating audit rows with long free-text.
value: override.value.slice(0, 200),
useOnlyForThisEoi: override.useOnlyForThisEoi,
setAsDefault: override.setAsDefault,
fromContactId: override.contactId ?? null,
},
ipAddress: meta.ipAddress, ipAddress: meta.ipAddress,
userAgent: meta.userAgent, userAgent: meta.userAgent,
}); });
@@ -278,27 +432,54 @@ export async function persistDocumentOverrides(
meta: AuditMeta, meta: AuditMeta,
): Promise<void> { ): Promise<void> {
const cols = applied.documentOverrideColumns; const cols = applied.documentOverrideColumns;
if (Object.keys(cols).length === 0) return; // Even when cols is empty (every override ran setAsDefault), we still
// need to backfill source_document_id on freshly-inserted contact /
// address / yacht rows whose insertion preceded the document row's
// existence. Skip only when applied is the empty default.
const hasResolved = Object.keys(applied.resolved).length > 0;
if (Object.keys(cols).length > 0) {
await db.update(documents).set(cols).where(eq(documents.id, documentId));
} else if (!hasResolved) {
return;
}
await db.update(documents).set(cols).where(eq(documents.id, documentId)); // Backfill source_document_id on freshly-inserted contact + address +
// yacht rows from this generation pass. Bounded by createdAt < 1 min
// Backfill source_document_id on any client_contacts rows this run // so re-runs don't sweep older orphans. Done outside the override
// inserted. Done outside the override transaction because the // transaction because the document id wasn't known yet at that point.
// document id wasn't known yet at that point.
await db await db
.update(clientContacts) .update(clientContacts)
.set({ sourceDocumentId: documentId }) .set({ sourceDocumentId: documentId })
.where( .where(
and( and(
eq(clientContacts.source, 'eoi-custom-input'), eq(clientContacts.source, 'eoi-custom-input'),
// Backfill only the recently-inserted rows that haven't been
// attributed yet. Bounded by createdAt so re-runs don't sweep up
// older orphans.
sql`${clientContacts.createdAt} > NOW() - INTERVAL '1 minute'`, sql`${clientContacts.createdAt} > NOW() - INTERVAL '1 minute'`,
sql`${clientContacts.sourceDocumentId} IS NULL`, sql`${clientContacts.sourceDocumentId} IS NULL`,
), ),
); );
await db
.update(clientAddresses)
.set({ sourceDocumentId: documentId })
.where(
and(
eq(clientAddresses.source, 'eoi-custom-input'),
sql`${clientAddresses.createdAt} > NOW() - INTERVAL '1 minute'`,
sql`${clientAddresses.sourceDocumentId} IS NULL`,
),
);
// Phase 3 follow-up — yacht spawn from EOI runs BEFORE generateAndSign
// so the yacht row's source_document_id is NULL at insert time. Same
// bounded backfill pattern as contacts.
await db
.update(yachts)
.set({ sourceDocumentId: documentId })
.where(
and(
eq(yachts.source, 'eoi-generated'),
sql`${yachts.createdAt} > NOW() - INTERVAL '1 minute'`,
sql`${yachts.sourceDocumentId} IS NULL`,
),
);
void createAuditLog({ void createAuditLog({
userId: meta.userId, userId: meta.userId,
portId: meta.portId, portId: meta.portId,
@@ -319,7 +500,18 @@ export async function persistDocumentOverrides(
*/ */
export function applyOverridesToContext< export function applyOverridesToContext<
T extends { T extends {
client: { primaryEmail: string | null; primaryPhone: string | null }; client: {
primaryEmail: string | null;
primaryPhone: string | null;
address: {
street: string;
city: string;
subdivision: string;
postalCode: string;
country: string;
countryIso: string;
} | null;
};
yacht: { name: string } | null; yacht: { name: string } | null;
}, },
>(context: T, applied: AppliedOverrides): T { >(context: T, applied: AppliedOverrides): T {
@@ -332,5 +524,26 @@ export function applyOverridesToContext<
if (applied.resolved.yachtName !== undefined && context.yacht) { if (applied.resolved.yachtName !== undefined && context.yacht) {
context.yacht.name = applied.resolved.yachtName; context.yacht.name = applied.resolved.yachtName;
} }
if (applied.resolved.clientAddress) {
const a = applied.resolved.clientAddress;
const combinedStreet = a.line2 ? `${a.line1}\n${a.line2}` : a.line1;
// Strip the country-code prefix from the subdivision ISO so the EOI
// renders the subdivision suffix exactly the way the canonical
// address pipeline does (e.g. 'US-CA' → 'CA').
const subdivisionSuffix = a.subdivisionIso.includes('-')
? a.subdivisionIso.split('-').slice(1).join('-')
: a.subdivisionIso;
context.client.address = {
street: combinedStreet,
city: a.city,
subdivision: subdivisionSuffix,
postalCode: a.postalCode,
// `country` (long name) is only used by the deprecated UI preview
// line; the EOI's Address field renders countryIso, so we set them
// consistently and leave the long-name lookup to the renderer.
country: a.countryIso,
countryIso: a.countryIso,
};
}
return context; return context;
} }

View File

@@ -109,12 +109,29 @@ export const generateAndSignSchema = generateSchema.extend({
* EOI's Length/Width/Draft formValues. The drawer's toggle drives this; * EOI's Length/Width/Draft formValues. The drawer's toggle drives this;
* server defaults to the yacht's `lengthUnit` column when omitted. */ * server defaults to the yacht's `lengthUnit` column when omitted. */
dimensionUnit: z.enum(['ft', 'm']).optional(), dimensionUnit: z.enum(['ft', 'm']).optional(),
/** Phase 3b — optional per-field overrides applied at generation. */ /** Phase 3b/3-follow-up — optional per-field overrides applied at generation. */
overrides: z overrides: z
.object({ .object({
clientEmail: fieldOverrideSchema.optional(), clientEmail: fieldOverrideSchema.optional(),
clientPhone: fieldOverrideSchema.optional(), clientPhone: fieldOverrideSchema.optional(),
yachtName: fieldOverrideSchema.optional(), yachtName: fieldOverrideSchema.optional(),
// Phase 3 follow-up — multi-component address override. Treated as
// one logical "field" with one pair of checkboxes (the dialog
// surfaces it that way, and the side-effects helper applies the
// intent to the whole address rather than per-component).
clientAddress: z
.object({
line1: z.string().max(500).optional(),
line2: z.string().max(500).optional(),
city: z.string().max(200).optional(),
subdivisionIso: z.string().max(20).optional(),
postalCode: z.string().max(50).optional(),
countryIso: z.string().length(2).optional(),
useOnlyForThisEoi: z.boolean().default(false),
setAsDefault: z.boolean().default(false),
addressId: z.string().uuid().optional(),
})
.optional(),
}) })
.optional(), .optional(),
}); });