Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
990 lines
36 KiB
JavaScript
990 lines
36 KiB
JavaScript
const addItemButton = document.getElementById("addItemButton");
|
||
const itemsContainer = document.getElementById("itemsContainer");
|
||
const printInvoiceButton = document.getElementById("printInvoiceButton");
|
||
const saveInvoiceButton = document.getElementById("saveInvoiceButton");
|
||
const invoiceSaveStatus = document.getElementById("invoiceSaveStatus");
|
||
const invoiceNumberDisplay = document.getElementById("invoiceNumberDisplay");
|
||
const invoiceList = document.getElementById("invoiceList");
|
||
const invoiceListScreen = document.getElementById("invoice-list-screen");
|
||
const invoiceEditorScreen = document.getElementById("invoice-editor-screen");
|
||
const invoiceListNavButton = document.getElementById("invoiceListNavButton");
|
||
const newInvoiceButton = document.getElementById("newInvoiceButton");
|
||
const exportInvoicesButton = document.getElementById("exportInvoicesButton");
|
||
const deleteAllInvoicesButton = document.getElementById("deleteAllInvoicesButton");
|
||
const currentViewLabel = document.getElementById("currentViewLabel");
|
||
const saveSupplierButton = document.getElementById("saveSupplierButton");
|
||
const loadSupplierButton = document.getElementById("loadSupplierButton");
|
||
const supplierSaveStatus = document.getElementById("supplierSaveStatus");
|
||
const clientSelect = document.getElementById("clientSelect");
|
||
const clientTypeSelect = document.getElementById("odberatelTypSubjektu");
|
||
const personFields = document.getElementById("personFields");
|
||
const companyFields = document.getElementById("companyFields");
|
||
const supplierTypeSelect = document.getElementById("dodavatelTypSubjektu");
|
||
const supplierPersonFields = document.getElementById("supplierPersonFields");
|
||
const supplierCompanyFields = document.getElementById("supplierCompanyFields");
|
||
const saveClientButton = document.getElementById("saveClientButton");
|
||
const deleteClientButton = document.getElementById("deleteClientButton");
|
||
const authScreen = document.getElementById("auth-screen");
|
||
const authForm = document.getElementById("auth-form");
|
||
const authEmail = document.getElementById("authEmail");
|
||
const authPassword = document.getElementById("authPassword");
|
||
const authSubmitButton = document.getElementById("authSubmitButton");
|
||
const authModeButton = document.getElementById("authModeButton");
|
||
const forgotPasswordButton = document.getElementById("forgotPasswordButton");
|
||
const authStatus = document.getElementById("authStatus");
|
||
const logoutButton = document.getElementById("logoutButton");
|
||
const signedInEmail = document.getElementById("navEmail");
|
||
const passwordButton = document.getElementById("passwordButton");
|
||
const passwordPanel = document.getElementById("passwordPanel");
|
||
const passwordForm = document.getElementById("passwordForm");
|
||
const savePasswordButton = document.getElementById("savePasswordButton");
|
||
const cancelPasswordButton = document.getElementById("cancelPasswordButton");
|
||
const passwordStatus = document.getElementById("passwordStatus");
|
||
const numberingSettingsButton = document.getElementById("numberingSettingsButton");
|
||
const numberingSettingsPanel = document.getElementById("numberingSettingsPanel");
|
||
const invoiceStartingNumberInput = document.getElementById("invoiceStartingNumber");
|
||
const saveNumberingSettingsButton = document.getElementById("saveNumberingSettingsButton");
|
||
const cancelNumberingSettingsButton = document.getElementById("cancelNumberingSettingsButton");
|
||
const numberingSettingsStatus = document.getElementById("numberingSettingsStatus");
|
||
const issueDateInput = document.getElementById("issueDate");
|
||
const dueDateInput = document.getElementById("dueDate");
|
||
const paymentPresetSelect = document.getElementById("paymentPresetSelect");
|
||
const paymentPresetName = document.getElementById("paymentPresetName");
|
||
const savePaymentPresetButton = document.getElementById("savePaymentPresetButton");
|
||
const signatureDropzone = document.getElementById("signatureDropzone");
|
||
const signatureInput = document.getElementById("signatureInput");
|
||
const signaturePreview = document.getElementById("signaturePreview");
|
||
const signatureDropzoneText = document.getElementById("signatureDropzoneText");
|
||
const removeSignatureButton = document.getElementById("removeSignatureButton");
|
||
const signatureStatus = document.getElementById("signatureStatus");
|
||
const signaturePresetSelect = document.getElementById("signaturePresetSelect");
|
||
const signaturePresetName = document.getElementById("signaturePresetName");
|
||
const saveSignaturePresetButton = document.getElementById("saveSignaturePresetButton");
|
||
const deleteSignaturePresetButton = document.getElementById("deleteSignaturePresetButton");
|
||
const loadPaymentPresetButton = document.getElementById("loadPaymentPresetButton");
|
||
const deletePaymentPresetButton = document.getElementById("deletePaymentPresetButton");
|
||
|
||
document.querySelectorAll(".nav-menu button").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
button.closest("details").open = false;
|
||
});
|
||
});
|
||
|
||
document.addEventListener("click", (event) => {
|
||
document.querySelectorAll(".nav-menu[open]").forEach((menu) => {
|
||
if (!menu.contains(event.target)) {
|
||
menu.open = false;
|
||
}
|
||
});
|
||
});
|
||
|
||
let itemIndex = 0;
|
||
let clients = [];
|
||
let invoices = [];
|
||
let paymentPresets = [];
|
||
let signaturePresets = [];
|
||
let currentInvoiceId = null;
|
||
let isRegistrationMode = false;
|
||
let signatureDataUrl = null;
|
||
|
||
const SIGNATURE_WIDTH = 420;
|
||
const SIGNATURE_HEIGHT = 210;
|
||
|
||
const setSignatureStatus = (message, isError = false) => {
|
||
signatureStatus.textContent = message || "";
|
||
signatureStatus.classList.toggle("is-error", Boolean(isError));
|
||
};
|
||
|
||
const applySignaturePreview = (dataUrl) => {
|
||
signatureDataUrl = dataUrl || null;
|
||
if (signatureDataUrl) {
|
||
signaturePreview.src = signatureDataUrl;
|
||
signaturePreview.hidden = false;
|
||
signatureDropzoneText.hidden = true;
|
||
removeSignatureButton.hidden = false;
|
||
} else {
|
||
signaturePreview.hidden = true;
|
||
signaturePreview.removeAttribute("src");
|
||
signatureDropzoneText.hidden = false;
|
||
removeSignatureButton.hidden = true;
|
||
}
|
||
};
|
||
|
||
const renderSignaturePresetOptions = (selectedId = signaturePresetSelect.value) => {
|
||
signaturePresetSelect.innerHTML = "<option value=\"\">Vyberte podpis</option>";
|
||
signaturePresets.forEach((preset) => {
|
||
const option = document.createElement("option");
|
||
option.value = preset.id;
|
||
option.textContent = preset.name;
|
||
signaturePresetSelect.appendChild(option);
|
||
});
|
||
signaturePresetSelect.value = selectedId;
|
||
deleteSignaturePresetButton.disabled = !signaturePresetSelect.value;
|
||
};
|
||
|
||
const loadSignaturePresets = async () => {
|
||
const response = await apiRequest("/api/signature-presets");
|
||
signaturePresets = response.presets;
|
||
renderSignaturePresetOptions();
|
||
};
|
||
|
||
const readImageDimensions = (dataUrl) => new Promise((resolve, reject) => {
|
||
const image = new Image();
|
||
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
|
||
image.onerror = () => reject(new Error("Obrázek se nepodařilo načíst."));
|
||
image.src = dataUrl;
|
||
});
|
||
|
||
const handleSignatureFile = async (file) => {
|
||
if (!file) {
|
||
return;
|
||
}
|
||
if (!["image/png", "image/jpeg"].includes(file.type)) {
|
||
setSignatureStatus("Podporovány jsou pouze soubory JPG nebo PNG.", true);
|
||
return;
|
||
}
|
||
try {
|
||
const dataUrl = await new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result);
|
||
reader.onerror = () => reject(new Error("Soubor se nepodařilo načíst."));
|
||
reader.readAsDataURL(file);
|
||
});
|
||
const { width, height } = await readImageDimensions(dataUrl);
|
||
if (width !== SIGNATURE_WIDTH || height !== SIGNATURE_HEIGHT) {
|
||
setSignatureStatus(`Obrázek musí mít rozměry přesně ${SIGNATURE_WIDTH}×${SIGNATURE_HEIGHT} px (nahráno ${width}×${height} px).`, true);
|
||
return;
|
||
}
|
||
applySignaturePreview(dataUrl);
|
||
setSignatureStatus("Podpis byl nahrán.");
|
||
} catch (error) {
|
||
setSignatureStatus(error.message, true);
|
||
}
|
||
};
|
||
|
||
signatureDropzone.addEventListener("click", () => signatureInput.click());
|
||
signatureDropzone.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
signatureInput.click();
|
||
}
|
||
});
|
||
signatureDropzone.addEventListener("dragover", (event) => {
|
||
event.preventDefault();
|
||
signatureDropzone.classList.add("is-dragover");
|
||
});
|
||
signatureDropzone.addEventListener("dragleave", () => {
|
||
signatureDropzone.classList.remove("is-dragover");
|
||
});
|
||
signatureDropzone.addEventListener("drop", (event) => {
|
||
event.preventDefault();
|
||
signatureDropzone.classList.remove("is-dragover");
|
||
handleSignatureFile(event.dataTransfer.files[0]);
|
||
});
|
||
signatureInput.addEventListener("change", () => {
|
||
handleSignatureFile(signatureInput.files[0]);
|
||
signatureInput.value = "";
|
||
});
|
||
signaturePresetSelect.addEventListener("change", () => {
|
||
const preset = signaturePresets.find(({ id }) => String(id) === signaturePresetSelect.value);
|
||
deleteSignaturePresetButton.disabled = !preset;
|
||
if (preset) {
|
||
signaturePresetName.value = preset.name;
|
||
applySignaturePreview(preset.dataUrl);
|
||
setSignatureStatus("Uložený podpis načten.");
|
||
}
|
||
});
|
||
saveSignaturePresetButton.addEventListener("click", async () => {
|
||
if (!signatureDataUrl) {
|
||
setSignatureStatus("Nejprve nahrajte nebo vyberte podpis.", true);
|
||
return;
|
||
}
|
||
try {
|
||
const response = await apiRequest("/api/signature-presets", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
name: signaturePresetName.value,
|
||
dataUrl: signatureDataUrl
|
||
})
|
||
});
|
||
const preset = response.preset;
|
||
signaturePresets = [
|
||
...signaturePresets.filter((entry) => entry.id !== preset.id && entry.name !== preset.name),
|
||
preset
|
||
].sort((left, right) => left.name.localeCompare(right.name));
|
||
renderSignaturePresetOptions(String(preset.id));
|
||
signaturePresetName.value = preset.name;
|
||
setSignatureStatus("Podpis byl uložen.");
|
||
} catch (error) {
|
||
setSignatureStatus(error.message, true);
|
||
}
|
||
});
|
||
deleteSignaturePresetButton.addEventListener("click", async () => {
|
||
if (!signaturePresetSelect.value) {
|
||
return;
|
||
}
|
||
try {
|
||
await apiRequest(`/api/signature-presets/${signaturePresetSelect.value}`, { method: "DELETE" });
|
||
signaturePresets = signaturePresets.filter(({ id }) => String(id) !== signaturePresetSelect.value);
|
||
signaturePresetName.value = "";
|
||
renderSignaturePresetOptions();
|
||
} catch (error) {
|
||
setSignatureStatus(error.message, true);
|
||
}
|
||
});
|
||
removeSignatureButton.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
applySignaturePreview(null);
|
||
setSignatureStatus("");
|
||
});
|
||
|
||
const profileFieldMap = {
|
||
nazevSpolecnosti: "NazevSpolecnosti",
|
||
ico: "Ico",
|
||
jmeno: "Jmeno",
|
||
prijmeni: "Prijmeni",
|
||
uliceCp: "UliceCp",
|
||
psc: "Psc",
|
||
mesto: "Mesto"
|
||
};
|
||
|
||
const readProfileFields = (prefix) => Object.fromEntries(
|
||
Object.entries(profileFieldMap).map(([key, suffix]) => [
|
||
key,
|
||
document.getElementById(`${prefix}${suffix}`).value.trim()
|
||
])
|
||
);
|
||
|
||
const writeProfileFields = (prefix, fields) => Object.entries(profileFieldMap).forEach(([key, suffix]) => {
|
||
const field = document.getElementById(`${prefix}${suffix}`);
|
||
if (field) {
|
||
field.value = fields[key] || "";
|
||
}
|
||
});
|
||
|
||
const writeFields = (fields) => Object.entries(fields).forEach(([id, value]) => {
|
||
const field = document.getElementById(id);
|
||
if (field) {
|
||
field.value = value || "";
|
||
}
|
||
});
|
||
|
||
const getClientLabel = (client) => [
|
||
client.data.nazevSpolecnosti,
|
||
[client.data.jmeno, client.data.prijmeni].filter(Boolean).join(" "),
|
||
client.data.ico && `IČO: ${client.data.ico}`
|
||
].filter(Boolean).join(" ") || "Bez názvu";
|
||
|
||
const formatCzechDate = (dateValue) => new Date(`${dateValue}T00:00:00`).toLocaleDateString("cs-CZ");
|
||
|
||
const toDateInputValue = (date) => date.toISOString().slice(0, 10);
|
||
|
||
const getIssueDate = () => issueDateInput.value || toDateInputValue(new Date());
|
||
|
||
const getDueDate = () => {
|
||
if (dueDateInput.value) {
|
||
return dueDateInput.value;
|
||
}
|
||
const dueDate = new Date(`${getIssueDate()}T00:00:00`);
|
||
dueDate.setDate(dueDate.getDate() + 14);
|
||
return toDateInputValue(dueDate);
|
||
};
|
||
|
||
const updateClientTypeFields = () => {
|
||
const isCompany = clientTypeSelect.value === "spolecnost";
|
||
personFields.hidden = isCompany;
|
||
companyFields.hidden = !isCompany;
|
||
};
|
||
|
||
const updateSupplierTypeFields = () => {
|
||
const isCompany = supplierTypeSelect.value === "spolecnost";
|
||
supplierPersonFields.hidden = isCompany;
|
||
supplierCompanyFields.hidden = !isCompany;
|
||
};
|
||
|
||
const apiRequest = async (url, options = {}) => {
|
||
const response = await fetch(url, {
|
||
...options,
|
||
credentials: "same-origin",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
...(options.headers || {})
|
||
}
|
||
});
|
||
const data = response.status === 204 ? null : await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(data?.error || "Požadavek se nepodařilo dokončit.");
|
||
}
|
||
return data;
|
||
};
|
||
|
||
const showInvoiceScreen = (screen) => {
|
||
invoiceListScreen.hidden = screen !== "list";
|
||
invoiceEditorScreen.hidden = screen !== "editor";
|
||
currentViewLabel.textContent = screen === "list"
|
||
? "Seznam faktur"
|
||
: (currentInvoiceId ? "Upravit fakturu" : "Nová faktura");
|
||
};
|
||
|
||
const renderPaymentPresetOptions = (selectedId = paymentPresetSelect.value) => {
|
||
paymentPresetSelect.innerHTML = "<option value=\"\">Vyberte konfiguraci</option>";
|
||
paymentPresets.forEach((preset) => {
|
||
const option = document.createElement("option");
|
||
option.value = preset.id;
|
||
option.textContent = preset.name;
|
||
paymentPresetSelect.appendChild(option);
|
||
});
|
||
paymentPresetSelect.value = selectedId;
|
||
deletePaymentPresetButton.disabled = !paymentPresetSelect.value;
|
||
};
|
||
|
||
const loadPaymentPresets = async () => {
|
||
const response = await apiRequest("/api/payment-presets");
|
||
paymentPresets = response.presets;
|
||
renderPaymentPresetOptions();
|
||
};
|
||
|
||
paymentPresetSelect.addEventListener("change", () => {
|
||
const preset = paymentPresets.find(({ id }) => String(id) === paymentPresetSelect.value);
|
||
if (!preset) {
|
||
deletePaymentPresetButton.disabled = true;
|
||
return;
|
||
}
|
||
paymentPresetName.value = preset.name;
|
||
deletePaymentPresetButton.disabled = false;
|
||
});
|
||
|
||
loadPaymentPresetButton.addEventListener("click", () => {
|
||
const preset = paymentPresets.find(({ id }) => String(id) === paymentPresetSelect.value);
|
||
if (!preset) {
|
||
window.alert("Nejprve vyberte uložené platební údaje.");
|
||
return;
|
||
}
|
||
document.getElementById("cisloUctu").value = preset.accountNumber;
|
||
document.getElementById("iban").value = preset.iban;
|
||
document.getElementById("swift").value = preset.swift;
|
||
});
|
||
|
||
savePaymentPresetButton.addEventListener("click", async () => {
|
||
try {
|
||
const response = await apiRequest("/api/payment-presets", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
name: paymentPresetName.value,
|
||
accountNumber: getValue("cisloUctu"),
|
||
iban: getValue("iban"),
|
||
swift: getValue("swift")
|
||
})
|
||
});
|
||
const preset = response.preset;
|
||
paymentPresets = [
|
||
...paymentPresets.filter((entry) => entry.id !== preset.id && entry.name !== preset.name),
|
||
preset
|
||
].sort((left, right) => left.name.localeCompare(right.name));
|
||
renderPaymentPresetOptions(String(preset.id));
|
||
paymentPresetName.value = preset.name;
|
||
} catch (error) {
|
||
window.alert(error.message);
|
||
}
|
||
});
|
||
|
||
deletePaymentPresetButton.addEventListener("click", async () => {
|
||
if (!paymentPresetSelect.value) {
|
||
return;
|
||
}
|
||
try {
|
||
await apiRequest(`/api/payment-presets/${paymentPresetSelect.value}`, { method: "DELETE" });
|
||
paymentPresets = paymentPresets.filter(({ id }) => String(id) !== paymentPresetSelect.value);
|
||
paymentPresetName.value = "";
|
||
renderPaymentPresetOptions();
|
||
} catch (error) {
|
||
window.alert(error.message);
|
||
}
|
||
});
|
||
|
||
const readInvoiceData = () => ({
|
||
issueDate: getIssueDate(),
|
||
dueDate: getDueDate(),
|
||
supplier: {
|
||
...readProfileFields("dodavatel"),
|
||
typSubjektu: supplierTypeSelect.value
|
||
},
|
||
customer: {
|
||
...readProfileFields("odberatel"),
|
||
typSubjektu: clientTypeSelect.value
|
||
},
|
||
payment: {
|
||
cisloUctu: getValue("cisloUctu"),
|
||
iban: getValue("iban"),
|
||
swift: getValue("swift")
|
||
},
|
||
signature: signatureDataUrl,
|
||
total: Number.parseFloat(getValue("total").replace(",", ".")) || 0,
|
||
items: Array.from(itemsContainer.querySelectorAll(".invoice-item")).map((item) => {
|
||
const inputs = item.querySelectorAll("input");
|
||
return {
|
||
popis: inputs[0].value.trim(),
|
||
mnozstvi: Number(inputs[1].value) || 0,
|
||
mernaJednotka: inputs[2].value.trim(),
|
||
cenaZaMj: Number(inputs[3].value) || 0
|
||
};
|
||
})
|
||
});
|
||
|
||
const clearInvoiceEditor = () => {
|
||
currentInvoiceId = null;
|
||
invoiceNumberDisplay.textContent = "Nová faktura";
|
||
invoiceSaveStatus.textContent = "";
|
||
issueDateInput.value = "";
|
||
dueDateInput.value = "";
|
||
writeProfileFields("odberatel", {});
|
||
clientSelect.value = "";
|
||
clientTypeSelect.value = "osoba";
|
||
updateClientTypeFields();
|
||
paymentPresetSelect.value = "";
|
||
paymentPresetName.value = "";
|
||
deletePaymentPresetButton.disabled = true;
|
||
signaturePresetSelect.value = "";
|
||
signaturePresetName.value = "";
|
||
deleteSignaturePresetButton.disabled = true;
|
||
["cisloUctu", "iban", "swift"].forEach((id) => {
|
||
document.getElementById(id).value = "";
|
||
});
|
||
applySignaturePreview(null);
|
||
setSignatureStatus("");
|
||
itemsContainer.replaceChildren();
|
||
itemIndex = 0;
|
||
updateTotal();
|
||
};
|
||
|
||
const loadInvoiceIntoEditor = (invoice) => {
|
||
currentInvoiceId = invoice.id;
|
||
invoiceNumberDisplay.textContent = invoice.invoiceNumber;
|
||
invoiceSaveStatus.textContent = "";
|
||
issueDateInput.value = invoice.data.issueDate || "";
|
||
dueDateInput.value = invoice.data.dueDate || "";
|
||
writeProfileFields("dodavatel", invoice.data.supplier || {});
|
||
supplierTypeSelect.value = invoice.data.supplier?.typSubjektu || "osoba";
|
||
updateSupplierTypeFields();
|
||
writeProfileFields("odberatel", invoice.data.customer || {});
|
||
clientTypeSelect.value = invoice.data.customer?.typSubjektu || "osoba";
|
||
updateClientTypeFields();
|
||
paymentPresetSelect.value = "";
|
||
paymentPresetName.value = "";
|
||
deletePaymentPresetButton.disabled = true;
|
||
["cisloUctu", "iban", "swift"].forEach((id) => {
|
||
document.getElementById(id).value = invoice.data.payment?.[id] || "";
|
||
});
|
||
applySignaturePreview(invoice.data.signature || null);
|
||
signaturePresetSelect.value = "";
|
||
signaturePresetName.value = "";
|
||
deleteSignaturePresetButton.disabled = true;
|
||
setSignatureStatus("");
|
||
itemsContainer.replaceChildren();
|
||
itemIndex = 0;
|
||
(invoice.data.items || []).forEach((item) => addInvoiceItem(item));
|
||
updateTotal();
|
||
showInvoiceScreen("editor");
|
||
};
|
||
|
||
const getInvoiceLabel = (invoice) => {
|
||
const customer = invoice.data.customer || {};
|
||
return customer.typSubjektu === "spolecnost"
|
||
? customer.nazevSpolecnosti || "Bez odběratele"
|
||
: [customer.jmeno, customer.prijmeni].filter(Boolean).join(" ") || "Bez odběratele";
|
||
};
|
||
|
||
const renderInvoiceList = () => {
|
||
if (!invoices.length) {
|
||
invoiceList.innerHTML = "<p class=\"empty-list\">Zatím nemáte uložené žádné faktury.</p>";
|
||
return;
|
||
}
|
||
invoiceList.innerHTML = invoices.map((invoice) => `
|
||
<article class="invoice-list-row">
|
||
<button type="button" class="invoice-open-button" data-invoice-id="${invoice.id}">
|
||
<strong>${escapeHtml(invoice.invoiceNumber)}</strong>
|
||
<span>${escapeHtml(getInvoiceLabel(invoice))}</span>
|
||
<small>${new Date(invoice.updatedAt).toLocaleDateString("cs-CZ")}</small>
|
||
</button>
|
||
<button type="button" class="invoice-delete-button" data-delete-invoice-id="${invoice.id}">Smazat</button>
|
||
</article>
|
||
`).join("");
|
||
};
|
||
|
||
const loadInvoices = async () => {
|
||
const response = await apiRequest("/api/invoices");
|
||
invoices = response.invoices;
|
||
renderInvoiceList();
|
||
};
|
||
|
||
const openNewInvoice = () => {
|
||
clearInvoiceEditor();
|
||
showInvoiceScreen("editor");
|
||
};
|
||
|
||
const saveInvoice = async () => {
|
||
try {
|
||
const response = await apiRequest(currentInvoiceId ? `/api/invoices/${currentInvoiceId}` : "/api/invoices", {
|
||
method: currentInvoiceId ? "PUT" : "POST",
|
||
body: JSON.stringify({ data: readInvoiceData() })
|
||
});
|
||
const invoice = response.invoice;
|
||
currentInvoiceId = invoice.id;
|
||
invoiceNumberDisplay.textContent = invoice.invoiceNumber;
|
||
invoiceSaveStatus.textContent = "Faktura uložena";
|
||
await loadInvoices();
|
||
showInvoiceScreen("list");
|
||
} catch (error) {
|
||
invoiceSaveStatus.textContent = error.message;
|
||
}
|
||
};
|
||
|
||
invoiceListNavButton.addEventListener("click", async () => {
|
||
await loadInvoices();
|
||
showInvoiceScreen("list");
|
||
});
|
||
|
||
newInvoiceButton.addEventListener("click", openNewInvoice);
|
||
exportInvoicesButton.addEventListener("click", async () => {
|
||
try {
|
||
const response = await fetch("/api/invoices/export", { credentials: "same-origin" });
|
||
if (!response.ok) {
|
||
const data = await response.json();
|
||
throw new Error(data.error || "Export se nepodařil.");
|
||
}
|
||
const blob = await response.blob();
|
||
const downloadUrl = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = downloadUrl;
|
||
link.download = "faktury.zip";
|
||
link.click();
|
||
URL.revokeObjectURL(downloadUrl);
|
||
} catch (error) {
|
||
window.alert(error.message);
|
||
}
|
||
});
|
||
saveInvoiceButton.addEventListener("click", saveInvoice);
|
||
deleteAllInvoicesButton.addEventListener("click", async () => {
|
||
if (!invoices.length || !window.confirm("Opravdu chcete smazat všechny faktury a začít číslovat znovu od 0001?")) {
|
||
return;
|
||
}
|
||
try {
|
||
await apiRequest("/api/invoices", { method: "DELETE" });
|
||
invoices = [];
|
||
renderInvoiceList();
|
||
clearInvoiceEditor();
|
||
showInvoiceScreen("list");
|
||
} catch (error) {
|
||
window.alert(error.message);
|
||
}
|
||
});
|
||
|
||
invoiceList.addEventListener("click", async (event) => {
|
||
const openButton = event.target.closest("[data-invoice-id]");
|
||
const deleteButton = event.target.closest("[data-delete-invoice-id]");
|
||
if (deleteButton) {
|
||
if (!window.confirm("Opravdu chcete tuto fakturu smazat?")) {
|
||
return;
|
||
}
|
||
await apiRequest(`/api/invoices/${deleteButton.dataset.deleteInvoiceId}`, { method: "DELETE" });
|
||
await loadInvoices();
|
||
return;
|
||
}
|
||
if (openButton) {
|
||
const invoice = invoices.find(({ id }) => String(id) === openButton.dataset.invoiceId);
|
||
if (invoice) {
|
||
loadInvoiceIntoEditor(invoice);
|
||
}
|
||
}
|
||
});
|
||
|
||
const saveSupplier = async () => {
|
||
try {
|
||
const data = readProfileFields("dodavatel");
|
||
data.typSubjektu = supplierTypeSelect.value;
|
||
await apiRequest("/api/supplier", {
|
||
method: "PUT",
|
||
body: JSON.stringify(data)
|
||
});
|
||
supplierSaveStatus.textContent = "Uloženo na serveru";
|
||
} catch (error) {
|
||
supplierSaveStatus.textContent = error.message;
|
||
}
|
||
};
|
||
|
||
const loadSupplier = async () => {
|
||
const { supplier } = await apiRequest("/api/supplier");
|
||
if (supplier) {
|
||
writeProfileFields("dodavatel", supplier);
|
||
supplierTypeSelect.value = supplier.typSubjektu || (supplier.nazevSpolecnosti ? "spolecnost" : "osoba");
|
||
updateSupplierTypeFields();
|
||
supplierSaveStatus.textContent = "Uloženo na serveru";
|
||
return true;
|
||
}
|
||
supplierSaveStatus.textContent = "Zatím není uložený dodavatel";
|
||
return false;
|
||
};
|
||
|
||
const renderClientOptions = (selectedId = clientSelect.value) => {
|
||
clientSelect.innerHTML = "<option value=\"\">Vyberte odběratele</option>";
|
||
|
||
clients.forEach((client) => {
|
||
const option = document.createElement("option");
|
||
option.value = client.id;
|
||
option.textContent = getClientLabel(client);
|
||
clientSelect.appendChild(option);
|
||
});
|
||
|
||
clientSelect.value = selectedId;
|
||
deleteClientButton.disabled = !clientSelect.value;
|
||
};
|
||
|
||
const loadClients = async () => {
|
||
const response = await apiRequest("/api/clients");
|
||
clients = response.clients;
|
||
renderClientOptions();
|
||
};
|
||
|
||
const loadSelectedClient = () => {
|
||
const client = clients.find(({ id }) => String(id) === clientSelect.value);
|
||
if (client) {
|
||
writeProfileFields("odberatel", client.data);
|
||
clientTypeSelect.value = client.typSubjektu || (client.data.nazevSpolecnosti ? "spolecnost" : "osoba");
|
||
updateClientTypeFields();
|
||
}
|
||
deleteClientButton.disabled = !client;
|
||
};
|
||
|
||
saveSupplierButton.addEventListener("click", saveSupplier);
|
||
supplierTypeSelect.addEventListener("change", updateSupplierTypeFields);
|
||
loadSupplierButton.addEventListener("click", async () => {
|
||
try {
|
||
await loadSupplier();
|
||
supplierSaveStatus.textContent = "Dodavatel načten";
|
||
} catch (error) {
|
||
supplierSaveStatus.textContent = error.message;
|
||
}
|
||
});
|
||
clientTypeSelect.addEventListener("change", updateClientTypeFields);
|
||
|
||
clientSelect.addEventListener("change", loadSelectedClient);
|
||
|
||
saveClientButton.addEventListener("click", async () => {
|
||
const data = readProfileFields("odberatel");
|
||
data.typSubjektu = clientTypeSelect.value;
|
||
const existingClient = clients.find((client) => String(client.id) === clientSelect.value);
|
||
try {
|
||
const response = await apiRequest(existingClient ? `/api/clients/${existingClient.id}` : "/api/clients", {
|
||
method: existingClient ? "PUT" : "POST",
|
||
body: JSON.stringify(data)
|
||
});
|
||
const client = response.client;
|
||
clients = existingClient
|
||
? clients.map((entry) => entry.id === client.id ? client : entry)
|
||
: [...clients, client];
|
||
renderClientOptions(String(client.id));
|
||
} catch (error) {
|
||
window.alert(error.message);
|
||
}
|
||
});
|
||
|
||
deleteClientButton.addEventListener("click", async () => {
|
||
if (!clientSelect.value) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await apiRequest(`/api/clients/${clientSelect.value}`, { method: "DELETE" });
|
||
clients = clients.filter(({ id }) => String(id) !== clientSelect.value);
|
||
renderClientOptions();
|
||
} catch (error) {
|
||
window.alert(error.message);
|
||
}
|
||
});
|
||
|
||
const showInvoiceApp = async () => {
|
||
authScreen.hidden = true;
|
||
document.getElementById("main-container").hidden = false;
|
||
await Promise.all([loadSupplier(), loadClients(), loadInvoices(), loadPaymentPresets(), loadSignaturePresets(), loadNumberingSettings()]);
|
||
showInvoiceScreen("list");
|
||
};
|
||
|
||
const loadNumberingSettings = async () => {
|
||
try {
|
||
const { settings } = await apiRequest("/api/settings");
|
||
invoiceStartingNumberInput.value = settings.invoiceStartingNumber;
|
||
invoiceStartingNumberInput.placeholder = String(settings.invoiceStartingNumber);
|
||
} catch {
|
||
// Keep the placeholder default if settings cannot be loaded.
|
||
}
|
||
};
|
||
|
||
const showNumberingSettingsPanel = (visible) => {
|
||
numberingSettingsPanel.hidden = !visible;
|
||
if (!visible) {
|
||
numberingSettingsStatus.textContent = "";
|
||
}
|
||
};
|
||
|
||
const showPasswordPanel = (visible) => {
|
||
passwordPanel.hidden = !visible;
|
||
if (!visible) {
|
||
passwordForm.querySelectorAll("input").forEach((input) => {
|
||
input.value = "";
|
||
});
|
||
passwordStatus.textContent = "";
|
||
}
|
||
};
|
||
|
||
const setAuthMode = (registrationMode) => {
|
||
isRegistrationMode = registrationMode;
|
||
authSubmitButton.textContent = registrationMode ? "Vytvořit účet" : "Přihlásit se";
|
||
authModeButton.textContent = registrationMode
|
||
? "Máte účet? Přihlásit se"
|
||
: "Nemáte účet? Zaregistrovat se";
|
||
authPassword.autocomplete = registrationMode ? "new-password" : "current-password";
|
||
authStatus.textContent = "";
|
||
};
|
||
|
||
authModeButton.addEventListener("click", () => setAuthMode(!isRegistrationMode));
|
||
|
||
forgotPasswordButton.addEventListener("click", () => {
|
||
authStatus.textContent = "Obnova hesla e-mailem zatím není nakonfigurovaná. Přihlaste se a použijte Změnit heslo, nebo kontaktujte správce aplikace.";
|
||
});
|
||
|
||
authForm.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
authStatus.textContent = "";
|
||
authSubmitButton.disabled = true;
|
||
try {
|
||
const { user } = await apiRequest(isRegistrationMode ? "/api/auth/register" : "/api/auth/login", {
|
||
method: "POST",
|
||
body: JSON.stringify({ email: authEmail.value, password: authPassword.value })
|
||
});
|
||
signedInEmail.textContent = user.email;
|
||
await showInvoiceApp();
|
||
} catch (error) {
|
||
authStatus.textContent = error.message;
|
||
} finally {
|
||
authSubmitButton.disabled = false;
|
||
}
|
||
});
|
||
|
||
logoutButton.addEventListener("click", async () => {
|
||
await apiRequest("/api/auth/logout", { method: "POST" });
|
||
window.location.reload();
|
||
});
|
||
|
||
passwordButton.addEventListener("click", () => showPasswordPanel(passwordPanel.hidden));
|
||
cancelPasswordButton.addEventListener("click", () => showPasswordPanel(false));
|
||
|
||
numberingSettingsButton.addEventListener("click", () => showNumberingSettingsPanel(numberingSettingsPanel.hidden));
|
||
cancelNumberingSettingsButton.addEventListener("click", () => showNumberingSettingsPanel(false));
|
||
|
||
saveNumberingSettingsButton.addEventListener("click", async () => {
|
||
numberingSettingsStatus.textContent = "";
|
||
const invoiceStartingNumber = Number.parseInt(invoiceStartingNumberInput.value, 10) || 1;
|
||
try {
|
||
const { settings } = await apiRequest("/api/settings", {
|
||
method: "PUT",
|
||
body: JSON.stringify({ invoiceStartingNumber })
|
||
});
|
||
invoiceStartingNumberInput.value = settings.invoiceStartingNumber;
|
||
numberingSettingsStatus.textContent = "Uloženo";
|
||
} catch (error) {
|
||
numberingSettingsStatus.textContent = error.message;
|
||
}
|
||
});
|
||
|
||
savePasswordButton.addEventListener("click", async () => {
|
||
passwordStatus.textContent = "";
|
||
try {
|
||
await apiRequest("/api/auth/password", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
currentPassword: document.getElementById("currentPassword").value,
|
||
newPassword: document.getElementById("newPassword").value
|
||
})
|
||
});
|
||
passwordForm.querySelectorAll("input").forEach((input) => {
|
||
input.value = "";
|
||
});
|
||
passwordStatus.textContent = "Heslo bylo změněno";
|
||
} catch (error) {
|
||
passwordStatus.textContent = error.message;
|
||
}
|
||
});
|
||
|
||
(async () => {
|
||
try {
|
||
const { user } = await apiRequest("/api/auth/me");
|
||
signedInEmail.textContent = user.email;
|
||
await showInvoiceApp();
|
||
} catch {
|
||
authScreen.hidden = false;
|
||
document.getElementById("main-container").hidden = true;
|
||
}
|
||
})();
|
||
|
||
const escapeHtml = (value) => String(value ?? "")
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
|
||
const getValue = (id) => {
|
||
const field = document.getElementById(id);
|
||
if (!field) {
|
||
return "";
|
||
}
|
||
return ("value" in field ? field.value : field.textContent).trim();
|
||
};
|
||
|
||
const formatCzechNumber = (value) => Number(value || 0).toLocaleString("cs-CZ", {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2
|
||
});
|
||
|
||
const updateTotal = () => {
|
||
const total = Array.from(itemsContainer.querySelectorAll(".invoice-item"))
|
||
.reduce((sum, item) => {
|
||
const quantity = Number(item.querySelector('[name$="[mnozstvi]"]').value) || 0;
|
||
const unitPrice = Number(item.querySelector('[name$="[cenaZaMj]"]').value) || 0;
|
||
return sum + quantity * unitPrice;
|
||
}, 0);
|
||
|
||
document.getElementById("total").textContent = `${formatCzechNumber(total)} Kč`;
|
||
};
|
||
|
||
printInvoiceButton.addEventListener("click", async () => {
|
||
try {
|
||
const response = await fetch("/api/invoices/pdf", {
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
data: readInvoiceData(),
|
||
invoiceNumber: invoiceNumberDisplay.textContent
|
||
})
|
||
});
|
||
if (!response.ok) {
|
||
const data = await response.json();
|
||
throw new Error(data.error || "Generování faktury se nepodařilo.");
|
||
}
|
||
|
||
const blob = await response.blob();
|
||
const downloadUrl = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = downloadUrl;
|
||
link.download = `${invoiceNumberDisplay.textContent}.pdf`;
|
||
link.click();
|
||
URL.revokeObjectURL(downloadUrl);
|
||
} catch (error) {
|
||
window.alert(error.message);
|
||
}
|
||
});
|
||
|
||
const addInvoiceItem = (values = {}) => {
|
||
itemIndex++;
|
||
|
||
const item = document.createElement("div");
|
||
item.classList.add("invoice-item");
|
||
|
||
item.innerHTML = `
|
||
<div class="form-group">
|
||
<label for="polozka${itemIndex}Popis">Položka</label>
|
||
<input
|
||
id="polozka${itemIndex}Popis"
|
||
name="polozky[${itemIndex}][popis]"
|
||
type="text"
|
||
/>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="polozka${itemIndex}Mnozstvi">Množství</label>
|
||
<input
|
||
id="polozka${itemIndex}Mnozstvi"
|
||
name="polozky[${itemIndex}][mnozstvi]"
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
/>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="polozka${itemIndex}MernaJednotka">
|
||
Měrná jednotka
|
||
</label>
|
||
<input
|
||
id="polozka${itemIndex}MernaJednotka"
|
||
name="polozky[${itemIndex}][mernaJednotka]"
|
||
type="text"
|
||
placeholder="ks"
|
||
/>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="polozka${itemIndex}CenaZaMj">
|
||
Cena za MJ
|
||
</label>
|
||
<input
|
||
id="polozka${itemIndex}CenaZaMj"
|
||
name="polozky[${itemIndex}][cenaZaMj]"
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
class="delete-item-button"
|
||
aria-label="Smazat položku"
|
||
title="Smazat položku"
|
||
>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
width="18"
|
||
height="18"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M3 6h18"></path>
|
||
<path d="M8 6V4h8v2"></path>
|
||
<path d="M19 6l-1 14H6L5 6"></path>
|
||
<path d="M10 11v5"></path>
|
||
<path d="M14 11v5"></path>
|
||
</svg>
|
||
</button>
|
||
`;
|
||
|
||
const inputs = item.querySelectorAll("input");
|
||
inputs[0].value = values.popis || "";
|
||
inputs[1].value = values.mnozstvi ?? "";
|
||
inputs[2].value = values.mernaJednotka || "";
|
||
inputs[3].value = values.cenaZaMj ?? "";
|
||
|
||
const deleteButton = item.querySelector(".delete-item-button");
|
||
|
||
deleteButton.addEventListener("click", () => {
|
||
item.remove();
|
||
updateTotal();
|
||
});
|
||
|
||
itemsContainer.appendChild(item);
|
||
updateTotal();
|
||
};
|
||
|
||
addItemButton.addEventListener("click", () => addInvoiceItem());
|
||
|
||
itemsContainer.addEventListener("input", updateTotal);
|
||
|
||
updateClientTypeFields();
|
||
updateSupplierTypeFields();
|