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