Files
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

180 lines
6.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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) =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
})();