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
This commit is contained in:
130
vv-compare/static/app.js
Normal file
130
vv-compare/static/app.js
Normal file
@@ -0,0 +1,130 @@
|
||||
// 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) =>
|
||||
({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user