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
353 lines
14 KiB
JavaScript
353 lines
14 KiB
JavaScript
// 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); }
|
||
})();
|