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 (
{/* Left panel */}

PDF Trainer

Document Source
pdf_id: {pdfId || "—"}
file: {pdfName || "—"}
{!pdfId && (
Missing pdf_id. Open this page using the pipeline link from the email.
)}
{/* NEW: Template ID */}
Template ID (required)
setTemplateId(e.target.value)} placeholder="e.g. T1_IFACTOR_DELIVERED_ORDER" />
Prefer URL: ?pdf_id=...&template_id=...
{/* Zoom / Pan controls */}
View (Zoom / Pan)
setScale(+e.target.value)} style={{ width: "100%" }} />
Pan: hold Space and drag on the PDF.
{!selectedIsTable && (
Offset mode = drift fix: store value region relative to anchor.
)} {/* Field selector */}
Field
Selected: {selectedField?.label}{" "} ({selectedField?.type})
{selectedIsTable && (
Row Height Hint (optional)
setRowHeightHintPx(Number(e.target.value || 0))} placeholder="e.g. 28" />
Used later to split rows. Leave 0 if unknown.
)} {/* Draw mode */}
{drawModeTitle}
{!selectedIsTable && (
setScalarMode("anchor")} style={UI.pill(scalarMode === "anchor", UI.blue)}> Anchor (Blue)
setScalarMode("value")} style={UI.pill(scalarMode === "value", UI.green2)}> Value (Green)
)} {selectedIsTable && ( <>
setTableMode("table")} style={UI.pill(tableMode === "table", UI.purple)}> Table (Purple)
setTableMode("header")} style={UI.pill(tableMode === "header", UI.orange)}> Header (Orange)
setTableMode("anchor")} style={UI.pill(tableMode === "anchor", UI.blue)}> Anchor (Blue)
setTableMode("column")} style={UI.pill(tableMode === "column", UI.teal)}> Column (Teal)
{tableMode === "anchor" && (
Which Anchor?
Draw a small box tightly around the printed header word(s).
)} {tableMode === "column" && (
Which Column?
Columns should be drawn inside the table region.
)} )}
Active: {activeModeLabel}
Drag on the PDF to place this region. (Hold Space to pan)
{/* Clear buttons */}
{!selectedIsTable ? ( <> ) : ( <> )}
{/* SAVE */} {sendStatus.state !== "idle" && (
{sendStatus.msg}
)}
{showPreview && (
Config Preview
              {previewConfig ? JSON.stringify(previewConfig, null, 2) : "PDF not loaded yet..."}
            
)}
{/* Middle panel: PDF + overlay */}
{!pdfUrl ? (
No PDF loaded.
Open using pipeline link:
...?pdf_id=xxxx&template_id=T1_...
) : (
{ console.error("PDF load error:", err); alert(err.message); }} > setPageSize({ w: page.width, h: page.height })} /> {pageSize.w > 0 && pageSize.h > 0 && (
{ onMouseUp(); setIsPanning(false); panStartRef.current = null; }} > {FIELDS.map((f) => { const st = boxes[f.fieldId]; if (st?.kind === "scalar") { const vDisp = getScalarDisplayValueBox(f.fieldId); return ( {st.anchor && ( )} {vDisp && ( )} ); } if (st?.kind === "table") { return ( {st.tableAbs && ( )} {st.headerAbs && ( )} {TABLE_ANCHORS.map((a) => { const b = st.anchorsAbs[a.key]; return ( b && ); })} {TABLE_COLUMNS.map((c) => { const b = st.columnsAbs[c.key]; return ( b && ); })} ); } return null; })} {draft && !isPanning && ( )}
)}
)}
{/* Right panel */}

Config Preview

Live preview of what will be saved.
          {previewConfig ? JSON.stringify(previewConfig, null, 2) : "PDF not loaded yet..."}
        
); }