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) { )} + + +