(() => { const queueList = document.getElementById("queueList"); const queueSearch = document.getElementById("queueSearch"); const queueCount = document.getElementById("queueCount"); const counter = document.getElementById("counter"); const form = document.getElementById("fieldsForm"); const preview = document.getElementById("preview"); const issuesEl = document.getElementById("issues"); const typeBadge = document.getElementById("typeBadge"); const fileBadge = document.getElementById("fileBadge"); const saveBtn = document.getElementById("saveBtn"); const prevBtn = document.getElementById("prevBtn"); const nextBtn = document.getElementById("nextBtn"); const toast = document.getElementById("toast"); let docs = []; let filtered = []; let currentId = null; let current = null; const params = new URLSearchParams(location.search); const focusId = params.get("id"); function showToast(message) { toast.textContent = message; toast.classList.add("show"); setTimeout(() => toast.classList.remove("show"), 1800); } function typeLabel(type) { return (type || "other").replaceAll("_", " "); } function fieldLabel(key) { return key.replaceAll("_", " "); } function needsReview(doc) { const conf = doc.confidence || {}; return Object.values(conf).some( (v) => v && v.needs_review ); } async function load() { const [readyRes, validatedRes] = await Promise.all([ fetch("/api/documents?limit=200&status=ready"), fetch("/api/documents?limit=200&status=validated"), ]); const ready = await readyRes.json(); const validated = await validatedRes.json(); const map = new Map(); [...ready, ...validated].forEach((d) => map.set(d.id, d)); docs = Array.from(map.values()).sort( (a, b) => new Date(b.created_at) - new Date(a.created_at) ); filtered = docs; queueCount.textContent = String(docs.length); renderQueue(); if (!docs.length) { preview.innerHTML = '

No documents ready for review yet. Upload files first.

'; form.innerHTML = '

Nothing to validate.

'; updateCounter(-1); return; } const start = docs.find((d) => d.id === focusId) || docs[0]; await show(start.id); } function renderQueue() { const q = (queueSearch.value || "").trim().toLowerCase(); filtered = docs.filter((d) => { if (!q) return true; return ( d.filename.toLowerCase().includes(q) || d.doc_type.toLowerCase().includes(q) ); }); queueList.innerHTML = ""; filtered.forEach((d) => { const li = document.createElement("li"); const btn = document.createElement("button"); btn.type = "button"; btn.className = "queue-item" + (d.id === currentId ? " active" : ""); const review = needsReview(d) ? 'review' : 'ok'; const dup = d.is_duplicate ? 'duplicate' : ""; btn.innerHTML = `
${escapeHtml(d.filename)}
` + `
` + `` + `${escapeHtml(typeLabel(d.doc_type))}` + review + dup + `
`; btn.addEventListener("click", () => show(d.id)); li.appendChild(btn); queueList.appendChild(li); }); if (!filtered.length) { queueList.innerHTML = '
  • No matches.
  • '; } } function updateCounter(index) { if (index < 0) { counter.innerHTML = "0 / 0 documents"; return; } counter.innerHTML = `${index + 1} / ${docs.length} documents`; } async function show(id) { currentId = id; const res = await fetch(`/api/documents/${id}`); current = await res.json(); const index = docs.findIndex((d) => d.id === id); updateCounter(index); typeBadge.textContent = typeLabel(current.doc_type); typeBadge.className = `badge type-${current.doc_type}`; fileBadge.textContent = current.filename; renderPreview(current); renderIssues(current.issues || []); renderFields(current); renderQueue(); history.replaceState(null, "", `/validate?id=${id}`); } async function renderPreview(doc) { const url = `/api/documents/${doc.id}/file`; const name = (doc.filename || "").toLowerCase(); const missing = '

    Preview unavailable — ' + "the original file was removed. Extracted fields " + "on the right are still valid.

    "; if (/\.(png|jpe?g|webp|gif|tif{1,2})$/.test(name)) { const img = new Image(); img.alt = "document"; img.onerror = () => { preview.innerHTML = missing; }; img.src = url; preview.innerHTML = ""; preview.appendChild(img); return; } if (name.endsWith(".pdf")) { preview.innerHTML = ``; return; } try { const res = await fetch(url); if (!res.ok) { preview.innerHTML = missing; return; } if (name.endsWith(".txt")) { const text = await res.text(); preview.innerHTML = `
    ${escapeHtml(text)}
    `; return; } preview.innerHTML = `

    Open ${escapeHtml(doc.filename)}

    `; } catch { preview.innerHTML = missing; } } function renderIssues(issues) { issuesEl.innerHTML = ""; issues.forEach((issue) => { const li = document.createElement("li"); li.textContent = issue.message || issue.code; if (issue.severity === "error") { li.classList.add("error"); } issuesEl.appendChild(li); }); } function renderFields(doc) { form.innerHTML = ""; const data = doc.extracted || {}; const conf = doc.confidence || {}; Object.keys(data).forEach((key) => { if ( key === "lines" || key === "items" || key === "parties" ) { const wrap = document.createElement("div"); wrap.className = "field"; wrap.innerHTML = `` + ``; form.appendChild(wrap); return; } const meta = conf[key] || {}; const wrap = document.createElement("div"); wrap.className = "field" + (meta.needs_review ? " needs-review" : ""); const score = meta.score != null ? `${(meta.score * 100).toFixed(0)}%` : ""; wrap.innerHTML = `` + ``; form.appendChild(wrap); }); } function escapeHtml(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">"); } function escapeAttr(s) { return String(s).replace(/"/g, """); } function collectPayload() { const extracted = {}; const fd = new FormData(form); for (const [key, value] of fd.entries()) { if ( key === "lines" || key === "items" || key === "parties" ) { try { extracted[key] = JSON.parse(value); } catch { extracted[key] = value; } continue; } if (value === "") { extracted[key] = null; continue; } if ( [ "amount_ht", "vat_amount", "amount_ttc", "amount", "quantity", ].includes(key) ) { const n = Number(value); extracted[key] = Number.isFinite(n) ? n : value; } else if (key === "auto_renewal") { extracted[key] = value === "true" || value === "1"; } else { extracted[key] = value; } } return extracted; } saveBtn.addEventListener("click", async () => { if (!current) return; const body = { extracted: collectPayload(), doc_type: current.doc_type, }; const res = await fetch(`/api/documents/${current.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { showToast("Save failed"); return; } current = await res.json(); const idx = docs.findIndex((d) => d.id === current.id); if (idx >= 0) docs[idx] = current; renderIssues(current.issues || []); renderFields(current); renderQueue(); showToast("Corrections saved"); }); prevBtn.addEventListener("click", () => { const i = docs.findIndex((d) => d.id === currentId); if (i > 0) show(docs[i - 1].id); }); nextBtn.addEventListener("click", () => { const i = docs.findIndex((d) => d.id === currentId); if (i >= 0 && i < docs.length - 1) { show(docs[i + 1].id); } }); queueSearch.addEventListener("input", renderQueue); load(); })();