Files
2026-07-07 13:16:26 +02:00

636 lines
25 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.

// Offer → Excel 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]));
// Close any open combobox menu when clicking outside it (bound once).
document.addEventListener("click", (e) => {
document.querySelectorAll(".combo-menu").forEach((m) => {
if (!m.closest(".combo-wrap").contains(e.target)) m.classList.add("hidden");
});
});
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 || {};
await initOrder();
renderForm();
show("result");
} catch (err) {
alert("Chyba: " + err.message);
show("upload");
}
}
// ── Render editable form ──
function nestedField(parent, key, label, value, opts = {}) {
const isEmpty = value === null || value === undefined || 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="text" value="${escapeHtmlAttr(isEmpty ? "" : value)}"
placeholder="${escapeHtmlAttr(opts.placeholder || "—")}">
</div>`;
}
function renderForm() {
const d = state.data;
const sup = d.supplier || {};
const items = d.line_items || [];
const warn = (d.is_offer === false)
? `<div class="offer-warn">⚠️ Tento dokument zřejmě není dodavatelská
nabídka. Data mohou být neúplná — zkontrolujte je, nebo nahrajte
jiný soubor.</div>`
: "";
const html = `
${warn}
<!-- Dodavatel -->
<div class="inv-card">
<div class="inv-card-title">Dodavatel</div>
<div class="inv-grid">
${nestedField("supplier", "name", "Jméno", sup.name, {wide: true})}
${nestedField("supplier", "ico", "IČO", sup.ico)}
${nestedField("supplier", "dic", "DIČ", sup.dic)}
${nestedField("supplier", "email", "Kontaktní email", sup.email)}
${nestedField("supplier", "address", "Adresa", sup.address, {wide: true})}
</div>
</div>
<!-- Položky -->
<div class="inv-card">
<div class="inv-card-title">Položky (${items.length})</div>
<div class="inv-table-wrap">
<table class="inv-table" id="items-table">
<thead>
<tr>
<th style="min-width:220px">Název zboží</th>
<th style="width:150px">ID zboží dodavatele</th>
<th style="width:90px">Množství</th>
<th style="width:80px">Jednotka</th>
<th style="width:120px">Jednotková cena</th>
<th style="width:120px">Cena celkem</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>
<div id="order-card-wrap">${orderCardHtml()}</div>
`;
$("offer-form").innerHTML = html;
bindFieldHandlers();
bindOrderHandlers();
initDatePickers();
}
function rerenderOrderCard() {
const wrap = $("order-card-wrap");
if (!wrap) return;
wrap.innerHTML = orderCardHtml();
bindOrderHandlers();
initDatePickers();
}
function initDatePickers() {
if (typeof flatpickr === "undefined") return;
if (flatpickr.l10ns && flatpickr.l10ns.cs) flatpickr.localize(flatpickr.l10ns.cs);
document.querySelectorAll(".fp-date").forEach((el) => {
if (el._flatpickr) return;
flatpickr(el, {
dateFormat: "d.m.Y", // matches the PDF / store format
allowInput: true,
disableMobile: true,
onChange: () => {
state.order[el.dataset.order] = el.value;
el.classList.toggle("is-empty", el.value === "");
},
});
});
}
function renderItemRow(item, i) {
return `
<tr data-row="${i}">
<td><input data-field="name" type="text" value="${escapeHtmlAttr(item.name || "")}"></td>
<td><input data-field="supplier_id" type="text" value="${escapeHtmlAttr(item.supplier_id || "")}"></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" type="text" value="${fmtNum(item.unit_price)}"></td>
<td class="num"><input data-field="total_price" type="text" value="${fmtNum(item.total_price)}"></td>
<td class="inv-row-actions"><button class="inv-row-delete" type="button" data-action="delete-item" title="Smazat">×</button></td>
</tr>`;
}
function bindFieldHandlers() {
$("offer-form").querySelectorAll("input[data-key]").forEach((el) => {
el.addEventListener("input", () => {
const key = el.dataset.key;
const parent = el.dataset.parent;
if (parent) {
if (!state.data[parent]) state.data[parent] = {};
state.data[parent][key] = el.value;
} else {
state.data[key] = el.value;
}
el.classList.toggle("is-empty", el.value === "");
});
});
const itemsBody = $("items-body");
if (itemsBody) {
itemsBody.addEventListener("input", (e) => {
const inp = e.target.closest("input");
if (!inp) return;
const i = +inp.closest("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", "total_price"];
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 i = +btn.closest("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({name: "", supplier_id: "", quantity: null,
unit: "", unit_price: null, total_price: null});
renderForm();
});
}
}
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 "";
return String(v);
}
// ── Export ──
$("export-btn").addEventListener("click", async () => {
if (!state.jobId) return;
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 = "";
$("offer-form").innerHTML = "";
show("upload");
});
// ── Objednávka (order form) ──────────────────────────────────
function sumTotals() {
return (state.data.line_items || []).reduce(
(a, it) => a + (Number(it.total_price) || 0), 0);
}
async function initOrder() {
const d = state.data;
const offerNo = d.offer_number || "";
state.order = {
objednavka_number: "",
kod_zakazky: "",
nazev_zakazky: "",
dodavatel: d.supplier || {},
text: "Objednáváme u vás zboží dle cenové nabídky " + offerNo,
prilohy: offerNo,
termin_dodani_text: "Co nejdříve",
misto_dodani: "",
kontaktni_osoba: "",
splatnost: "dle VNP nebo VSP",
zaruka: "",
poznamka: "",
vystavil: "",
popis: "",
zpusob_platby: "Bankovním převodem",
zpusob_odberu: "-",
zpusob_dopravy: "-",
forma_objednavky: "mailem",
termin_dodani: "",
fakturujte: "s DPH",
};
state.projects = [];
state.orderHistory = {};
try {
const r = await fetch("api/projects");
if (r.ok) {
const j = await r.json();
state.projects = j.projects || [];
if (j.next_order_number) state.order.objednavka_number = j.next_order_number;
}
} catch (_) {}
}
// Body fields. `hist` = remembered per project, shown with a history
// quick-pick (most-used first).
const ORDER_FIELDS = [
["text", "Text", true, false],
["termin_dodani_text", "Termín dodání (text)", false, false],
["misto_dodani", "Místo dodání", true, true],
["kontaktni_osoba", "Kontaktní osoba v místě dodání", true, true],
["splatnost", "Splatnost", false, true],
["zaruka", "Záruka", false, true],
["poznamka", "Poznámka", false, false],
["prilohy", "Přílohy", false, false],
["vystavil", "Vystavil", false, true],
["popis", "Popis", false, true],
];
function fieldHtml(key, label, wide, hist) {
return hist ? historyField(key, label, wide) : textField(key, label, wide);
}
// ── date helpers (PDF/store use DD.MM.YYYY, <input type=date> uses ISO) ──
function toDateInput(s) {
s = (s == null ? "" : String(s)).trim();
let m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (m) return `${m[3]}-${m[2].padStart(2,"0")}-${m[1].padStart(2,"0")}`;
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
return "";
}
function fromDateInput(s) {
const m = (s || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
return m ? `${+m[3]}.${+m[2]}.${m[1]}` : (s || "");
}
// Combobox: type freely OR pick a previously-used value from a themed
// dropdown attached to the field; each option can be deleted from history.
function comboMenuHtml(key, filter) {
const hist = (state.orderHistory && state.orderHistory[key]) || [];
const f = (filter || "").toLowerCase();
const items = hist.filter((h) => !f || String(h.value).toLowerCase().includes(f));
if (!items.length) return '<div class="combo-empty">Žádné dříve použité</div>';
return items.map((h) => `
<div class="combo-opt" data-val="${escapeHtmlAttr(h.value)}">
<span class="combo-opt-text">${escapeHtml(h.value)}
<span class="combo-opt-count">${h.count}×</span></span>
<button class="combo-del" type="button" title="Smazat z historie">×</button>
</div>`).join("");
}
function historyField(key, label, wide) {
const v = state.order[key];
const cur = v == null ? "" : String(v);
const hasHist = ((state.orderHistory && state.orderHistory[key]) || []).length > 0;
return `
<div class="inv-field ${wide ? "inv-field-wide" : ""}">
<label class="inv-label">${escapeHtml(label)}</label>
<div class="combo-wrap" data-combo="${key}">
<input class="inv-input combo-input${cur === "" ? " is-empty" : ""}" data-order="${key}"
type="text" autocomplete="off" value="${escapeHtmlAttr(cur)}">
${hasHist ? '<button class="combo-toggle" type="button" tabindex="-1" aria-label="Dříve použité">⌄</button>' : ""}
<div class="combo-menu hidden">${comboMenuHtml(key)}</div>
</div>
</div>`;
}
// Dodací a platební podmínky — selectable presets (editable: type or pick).
const TERMS_FIELDS = [
["zpusob_platby", "Způsob platby", ["Bankovním převodem", "Hotově", "Kartou", "Zálohově", "Dobírka"]],
["forma_objednavky", "Forma objednávky", ["mailem", "telefonicky", "osobně", "písemně"]],
["zpusob_odberu", "Způsob odběru", ["-", "Osobní odběr", "Dodání na stavbu", "Dopravcem"]],
["termin_dodani", "Termín dodání", []],
["zpusob_dopravy", "Způsob dopravy", ["-", "Dodavatel", "Vlastní", "Dopravce"]],
["fakturujte", "Fakturujte", ["s DPH", "bez DPH"]],
];
function textField(key, label, wide) {
const v = state.order[key];
const isEmpty = v === null || v === undefined || v === "";
return `
<div class="inv-field ${wide ? "inv-field-wide" : ""}">
<label class="inv-label">${escapeHtml(label)}</label>
<input class="inv-input${isEmpty ? " is-empty" : ""}" data-order="${key}"
type="text" value="${escapeHtmlAttr(isEmpty ? "" : v)}">
</div>`;
}
function comboField(key, label, options) {
const v = state.order[key];
const cur = v == null ? "" : String(v);
// Termín dodání → flatpickr date picker (value stays DD.MM.YYYY).
if (key === "termin_dodani") {
return `
<div class="inv-field">
<label class="inv-label">${escapeHtml(label)}</label>
<input class="inv-input fp-date${cur === "" ? " is-empty" : ""}" data-order="${key}"
type="text" placeholder="dd.mm.rrrr" value="${escapeHtmlAttr(cur)}">
</div>`;
}
// Real dropdown: history (most-used first) + presets + any custom value.
const histVals = ((state.orderHistory && state.orderHistory[key]) || [])
.map((h) => h.value);
const vals = [];
for (const x of [...histVals, ...(options || []), cur]) {
if (x && !vals.includes(x)) vals.push(x);
}
const opts = ['<option value=""></option>']
.concat(vals.map((o) =>
`<option value="${escapeHtmlAttr(o)}"${o === cur ? " selected" : ""}>${escapeHtml(o)}</option>`))
.join("");
return `
<div class="inv-field">
<label class="inv-label">${escapeHtml(label)}</label>
<select class="inv-input" data-order="${key}">${opts}</select>
</div>`;
}
function projectOptions() {
const cur = state.order.kod_zakazky || "";
const opts = ['<option value="">— vyberte zakázku —</option>'];
for (const p of state.projects || []) {
const sel = p.kod === cur ? " selected" : "";
const label = p.nazev ? `${p.kod}${p.nazev}` : p.kod;
opts.push(`<option value="${escapeHtmlAttr(p.kod)}"${sel}>${escapeHtml(label)}</option>`);
}
return opts.join("");
}
function orderCardHtml() {
return `
<div class="inv-card">
<div class="inv-card-title">Objednávka (vyplní se do PDF)</div>
<p class="order-hint">Zakázku vyberte z nabídky (nebo přidejte přes
<strong>Spravovat zakázky</strong>). Pole jako <strong>Místo dodání</strong>,
kontaktní osoba či Vystavil se pamatují podle zakázky. Číslo objednávky
se předvyplní jako poslední&nbsp;+&nbsp;1. Cena celkem z položek:
${fmtCzk(sumTotals())}.</p>
<div class="inv-grid">
${textField("objednavka_number", "Číslo objednávky", false)}
<div class="inv-field">
<label class="inv-label">Kód zakázky / NS</label>
<div class="proj-row">
<select class="inv-input" id="proj-select">${projectOptions()}</select>
<button class="btn btn-secondary btn-sm" id="proj-manage" type="button">Spravovat</button>
</div>
</div>
${textField("nazev_zakazky", "Název zakázky", true)}
${ORDER_FIELDS.map(([k, l, w, h]) => fieldHtml(k, l, w, h)).join("")}
</div>
<div class="terms-subtitle">Dodací a platební podmínky</div>
<div class="inv-grid">
${TERMS_FIELDS.map(([k, l, o]) => comboField(k, l, o)).join("")}
</div>
<div class="proj-panel hidden" id="proj-panel"></div>
</div>`;
}
function fmtCzk(n) {
return (Number(n) || 0).toLocaleString("cs-CZ", {minimumFractionDigits: 2, maximumFractionDigits: 2}) + " Kč";
}
function applyOrderToInputs() {
document.querySelectorAll('[data-order]').forEach((inp) => {
const k = inp.dataset.order;
if (state.order[k] != null) {
inp.value = state.order[k];
inp.classList.toggle("is-empty", inp.value === "");
}
});
document.querySelectorAll('[data-order-date]').forEach((inp) => {
const k = inp.dataset.orderDate;
inp.value = toDateInput(state.order[k]);
});
}
async function loadProjectDefaults(code) {
if (!code) { state.orderHistory = {}; return; }
try {
const r = await fetch(`api/order-defaults?code=${encodeURIComponent(code)}`);
if (!r.ok) return;
const { defaults } = await r.json();
if (defaults && Object.keys(defaults).length) {
state.orderHistory = defaults._history || {};
delete defaults._history;
Object.assign(state.order, defaults);
// Re-render so history dropdowns reflect the selected project.
rerenderOrderCard();
}
} catch (_) {}
}
function renderProjPanel() {
const rows = (state.projects || []).map((p) => `
<div class="proj-item" data-kod="${escapeHtmlAttr(p.kod)}">
<span class="proj-kod">${escapeHtml(p.kod)}</span>
<input class="inv-input proj-nazev" value="${escapeHtmlAttr(p.nazev || "")}" placeholder="Název zakázky">
<button class="btn btn-secondary btn-sm proj-save" type="button">Uložit</button>
<button class="btn btn-secondary btn-sm proj-del" type="button">Smazat</button>
</div>`).join("");
return `
<div class="proj-add">
<input class="inv-input" id="proj-new-kod" placeholder="Číslo zakázky (kód/NS)">
<input class="inv-input" id="proj-new-nazev" placeholder="Název zakázky">
<button class="btn btn-primary btn-sm" id="proj-add-btn" type="button">Přidat</button>
</div>
<div class="proj-list">${rows || '<p class="order-hint">Zatím žádné zakázky.</p>'}</div>`;
}
async function refreshProjects(list) {
state.projects = list || state.projects;
const sel = $("proj-select");
if (sel) sel.innerHTML = projectOptions();
const panel = $("proj-panel");
if (panel && !panel.classList.contains("hidden")) panel.innerHTML = renderProjPanel();
}
function bindOrderHandlers() {
document.querySelectorAll('[data-order]').forEach((el) => {
const upd = () => {
state.order[el.dataset.order] = el.value;
el.classList.toggle("is-empty", el.value === "");
};
el.addEventListener("input", upd);
el.addEventListener("change", upd); // <select> fires change
});
// Date pickers store DD.MM.YYYY in the order.
document.querySelectorAll('[data-order-date]').forEach((el) => {
el.addEventListener("change", () => {
state.order[el.dataset.orderDate] = fromDateInput(el.value);
el.classList.toggle("is-empty", el.value === "");
});
});
// Combobox fields: open menu on focus/toggle, filter on type, pick or
// delete a previously-used value.
document.querySelectorAll(".combo-wrap").forEach((wrap) => {
const key = wrap.dataset.combo;
const input = wrap.querySelector(".combo-input");
const menu = wrap.querySelector(".combo-menu");
const toggle = wrap.querySelector(".combo-toggle");
const open = () => { menu.innerHTML = comboMenuHtml(key, input.value); menu.classList.remove("hidden"); };
const close = () => menu.classList.add("hidden");
if (toggle) toggle.addEventListener("click", (e) => {
e.stopPropagation();
menu.classList.contains("hidden") ? open() : close();
});
input.addEventListener("focus", open);
input.addEventListener("input", open); // value already synced by generic handler
menu.addEventListener("click", async (e) => {
const del = e.target.closest(".combo-del");
if (del) {
e.stopPropagation();
const val = del.closest(".combo-opt").dataset.val;
const code = state.order.kod_zakazky;
if (!code) { menu.querySelector(`[data-val="${CSS.escape(val)}"]`)?.remove(); return; }
try {
const r = await fetch("api/order-history/delete", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({ code, field: key, value: val }),
});
if (r.ok) {
state.orderHistory = (await r.json()).defaults._history || {};
menu.innerHTML = comboMenuHtml(key, input.value);
}
} catch (_) {}
return;
}
const opt = e.target.closest(".combo-opt");
if (opt) {
input.value = opt.dataset.val;
input.classList.remove("is-empty");
state.order[key] = opt.dataset.val;
close();
}
});
});
const sel = $("proj-select");
if (sel) sel.addEventListener("change", async () => {
const code = sel.value;
state.order.kod_zakazky = code;
const p = (state.projects || []).find((x) => x.kod === code);
state.order.nazev_zakazky = p ? (p.nazev || "") : "";
applyOrderToInputs();
await loadProjectDefaults(code);
});
const manage = $("proj-manage");
if (manage) manage.addEventListener("click", () => {
const panel = $("proj-panel");
panel.classList.toggle("hidden");
if (!panel.classList.contains("hidden")) {
panel.innerHTML = renderProjPanel();
panel.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
const panel = $("proj-panel");
if (panel) {
panel.addEventListener("click", async (e) => {
const addBtn = e.target.closest("#proj-add-btn");
if (addBtn) {
const kod = $("proj-new-kod").value.trim();
const nazev = $("proj-new-nazev").value.trim();
if (!kod) { alert("Zadejte číslo zakázky."); return; }
const r = await fetch("api/projects", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({kod, nazev})});
if (r.ok) { refreshProjects((await r.json()).projects); }
return;
}
const item = e.target.closest(".proj-item");
if (!item) return;
const kod = item.dataset.kod;
if (e.target.closest(".proj-save")) {
const nazev = item.querySelector(".proj-nazev").value.trim();
const r = await fetch("api/projects", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({kod, nazev})});
if (r.ok) refreshProjects((await r.json()).projects);
} else if (e.target.closest(".proj-del")) {
if (!confirm(`Smazat zakázku ${kod}?`)) return;
const r = await fetch(`api/projects/${encodeURIComponent(kod)}`, {method:"DELETE"});
if (r.ok) refreshProjects((await r.json()).projects);
}
});
}
}
$("order-pdf-btn").addEventListener("click", async () => {
if (!state.jobId) return;
const btn = $("order-pdf-btn");
const old = btn.textContent;
btn.disabled = true; btn.textContent = "Generuji…";
try {
// keep supplier in sync as dodavatel
state.order.dodavatel = state.data.supplier || {};
state.order.cena_celkem = sumTotals();
const r = await fetch(`api/order-pdf/${state.jobId}`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ order: state.order, items: state.data.line_items || [] }),
});
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const cd = r.headers.get("content-disposition") || "";
const m = cd.match(/filename="?([^"]+)"?/);
const blob = await r.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = m ? m[1] : "objednavka.pdf";
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 60000);
} catch (err) {
alert("Chyba: " + err.message);
} finally {
btn.disabled = false; btn.textContent = old;
}
});
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
function escapeHtmlAttr(s) { return escapeHtml(s); }
})();