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:
352
invoice-extractor/static/app.js
Normal file
352
invoice-extractor/static/app.js
Normal 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) =>
|
||||
({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
function escapeHtmlAttr(s) { return escapeHtml(s); }
|
||||
})();
|
||||
Reference in New Issue
Block a user