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

353 lines
14 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.

// 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); }
})();