Sheetly / app /static /js /validate.js
nvikou's picture
initial commit
da6ee5e verified
Raw History Blame Contribute Delete
11.1 kB
(() => {
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 =
'<p class="preview-empty">No documents ready for review yet. Upload files first.</p>';
form.innerHTML =
'<p class="fields-empty">Nothing to validate.</p>';
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)
? '<span class="badge warn">review</span>'
: '<span class="badge ok">ok</span>';
const dup = d.is_duplicate
? '<span class="badge warn">duplicate</span>'
: "";
btn.innerHTML =
`<div class="name">${escapeHtml(d.filename)}</div>` +
`<div class="row">` +
`<span class="badge type-${escapeAttr(d.doc_type)}">` +
`${escapeHtml(typeLabel(d.doc_type))}</span>` +
review +
dup +
`</div>`;
btn.addEventListener("click", () => show(d.id));
li.appendChild(btn);
queueList.appendChild(li);
});
if (!filtered.length) {
queueList.innerHTML =
'<li class="preview-empty">No matches.</li>';
}
}
function updateCounter(index) {
if (index < 0) {
counter.innerHTML = "<strong>0</strong> / 0 documents";
return;
}
counter.innerHTML =
`<strong>${index + 1}</strong> / ${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 =
'<p class="preview-empty">Preview unavailable — ' +
"the original file was removed. Extracted fields " +
"on the right are still valid.</p>";
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 =
`<iframe src="${url}" title="document"></iframe>`;
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 =
`<pre style="white-space:pre-wrap;margin:0;font:inherit;line-height:1.5">${escapeHtml(text)}</pre>`;
return;
}
preview.innerHTML =
`<p class="preview-empty"><a href="${url}" target="_blank">Open ${escapeHtml(doc.filename)}</a></p>`;
} 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 =
`<label><span>${fieldLabel(key)}</span></label>` +
`<textarea name="${key}" rows="6">` +
`${escapeHtml(JSON.stringify(data[key], null, 2))}` +
`</textarea>`;
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
? `<span class="conf">${(meta.score * 100).toFixed(0)}%</span>`
: "";
wrap.innerHTML =
`<label><span>${fieldLabel(key)}</span>${score}</label>` +
`<input name="${key}" value="${escapeAttr(
data[key] == null ? "" : data[key]
)}" />`;
form.appendChild(wrap);
});
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function escapeAttr(s) {
return String(s).replace(/"/g, "&quot;");
}
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();
})();