Files
AI_portal/vv-compare/static/app.js
Ondřej Glaser 48cef99257 Initial portal commit: landing + 9 AI-powered apps
Apps:
- dwg-rooms: extract room numbers from DWG/DXF
- dwg-counting: count symbols in PDF drawings (OpenCV template matching)
- contract-check: review PDF contracts against a checklist (Claude vision + Tesseract OCR fallback)
- email-drafter: bullet notes → polished Czech/English business emails
- invoice-extractor: PDF/image invoice → structured data → Excel
- translator: Czech-first translator across 19 languages with tone control
- vv-check: find inconsistent unit prices across VV sheets in one workbook
- vv-compare: diff original vs new VV files (changes / added / removed)
- feature-request: portal users submit ideas + sample files

Infrastructure:
- LiteLLM gateway with per-app virtual keys + budgets
- Langfuse observability
- Geist font, shared theme, cross-subdomain back link + theme sync via cookie/URL
- Caddy reverse proxy on *.klas.chat
2026-05-13 15:25:04 +02:00

131 lines
5.0 KiB
JavaScript

// VV compare frontend
(() => {
const $ = (id) => document.getElementById(id);
const sections = {
upload: $("s-upload"),
processing: $("s-processing"),
result: $("s-result"),
};
const show = (n) => {
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== n);
};
let state = { jobId: null, result: null, origFile: null, newFile: null };
function attachDrop(zoneId, inputId, textId, which) {
const zone = $(zoneId);
const input = $(inputId);
const text = $(textId);
zone.addEventListener("click", () => input.click());
input.addEventListener("change", (e) => {
if (e.target.files[0]) setFile(which, e.target.files[0], zone, text);
});
["dragenter", "dragover"].forEach((ev) =>
zone.addEventListener(ev, (e) => { e.preventDefault(); zone.classList.add("drag-over"); }));
["dragleave", "drop"].forEach((ev) =>
zone.addEventListener(ev, (e) => { e.preventDefault(); zone.classList.remove("drag-over"); }));
zone.addEventListener("drop", (e) => {
if (e.dataTransfer.files[0]) setFile(which, e.dataTransfer.files[0], zone, text);
});
}
function setFile(which, file, zone, textEl) {
if (which === "orig") state.origFile = file;
else state.newFile = file;
zone.classList.add("has-file");
const size = file.size > 1024 * 1024
? `${(file.size / 1024 / 1024).toFixed(1)} MB`
: `${(file.size / 1024).toFixed(0)} kB`;
textEl.innerHTML = `<strong>${escapeHtml(file.name)}</strong><br><span style="color:var(--text-tertiary);font-size:12px">${size}</span>`;
updateRunBtn();
}
attachDrop("drop-original", "orig-input", "orig-text", "orig");
attachDrop("drop-new", "new-input", "new-text", "new");
function updateRunBtn() {
const btn = $("compare-btn");
const hint = $("compare-hint");
if (!state.origFile && !state.newFile) {
btn.disabled = true; hint.textContent = "Nahrajte oba soubory.";
} else if (!state.origFile) {
btn.disabled = true; hint.textContent = "Chybí původní VV.";
} else if (!state.newFile) {
btn.disabled = true; hint.textContent = "Chybí nový VV.";
} else {
btn.disabled = false; hint.textContent = "Připraveno k porovnání.";
}
}
$("compare-btn").addEventListener("click", async () => {
if (!state.origFile || !state.newFile) return;
show("processing");
try {
const fd = new FormData();
fd.append("original", state.origFile);
fd.append("new", state.newFile);
const r = await fetch("/api/compare", { method: "POST", body: fd });
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const json = await r.json();
state.jobId = json.job_id;
state.result = json.result;
renderResult();
show("result");
} catch (err) {
alert("Chyba: " + err.message);
show("upload");
}
});
function renderResult() {
const r = state.result;
const c = r.changes.length, a = r.added.length, d = r.removed.length;
$("cnt-changed").textContent = c;
$("cnt-added").textContent = a;
$("cnt-removed").textContent = d;
$("results-meta").textContent =
`Porovnání ${r.per_sheet.length} listů — ${c + a + d} celkových rozdílů.`;
const psHtml = r.per_sheet.map((ps) => `
<tr>
<td>${escapeHtml(ps.sheet)}</td>
<td>${escapeHtml(ps.hala || "")}</td>
<td class="num">${ps.orig_count}</td>
<td class="num">${ps.new_count}</td>
<td class="num"><span class="pill ${ps.changes ? "changed" : "zero"}">${ps.changes}</span></td>
<td class="num"><span class="pill ${ps.added ? "added" : "zero"}">${ps.added}</span></td>
<td class="num"><span class="pill ${ps.removed ? "removed" : "zero"}">${ps.removed}</span></td>
</tr>`).join("");
$("per-sheet").innerHTML = r.per_sheet.length ? `
<table>
<thead>
<tr>
<th>List</th><th>Hala</th>
<th>Pol. původní</th><th>Pol. nový</th>
<th>Změněné</th><th>Přidané</th><th>Odebrané</th>
</tr>
</thead>
<tbody>${psHtml}</tbody>
</table>` : `<p style="color:var(--text-tertiary)">Nepodařilo se najít listy VV v žádném ze souborů.</p>`;
}
$("download-btn").addEventListener("click", () => {
if (state.jobId) window.location.href = `/api/report/${state.jobId}`;
});
$("restart-btn").addEventListener("click", () => {
state = { jobId: null, result: null, origFile: null, newFile: null };
$("orig-input").value = ""; $("new-input").value = "";
$("drop-original").classList.remove("has-file");
$("drop-new").classList.remove("has-file");
$("orig-text").textContent = "Přetáhněte soubor sem nebo klikněte";
$("new-text").textContent = "Přetáhněte soubor sem nebo klikněte";
updateRunBtn();
show("upload");
});
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
})();