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
180 lines
6.8 KiB
JavaScript
180 lines
6.8 KiB
JavaScript
// VV price-check 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 };
|
||
|
||
// Upload
|
||
const fileInput = $("file-input");
|
||
const dropZone = $("drop-zone");
|
||
$("browse-btn").addEventListener("click", () => fileInput.click());
|
||
fileInput.addEventListener("change", (e) => e.target.files[0] && upload(e.target.files[0]));
|
||
["dragenter", "dragover"].forEach((ev) =>
|
||
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); }));
|
||
["dragleave", "drop"].forEach((ev) =>
|
||
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove("drag-over"); }));
|
||
dropZone.addEventListener("drop", (e) => e.dataTransfer.files[0] && upload(e.dataTransfer.files[0]));
|
||
|
||
async function upload(file) {
|
||
show("processing");
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
const r = await fetch("/api/check", { 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 fmtPrice(v) {
|
||
if (v == null || isNaN(v)) return "—";
|
||
return v.toLocaleString("cs-CZ", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " Kč";
|
||
}
|
||
|
||
function renderResult() {
|
||
const r = state.result;
|
||
const vvCount = r.vv_sheet_count;
|
||
const pricedCount = r.vv_sheets_with_prices || 0;
|
||
const incCount = r.total_inconsistencies;
|
||
const totalSheets = r.sheets.length;
|
||
$("results-meta").textContent =
|
||
`${totalSheets} listů v sešitu, ${vvCount} identifikováno jako VV, ${pricedCount} s vyplněnými cenami.`;
|
||
|
||
const sheetsHtml = r.sheets.map((s) => {
|
||
let cls = "not-vv";
|
||
let label = s.name;
|
||
let icon = "·";
|
||
if (s.is_vv && s.priced_items > 0) {
|
||
cls = "vv";
|
||
icon = "✓";
|
||
label = `${s.name} (${s.priced_items} s cenou)`;
|
||
} else if (s.is_vv && s.items > 0) {
|
||
cls = "vv-noprice";
|
||
icon = "⚠";
|
||
label = `${s.name} (${s.items} bez cen)`;
|
||
} else if (s.is_vv) {
|
||
cls = "not-vv";
|
||
label = `${s.name} (prázdné)`;
|
||
}
|
||
return `<span class="sheet-pill ${cls}" title="${escapeHtml(s.name)}">${icon} ${escapeHtml(label)}</span>`;
|
||
}).join("");
|
||
|
||
$("sheets-summary").innerHTML = `
|
||
<div class="summary-stat">
|
||
<div class="summary-stat-value">${vvCount}</div>
|
||
<div class="summary-stat-label">listů VV</div>
|
||
</div>
|
||
<div class="summary-stat">
|
||
<div class="summary-stat-value">${pricedCount}</div>
|
||
<div class="summary-stat-label">s cenami</div>
|
||
</div>
|
||
<div class="summary-stat">
|
||
<div class="summary-stat-value ${incCount === 0 ? "ok" : "problem"}">${incCount}</div>
|
||
<div class="summary-stat-label">nesouladů</div>
|
||
</div>
|
||
<div class="detected-sheets">${sheetsHtml}</div>
|
||
`;
|
||
|
||
const wrap = $("incs-wrap");
|
||
|
||
// Special case: no VVs at all
|
||
if (vvCount === 0) {
|
||
wrap.innerHTML = `
|
||
<div class="empty-state">
|
||
<p><strong>V sešitu nebyl rozpoznán žádný výkaz výměr.</strong></p>
|
||
<p style="margin-top:8px;color:var(--text-tertiary);font-size:13px">
|
||
Aplikace hledá listy s hlavičkou „Popis / MJ / Výměra / Jedn. cena".
|
||
Pokud váš sešit používá jiný formát, dejte vědět přes
|
||
<a href="https://navrhy.klas.chat">Návrh nového nástroje</a>.
|
||
</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
// VVs found but none have prices — point user at correct tool
|
||
if (pricedCount === 0) {
|
||
wrap.innerHTML = `
|
||
<div class="empty-state">
|
||
<p><strong>Nalezeno ${vvCount} listů VV, ale žádný nemá vyplněné jednotkové ceny.</strong></p>
|
||
<p style="margin-top:8px;color:var(--text-tertiary);font-size:13px">
|
||
„Kontrola cen ve VV" porovnává jednotkové ceny napříč listy — bez vyplněných cen není co porovnávat.<br>
|
||
Pokud chcete porovnat tento sešit s předchozí verzí (změny ve výměrách, přidané/odebrané položky),
|
||
použijte <a href="https://porovnani-vv.klas.chat">Porovnání VV</a>.
|
||
</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
// VVs with prices found but no inconsistencies
|
||
if (incCount === 0) {
|
||
wrap.innerHTML = `
|
||
<div class="empty-state">
|
||
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<circle cx="12" cy="12" r="10"/>
|
||
<path d="m9 12 2 2 4-4"/>
|
||
</svg>
|
||
<p>Všechny položky se stejným názvem mají shodnou jednotkovou cenu ve všech VV listech.</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
wrap.innerHTML = r.inconsistencies.map((inc) => {
|
||
const minP = Math.min(...inc.rows.map(r => r.unit_price));
|
||
const maxP = Math.max(...inc.rows.map(r => r.unit_price));
|
||
const rows = inc.rows.map((rw) => {
|
||
const cls = rw.unit_price === maxP && minP !== maxP ? "row-max"
|
||
: rw.unit_price === minP && minP !== maxP ? "row-min" : "";
|
||
return `<tr class="${cls}">
|
||
<td>${escapeHtml(rw.sheet)}</td>
|
||
<td>${rw.row}</td>
|
||
<td>${escapeHtml(rw.mj)}</td>
|
||
<td class="num">${fmtPrice(rw.unit_price)}</td>
|
||
</tr>`;
|
||
}).join("");
|
||
const spread = maxP > 0 ? ((maxP - minP) / minP * 100) : 0;
|
||
return `
|
||
<div class="inc-card">
|
||
<div class="inc-card-title">${escapeHtml(inc.description)}</div>
|
||
<div class="inc-card-meta">
|
||
Vyskytuje se ${inc.occurrences}× ve ${new Set(inc.rows.map(r => r.sheet)).size} listech ·
|
||
rozdíl ${fmtPrice(minP)} – ${fmtPrice(maxP)} (rozptyl ${spread.toFixed(1)} %)
|
||
</div>
|
||
<table class="inc-table">
|
||
<thead><tr><th>List VV</th><th>Řádek</th><th>MJ</th><th>Jed. cena</th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>`;
|
||
}).join("");
|
||
}
|
||
|
||
$("download-btn").addEventListener("click", () => {
|
||
if (!state.jobId) return;
|
||
window.location.href = `/api/report/${state.jobId}`;
|
||
});
|
||
$("restart-btn").addEventListener("click", () => {
|
||
state = { jobId: null, result: null };
|
||
fileInput.value = "";
|
||
show("upload");
|
||
});
|
||
|
||
function escapeHtml(s) {
|
||
return String(s ?? "").replace(/[&<>"']/g, (c) =>
|
||
({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||
}
|
||
})();
|