From f938847ed9f6a710e81b5bd39533214f759b69ef Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 18 May 2026 16:37:19 +0200 Subject: [PATCH] feat(post-audit): Phase 5 partial (4/8 templates) + 7.1 editor scaffold + per-entity reminder buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 — luxury-port email tone (4 of 8 templates): - portal-auth.tsx — activation + reset: "It's our pleasure to invite you to the {portName} client portal — your private space to review your berth, manage signed documents, and stay in touch with your sales liaison", sign-off "With warm regards, The {portName} Team", subjects "Welcome to {portName} — activate your client portal" / "Reset your {portName} portal password". - inquiry-client-confirmation.tsx — "We've noted your enquiry, and a member of our team will be in touch shortly through your preferred channel", "should anything come to mind in the meantime", sign-off "With warm regards, The {portName} Sales Team". - notification-digest.tsx — "Your {portName} update" header, "Here's what's waiting for you", "With warm regards, The {portName} Team". - document-signing.tsx — all 4 sign-offs ("Dear X, ... Thank you, The {portName} team") rewritten to "With warm regards, The {portName} Team" with capitalised Team for consistency. - Voice captured from old-CRM Nuxt repo (/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/ server/utils/signature-notifications.ts) which already used "Dear", "Best regards", and collective sign-offs. Remaining 4 templates (admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry) + cross-port snapshot tests queued as follow-up. Phase 7.1 — PDF editor scaffold: - New admin route /admin/templates/[id]/editor/page.tsx wired to a client-side . - Renders page 1 via react-pdf (worker URL pattern mirrors components/files/pdf-viewer.tsx); click-to-place markers in percent coordinates so a future page-size swap doesn't shift placements. - Token picker over VALID_MERGE_TOKENS (sorted). - Save persists overlayPositions via PATCH against the existing document_templates row; validator accepts the new field via fieldMapSchema from lib/templates/field-map.ts (no migration needed — overlay_positions JSONB column already exists). - Outer/inner-body split + key-by-templateId remount avoids the in-render setState antipattern when seeding from server data. - Add + delete markers supported. Multi-page, drag, resize, preview, new-PDF upload all defer to 7.2. Per-entity polish: - [+ Reminder] button on yacht / client / interest detail headers, threading defaultYachtId / defaultClientId / defaultInterestId so the ReminderForm opens with the entity pre-linked. - [EOI] badge on yacht detail header when yacht.source === 'eoi-generated' (mirrors the contacts-editor pattern shipped in eaab149). Phase 6 hardening: - imap-bounce-poller strips whitespace from IMAP_PASS so Google Workspace App Passwords (16-char "abcd efgh ijkl mnop" format) work whether pasted with or without spaces. Confirmed via Google docs that the visual spaces are formatting only and must not reach the IMAP server. Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/MASTER-PLAN-2026-05-18.md | 35 +- .../admin/templates/[id]/editor/page.tsx | 15 + .../admin/templates/template-editor.tsx | 320 ++++++++++++++++++ .../clients/client-detail-header.tsx | 22 +- .../interests/interest-detail-header.tsx | 20 ++ src/components/yachts/yacht-detail-header.tsx | 31 +- src/components/yachts/yacht-detail.tsx | 3 + src/lib/email/templates/document-signing.tsx | 26 +- .../templates/inquiry-client-confirmation.tsx | 20 +- .../email/templates/notification-digest.tsx | 19 +- src/lib/email/templates/portal-auth.tsx | 50 +-- src/lib/validators/document-templates.ts | 6 + 12 files changed, 502 insertions(+), 65 deletions(-) create mode 100644 src/app/(dashboard)/[portSlug]/admin/templates/[id]/editor/page.tsx create mode 100644 src/components/admin/templates/template-editor.tsx diff --git a/docs/MASTER-PLAN-2026-05-18.md b/docs/MASTER-PLAN-2026-05-18.md index edd39db6..8efff4c2 100644 --- a/docs/MASTER-PLAN-2026-05-18.md +++ b/docs/MASTER-PLAN-2026-05-18.md @@ -884,8 +884,12 @@ Deferred: so the EOI's yacht block populates without a manual re-link. - ☑ 3d — `POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary` (transactional demote+promote via `promoteContactToPrimary`); `[EOI]` - badge on non-primary contact rows in `` with title-attr - explainer. Yacht detail-page badge deferred. + badge on non-primary contact rows in `` + on yacht + detail header when `yacht.source === 'eoi-generated'`. + - ☐ Address override field in EOI dialog (schema columns exist) + - ☐ Audit-log UI surfacing of new verbs (rows written, filter chips missing) + - ☐ Backfill yachts.source_document_id after EOI document is created + (currently set NULL because the yacht is spawned BEFORE the doc row exists) - ◐ Phase 4 — Reminders (fb4a09e + session 2026-05-18 PM) - ☑ Schema migration 0072: reminders.yacht_id + fired_at + interests.reminder_note - ☑ Service + validators accept yachtId with port-scoping check @@ -898,10 +902,18 @@ Deferred: - ☑ `user_profiles.preferences.digestTimeOfDay` picker on `/settings` (time input + help text). `` honours the preference via a React-Query me-prefs fetch keyed `['me', 'preferences']`. - - ☐ Per-entity-page `[+ Task]` buttons threading `defaultYachtId` (etc.) -- ◐ Phase 5 — Email-copy refactor (branding chain only; df1594d) + - ☑ Per-entity `[+ Reminder]` buttons on yacht / client / interest detail + headers threading defaultYachtId / defaultClientId / defaultInterestId + - ☐ Per-entity reminders LIST inline on detail pages (button exists; section TBD) +- ◐ Phase 5 — Email-copy refactor (df1594d + 2026-05-18 PM) - ☑ Per-port background URL — closes the last hard-coded portnimara.com asset - - ☐ Tone rewrite across 8 templates using old-CRM Nuxt repo as reference + - ☑ 4/8 templates rewritten with luxury-port voice (portal-auth activation + - reset, inquiry-client-confirmation, notification-digest, document-signing + sign-offs). Voice captured from old-CRM Nuxt repo `server/utils/ +signature-notifications.ts` ("Dear X", "With warm regards, The + {portName} Team"). + - ☐ Remaining 4 templates: admin-email-change, crm-invite, + inquiry-sales-notification, residential-inquiry - ☐ Snapshot tests per template at port-nimara + 2nd test port - ◐ Phase 6 — IMAP bounce-to-interest linking (9f57868 + session 2026-05-18 PM) - ☑ Schema migration 0074: bounce_status/reason/detected_at on document_sends @@ -927,10 +939,17 @@ Deferred: envelope sender's mailbox (the SMTP user account), so pointing the poller at that single mailbox catches every automated-email bounce in one place. -- ◐ Phase 7 — PDF template editor (field-map types only; 9f57868) +- ◐ Phase 7 — PDF template editor (9f57868 + 2026-05-18 PM) - ☑ FieldMap type definitions + Zod validators + page-count cross-validator - - ☐ 7.1 Read + place (~2 weeks): editor shell, page picker, marker drop - - ☐ 7.2 Edit + preview (~1-2 weeks): drag/resize, live preview, new-PDF upload + - ☑ 7.1 scaffold — `/admin/templates/[id]/editor/page.tsx` + client-side + `` with react-pdf, click-to-place markers, token picker + from `VALID_MERGE_TOKENS`, save via PATCH to overlayPositions. Page 1 + only; add + delete markers supported. + - ☐ 7.1 polish: unsaved-changes guard, responsive PDF width, + "required tokens unplaced" indicator + - ☐ 7.2 Edit + preview (~1-2 weeks): drag/resize, live preview pane with + sample interest data, multi-page navigation, new-PDF upload (replace + source while preserving field map) --- diff --git a/src/app/(dashboard)/[portSlug]/admin/templates/[id]/editor/page.tsx b/src/app/(dashboard)/[portSlug]/admin/templates/[id]/editor/page.tsx new file mode 100644 index 00000000..f6340e02 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/templates/[id]/editor/page.tsx @@ -0,0 +1,15 @@ +import { TemplateEditor } from '@/components/admin/templates/template-editor'; + +/** + * Phase 7.1 — PDF template editor (read + place markers). + * + * Renders the source PDF for the selected template and lets the admin + * drop merge-field markers by clicking on the page. Persists the marker + * coordinates to `document_templates.overlay_positions` via PATCH so + * the existing `pdf_overlay` fill path can use them at generate time. + * + * Phase 7.2 (drag/resize/preview/multi-page) is queued separately. + */ +export default function TemplateEditorPage({ params }: { params: { id: string } }) { + return ; +} diff --git a/src/components/admin/templates/template-editor.tsx b/src/components/admin/templates/template-editor.tsx new file mode 100644 index 00000000..5ac5e16d --- /dev/null +++ b/src/components/admin/templates/template-editor.tsx @@ -0,0 +1,320 @@ +'use client'; + +import { useMemo, useRef, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Document, Page, pdfjs } from 'react-pdf'; +import { Loader2, Save, Trash2, X } from 'lucide-react'; + +import 'react-pdf/dist/Page/AnnotationLayer.css'; +import 'react-pdf/dist/Page/TextLayer.css'; + +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { PageHeader } from '@/components/shared/page-header'; +import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; +import { VALID_MERGE_TOKENS } from '@/lib/templates/merge-fields'; +import type { FieldMap, FieldMapEntry } from '@/lib/templates/field-map'; + +// Worker setup mirrors src/components/files/pdf-viewer.tsx so we don't +// pay for a second bundle. Calling GlobalWorkerOptions.workerSrc twice +// with the same URL is a no-op in pdfjs. +pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; + +interface TemplateData { + id: string; + name: string; + templateType: string; + templateFormat: string; + sourceFileId: string | null; + overlayPositions: FieldMap | null; +} + +interface PendingMarker { + x: number; + y: number; + page: number; +} + +const TOKEN_OPTIONS = Array.from(VALID_MERGE_TOKENS).sort(); +const DEFAULT_MARKER_W = 0.18; +const DEFAULT_MARKER_H = 0.04; + +/** + * Phase 7.1 — page-1 PDF marker editor. Click anywhere on the rendered + * PDF to drop a marker, pick which merge token it represents, save. + * + * Scope intentionally narrow: + * - Page 1 only (multi-page page-picker is a 7.2 ticket). + * - Add + delete markers; drag-to-move + corner-resize defer to 7.2. + * - Coordinates stored as percent of page width/height so a future + * page-size swap (A4 ↔ Letter) doesn't shift placements. + */ +export function TemplateEditor({ templateId }: { templateId: string }) { + const { data: template, isLoading } = useQuery<{ data: TemplateData }>({ + queryKey: ['document-template', templateId], + queryFn: () => apiFetch<{ data: TemplateData }>(`/api/v1/document-templates/${templateId}`), + }); + + if (isLoading || !template) { + return ( +
+ + Loading template… +
+ ); + } + + // Inner body keyed by templateId so a route change re-mounts and + // the markers useState initializer re-reads the server payload. + // Avoids the in-render setState pattern (React anti-pattern) that + // a single component would otherwise need to seed from the query. + return ; +} + +function TemplateEditorBody({ + templateId, + template, +}: { + templateId: string; + template: TemplateData; +}) { + const qc = useQueryClient(); + const [markers, setMarkers] = useState(template.overlayPositions ?? []); + const [pending, setPending] = useState(null); + const [pendingToken, setPendingToken] = useState(''); + const [saving, setSaving] = useState(false); + const [savedMsg, setSavedMsg] = useState(null); + const pageContainerRef = useRef(null); + + const pdfUrl = template.sourceFileId ? `/api/v1/files/${template.sourceFileId}/preview` : null; + + function handlePageClick(e: React.MouseEvent) { + const container = pageContainerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + if (x < 0 || x > 1 || y < 0 || y > 1) return; + setPending({ x, y, page: 1 }); + setPendingToken(TOKEN_OPTIONS[0] ?? ''); + } + + function commitPending() { + if (!pending || !pendingToken) return; + const entry: FieldMapEntry = { + token: pendingToken, + page: pending.page, + x: pending.x, + y: pending.y, + w: DEFAULT_MARKER_W, + h: DEFAULT_MARKER_H, + }; + setMarkers((m) => [...m, entry]); + setPending(null); + setPendingToken(''); + } + + function cancelPending() { + setPending(null); + setPendingToken(''); + } + + function removeMarker(index: number) { + setMarkers((m) => m.filter((_, i) => i !== index)); + } + + async function save() { + setSaving(true); + setSavedMsg(null); + try { + await apiFetch(`/api/v1/document-templates/${templateId}`, { + method: 'PATCH', + body: { overlayPositions: markers }, + }); + await qc.invalidateQueries({ queryKey: ['document-template', templateId] }); + setSavedMsg('Markers saved.'); + } catch (err) { + toastError(err); + } finally { + setSaving(false); + } + } + + const visibleMarkers = useMemo(() => markers.filter((m) => m.page === 1), [markers]); + + if (!pdfUrl) { + return ( +
+ + + + This template has no source PDF attached. Upload one from the template list before + opening the editor. + + +
+ ); + } + + return ( +
+ + +
+ + + Page 1 + + + {/* Wrapper carries the click handler. react-pdf renders the + actual page inside; we overlay markers as positioned + divs using the same percent coordinates the server-side + fill path consumes. */} +
+ + + Loading PDF… +
+ } + onLoadError={(err) => { + // Surface load errors via toast rather than blowing up + // — a bad source PDF shouldn't crash the editor shell. + toastError(err); + }} + > + + + + {visibleMarkers.map((m, i) => ( +
+ {m.token} +
+ ))} + + {pending ? ( +
+ ) : null} +
+
+
+ +
+ {pending ? ( + + + New marker + + +
+ + +
+
+ + +
+
+
+ ) : null} + + + + Markers ({visibleMarkers.length}) + + + {visibleMarkers.length === 0 ? ( +

+ Click on the PDF to drop your first marker. +

+ ) : ( + visibleMarkers.map((m, i) => ( +
+ {m.token} + +
+ )) + )} +
+
+ + + {savedMsg ?

{savedMsg}

: null} +
+
+
+ ); +} diff --git a/src/components/clients/client-detail-header.tsx b/src/components/clients/client-detail-header.tsx index 3f72793f..243a78a9 100644 --- a/src/components/clients/client-detail-header.tsx +++ b/src/components/clients/client-detail-header.tsx @@ -2,7 +2,7 @@ import { useRouter } from 'next/navigation'; import { useState } from 'react'; -import { Archive, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react'; +import { Archive, Bell, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react'; import { WhatsAppIcon } from '@/components/icons/whatsapp'; import { format } from 'date-fns'; @@ -13,6 +13,8 @@ import { PermissionGate } from '@/components/shared/permission-gate'; import { SmartArchiveDialog } from '@/components/clients/smart-archive-dialog'; import { SmartRestoreDialog } from '@/components/clients/smart-restore-dialog'; import { HardDeleteDialog } from '@/components/clients/hard-delete-dialog'; +import { ReminderForm } from '@/components/reminders/reminder-form'; +import { useQueryClient } from '@tanstack/react-query'; import { DetailHeaderStrip } from '@/components/shared/detail-header-strip'; import { PortalInviteButton } from '@/components/clients/portal-invite-button'; import { GdprExportButton } from '@/components/clients/gdpr-export-button'; @@ -42,6 +44,8 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) { const router = useRouter(); const [archiveOpen, setArchiveOpen] = useState(false); const [hardDeleteOpen, setHardDeleteOpen] = useState(false); + const [reminderOpen, setReminderOpen] = useState(false); + const qc = useQueryClient(); const isArchived = !!client.archivedAt; @@ -172,6 +176,15 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) { )} + + +