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:
Ondřej Glaser
2026-05-13 15:25:04 +02:00
commit 48cef99257
139 changed files with 20171 additions and 0 deletions

179
vv-check/static/app.js Normal file
View File

@@ -0,0 +1,179 @@
// 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]));
}
})();