Spaces:
Sleeping
Sleeping
| import { useEffect, useMemo, useRef, useState } from "react"; | |
| import { Document, Page, pdfjs } from "react-pdf"; | |
| import workerSrc from "pdfjs-dist/build/pdf.worker.min.mjs?url"; | |
| import "react-pdf/dist/Page/AnnotationLayer.css"; | |
| import "react-pdf/dist/Page/TextLayer.css"; | |
| pdfjs.GlobalWorkerOptions.workerSrc = workerSrc; | |
| /** | |
| * FIELDS: | |
| * - scalar fields use: anchor + value (stored as offset from anchor) | |
| * - items uses: table + header + anchors + columns (+ row_height_hint) | |
| */ | |
| const FIELDS = [ | |
| { fieldId: "facility_organization", label: "Facility / Organization", type: "entity" }, | |
| { fieldId: "case_location", label: "Case Location / Address", type: "text" }, | |
| { fieldId: "vendor", label: "Vendor", type: "entity" }, | |
| { fieldId: "physician_name", label: "Physician Name", type: "person" }, | |
| { fieldId: "date_of_surgery", label: "Date of Surgery", type: "date" }, | |
| { fieldId: "items", label: "Items / Line Items", type: "table" }, | |
| ]; | |
| // Table config keys (trainer only) | |
| const TABLE_ANCHORS = [ | |
| { key: "item_number", expected_text: "Item Number" }, | |
| { key: "description", expected_text: "Description" }, | |
| { key: "qty", expected_text: "Qty" }, | |
| ]; | |
| const TABLE_COLUMNS = [ | |
| { key: "item_number", label: "Item Number" }, | |
| { key: "description", label: "Description" }, | |
| { key: "qty", label: "Qty" }, | |
| ]; | |
| /** ---------- normalization helpers ---------- */ | |
| function normBBox(b, w, h) { | |
| return { | |
| x: +(b.x / w).toFixed(6), | |
| y: +(b.y / h).toFixed(6), | |
| w: +(b.w / w).toFixed(6), | |
| h: +(b.h / h).toFixed(6), | |
| }; | |
| } | |
| function normOffset(dx, dy, w, h, pageW, pageH) { | |
| return { | |
| dx: +(dx / pageW).toFixed(6), | |
| dy: +(dy / pageH).toFixed(6), | |
| w: +(w / pageW).toFixed(6), | |
| h: +(h / pageH).toFixed(6), | |
| }; | |
| } | |
| function applyOffset(anchorBox, offsetPx) { | |
| if (!anchorBox || !offsetPx) return null; | |
| return { | |
| x: anchorBox.x + offsetPx.dx, | |
| y: anchorBox.y + offsetPx.dy, | |
| w: offsetPx.w, | |
| h: offsetPx.h, | |
| }; | |
| } | |
| function toRelativeBBox(childAbs, parentAbs) { | |
| if (!childAbs || !parentAbs) return null; | |
| return { | |
| x: childAbs.x - parentAbs.x, | |
| y: childAbs.y - parentAbs.y, | |
| w: childAbs.w, | |
| h: childAbs.h, | |
| }; | |
| } | |
| function clampBBoxToParent(childAbs, parentAbs) { | |
| if (!childAbs || !parentAbs) return childAbs; | |
| const x = Math.max(parentAbs.x, Math.min(childAbs.x, parentAbs.x + parentAbs.w)); | |
| const y = Math.max(parentAbs.y, Math.min(childAbs.y, parentAbs.y + parentAbs.h)); | |
| const x2 = Math.max(parentAbs.x, Math.min(childAbs.x + childAbs.w, parentAbs.x + parentAbs.w)); | |
| const y2 = Math.max(parentAbs.y, Math.min(childAbs.y + childAbs.h, parentAbs.y + parentAbs.h)); | |
| return { x, y, w: Math.max(0, x2 - x), h: Math.max(0, y2 - y) }; | |
| } | |
| /** ---------- UI styles ---------- */ | |
| const UI = { | |
| panelBg: "#141414", | |
| panelText: "#EDEDED", | |
| subtle: "#A7A7A7", | |
| border: "rgba(255,255,255,0.10)", | |
| btnBase: { | |
| width: "100%", | |
| padding: "10px 12px", | |
| borderRadius: 10, | |
| border: "1px solid rgba(255,255,255,0.15)", | |
| fontWeight: 700, | |
| cursor: "pointer", | |
| userSelect: "none", | |
| }, | |
| greenDark: { background: "#0f7a36", color: "#fff" }, | |
| blue: { background: "#2563eb", color: "#fff" }, | |
| green2: { background: "#22c55e", color: "#fff" }, | |
| purple: { background: "#7c3aed", color: "#fff" }, | |
| orange: { background: "#f59e0b", color: "#111" }, | |
| teal: { background: "#14b8a6", color: "#081016" }, | |
| gray: { background: "rgba(255,255,255,0.08)", color: "#fff" }, | |
| pill: (active, colorObj) => ({ | |
| padding: "8px 10px", | |
| borderRadius: 10, | |
| border: active ? "1px solid rgba(255,255,255,0.35)" : "1px solid rgba(255,255,255,0.15)", | |
| ...colorObj, | |
| opacity: active ? 1 : 0.65, | |
| cursor: "pointer", | |
| flex: 1, | |
| fontWeight: 800, | |
| textAlign: "center", | |
| }), | |
| label: { fontSize: 12, color: "#CFCFCF", marginBottom: 6, fontWeight: 700 }, | |
| input: { | |
| width: "100%", | |
| padding: 10, | |
| borderRadius: 10, | |
| border: "1px solid rgba(255,255,255,0.12)", | |
| background: "rgba(255,255,255,0.06)", | |
| color: "#fff", | |
| outline: "none", | |
| }, | |
| select: { | |
| width: "100%", | |
| padding: 10, | |
| borderRadius: 10, | |
| border: "1px solid rgba(255,255,255,0.12)", | |
| background: "rgba(255,255,255,0.06)", | |
| color: "#fff", | |
| outline: "none", | |
| }, | |
| }; | |
| function isTableField(field) { | |
| return field?.type === "table"; | |
| } | |
| const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8000"; | |
| export default function App() { | |
| // PDF comes from pdf_id in URL | |
| const [pdfId, setPdfId] = useState(null); | |
| const [pdfUrl, setPdfUrl] = useState(null); | |
| const [pdfName, setPdfName] = useState(null); | |
| // NEW: template id comes from URL OR manual input | |
| const [templateId, setTemplateId] = useState(""); | |
| const [pageSize, setPageSize] = useState({ w: 0, h: 0 }); | |
| const [selectedFieldId, setSelectedFieldId] = useState(FIELDS[0].fieldId); | |
| const [scalarMode, setScalarMode] = useState("anchor"); // anchor | value | |
| const [tableMode, setTableMode] = useState("table"); // table | header | anchor | column | |
| const [selectedTableAnchorKey, setSelectedTableAnchorKey] = useState(TABLE_ANCHORS[0].key); | |
| const [selectedColumnKey, setSelectedColumnKey] = useState(TABLE_COLUMNS[0].key); | |
| const [offsetMode, setOffsetMode] = useState(true); | |
| const [rowHeightHintPx, setRowHeightHintPx] = useState(0); | |
| const [scale, setScale] = useState(1); | |
| const [pan, setPan] = useState({ x: 0, y: 0 }); | |
| const [spaceDown, setSpaceDown] = useState(false); | |
| const [isPanning, setIsPanning] = useState(false); | |
| const panStartRef = useRef(null); | |
| const stageRef = useRef(null); | |
| const [draft, setDraft] = useState(null); | |
| const dragStartRef = useRef(null); | |
| const overlayRef = useRef(null); | |
| const [showPreview, setShowPreview] = useState(true); | |
| const [sendStatus, setSendStatus] = useState({ state: "idle", msg: "" }); // idle|sending|ok|err | |
| const [boxes, setBoxes] = useState(() => { | |
| const o = {}; | |
| for (const f of FIELDS) { | |
| if (f.type === "table") { | |
| const anchorsAbs = {}; | |
| for (const a of TABLE_ANCHORS) anchorsAbs[a.key] = null; | |
| const columnsAbs = {}; | |
| for (const c of TABLE_COLUMNS) columnsAbs[c.key] = null; | |
| o[f.fieldId] = { | |
| kind: "table", | |
| tableAbs: null, | |
| headerAbs: null, | |
| anchorsAbs, | |
| columnsAbs, | |
| }; | |
| } else { | |
| o[f.fieldId] = { | |
| kind: "scalar", | |
| anchor: null, | |
| valueAbs: null, | |
| valueOffsetPx: null, | |
| }; | |
| } | |
| } | |
| return o; | |
| }); | |
| // Spacebar handling for pan | |
| useEffect(() => { | |
| function onKeyDown(e) { | |
| if (e.code === "Space") { | |
| e.preventDefault(); | |
| setSpaceDown(true); | |
| } | |
| } | |
| function onKeyUp(e) { | |
| if (e.code === "Space") { | |
| e.preventDefault(); | |
| setSpaceDown(false); | |
| setIsPanning(false); | |
| panStartRef.current = null; | |
| } | |
| } | |
| window.addEventListener("keydown", onKeyDown, { passive: false }); | |
| window.addEventListener("keyup", onKeyUp, { passive: false }); | |
| return () => { | |
| window.removeEventListener("keydown", onKeyDown); | |
| window.removeEventListener("keyup", onKeyUp); | |
| }; | |
| }, []); | |
| // Read pdf_id + template_id from URL and load PDF from backend | |
| useEffect(() => { | |
| const sp = new URLSearchParams(window.location.search); | |
| console.log("URL", window.location.href); | |
| console.log("query", Object.fromEntries(sp.entries())); | |
| const id = sp.get("pdf_id") || sp.get("id") || sp.get("pdfId") || sp.get("pdf"); | |
| setPdfId(id || null); | |
| const tid = sp.get("template_id"); | |
| setTemplateId((tid || "").trim()); | |
| if (!id) { | |
| setPdfUrl(null); | |
| setPdfName(null); | |
| return; | |
| } | |
| let alive = true; | |
| let objectUrl = null; | |
| async function loadPdf() { | |
| try { | |
| const resp = await fetch(`${API_BASE}/api/pdf/${encodeURIComponent(id)}`); | |
| if (!resp.ok) throw new Error(`Failed to fetch PDF (${resp.status})`); | |
| const name = resp.headers.get("X-PDF-Name") || `document_${id}.pdf`; | |
| const blob = await resp.blob(); | |
| objectUrl = URL.createObjectURL(blob); | |
| if (!alive) return; | |
| setPdfUrl(objectUrl); | |
| setPdfName(name); | |
| setScale(1); | |
| setPan({ x: 0, y: 0 }); | |
| } catch (e) { | |
| console.error(e); | |
| if (!alive) return; | |
| setPdfUrl(null); | |
| setPdfName(null); | |
| } | |
| } | |
| loadPdf(); | |
| return () => { | |
| alive = false; | |
| if (objectUrl) URL.revokeObjectURL(objectUrl); | |
| }; | |
| }, []); | |
| const selectedField = useMemo( | |
| () => FIELDS.find((f) => f.fieldId === selectedFieldId), | |
| [selectedFieldId] | |
| ); | |
| const selectedIsTable = isTableField(selectedField); | |
| function getPoint(e) { | |
| const el = overlayRef.current; | |
| if (!el) return null; | |
| const r = el.getBoundingClientRect(); | |
| const mx = e.clientX - r.left; | |
| const my = e.clientY - r.top; | |
| const x = mx / scale; | |
| const y = my / scale; | |
| return { | |
| x: Math.max(0, Math.min(x, pageSize.w)), | |
| y: Math.max(0, Math.min(y, pageSize.h)), | |
| }; | |
| } | |
| function onMouseDown(e) { | |
| if (!pdfUrl || !pageSize.w || !pageSize.h) return; | |
| if (spaceDown) { | |
| setIsPanning(true); | |
| panStartRef.current = { | |
| mouseX: e.clientX, | |
| mouseY: e.clientY, | |
| panX: pan.x, | |
| panY: pan.y, | |
| }; | |
| return; | |
| } | |
| const p = getPoint(e); | |
| if (!p) return; | |
| dragStartRef.current = p; | |
| setDraft({ x: p.x, y: p.y, w: 0, h: 0 }); | |
| } | |
| function onMouseMove(e) { | |
| if (isPanning && panStartRef.current) { | |
| const s = panStartRef.current; | |
| const dx = e.clientX - s.mouseX; | |
| const dy = e.clientY - s.mouseY; | |
| setPan({ x: s.panX + dx, y: s.panY + dy }); | |
| return; | |
| } | |
| if (!dragStartRef.current) return; | |
| const p = getPoint(e); | |
| if (!p) return; | |
| const s = dragStartRef.current; | |
| const x1 = Math.min(s.x, p.x); | |
| const y1 = Math.min(s.y, p.y); | |
| const x2 = Math.max(s.x, p.x); | |
| const y2 = Math.max(s.y, p.y); | |
| setDraft({ x: x1, y: y1, w: x2 - x1, h: y2 - y1 }); | |
| } | |
| function onMouseUp() { | |
| if (isPanning) { | |
| setIsPanning(false); | |
| panStartRef.current = null; | |
| return; | |
| } | |
| if (!dragStartRef.current) return; | |
| dragStartRef.current = null; | |
| if (!draft || draft.w < 8 || draft.h < 8) { | |
| setDraft(null); | |
| return; | |
| } | |
| setBoxes((prev) => { | |
| const next = structuredClone(prev); | |
| const st = next[selectedFieldId]; | |
| if (!st) return prev; | |
| if (selectedIsTable && st.kind === "table") { | |
| if (tableMode === "table") { | |
| st.tableAbs = draft; | |
| if (st.headerAbs) st.headerAbs = clampBBoxToParent(st.headerAbs, st.tableAbs); | |
| for (const k of Object.keys(st.anchorsAbs)) { | |
| if (st.anchorsAbs[k]) st.anchorsAbs[k] = clampBBoxToParent(st.anchorsAbs[k], st.tableAbs); | |
| } | |
| for (const k of Object.keys(st.columnsAbs)) { | |
| if (st.columnsAbs[k]) st.columnsAbs[k] = clampBBoxToParent(st.columnsAbs[k], st.tableAbs); | |
| } | |
| } | |
| if (tableMode === "header") { | |
| st.headerAbs = st.tableAbs ? clampBBoxToParent(draft, st.tableAbs) : draft; | |
| } | |
| if (tableMode === "anchor") { | |
| const key = selectedTableAnchorKey; | |
| st.anchorsAbs[key] = st.tableAbs ? clampBBoxToParent(draft, st.tableAbs) : draft; | |
| } | |
| if (tableMode === "column") { | |
| const key = selectedColumnKey; | |
| st.columnsAbs[key] = st.tableAbs ? clampBBoxToParent(draft, st.tableAbs) : draft; | |
| } | |
| next[selectedFieldId] = st; | |
| return next; | |
| } | |
| if (!selectedIsTable && st.kind === "scalar") { | |
| if (scalarMode === "anchor") { | |
| st.anchor = draft; | |
| } else { | |
| if (offsetMode) { | |
| if (!st.anchor) { | |
| st.valueAbs = draft; | |
| st.valueOffsetPx = null; | |
| } else { | |
| st.valueOffsetPx = { | |
| dx: draft.x - st.anchor.x, | |
| dy: draft.y - st.anchor.y, | |
| w: draft.w, | |
| h: draft.h, | |
| }; | |
| st.valueAbs = null; | |
| } | |
| } else { | |
| st.valueAbs = draft; | |
| st.valueOffsetPx = null; | |
| } | |
| } | |
| next[selectedFieldId] = st; | |
| return next; | |
| } | |
| return prev; | |
| }); | |
| setDraft(null); | |
| } | |
| function clearCurrent(kind) { | |
| setBoxes((prev) => { | |
| const next = structuredClone(prev); | |
| const st = next[selectedFieldId]; | |
| if (!st) return prev; | |
| if (selectedIsTable && st.kind === "table") { | |
| if (kind === "table") st.tableAbs = null; | |
| if (kind === "header") st.headerAbs = null; | |
| if (kind === "anchor") st.anchorsAbs[selectedTableAnchorKey] = null; | |
| if (kind === "column") st.columnsAbs[selectedColumnKey] = null; | |
| return next; | |
| } | |
| if (!selectedIsTable && st.kind === "scalar") { | |
| if (kind === "anchor") st.anchor = null; | |
| if (kind === "value") { | |
| st.valueAbs = null; | |
| st.valueOffsetPx = null; | |
| } | |
| return next; | |
| } | |
| return prev; | |
| }); | |
| } | |
| function getScalarDisplayValueBox(fieldId) { | |
| const st = boxes[fieldId]; | |
| if (!st || st.kind !== "scalar") return null; | |
| if (!offsetMode) return st.valueAbs; | |
| return applyOffset(st.anchor, st.valueOffsetPx); | |
| } | |
| function buildConfig() { | |
| if (!pageSize.w || !pageSize.h) return null; | |
| return { | |
| form_id: pdfId ? `trainer_${pdfId}` : "trainer_form_v1", | |
| version: 3, | |
| page: 1, | |
| scalar_value_region_mode: offsetMode ? "offset_from_anchor_v1" : "absolute_bbox_v1", | |
| fields: FIELDS.map((f) => { | |
| const st = boxes[f.fieldId]; | |
| if (f.type === "table" && st?.kind === "table") { | |
| const tableAbs = st.tableAbs; | |
| const headerAbs = st.headerAbs; | |
| const table_bbox_norm = tableAbs ? normBBox(tableAbs, pageSize.w, pageSize.h) : null; | |
| const header_bbox_norm = headerAbs ? normBBox(headerAbs, pageSize.w, pageSize.h) : null; | |
| const row_height_hint_norm = | |
| rowHeightHintPx > 0 ? +(rowHeightHintPx / pageSize.h).toFixed(6) : null; | |
| const table_anchors = TABLE_ANCHORS.map((a) => { | |
| const abs = st.anchorsAbs[a.key]; | |
| return { | |
| key: a.key, | |
| expected_text: a.expected_text, | |
| bbox_norm: abs ? normBBox(abs, pageSize.w, pageSize.h) : null, | |
| }; | |
| }); | |
| const columns = | |
| tableAbs | |
| ? TABLE_COLUMNS.map((c) => { | |
| const abs = st.columnsAbs[c.key]; | |
| const rel = abs ? toRelativeBBox(abs, tableAbs) : null; | |
| return { | |
| key: c.key, | |
| label: c.label, | |
| bbox_rel_norm: rel ? normBBox(rel, tableAbs.w, tableAbs.h) : null, | |
| }; | |
| }) | |
| : TABLE_COLUMNS.map((c) => ({ | |
| key: c.key, | |
| label: c.label, | |
| bbox_rel_norm: null, | |
| })); | |
| return { | |
| field_id: f.fieldId, | |
| label: f.label, | |
| type: f.type, | |
| table_bbox_norm, | |
| header_bbox_norm, | |
| row_height_hint_norm, | |
| columns, | |
| table_anchors, | |
| notes: "Anchors are used at runtime to localize table/header/columns under drift.", | |
| }; | |
| } | |
| if (st?.kind === "scalar") { | |
| const anchorNorm = st.anchor ? normBBox(st.anchor, pageSize.w, pageSize.h) : null; | |
| const valueAbsNorm = st.valueAbs ? normBBox(st.valueAbs, pageSize.w, pageSize.h) : null; | |
| const valueOffsetNorm = | |
| st.valueOffsetPx && offsetMode | |
| ? normOffset( | |
| st.valueOffsetPx.dx, | |
| st.valueOffsetPx.dy, | |
| st.valueOffsetPx.w, | |
| st.valueOffsetPx.h, | |
| pageSize.w, | |
| pageSize.h | |
| ) | |
| : null; | |
| return { | |
| field_id: f.fieldId, | |
| label: f.label, | |
| type: f.type, | |
| anchor_bbox_norm: anchorNorm, | |
| value_bbox_norm: offsetMode ? null : valueAbsNorm, | |
| value_offset_norm: offsetMode ? valueOffsetNorm : null, | |
| }; | |
| } | |
| return { field_id: f.fieldId, label: f.label, type: f.type }; | |
| }), | |
| notes: | |
| "Trainer exports config only. Runtime should localize anchors then apply offsets/table mappings to extract values + line items.", | |
| }; | |
| } | |
| const previewConfig = useMemo( | |
| () => buildConfig(), | |
| [pageSize.w, pageSize.h, boxes, offsetMode, rowHeightHintPx, pdfId] | |
| ); | |
| async function saveConfig() { | |
| if (!pdfId) { | |
| setSendStatus({ state: "err", msg: "Missing pdf_id in URL." }); | |
| return; | |
| } | |
| const tid = (templateId || "").trim(); | |
| if (!tid) { | |
| setSendStatus({ state: "err", msg: "Missing template_id. Add ?template_id=... or type it in the left panel." }); | |
| return; | |
| } | |
| const cfg = buildConfig(); | |
| if (!cfg) { | |
| setSendStatus({ state: "err", msg: "Config not ready. PDF not loaded or page size missing." }); | |
| return; | |
| } | |
| setSendStatus({ state: "sending", msg: "Saving..." }); | |
| try { | |
| const resp = await fetch(`${API_BASE}/api/send-config`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ pdf_id: pdfId, template_id: tid, config: cfg }), | |
| }); | |
| if (!resp.ok) { | |
| const t = await resp.text(); | |
| throw new Error(`Save failed (${resp.status}): ${t}`); | |
| } | |
| const data = await resp.json().catch(() => ({})); | |
| setSendStatus({ state: "ok", msg: data?.message || "Saved." }); | |
| } catch (e) { | |
| console.error(e); | |
| setSendStatus({ state: "err", msg: e.message || "Save failed." }); | |
| } | |
| } | |
| const drawModeTitle = useMemo(() => { | |
| if (!selectedField) return ""; | |
| if (selectedIsTable) return "Draw Mode (Table)"; | |
| return "Draw Mode (Scalar)"; | |
| }, [selectedField, selectedIsTable]); | |
| const activeModeLabel = useMemo(() => { | |
| if (!selectedField) return ""; | |
| if (!selectedIsTable) return scalarMode === "anchor" ? "Anchor (Blue)" : "Value (Green)"; | |
| if (tableMode === "table") return "Table Region (Purple)"; | |
| if (tableMode === "header") return "Header Row (Orange)"; | |
| if (tableMode === "anchor") return `Table Anchor (Blue): ${selectedTableAnchorKey}`; | |
| if (tableMode === "column") return `Column (Teal): ${selectedColumnKey}`; | |
| return ""; | |
| }, [selectedField, selectedIsTable, scalarMode, tableMode, selectedTableAnchorKey, selectedColumnKey]); | |
| return ( | |
| <div style={{ display: "grid", gridTemplateColumns: "380px 1fr 420px", height: "100vh" }}> | |
| {/* Left panel */} | |
| <div | |
| style={{ | |
| padding: 16, | |
| borderRight: `1px solid ${UI.border}`, | |
| overflow: "auto", | |
| background: UI.panelBg, | |
| color: UI.panelText, | |
| }} | |
| > | |
| <h2 style={{ margin: "0 0 14px", fontSize: 26, letterSpacing: 0.2 }}>PDF Trainer</h2> | |
| <div style={{ marginBottom: 14 }}> | |
| <div style={UI.label}>Document Source</div> | |
| <div style={{ fontSize: 12, color: UI.subtle, lineHeight: 1.4 }}> | |
| <b>pdf_id:</b> {pdfId || "—"} | |
| <br /> | |
| <b>file:</b> {pdfName || "—"} | |
| </div> | |
| {!pdfId && ( | |
| <div style={{ marginTop: 10, fontSize: 12, color: "#ffb4b4" }}> | |
| Missing <b>pdf_id</b>. Open this page using the pipeline link from the email. | |
| </div> | |
| )} | |
| </div> | |
| {/* NEW: Template ID */} | |
| <div style={{ marginBottom: 14 }}> | |
| <div style={UI.label}>Template ID (required)</div> | |
| <input | |
| style={{ ...UI.input, fontWeight: 800 }} | |
| value={templateId} | |
| onChange={(e) => setTemplateId(e.target.value)} | |
| placeholder="e.g. T1_IFACTOR_DELIVERED_ORDER" | |
| /> | |
| <div style={{ fontSize: 12, color: UI.subtle, marginTop: 6 }}> | |
| Prefer URL: <code>?pdf_id=...&template_id=...</code> | |
| </div> | |
| </div> | |
| {/* Zoom / Pan controls */} | |
| <div style={{ marginBottom: 14 }}> | |
| <div style={UI.label}>View (Zoom / Pan)</div> | |
| <div style={{ display: "flex", gap: 10, alignItems: "center" }}> | |
| <button | |
| type="button" | |
| onClick={() => setScale((s) => Math.max(0.5, +(s - 0.1).toFixed(2)))} | |
| style={{ ...UI.btnBase, ...UI.gray, width: 56, padding: "10px 0" }} | |
| title="Zoom out" | |
| > | |
| − | |
| </button> | |
| <input | |
| type="range" | |
| min="0.5" | |
| max="2.5" | |
| step="0.05" | |
| value={scale} | |
| onChange={(e) => setScale(+e.target.value)} | |
| style={{ width: "100%" }} | |
| /> | |
| <button | |
| type="button" | |
| onClick={() => setScale((s) => Math.min(2.5, +(s + 0.1).toFixed(2)))} | |
| style={{ ...UI.btnBase, ...UI.gray, width: 56, padding: "10px 0" }} | |
| title="Zoom in" | |
| > | |
| + | |
| </button> | |
| </div> | |
| <div style={{ display: "flex", gap: 10, marginTop: 10 }}> | |
| <button | |
| type="button" | |
| onClick={() => { | |
| setScale(1); | |
| setPan({ x: 0, y: 0 }); | |
| }} | |
| style={{ ...UI.btnBase, background: "#0f172a", color: "#fff" }} | |
| > | |
| Reset View (1x) | |
| </button> | |
| </div> | |
| <div style={{ fontSize: 12, color: UI.subtle, marginTop: 8 }}> | |
| Pan: hold <b>Space</b> and drag on the PDF. | |
| </div> | |
| </div> | |
| {!selectedIsTable && ( | |
| <div style={{ marginBottom: 14 }}> | |
| <label style={{ display: "flex", gap: 10, alignItems: "center", fontSize: 12 }}> | |
| <input type="checkbox" checked={offsetMode} onChange={(e) => setOffsetMode(e.target.checked)} /> | |
| <span style={{ fontWeight: 800 }}>Value follows anchor (offset mode)</span> | |
| </label> | |
| <div style={{ fontSize: 12, color: UI.subtle, marginTop: 6 }}> | |
| Offset mode = drift fix: store value region relative to anchor. | |
| </div> | |
| </div> | |
| )} | |
| {/* Field selector */} | |
| <div style={{ marginBottom: 14 }}> | |
| <div style={UI.label}>Field</div> | |
| <select | |
| value={selectedFieldId} | |
| onChange={(e) => setSelectedFieldId(e.target.value)} | |
| style={{ ...UI.select, padding: 14, fontSize: 16, fontWeight: 800 }} | |
| > | |
| {FIELDS.map((f) => ( | |
| <option key={f.fieldId} value={f.fieldId}> | |
| {f.label} | |
| </option> | |
| ))} | |
| </select> | |
| <div style={{ fontSize: 12, color: UI.subtle, marginTop: 8 }}> | |
| Selected: <b style={{ color: "#fff" }}>{selectedField?.label}</b>{" "} | |
| <span style={{ color: UI.subtle }}>({selectedField?.type})</span> | |
| </div> | |
| </div> | |
| {selectedIsTable && ( | |
| <div style={{ marginBottom: 14 }}> | |
| <div style={UI.label}>Row Height Hint (optional)</div> | |
| <input | |
| style={UI.input} | |
| type="number" | |
| min="0" | |
| step="1" | |
| value={rowHeightHintPx} | |
| onChange={(e) => setRowHeightHintPx(Number(e.target.value || 0))} | |
| placeholder="e.g. 28" | |
| /> | |
| <div style={{ fontSize: 12, color: UI.subtle, marginTop: 6 }}> | |
| Used later to split rows. Leave 0 if unknown. | |
| </div> | |
| </div> | |
| )} | |
| {/* Draw mode */} | |
| <div style={{ marginBottom: 14 }}> | |
| <div style={UI.label}>{drawModeTitle}</div> | |
| {!selectedIsTable && ( | |
| <div style={{ display: "flex", gap: 10 }}> | |
| <div role="button" onClick={() => setScalarMode("anchor")} style={UI.pill(scalarMode === "anchor", UI.blue)}> | |
| Anchor (Blue) | |
| </div> | |
| <div role="button" onClick={() => setScalarMode("value")} style={UI.pill(scalarMode === "value", UI.green2)}> | |
| Value (Green) | |
| </div> | |
| </div> | |
| )} | |
| {selectedIsTable && ( | |
| <> | |
| <div style={{ display: "flex", gap: 10, marginBottom: 10 }}> | |
| <div role="button" onClick={() => setTableMode("table")} style={UI.pill(tableMode === "table", UI.purple)}> | |
| Table (Purple) | |
| </div> | |
| <div role="button" onClick={() => setTableMode("header")} style={UI.pill(tableMode === "header", UI.orange)}> | |
| Header (Orange) | |
| </div> | |
| </div> | |
| <div style={{ display: "flex", gap: 10, marginBottom: 10 }}> | |
| <div role="button" onClick={() => setTableMode("anchor")} style={UI.pill(tableMode === "anchor", UI.blue)}> | |
| Anchor (Blue) | |
| </div> | |
| <div role="button" onClick={() => setTableMode("column")} style={UI.pill(tableMode === "column", UI.teal)}> | |
| Column (Teal) | |
| </div> | |
| </div> | |
| {tableMode === "anchor" && ( | |
| <div style={{ marginTop: 6 }}> | |
| <div style={UI.label}>Which Anchor?</div> | |
| <select value={selectedTableAnchorKey} onChange={(e) => setSelectedTableAnchorKey(e.target.value)} style={UI.select}> | |
| {TABLE_ANCHORS.map((a) => ( | |
| <option key={a.key} value={a.key}> | |
| {a.expected_text} | |
| </option> | |
| ))} | |
| </select> | |
| <div style={{ fontSize: 12, color: UI.subtle, marginTop: 6 }}> | |
| Draw a small box tightly around the printed header word(s). | |
| </div> | |
| </div> | |
| )} | |
| {tableMode === "column" && ( | |
| <div style={{ marginTop: 6 }}> | |
| <div style={UI.label}>Which Column?</div> | |
| <select value={selectedColumnKey} onChange={(e) => setSelectedColumnKey(e.target.value)} style={UI.select}> | |
| {TABLE_COLUMNS.map((c) => ( | |
| <option key={c.key} value={c.key}> | |
| {c.label} | |
| </option> | |
| ))} | |
| </select> | |
| <div style={{ fontSize: 12, color: UI.subtle, marginTop: 6 }}> | |
| Columns should be drawn inside the table region. | |
| </div> | |
| </div> | |
| )} | |
| </> | |
| )} | |
| <div style={{ fontSize: 12, color: UI.subtle, marginTop: 10 }}> | |
| Active: <b style={{ color: "#fff" }}>{activeModeLabel}</b> | |
| </div> | |
| <div style={{ fontSize: 12, color: UI.subtle, marginTop: 6 }}> | |
| Drag on the PDF to place this region. (Hold Space to pan) | |
| </div> | |
| </div> | |
| {/* Clear buttons */} | |
| <div style={{ display: "flex", gap: 10, marginBottom: 14 }}> | |
| {!selectedIsTable ? ( | |
| <> | |
| <button onClick={() => clearCurrent("anchor")} style={{ ...UI.btnBase, ...UI.gray }}> | |
| Clear Anchor | |
| </button> | |
| <button onClick={() => clearCurrent("value")} style={{ ...UI.btnBase, ...UI.gray }}> | |
| Clear Value | |
| </button> | |
| </> | |
| ) : ( | |
| <> | |
| <button onClick={() => clearCurrent(tableMode)} style={{ ...UI.btnBase, ...UI.gray }}> | |
| Clear Current ({tableMode}) | |
| </button> | |
| <button onClick={() => clearCurrent("table")} style={{ ...UI.btnBase, ...UI.gray }}> | |
| Clear Table | |
| </button> | |
| </> | |
| )} | |
| </div> | |
| {/* SAVE */} | |
| <button | |
| onClick={saveConfig} | |
| disabled={!pdfUrl || sendStatus.state === "sending"} | |
| style={{ | |
| ...UI.btnBase, | |
| ...(pdfUrl ? UI.greenDark : { background: "rgba(255,255,255,0.10)", color: "#aaa" }), | |
| border: pdfUrl ? "1px solid rgba(22,163,74,0.55)" : "1px solid rgba(255,255,255,0.12)", | |
| }} | |
| > | |
| {sendStatus.state === "sending" ? "Saving..." : "Save Config"} | |
| </button> | |
| {sendStatus.state !== "idle" && ( | |
| <div | |
| style={{ | |
| marginTop: 10, | |
| fontSize: 12, | |
| color: sendStatus.state === "ok" ? "#86efac" : sendStatus.state === "err" ? "#fecaca" : UI.subtle, | |
| }} | |
| > | |
| {sendStatus.msg} | |
| </div> | |
| )} | |
| <hr style={{ margin: "16px 0", borderColor: UI.border }} /> | |
| <div style={{ display: "flex", gap: 10, marginBottom: 12 }}> | |
| <button | |
| type="button" | |
| onClick={() => setShowPreview((v) => !v)} | |
| style={{ ...UI.btnBase, background: "#0f172a", color: "#fff" }} | |
| > | |
| {showPreview ? "Hide Config Preview" : "Show Config Preview"} | |
| </button> | |
| </div> | |
| {showPreview && ( | |
| <div style={{ marginTop: 14 }}> | |
| <div style={{ fontWeight: 900, marginBottom: 8 }}>Config Preview</div> | |
| <pre | |
| style={{ | |
| margin: 0, | |
| padding: 12, | |
| borderRadius: 12, | |
| background: "#0b0f14", | |
| border: "1px solid rgba(255,255,255,0.10)", | |
| color: "#d1d5db", | |
| fontSize: 11, | |
| lineHeight: 1.35, | |
| whiteSpace: "pre-wrap", | |
| wordBreak: "break-word", | |
| maxHeight: 260, | |
| overflow: "auto", | |
| }} | |
| > | |
| {previewConfig ? JSON.stringify(previewConfig, null, 2) : "PDF not loaded yet..."} | |
| </pre> | |
| </div> | |
| )} | |
| </div> | |
| {/* Middle panel: PDF + overlay */} | |
| <div style={{ padding: 16, overflow: "auto", background: "#111" }}> | |
| {!pdfUrl ? ( | |
| <div style={{ color: "#999", lineHeight: 1.4 }}> | |
| No PDF loaded. | |
| <br /> | |
| Open using pipeline link: | |
| <br /> | |
| <code style={{ color: "#ddd" }}>...?pdf_id=xxxx&template_id=T1_...</code> | |
| </div> | |
| ) : ( | |
| <div | |
| style={{ | |
| position: "relative", | |
| width: "fit-content", | |
| background: "#0b0b0b", | |
| border: `1px solid ${UI.border}`, | |
| borderRadius: 14, | |
| padding: 12, | |
| }} | |
| > | |
| <div | |
| ref={stageRef} | |
| style={{ | |
| position: "relative", | |
| width: pageSize.w ? pageSize.w : "fit-content", | |
| transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`, | |
| transformOrigin: "top left", | |
| }} | |
| > | |
| <Document | |
| file={pdfUrl} | |
| loading="Loading PDF..." | |
| onLoadError={(err) => { | |
| console.error("PDF load error:", err); | |
| alert(err.message); | |
| }} | |
| > | |
| <Page | |
| pageNumber={1} | |
| renderTextLayer={false} | |
| renderAnnotationLayer={false} | |
| onRenderSuccess={(page) => setPageSize({ w: page.width, h: page.height })} | |
| /> | |
| </Document> | |
| {pageSize.w > 0 && pageSize.h > 0 && ( | |
| <div | |
| ref={overlayRef} | |
| style={{ | |
| position: "absolute", | |
| left: 0, | |
| top: 0, | |
| width: pageSize.w, | |
| height: pageSize.h, | |
| cursor: spaceDown ? (isPanning ? "grabbing" : "grab") : "crosshair", | |
| }} | |
| onMouseDown={onMouseDown} | |
| onMouseMove={onMouseMove} | |
| onMouseUp={onMouseUp} | |
| onMouseLeave={() => { | |
| onMouseUp(); | |
| setIsPanning(false); | |
| panStartRef.current = null; | |
| }} | |
| > | |
| <svg width={pageSize.w} height={pageSize.h}> | |
| {FIELDS.map((f) => { | |
| const st = boxes[f.fieldId]; | |
| if (st?.kind === "scalar") { | |
| const vDisp = getScalarDisplayValueBox(f.fieldId); | |
| return ( | |
| <g key={f.fieldId}> | |
| {st.anchor && ( | |
| <rect x={st.anchor.x} y={st.anchor.y} width={st.anchor.w} height={st.anchor.h} fill="none" stroke="#2563eb" strokeWidth="3" /> | |
| )} | |
| {vDisp && ( | |
| <rect x={vDisp.x} y={vDisp.y} width={vDisp.w} height={vDisp.h} fill="none" stroke="#22c55e" strokeWidth="3" /> | |
| )} | |
| </g> | |
| ); | |
| } | |
| if (st?.kind === "table") { | |
| return ( | |
| <g key={f.fieldId}> | |
| {st.tableAbs && ( | |
| <rect x={st.tableAbs.x} y={st.tableAbs.y} width={st.tableAbs.w} height={st.tableAbs.h} fill="none" stroke="#7c3aed" strokeWidth="3" /> | |
| )} | |
| {st.headerAbs && ( | |
| <rect x={st.headerAbs.x} y={st.headerAbs.y} width={st.headerAbs.w} height={st.headerAbs.h} fill="none" stroke="#f59e0b" strokeWidth="3" /> | |
| )} | |
| {TABLE_ANCHORS.map((a) => { | |
| const b = st.anchorsAbs[a.key]; | |
| return ( | |
| b && <rect key={`a-${a.key}`} x={b.x} y={b.y} width={b.w} height={b.h} fill="none" stroke="#2563eb" strokeWidth="3" /> | |
| ); | |
| })} | |
| {TABLE_COLUMNS.map((c) => { | |
| const b = st.columnsAbs[c.key]; | |
| return ( | |
| b && <rect key={`c-${c.key}`} x={b.x} y={b.y} width={b.w} height={b.h} fill="none" stroke="#14b8a6" strokeWidth="3" /> | |
| ); | |
| })} | |
| </g> | |
| ); | |
| } | |
| return null; | |
| })} | |
| {draft && !isPanning && ( | |
| <rect | |
| x={draft.x} | |
| y={draft.y} | |
| width={draft.w} | |
| height={draft.h} | |
| fill="none" | |
| stroke={ | |
| selectedIsTable | |
| ? tableMode === "table" | |
| ? "#7c3aed" | |
| : tableMode === "header" | |
| ? "#f59e0b" | |
| : tableMode === "anchor" | |
| ? "#2563eb" | |
| : "#14b8a6" | |
| : scalarMode === "anchor" | |
| ? "#2563eb" | |
| : "#22c55e" | |
| } | |
| strokeWidth="3" | |
| strokeDasharray="6 4" | |
| /> | |
| )} | |
| </svg> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Right panel */} | |
| <div | |
| style={{ | |
| padding: 16, | |
| overflow: "auto", | |
| background: "#0b0f14", | |
| borderLeft: `1px solid ${UI.border}`, | |
| color: "#e5e7eb", | |
| }} | |
| > | |
| <h3 style={{ margin: "0 0 10px", fontSize: 18 }}>Config Preview</h3> | |
| <div style={{ fontSize: 12, color: "#9aa4b2", marginBottom: 10 }}> | |
| Live preview of what will be saved. | |
| </div> | |
| <pre | |
| style={{ | |
| margin: 0, | |
| padding: 12, | |
| borderRadius: 12, | |
| background: "#05070a", | |
| border: "1px solid #1f2937", | |
| color: "#d1d5db", | |
| fontSize: 12, | |
| lineHeight: 1.35, | |
| whiteSpace: "pre-wrap", | |
| wordBreak: "break-word", | |
| }} | |
| > | |
| {previewConfig ? JSON.stringify(previewConfig, null, 2) : "PDF not loaded yet..."} | |
| </pre> | |
| </div> | |
| </div> | |
| ); | |
| } | |