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

View File

@@ -0,0 +1,352 @@
// Invoice extractor frontend
(() => {
const $ = (id) => document.getElementById(id);
const sections = {
upload: $("s-upload"),
processing: $("s-processing"),
result: $("s-result"),
};
const show = (name) => {
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name);
};
let state = { jobId: null, data: 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/upload", { 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.data = json.data || {};
renderForm();
show("result");
} catch (err) {
alert("Chyba: " + err.message);
show("upload");
}
}
// ── Render editable form ──
function field(key, label, value, opts = {}) {
const isEmpty = value === null || value === undefined || value === "";
const display = isEmpty ? "" : value;
const cls = "inv-input" + (isEmpty ? " is-empty" : "");
return `
<div class="inv-field ${opts.wide ? "inv-field-wide" : ""}">
<label class="inv-label">${escapeHtml(label)}</label>
<input class="${cls}" data-key="${key}" type="${opts.type || "text"}"
value="${escapeHtmlAttr(display)}"
placeholder="${escapeHtmlAttr(opts.placeholder || "—")}">
</div>`;
}
function nestedField(parent, key, label, value, opts = {}) {
const isEmpty = value === null || value === undefined || value === "";
const display = isEmpty ? "" : value;
const cls = "inv-input" + (isEmpty ? " is-empty" : "");
return `
<div class="inv-field ${opts.wide ? "inv-field-wide" : ""}">
<label class="inv-label">${escapeHtml(label)}</label>
<input class="${cls}" data-parent="${parent}" data-key="${key}"
type="${opts.type || "text"}"
value="${escapeHtmlAttr(display)}"
placeholder="${escapeHtmlAttr(opts.placeholder || "—")}">
</div>`;
}
function renderForm() {
const d = state.data;
const sup = d.supplier || {};
const cust = d.customer || {};
const items = d.line_items || [];
const vat = d.vat_breakdown || [];
const html = `
<!-- Identifikace -->
<div class="inv-card">
<div class="inv-card-title">Identifikace faktury</div>
<div class="inv-grid-3">
${field("invoice_number", "Číslo faktury", d.invoice_number)}
${field("variable_symbol", "Variabilní symbol", d.variable_symbol)}
${field("currency", "Měna", d.currency || "CZK")}
${field("issue_date", "Datum vystavení", d.issue_date, {type: "date"})}
${field("due_date", "Datum splatnosti", d.due_date, {type: "date"})}
${field("taxable_date", "DUZP", d.taxable_date, {type: "date"})}
${field("constant_symbol", "Konstantní symbol", d.constant_symbol)}
${field("specific_symbol", "Specifický symbol", d.specific_symbol)}
${field("payment_method", "Způsob platby", d.payment_method)}
</div>
</div>
<!-- Dodavatel -->
<div class="inv-card">
<div class="inv-card-title">Dodavatel</div>
<div class="inv-grid">
${nestedField("supplier", "name", "Název", sup.name, {wide: true})}
${nestedField("supplier", "ico", "IČO", sup.ico)}
${nestedField("supplier", "dic", "DIČ", sup.dic)}
${nestedField("supplier", "address", "Adresa", sup.address, {wide: true})}
</div>
</div>
<!-- Odběratel -->
<div class="inv-card">
<div class="inv-card-title">Odběratel</div>
<div class="inv-grid">
${nestedField("customer", "name", "Název", cust.name, {wide: true})}
${nestedField("customer", "ico", "IČO", cust.ico)}
${nestedField("customer", "dic", "DIČ", cust.dic)}
${nestedField("customer", "address", "Adresa", cust.address, {wide: true})}
</div>
</div>
<!-- Platba -->
<div class="inv-card">
<div class="inv-card-title">Platební údaje</div>
<div class="inv-grid">
${field("bank_account", "Číslo účtu", d.bank_account)}
${field("iban", "IBAN", d.iban)}
</div>
</div>
<!-- Položky -->
<div class="inv-card">
<div class="inv-card-title">Položky faktury</div>
<div class="inv-table-wrap">
<table class="inv-table" id="items-table">
<thead>
<tr>
<th style="min-width:200px">Popis</th>
<th style="width:80px">Množství</th>
<th style="width:70px">Jednotka</th>
<th style="width:110px">Cena/jed. bez DPH</th>
<th style="width:70px">DPH %</th>
<th style="width:110px">Bez DPH</th>
<th style="width:110px">S DPH</th>
<th class="inv-row-actions"></th>
</tr>
</thead>
<tbody id="items-body">
${items.map((it, i) => renderItemRow(it, i)).join("")}
</tbody>
</table>
</div>
<div class="inv-table-foot">
<button class="btn-add-row" type="button" id="add-item-btn">+ Přidat položku</button>
</div>
</div>
${vat.length ? `
<!-- Rekapitulace DPH -->
<div class="inv-card">
<div class="inv-card-title">Rekapitulace DPH</div>
<div class="inv-table-wrap">
<table class="inv-table" id="vat-table">
<thead>
<tr>
<th style="width:100px">Sazba (%)</th>
<th>Základ</th>
<th>DPH</th>
<th>Celkem</th>
<th class="inv-row-actions"></th>
</tr>
</thead>
<tbody id="vat-body">
${vat.map((br, i) => renderVatRow(br, i)).join("")}
</tbody>
</table>
</div>
</div>` : ""}
<!-- Totals -->
<div class="inv-card">
<div class="inv-card-title">Celkem</div>
<div class="totals-row">
<div class="total-cell">
<span class="total-cell-label">Bez DPH</span>
<input class="inv-input total-cell-input" data-key="total_excluding_vat"
type="text" value="${fmtNum(d.total_excluding_vat)}">
</div>
<div class="total-cell">
<span class="total-cell-label">DPH</span>
<input class="inv-input total-cell-input" data-key="total_vat"
type="text" value="${fmtNum(d.total_vat)}">
</div>
<div class="total-cell primary">
<span class="total-cell-label">K úhradě</span>
<input class="inv-input total-cell-input" data-key="total_including_vat"
type="text" value="${fmtNum(d.total_including_vat)}">
</div>
</div>
</div>
${d.notes ? `
<div class="inv-card">
<div class="inv-card-title">Poznámka</div>
<input class="inv-input" data-key="notes" type="text"
value="${escapeHtmlAttr(d.notes)}">
</div>` : ""}
`;
$("invoice-form").innerHTML = html;
bindFieldHandlers();
}
function renderItemRow(item, i) {
return `
<tr data-row="${i}">
<td><input data-field="description" type="text" value="${escapeHtmlAttr(item.description || "")}"></td>
<td class="num"><input data-field="quantity" type="text" value="${fmtNum(item.quantity)}"></td>
<td><input data-field="unit" type="text" value="${escapeHtmlAttr(item.unit || "")}"></td>
<td class="num"><input data-field="unit_price_excluding_vat" type="text" value="${fmtNum(item.unit_price_excluding_vat)}"></td>
<td class="num"><input data-field="vat_rate" type="text" value="${fmtNum(item.vat_rate)}"></td>
<td class="num"><input data-field="total_excluding_vat" type="text" value="${fmtNum(item.total_excluding_vat)}"></td>
<td class="num"><input data-field="total_including_vat" type="text" value="${fmtNum(item.total_including_vat)}"></td>
<td class="inv-row-actions"><button class="inv-row-delete" type="button" data-action="delete-item" title="Smazat">×</button></td>
</tr>`;
}
function renderVatRow(br, i) {
return `
<tr data-row="${i}">
<td class="num"><input data-field="rate" type="text" value="${fmtNum(br.rate)}"></td>
<td class="num"><input data-field="base" type="text" value="${fmtNum(br.base)}"></td>
<td class="num"><input data-field="vat" type="text" value="${fmtNum(br.vat)}"></td>
<td class="num"><input data-field="total" type="text" value="${fmtNum(br.total)}"></td>
<td class="inv-row-actions"><button class="inv-row-delete" type="button" data-action="delete-vat" title="Smazat">×</button></td>
</tr>`;
}
function bindFieldHandlers() {
// Top-level + nested fields
$("invoice-form").querySelectorAll("input[data-key]").forEach((el) => {
el.addEventListener("input", () => {
const key = el.dataset.key;
const parent = el.dataset.parent;
const val = parseFieldValue(key, el.value);
if (parent) {
if (!state.data[parent]) state.data[parent] = {};
state.data[parent][key] = val;
} else {
state.data[key] = val;
}
el.classList.toggle("is-empty", el.value === "");
});
});
// Item rows
const itemsBody = $("items-body");
if (itemsBody) {
itemsBody.addEventListener("input", (e) => {
const inp = e.target.closest("input");
if (!inp) return;
const tr = inp.closest("tr");
const i = +tr.dataset.row;
const field = inp.dataset.field;
if (!state.data.line_items) state.data.line_items = [];
if (!state.data.line_items[i]) state.data.line_items[i] = {};
const numFields = ["quantity", "unit_price_excluding_vat", "vat_rate",
"total_excluding_vat", "total_including_vat"];
state.data.line_items[i][field] =
numFields.includes(field) ? parseNum(inp.value) : inp.value;
});
itemsBody.addEventListener("click", (e) => {
const btn = e.target.closest('[data-action="delete-item"]');
if (!btn) return;
const tr = btn.closest("tr");
const i = +tr.dataset.row;
state.data.line_items.splice(i, 1);
renderForm();
});
}
const addItem = $("add-item-btn");
if (addItem) {
addItem.addEventListener("click", () => {
if (!state.data.line_items) state.data.line_items = [];
state.data.line_items.push({description: "", quantity: null, unit: "",
unit_price_excluding_vat: null, vat_rate: null,
total_excluding_vat: null, total_including_vat: null});
renderForm();
});
}
// VAT rows
const vatBody = $("vat-body");
if (vatBody) {
vatBody.addEventListener("input", (e) => {
const inp = e.target.closest("input");
if (!inp) return;
const tr = inp.closest("tr");
const i = +tr.dataset.row;
const field = inp.dataset.field;
if (!state.data.vat_breakdown) state.data.vat_breakdown = [];
if (!state.data.vat_breakdown[i]) state.data.vat_breakdown[i] = {};
state.data.vat_breakdown[i][field] = parseNum(inp.value);
});
vatBody.addEventListener("click", (e) => {
const btn = e.target.closest('[data-action="delete-vat"]');
if (!btn) return;
const tr = btn.closest("tr");
const i = +tr.dataset.row;
state.data.vat_breakdown.splice(i, 1);
renderForm();
});
}
}
function parseFieldValue(key, str) {
// Numeric top-level fields
const num = ["total_excluding_vat", "total_vat", "total_including_vat"];
if (num.includes(key)) return parseNum(str);
return str;
}
function parseNum(s) {
if (s === "" || s == null) return null;
const n = parseFloat(String(s).replace(",", ".").replace(/\s/g, ""));
return Number.isFinite(n) ? n : null;
}
function fmtNum(v) {
if (v === null || v === undefined || v === "") return "";
if (typeof v === "number") return String(v);
return String(v);
}
// ── Export ──
$("export-btn").addEventListener("click", async () => {
if (!state.jobId) return;
// Persist edits server-side before downloading
await fetch(`/api/save/${state.jobId}`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({data: state.data}),
}).catch(() => {});
window.location.href = `/api/export/${state.jobId}`;
});
$("restart-btn").addEventListener("click", () => {
state = { jobId: null, data: null };
fileInput.value = "";
$("invoice-form").innerHTML = "";
show("upload");
});
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
function escapeHtmlAttr(s) { return escapeHtml(s); }
})();