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:
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
80
src/app/api/v1/document-templates/[id]/preview/route.ts
Normal file
80
src/app/api/v1/document-templates/[id]/preview/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
114
src/app/api/v1/document-templates/[id]/source-pdf/route.ts
Normal file
114
src/app/api/v1/document-templates/[id]/source-pdf/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -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 } });
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
134
src/components/reminders/reminders-inline.tsx
Normal file
134
src/components/reminders/reminders-inline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'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't expecting this change, contact your administrator immediately. Your old
|
If this change wasn'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');
|
||||||
|
|||||||
@@ -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've been invited to the {portName} CRM as a {role}. Click the button below to set
|
You'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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'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'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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user