// 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 `
`;
}
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 `
`;
}
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 faktury
${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)}
Dodavatel
${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})}
Odběratel
${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})}
Platební údaje
${field("bank_account", "Číslo účtu", d.bank_account)}
${field("iban", "IBAN", d.iban)}
Položky faktury
| Popis |
Množství |
Jednotka |
Cena/jed. bez DPH |
DPH % |
Bez DPH |
S DPH |
|
${items.map((it, i) => renderItemRow(it, i)).join("")}
${vat.length ? `
Rekapitulace DPH
| Sazba (%) |
Základ |
DPH |
Celkem |
|
${vat.map((br, i) => renderVatRow(br, i)).join("")}
` : ""}
${d.notes ? `
` : ""}
`;
$("invoice-form").innerHTML = html;
bindFieldHandlers();
}
function renderItemRow(item, i) {
return `
|
|
|
|
|
|
|
|
`;
}
function renderVatRow(br, i) {
return `
|
|
|
|
|
`;
}
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); }
})();