diff --git "a/static/js/main.js" "b/static/js/main.js" --- "a/static/js/main.js" +++ "b/static/js/main.js" @@ -1,984 +1,2072 @@ -const VAT_OPTIONS = [ - { value: "23", label: "23%" }, - { value: "8", label: "8%" }, - { value: "5", label: "5%" }, - { value: "0", label: "0% (ZW)" }, - { value: "ZW", label: "ZW - zwolnione" }, - { value: "NP", label: "NP - poza zakresem" }, -]; - -const VAT_RATE_VALUES = { - "23": 0.23, - "8": 0.08, - "5": 0.05, - "0": 0, - ZW: 0, - NP: 0, -}; - -const setupSection = document.getElementById("setup-section"); -const loginSection = document.getElementById("login-section"); -const appSection = document.getElementById("app-section"); - -const setupForm = document.getElementById("setup-form"); -const loginForm = document.getElementById("login-form"); -const invoiceForm = document.getElementById("invoice-form"); -const businessForm = document.getElementById("business-form"); - -const setupFeedback = document.getElementById("setup-feedback"); -const loginFeedback = document.getElementById("login-feedback"); -const businessFeedback = document.getElementById("business-feedback"); - -const businessDisplay = document.getElementById("business-display"); -const toggleBusinessFormButton = document.getElementById("toggle-business-form"); -const cancelBusinessUpdateButton = document.getElementById("cancel-business-update"); - -const itemsBody = document.getElementById("items-body"); -const addItemButton = document.getElementById("add-item-button"); - -const totalNetLabel = document.getElementById("total-net"); -const totalVatLabel = document.getElementById("total-vat"); -const totalGrossLabel = document.getElementById("total-gross"); -const rateSummaryContainer = document.getElementById("rate-summary"); - -const exemptionNoteWrapper = document.getElementById("exemption-note-wrapper"); -const exemptionNoteInput = document.getElementById("exemption-note"); - -const invoiceResult = document.getElementById("invoice-result"); -const invoiceOutput = document.getElementById("invoice-output"); -const downloadButton = document.getElementById("download-button"); -const logoutButton = document.getElementById("logout-button"); - -let authToken = sessionStorage.getItem("invoiceAuthToken") || null; -let currentBusiness = null; -let lastInvoice = null; -let pdfFontPromise = null; -let pdfFontBase64 = null; - -function setState(state) { - setupSection.classList.add("hidden"); - loginSection.classList.add("hidden"); - appSection.classList.add("hidden"); - - if (state === "setup") { - setupSection.classList.remove("hidden"); - } else if (state === "login") { - loginSection.classList.remove("hidden"); - } else if (state === "app") { - appSection.classList.remove("hidden"); - } -} - -function clearFeedback(element) { - element.textContent = ""; - element.classList.remove("error", "success"); -} - -function showFeedback(element, message, type = "error") { - element.textContent = message; - element.classList.remove("error", "success"); - if (type) { - element.classList.add(type); - } -} - -function parseNumber(value) { - if (typeof value === "number") { - return Number.isFinite(value) ? value : 0; - } - if (!value) { - return 0; - } - const normalized = value.toString().replace(",", "."); - const parsed = Number.parseFloat(normalized); - return Number.isFinite(parsed) ? parsed : 0; -} - -function formatCurrency(value) { - const number = parseNumber(value); - return `${number.toFixed(2)} PLN`; -} - -function vatLabelFromCode(code) { - if (code === "ZW" || code === "0") { - return "ZW"; - } - if (code === "NP") { - return "NP"; - } - return `${code}%`; -} - -function requiresExemption(code) { - return code === "ZW" || code === "0"; -} - -async function apiRequest(path, { method = "GET", body, headers = {} } = {}, requireAuth = false) { - const options = { - method, - headers: { - "Content-Type": "application/json", - ...headers, - }, - }; - - if (body !== undefined) { - options.body = JSON.stringify(body); - } - - if (requireAuth) { - if (!authToken) { - throw new Error("Brak tokenu autoryzacyjnego."); - } - options.headers.Authorization = `Bearer ${authToken}`; - } - - const response = await fetch(path, options); - const isJson = response.headers.get("content-type")?.includes("application/json"); - const data = isJson ? await response.json() : {}; - - if (response.status === 401) { - authToken = null; - sessionStorage.removeItem("invoiceAuthToken"); - setState("login"); - throw new Error(data.error || "Sesja wygasla. Zaloguj sie ponownie."); - } - - if (!response.ok) { - throw new Error(data.error || "Wystapil blad podczas komunikacji z serwerem."); - } - - return data; -} - -function renderBusinessDisplay(business) { - if (!business) { - businessDisplay.textContent = "Brak zapisanych danych firmy."; - return; - } - - businessDisplay.innerHTML = ` -

${business.company_name}

-

${business.owner_name}

-

${business.address_line}

-

${business.postal_code} ${business.city}

-

NIP: ${business.tax_id}

-

Konto: ${business.bank_account}

- `; -} - -function fillBusinessForm(business) { - if (!business) { - return; - } - businessForm.elements.company_name.value = business.company_name || ""; - businessForm.elements.owner_name.value = business.owner_name || ""; - businessForm.elements.address_line.value = business.address_line || ""; - businessForm.elements.postal_code.value = business.postal_code || ""; - businessForm.elements.city.value = business.city || ""; - businessForm.elements.tax_id.value = business.tax_id || ""; - businessForm.elements.bank_account.value = business.bank_account || ""; -} - -function vatSelectElement(initialValue = "23") { - const select = document.createElement("select"); - select.className = "item-vat"; - VAT_OPTIONS.forEach((option) => { - const element = document.createElement("option"); - element.value = option.value; - element.textContent = option.label; - select.appendChild(element); - }); - select.value = VAT_OPTIONS.some((option) => option.value === initialValue) ? initialValue : "23"; - return select; -} - -function createItemRow(initialValues = {}) { - const row = document.createElement("tr"); - - const nameCell = document.createElement("td"); - const nameInput = document.createElement("input"); - nameInput.type = "text"; - nameInput.className = "item-name"; - nameInput.placeholder = "Nazwa towaru lub uslugi"; - if (initialValues.name) { - nameInput.value = initialValues.name; - } - nameCell.appendChild(nameInput); - - const quantityCell = document.createElement("td"); - const quantityInput = document.createElement("input"); - quantityInput.type = "number"; - quantityInput.className = "item-quantity"; - quantityInput.min = "0.01"; - quantityInput.step = "0.01"; - quantityInput.value = initialValues.quantity ?? "1"; - quantityCell.appendChild(quantityInput); - - const unitGrossCell = document.createElement("td"); - const unitGrossInput = document.createElement("input"); - unitGrossInput.type = "number"; - unitGrossInput.className = "item-gross"; - unitGrossInput.min = "0.01"; - unitGrossInput.step = "0.01"; - unitGrossInput.placeholder = "Brutto"; - if (initialValues.unit_price_gross) { - unitGrossInput.value = initialValues.unit_price_gross; - } - unitGrossCell.appendChild(unitGrossInput); - - const vatCell = document.createElement("td"); - const vatSelect = vatSelectElement(initialValues.vat_code); - vatCell.appendChild(vatSelect); - - const totalCell = document.createElement("td"); - totalCell.className = "item-total"; - totalCell.textContent = "0.00 PLN"; - - const actionsCell = document.createElement("td"); - const removeButton = document.createElement("button"); - removeButton.type = "button"; - removeButton.className = "remove-item"; - removeButton.textContent = "Usun"; - actionsCell.appendChild(removeButton); - - row.appendChild(nameCell); - row.appendChild(quantityCell); - row.appendChild(unitGrossCell); - row.appendChild(vatCell); - row.appendChild(totalCell); - row.appendChild(actionsCell); - - const handleChange = () => updateTotals(); - nameInput.addEventListener("input", handleChange); - quantityInput.addEventListener("input", handleChange); - unitGrossInput.addEventListener("input", handleChange); - vatSelect.addEventListener("change", handleChange); - - removeButton.addEventListener("click", () => { - if (itemsBody.children.length === 1) { - nameInput.value = ""; - quantityInput.value = "1"; - unitGrossInput.value = ""; - vatSelect.value = "23"; - updateTotals(); - return; - } - row.remove(); - updateTotals(); - }); - - itemsBody.appendChild(row); - updateTotals(); -} - -function calculateRowTotals(row) { - const name = row.querySelector(".item-name")?.value.trim() ?? ""; - const quantity = parseNumber(row.querySelector(".item-quantity")?.value); - const unitGross = parseNumber(row.querySelector(".item-gross")?.value); - const vatCode = row.querySelector(".item-vat")?.value ?? "23"; - const rate = VAT_RATE_VALUES[vatCode] ?? 0; - - const hasValues = name || quantity > 0 || unitGross > 0; - if (!hasValues) { - return { - valid: false, - vatCode, - vatLabel: vatLabelFromCode(vatCode), - requiresExemption: requiresExemption(vatCode), - quantity, - unitGross, - unitNet: 0, - netTotal: 0, - vatAmount: 0, - grossTotal: 0, - }; - } - - if (quantity <= 0 || unitGross <= 0) { - return { - valid: false, - vatCode, - vatLabel: vatLabelFromCode(vatCode), - requiresExemption: requiresExemption(vatCode), - quantity, - unitGross, - unitNet: 0, - netTotal: 0, - vatAmount: 0, - grossTotal: quantity * unitGross, - }; - } - - const grossTotal = quantity * unitGross; - const netTotal = rate > 0 ? grossTotal / (1 + rate) : grossTotal; - const vatAmount = grossTotal - netTotal; - const unitNet = netTotal / quantity; - - return { - valid: true, - vatCode, - vatLabel: vatLabelFromCode(vatCode), - requiresExemption: requiresExemption(vatCode), - quantity, - unitGross, - unitNet, - netTotal, - vatAmount, - grossTotal, - }; -} - -function updateTotals() { - let totalNet = 0; - let totalVat = 0; - let totalGross = 0; - const summary = new Map(); - let exemptionNeeded = false; - - const rows = Array.from(itemsBody.querySelectorAll("tr")); - rows.forEach((row) => { - const totals = calculateRowTotals(row); - if (totals.requiresExemption) { - exemptionNeeded = true; - } - const totalCell = row.querySelector(".item-total"); - totalCell.textContent = formatCurrency(totals.grossTotal); - - if (!totals.valid) { - return; - } - - totalNet += totals.netTotal; - totalVat += totals.vatAmount; - totalGross += totals.grossTotal; - - const existing = summary.get(totals.vatLabel) || { net: 0, vat: 0, gross: 0 }; - existing.net += totals.netTotal; - existing.vat += totals.vatAmount; - existing.gross += totals.grossTotal; - summary.set(totals.vatLabel, existing); - }); - - totalNetLabel.textContent = `Suma netto: ${totalNet.toFixed(2)} PLN`; - totalVatLabel.textContent = `Kwota VAT: ${totalVat.toFixed(2)} PLN`; - totalGrossLabel.textContent = `Suma brutto: ${totalGross.toFixed(2)} PLN`; - renderRateSummary(summary); - - if (exemptionNeeded) { - exemptionNoteWrapper.classList.remove("hidden"); - } else { - exemptionNoteWrapper.classList.add("hidden"); - exemptionNoteInput.value = ""; - } -} - -function renderRateSummary(summary) { - if (!summary || summary.size === 0) { - rateSummaryContainer.innerHTML = ""; - return; - } - - const entries = Array.from(summary.entries()).sort(([a], [b]) => a.localeCompare(b)); - const markup = entries - .map( - ([label, totals]) => - `
- ${label} - Netto: ${totals.net.toFixed(2)} PLN - VAT: ${totals.vat.toFixed(2)} PLN - Brutto: ${totals.gross.toFixed(2)} PLN -
` - ) - .join(""); - rateSummaryContainer.innerHTML = `

Podsumowanie stawek

${markup}`; -} - -function collectInvoicePayload() { - const items = []; - const rows = Array.from(itemsBody.querySelectorAll("tr")); - - rows.forEach((row) => { - const name = row.querySelector(".item-name")?.value.trim() ?? ""; - const quantity = parseNumber(row.querySelector(".item-quantity")?.value); - const unitGross = parseNumber(row.querySelector(".item-gross")?.value); - const vatCode = row.querySelector(".item-vat")?.value ?? "23"; - - const hasValues = name || quantity > 0 || unitGross > 0; - if (!hasValues) { - return; - } - - if (!name) { - throw new Error("Kazda pozycja musi miec nazwe."); - } - if (quantity <= 0) { - throw new Error("Ilosc musi byc wieksza od zera."); - } - if (unitGross <= 0) { - throw new Error("Cena brutto musi byc wieksza od zera."); - } - - items.push({ - name, - quantity: quantity.toFixed(2), - unit_price_gross: unitGross.toFixed(2), - vat_code: vatCode, - }); - }); - - if (items.length === 0) { - throw new Error("Dodaj przynajmniej jedna pozycje."); - } - - const saleDate = invoiceForm.elements.saleDate.value || null; - const exemptionNote = exemptionNoteInput.value.trim(); - const requiresExemptionNote = items.some((item) => item.vat_code === "ZW" || item.vat_code === "0"); - if (requiresExemptionNote && !exemptionNote) { - throw new Error("Podaj podstawe prawna zwolnienia dla pozycji rozliczanych jako ZW."); - } - - const client = { - name: (invoiceForm.elements.clientName.value || "").trim(), - tax_id: (invoiceForm.elements.clientTaxId.value || "").trim(), - address_line: (invoiceForm.elements.clientAddress.value || "").trim(), - postal_code: (invoiceForm.elements.clientPostalCode.value || "").trim(), - city: (invoiceForm.elements.clientCity.value || "").trim(), - }; - - return { - sale_date: saleDate, - client, - items, - exemption_note: exemptionNote, - }; -} - -function renderInvoicePreview(invoice) { - if (!invoice || !currentBusiness) { - invoiceOutput.innerHTML = "

Brak danych faktury.

"; - return; - } - - const client = invoice.client || {}; - const hasClientData = client.name || client.address_line || client.postal_code || client.city || client.tax_id; - - const itemsRows = invoice.items - .map( - (item) => ` - - ${item.name} - ${parseNumber(item.quantity).toFixed(2)} - ${formatCurrency(item.unit_price_net)} - ${formatCurrency(item.net_total)} - ${item.vat_label} - ${formatCurrency(item.vat_amount)} - ${formatCurrency(item.gross_total)} - ` - ) - .join(""); - - const summaryRows = (invoice.summary || []) - .map( - (entry) => - `
- ${entry.vat_label} - Netto: ${formatCurrency(entry.net_total)} - VAT: ${formatCurrency(entry.vat_total)} - Brutto: ${formatCurrency(entry.gross_total)} -
` - ) - .join(""); - - invoiceOutput.innerHTML = ` -
- Numer: ${invoice.invoice_id} - Data wystawienia: ${invoice.issued_at} - Data sprzedazy: ${invoice.sale_date} -
-
-
-

Nabywca

- ${ - hasClientData - ? ` -

${client.name || "---"}

-

${client.address_line || "---"}

-

${client.postal_code || ""} ${client.city || ""}

-

${client.tax_id ? `NIP: ${client.tax_id}` : ""}

- ` - : "

Brak danych nabywcy.

" - } -
-
-

Sprzedawca

-

${currentBusiness.company_name}

-

${currentBusiness.owner_name}

-

${currentBusiness.address_line}

-

${currentBusiness.postal_code} ${currentBusiness.city}

-

NIP: ${currentBusiness.tax_id}

-

Konto: ${currentBusiness.bank_account}

-
-
- - - - - - - - - - - - - ${itemsRows} -
NazwaIlośćCena jedn. nettoWartość netto (pozycja)Stawka VATKwota VAT (pozycja)Wartość brutto
-
-

Podsumowanie stawek

- ${summaryRows} -
-
- Netto: ${formatCurrency(invoice.totals.net)} - VAT: ${formatCurrency(invoice.totals.vat)} - Brutto: ${formatCurrency(invoice.totals.gross)} -
- ${ - invoice.exemption_note - ? `
Podstawa prawna zwolnienia: ${invoice.exemption_note}
` - : "" - } - `; -} - -function drawPartyBox(doc, title, lines, x, y, width) { - const lineHeight = 5; - const wrappedLines = lines.flatMap((line) => doc.splitTextToSize(line, width)); - const boxHeight = wrappedLines.length * lineHeight + 14; - - doc.roundedRect(x - 4, y - 8, width + 8, boxHeight, 2, 2); - doc.setFontSize(11); - doc.text(title, x, y); - doc.setFontSize(10); - - let cursor = y + 5; - wrappedLines.forEach((line) => { - doc.text(line, x, cursor); - cursor += lineHeight; - }); - - return y - 8 + boxHeight; -} - -function arrayBufferToBase64(buffer) { - const bytes = new Uint8Array(buffer); - const chunkSize = 0x8000; - let binary = ""; - for (let offset = 0; offset < bytes.length; offset += chunkSize) { - const chunk = bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length)); - binary += String.fromCharCode.apply(null, chunk); - } - return btoa(binary); -} - -const PDF_FONT_FILE = "Roboto-VariableFont_wdth,wght.ttf"; -const PDF_FONT_NAME = "RobotoPolish"; - -async function ensurePdfFont() { - if (pdfFontPromise) { - return pdfFontPromise; - } - - if (!window.jspdf || !window.jspdf.jsPDF) { - throw new Error("Biblioteka jsPDF nie zostala zaladowana."); - } - - const { jsPDF } = window.jspdf; - const loadBase64 = async () => { - if (typeof window !== "undefined" && window.PDF_FONT_BASE64) { - return window.PDF_FONT_BASE64; - } - const response = await fetch(`/${encodeURIComponent(PDF_FONT_FILE)}`); - if (!response.ok) { - throw new Error(`Nie udalo sie pobrac czcionki Roboto (status ${response.status}).`); - } - const buffer = await response.arrayBuffer(); - return arrayBufferToBase64(buffer); - }; - - pdfFontPromise = loadBase64().then((data) => { - pdfFontBase64 = data; - return data; - }); - - return pdfFontPromise; -} - -async function generatePdf(business, invoice) { - if (!window.jspdf || !window.jspdf.jsPDF) { - alert("Biblioteka jsPDF nie zostala zaladowana. Sprawdz polaczenie z internetem."); - return; - } - - let fontBase64; - try { - fontBase64 = await ensurePdfFont(); - } catch (error) { - alert(error.message || "Nie udalo sie przygotowac czcionki do PDF."); - return; - } - - const { jsPDF } = window.jspdf; - const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" }); - const marginX = 18; - let cursorY = 20; - - if (!doc.getFontList()[PDF_FONT_NAME]) { - const embeddedFont = pdfFontBase64 || fontBase64; - doc.addFileToVFS(PDF_FONT_FILE, embeddedFont); - doc.addFont(PDF_FONT_FILE, PDF_FONT_NAME, "normal"); - } - - doc.setFont(PDF_FONT_NAME, "normal"); - doc.setFontSize(16); - doc.text(`Faktura ${invoice.invoice_id}`, marginX, cursorY); - doc.setFontSize(10); - doc.text(`Data wystawienia: ${invoice.issued_at}`, marginX, cursorY + 6); - doc.text(`Data sprzedaży: ${invoice.sale_date}`, marginX, cursorY + 12); - - cursorY += 22; - const columnWidth = 85; - const sellerX = marginX + columnWidth + 12; - - const clientLines = invoice.client && (invoice.client.name || invoice.client.address_line || invoice.client.city || invoice.client.tax_id) - ? [ - invoice.client.name || "---", - invoice.client.address_line || "", - `${(invoice.client.postal_code || "").trim()} ${(invoice.client.city || "").trim()}`.trim(), - invoice.client.tax_id ? `NIP: ${invoice.client.tax_id}` : "", - ].filter((line) => line && line.trim()) - : ["Brak danych nabywcy"]; - - const sellerLines = [ - business.company_name, - business.owner_name, - business.address_line, - `${business.postal_code} ${business.city}`.trim(), - `NIP: ${business.tax_id}`, - `Konto: ${business.bank_account}`, - ]; - - const buyerBottom = drawPartyBox(doc, "NABYWCA", clientLines, marginX, cursorY, columnWidth); - const sellerBottom = drawPartyBox(doc, "SPRZEDAWCA", sellerLines, sellerX, cursorY, columnWidth); - cursorY = Math.max(buyerBottom, sellerBottom) + 12; - - const tableColumns = [ - { key: "name", label: "Nazwa", width: 52 }, - { key: "quantity", label: "Ilość", width: 16 }, - { key: "unitNet", label: "Cena jedn. netto", width: 24 }, - { key: "netTotal", label: "Wartość netto", width: 24 }, - { key: "vatLabel", label: "Stawka VAT", width: 15 }, - { key: "vatAmount", label: "Kwota VAT", width: 22 }, - { key: "grossTotal", label: "Wartość brutto", width: 21 }, - ]; - const tableWidth = tableColumns.reduce((sum, col) => sum + col.width, 0); - const lineHeight = 5; - const headerLineHeight = 4.2; - tableColumns.forEach((column) => { - column.headerLines = doc.splitTextToSize(column.label, column.width - 4); - }); - const headerHeight = Math.max(...tableColumns.map((column) => column.headerLines.length * headerLineHeight + 3)); - - doc.setFillColor(241, 243, 247); - doc.rect(marginX, cursorY, tableWidth, headerHeight, "F"); - doc.rect(marginX, cursorY, tableWidth, headerHeight); - let offsetX = marginX; - doc.setFontSize(10); - tableColumns.forEach((column) => { - doc.rect(offsetX, cursorY, column.width, headerHeight); - column.headerLines.forEach((line, index) => { - const textY = cursorY + 4 + index * headerLineHeight; - doc.text((line || "").trim(), offsetX + 2, textY); - }); - offsetX += column.width; - }); - cursorY += headerHeight; - - invoice.items.forEach((item) => { - const quantity = parseNumber(item.quantity).toFixed(2); - const unitNet = formatCurrency(item.unit_price_net); - const netTotal = formatCurrency(item.net_total); - const vatAmount = formatCurrency(item.vat_amount); - const grossTotal = formatCurrency(item.gross_total); - - const wrapText = (text, width) => - doc - .splitTextToSize(text ?? "", width) - .map((line) => line.trim()); - - const columnData = tableColumns.map((column) => { - switch (column.key) { - case "name": - return wrapText(item.name, column.width - 4); - case "quantity": - return [quantity]; - case "unitNet": - return [unitNet]; - case "netTotal": - return [netTotal]; - case "vatLabel": - return [item.vat_label]; - case "vatAmount": - return [vatAmount]; - case "grossTotal": - return [grossTotal]; - default: - return [""]; - } - }); - - const rowHeight = Math.max(...columnData.map((data) => data.length)) * lineHeight + 2; - offsetX = marginX; - tableColumns.forEach((column, index) => { - doc.rect(offsetX, cursorY, column.width, rowHeight); - const lines = columnData[index]; - lines.forEach((line, lineIndex) => { - const textY = cursorY + (lineIndex + 1) * lineHeight; - const content = (line || "").trim(); - doc.text(content, offsetX + 2, textY); - }); - offsetX += column.width; - }); - - cursorY += rowHeight; - }); - - cursorY += 10; - doc.setFontSize(11); - doc.text("Podsumowanie stawek:", marginX, cursorY); - cursorY += 6; - - const summaryEntries = Array.isArray(invoice.summary) ? invoice.summary : []; - summaryEntries.forEach((entry) => { - const summaryLine = `${entry.vat_label} – Netto: ${formatCurrency(entry.net_total)} / VAT: ${formatCurrency(entry.vat_total)} / Brutto: ${formatCurrency(entry.gross_total)}`; - const wrapped = doc.splitTextToSize(summaryLine, 170); - wrapped.forEach((line) => { - doc.text((line || "").trim(), marginX, cursorY); - cursorY += lineHeight; - }); - }); - - cursorY += 6; - doc.setFontSize(12); - doc.text(`Suma netto: ${formatCurrency(invoice.totals.net)}`, marginX, cursorY); - doc.text(`Suma VAT: ${formatCurrency(invoice.totals.vat)}`, marginX, cursorY + 6); - doc.text(`Suma brutto: ${formatCurrency(invoice.totals.gross)}`, marginX, cursorY + 12); - cursorY += 20; - - if (invoice.exemption_note) { - doc.setFontSize(10); - const noteLines = doc.splitTextToSize(`Podstawa prawna zwolnienia: ${invoice.exemption_note}`, 170); - doc.text(noteLines, marginX, cursorY); - } - - doc.save(`${invoice.invoice_id}.pdf`); -} - -async function loadBusinessData() { - const data = await apiRequest("/api/business", {}, true); - currentBusiness = data.business; - renderBusinessDisplay(currentBusiness); - fillBusinessForm(currentBusiness); -} - -function resetInvoiceForm() { - invoiceForm.reset(); - exemptionNoteInput.value = ""; - exemptionNoteWrapper.classList.add("hidden"); - itemsBody.innerHTML = ""; - createItemRow(); - const today = new Date().toISOString().slice(0, 10); - invoiceForm.elements.saleDate.value = today; -} - -async function bootstrapApp() { - try { - await loadBusinessData(); - setState("app"); - } catch (error) { - console.error(error); - authToken = null; - sessionStorage.removeItem("invoiceAuthToken"); - showFeedback(loginFeedback, error.message || "Nie udalo sie pobrac danych firmy."); - setState("login"); - } -} - -async function initialize() { - resetInvoiceForm(); - try { - const status = await apiRequest("/api/status"); - if (!status.configured) { - setState("setup"); - return; - } - - if (authToken) { - await bootstrapApp(); - } else { - setState("login"); - } - } catch (error) { - console.error(error); - setState("setup"); - showFeedback(setupFeedback, "Nie udalo sie nawiazac polaczenia z serwerem."); - } -} - -setupForm.addEventListener("submit", async (event) => { - event.preventDefault(); - clearFeedback(setupFeedback); - - const formData = new FormData(setupForm); - const password = formData.get("password")?.toString() ?? ""; - const confirmPassword = formData.get("confirm_password")?.toString() ?? ""; - - if (password !== confirmPassword) { - showFeedback(setupFeedback, "Hasla musza byc identyczne."); - return; - } - - if (password.trim().length < 4) { - showFeedback(setupFeedback, "Haslo musi miec co najmniej 4 znaki."); - return; - } - - const payload = { - company_name: formData.get("company_name")?.toString().trim(), - owner_name: formData.get("owner_name")?.toString().trim(), - address_line: formData.get("address_line")?.toString().trim(), - postal_code: formData.get("postal_code")?.toString().trim(), - city: formData.get("city")?.toString().trim(), - tax_id: formData.get("tax_id")?.toString().trim(), - bank_account: formData.get("bank_account")?.toString().trim(), - password, - }; - - try { - await apiRequest("/api/setup", { method: "POST", body: payload }); - showFeedback(setupFeedback, "Dane zapisane. Mozesz sie zalogowac.", "success"); - setTimeout(() => { - setState("login"); - clearFeedback(setupFeedback); - setupForm.reset(); - }, 1500); - } catch (error) { - showFeedback(setupFeedback, error.message || "Nie udalo sie zapisac danych."); - } -}); - -loginForm.addEventListener("submit", async (event) => { - event.preventDefault(); - clearFeedback(loginFeedback); - - const password = loginForm.elements.password.value; - if (!password) { - showFeedback(loginFeedback, "Podaj haslo."); - return; - } - - try { - const response = await apiRequest("/api/login", { method: "POST", body: { password } }); - authToken = response.token; - sessionStorage.setItem("invoiceAuthToken", authToken); - loginForm.reset(); - await bootstrapApp(); - } catch (error) { - showFeedback(loginFeedback, error.message || "Logowanie nie powiodlo sie."); - } -}); - -toggleBusinessFormButton.addEventListener("click", () => { - const isHidden = businessForm.classList.contains("hidden"); - if (isHidden) { - fillBusinessForm(currentBusiness); - businessForm.classList.remove("hidden"); - toggleBusinessFormButton.textContent = "Ukryj formularz"; - } else { - businessForm.classList.add("hidden"); - toggleBusinessFormButton.textContent = "Edytuj dane"; - clearFeedback(businessFeedback); - } -}); - -cancelBusinessUpdateButton.addEventListener("click", () => { - businessForm.classList.add("hidden"); - toggleBusinessFormButton.textContent = "Edytuj dane"; - clearFeedback(businessFeedback); -}); - -businessForm.addEventListener("submit", async (event) => { - event.preventDefault(); - clearFeedback(businessFeedback); - - const formData = new FormData(businessForm); - const payload = { - company_name: formData.get("company_name")?.toString().trim(), - owner_name: formData.get("owner_name")?.toString().trim(), - address_line: formData.get("address_line")?.toString().trim(), - postal_code: formData.get("postal_code")?.toString().trim(), - city: formData.get("city")?.toString().trim(), - tax_id: formData.get("tax_id")?.toString().trim(), - bank_account: formData.get("bank_account")?.toString().trim(), - }; - - try { - const data = await apiRequest("/api/business", { method: "PUT", body: payload }, true); - currentBusiness = data.business; - renderBusinessDisplay(currentBusiness); - showFeedback(businessFeedback, "Dane sprzedawcy zaktualizowane.", "success"); - setTimeout(() => clearFeedback(businessFeedback), 2000); - } catch (error) { - showFeedback(businessFeedback, error.message || "Nie udalo sie zaktualizowac danych."); - } -}); - -invoiceForm.addEventListener("submit", async (event) => { - event.preventDefault(); - try { - const payload = collectInvoicePayload(); - const response = await apiRequest("/api/invoices", { method: "POST", body: payload }, true); - lastInvoice = response.invoice; - renderInvoicePreview(lastInvoice); - invoiceResult.classList.remove("hidden"); - resetInvoiceForm(); - } catch (error) { - alert(error.message || "Nie udalo sie wygenerowac faktury."); - } -}); - -addItemButton.addEventListener("click", () => { - createItemRow(); -}); - -downloadButton.addEventListener("click", async () => { - if (!lastInvoice || !currentBusiness) { - alert("Brak faktury do pobrania. Wygeneruj ja najpierw."); - return; - } - await generatePdf(currentBusiness, lastInvoice); -}); - -logoutButton.addEventListener("click", () => { - authToken = null; - sessionStorage.removeItem("invoiceAuthToken"); - lastInvoice = null; - currentBusiness = null; - invoiceResult.classList.add("hidden"); - setState("login"); -}); - -initialize().catch((error) => { - console.error(error); - showFeedback(setupFeedback, "Nie udalo sie uruchomic aplikacji."); -}); +const VAT_OPTIONS = [ + { value: "23", label: "23%" }, + { value: "8", label: "8%" }, + { value: "5", label: "5%" }, + { value: "0", label: "0% (ZW)" }, + { value: "ZW", label: "ZW - zwolnione" }, + { value: "NP", label: "NP - poza zakresem" }, +]; + +const VAT_RATE_VALUES = { + "23": 0.23, + "8": 0.08, + "5": 0.05, + "0": 0, + ZW: 0, + NP: 0, +}; + +const UNIT_OPTIONS = [ + { value: "szt.", label: "szt." }, + { value: "godz.", label: "godz." }, +]; + +const DEFAULT_UNIT = UNIT_OPTIONS[0].value; + +const EXEMPTION_REASONS = [ + { + value: "art_43_1_19", + label: "Art. 43 ust. 1 pkt 19 ustawy o VAT - usługi medyczne", + note: "Art. 43 ust. 1 pkt 19 ustawy o VAT - usługi w zakresie opieki medycznej.", + }, + { + value: "art_43_1_18", + label: "Art. 43 ust. 1 pkt 18 ustawy o VAT - usługi edukacyjne", + note: "Art. 43 ust. 1 pkt 18 ustawy o VAT - usługi edukacyjne w formach przewidzianych w przepisach.", + }, + { + value: "art_43_1_37", + label: "Art. 43 ust. 1 pkt 37 ustawy o VAT - usługi finansowe", + note: "Art. 43 ust. 1 pkt 37 ustawy o VAT - usługi finansowe i pośrednictwa finansowego.", + }, + { + value: "art_113", + label: "Art. 113 ust. 1 i 9 ustawy o VAT - zwolnienie podmiotowe", + note: "Art. 113 ust. 1 i 9 ustawy o VAT - zwolnienie podmiotowe do 200 000 PLN obrotu.", + }, + { + value: "par_3_ust_1_pkt_1", + label: "Par. 3 ust. 1 pkt 1 rozporządzenia MF z 20.12.2013 r.", + note: "Par. 3 ust. 1 pkt 1 rozporządzenia MF z 20.12.2013 r. - dostawa towarów używanych.", + }, + { + value: "custom", + label: "Inne (wpisz własny opis)", + note: "", + }, +]; + +const EXEMPTION_REASON_LOOKUP = new Map(EXEMPTION_REASONS.map((reason) => [reason.value, reason])); + +const authSection = document.getElementById("auth-section"); +const appSection = document.getElementById("app-section"); + +const registerForm = document.getElementById("register-form"); +const loginForm = document.getElementById("login-form"); +const loginSubmitButton = loginForm ? loginForm.querySelector('button[type="submit"]') : null; +const loginSubmitButtonDefaultText = loginSubmitButton && loginSubmitButton.textContent ? loginSubmitButton.textContent.trim() || "Zaloguj" : "Zaloguj"; +const invoiceForm = document.getElementById("invoice-form"); +const businessForm = document.getElementById("business-form"); + +const registerFeedback = document.getElementById("register-feedback"); +const loginFeedback = document.getElementById("login-feedback"); +const businessFeedback = document.getElementById("business-feedback"); +const logoFeedback = document.getElementById("logo-feedback"); +const registerSection = document.getElementById("register-section"); +const showRegisterButton = document.getElementById("show-register-button"); +const backToLoginButton = document.getElementById("back-to-login"); +const cancelRegisterButton = document.getElementById("cancel-register"); + +const businessDisplay = document.getElementById("business-display"); +const toggleBusinessFormButton = document.getElementById("toggle-business-form"); +const cancelBusinessUpdateButton = document.getElementById("cancel-business-update"); +const currentLoginLabel = document.getElementById("current-login-label"); + +const itemsBody = document.getElementById("items-body"); +const addItemButton = document.getElementById("add-item-button"); + +const totalNetLabel = document.getElementById("total-net"); +const totalVatLabel = document.getElementById("total-vat"); +const totalGrossLabel = document.getElementById("total-gross"); +const rateSummaryContainer = document.getElementById("rate-summary"); + +const exemptionNoteWrapper = document.getElementById("exemption-note-wrapper"); +const exemptionReasonSelect = document.getElementById("exemption-reason"); +const exemptionNoteInput = document.getElementById("exemption-note"); + +const invoiceResult = document.getElementById("invoice-result"); +const invoiceOutput = document.getElementById("invoice-output"); +const downloadButton = document.getElementById("download-button"); +const logoutButton = document.getElementById("logout-button"); +const cancelEditInvoiceButton = document.getElementById("cancel-edit-invoice"); +const saveInvoiceButton = document.getElementById("save-invoice-button"); + +const invoiceBuilderSection = document.getElementById("invoice-builder-section"); +const dashboardSection = document.getElementById("dashboard-section"); +const appNavButtons = Array.from(document.querySelectorAll(".app-nav-button")); + +const invoicesTableBody = document.getElementById("invoices-table-body"); +const invoicesEmpty = document.getElementById("invoices-empty"); +const dashboardFeedback = document.getElementById("dashboard-feedback"); + +const filterStartDate = document.getElementById("filter-start-date"); +const filterEndDate = document.getElementById("filter-end-date"); +const clearFiltersButton = document.getElementById("clear-filters"); + +const summaryMonthCount = document.getElementById("summary-month-count"); +const summaryMonthAmount = document.getElementById("summary-month-amount"); +const summaryQuarterCount = document.getElementById("summary-quarter-count"); +const summaryQuarterAmount = document.getElementById("summary-quarter-amount"); +const summaryYearCount = document.getElementById("summary-year-count"); +const summaryYearAmount = document.getElementById("summary-year-amount"); + +const logoInput = document.getElementById("logo-input"); +const logoPreview = document.getElementById("logo-preview"); +const logoPreviewImage = document.getElementById("logo-preview-image"); +const removeLogoButton = document.getElementById("remove-logo-button"); +const legacyLoginHint = document.getElementById("legacy-login-hint"); +const invoicesChartCanvas = document.getElementById("invoices-chart"); + +let authToken = sessionStorage.getItem("invoiceAuthToken") || null; +let currentLogin = sessionStorage.getItem("invoiceLogin") || ""; +let currentBusiness = null; +let currentLogo = null; +let lastInvoice = null; +let invoicesCache = []; +let editingInvoiceId = null; +let activeView = "invoice-builder"; +let invoicesChart = null; +let maxLogoSize = 512 * 1024; +let pdfFontPromise = null; +let pdfFontBase64 = null; +let customExemptionNote = ""; + +function setVisibility(element, visible) { + if (!element) { + return; + } + if (visible) { + element.classList.remove("hidden"); + element.style.removeProperty("display"); + } else { + element.classList.add("hidden"); + element.style.display = "none"; + } +} + +function setAppState(state) { + if (state === "app") { + setVisibility(authSection, false); + setVisibility(registerSection, false); + setVisibility(appSection, true); + } else { + setVisibility(authSection, true); + setVisibility(registerSection, false); + setVisibility(appSection, false); + } +} + +function openRegisterPanel() { + if (!registerSection) { + return; + } + setVisibility(authSection, false); + setVisibility(registerSection, true); + setVisibility(appSection, false); + clearFeedback(registerFeedback); + clearFeedback(loginFeedback); + if (registerForm) { + const emailInput = registerForm.elements.email; + if (emailInput) { + emailInput.focus(); + } + } + const scrollTarget = registerSection.querySelector(".register-card") || registerSection; + const scrollIntoView = () => scrollTarget.scrollIntoView({ behavior: "smooth", block: "start" }); + if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") { + window.requestAnimationFrame(scrollIntoView); + } else if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(scrollIntoView); + } else { + scrollIntoView(); + } +} + +function closeRegisterPanel({ resetForm = true, focusTrigger = false } = {}) { + if (!registerSection) { + return; + } + setVisibility(registerSection, false); + setVisibility(authSection, true); + setVisibility(appSection, false); + clearFeedback(registerFeedback); + clearFeedback(loginFeedback); + if (resetForm && registerForm) { + registerForm.reset(); + } + if (focusTrigger) { + if (showRegisterButton) { + showRegisterButton.focus(); + } + const scrollTarget = authSection ? authSection.querySelector(".login-card") || authSection : null; + if (scrollTarget) { + const scrollToLogin = () => scrollTarget.scrollIntoView({ behavior: "smooth", block: "start" }); + if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") { + window.requestAnimationFrame(scrollToLogin); + } else if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(scrollToLogin); + } else { + scrollToLogin(); + } + } + } +} + +function clearFeedback(element) { + if (!element) { + return; + } + element.textContent = ""; + element.classList.remove("error", "success"); +} + +function showFeedback(element, message, type = "error") { + if (!element) { + return; + } + element.textContent = message; + element.classList.remove("error", "success"); + if (type) { + element.classList.add(type); + } +} + +function parseNumber(value) { + if (typeof value === "number") { + return Number.isFinite(value) ? value : 0; + } + if (!value) { + return 0; + } + const normalized = value.toString().replace(",", "."); + const parsed = Number.parseFloat(normalized); + return Number.isFinite(parsed) ? parsed : 0; +} + +function parseIntegerString(value) { + if (value === null || value === undefined) { + return Number.NaN; + } + const normalized = value.toString().trim(); + if (!normalized) { + return 0; + } + if (!/^\d+$/.test(normalized)) { + return Number.NaN; + } + const parsed = Number.parseInt(normalized, 10); + return Number.isNaN(parsed) ? Number.NaN : parsed; +} + +function formatQuantity(value) { + const parsed = parseIntegerString(value); + if (Number.isNaN(parsed)) { + return "0"; + } + return parsed.toString(); +} + +function formatCurrency(value) { + const number = parseNumber(value); + return `${number.toFixed(2)} PLN`; +} + +function vatLabelFromCode(code) { + if (code === "ZW" || code === "0") { + return "ZW"; + } + if (code === "NP") { + return "NP"; + } + return `${code}%`; +} + +function requiresExemption(code) { + return code === "ZW" || code === "0"; +} + +function populateExemptionReasons() { + if (!exemptionReasonSelect || exemptionReasonSelect.dataset.initialized === "true") { + return; + } + const existingValues = new Set(Array.from(exemptionReasonSelect.options).map((option) => option.value)); + EXEMPTION_REASONS.forEach((reason) => { + if (existingValues.has(reason.value)) { + return; + } + const option = document.createElement("option"); + option.value = reason.value; + option.textContent = reason.label; + exemptionReasonSelect.appendChild(option); + }); + exemptionReasonSelect.dataset.initialized = "true"; +} + +function applyExemptionReasonSelection({ preserveCustom = false } = {}) { + if (!exemptionReasonSelect || !exemptionNoteInput) { + return; + } + const selectedValue = exemptionReasonSelect.value; + const selectedReason = EXEMPTION_REASON_LOOKUP.get(selectedValue); + + // Ukryj pole "Podstawa prawna zwolnienia" jeśli nie wybrano opcji "Inne..." + const exemptionNoteLabel = document.getElementById("exemption-note-label"); + if (exemptionNoteLabel) { + if (selectedValue === "custom") { + exemptionNoteLabel.style.display = "block"; + exemptionNoteInput.style.display = "block"; + } else { + exemptionNoteLabel.style.display = "none"; + exemptionNoteInput.style.display = "none"; + } + } + + if (!selectedReason) { + if (!preserveCustom) { + exemptionNoteInput.readOnly = false; + exemptionNoteInput.value = ""; + } + return; + } + if (selectedValue === "custom") { + exemptionNoteInput.readOnly = false; + if (!preserveCustom) { + exemptionNoteInput.value = customExemptionNote; + } + return; + } + exemptionNoteInput.readOnly = true; + exemptionNoteInput.value = selectedReason.note; +} + +function findExemptionReasonByNote(note) { + if (!note) { + return null; + } + const normalized = note.trim().toLowerCase(); + return ( + EXEMPTION_REASONS.find( + (reason) => + reason.value !== "custom" && reason.note && reason.note.trim().toLowerCase() === normalized + ) || null + ); +} + +function syncExemptionControlsWithNote(note) { + if (!exemptionNoteInput) { + return; + } + const trimmed = (note || "").trim(); + exemptionNoteInput.readOnly = false; + if (!exemptionReasonSelect) { + exemptionNoteInput.value = trimmed; + return; + } + if (!trimmed) { + customExemptionNote = ""; + exemptionReasonSelect.value = ""; + exemptionNoteInput.value = ""; + return; + } + const matchedReason = findExemptionReasonByNote(trimmed); + if (matchedReason && matchedReason.value !== "custom") { + exemptionReasonSelect.value = matchedReason.value; + applyExemptionReasonSelection({ preserveCustom: true }); + } else { + customExemptionNote = trimmed; + exemptionReasonSelect.value = "custom"; + exemptionNoteInput.readOnly = false; + exemptionNoteInput.value = trimmed; + } +} + +function updateExemptionVisibility(exemptionNeeded) { + if (!exemptionNoteWrapper || !exemptionNoteInput) { + return; + } + if (exemptionNeeded) { + populateExemptionReasons(); + setVisibility(exemptionNoteWrapper, true); + applyExemptionReasonSelection({ preserveCustom: true }); + return; + } + setVisibility(exemptionNoteWrapper, false); + if (exemptionReasonSelect) { + exemptionReasonSelect.value = ""; + } + customExemptionNote = ""; + exemptionNoteInput.readOnly = false; + exemptionNoteInput.value = ""; +} + +function formatInvoicesCount(count) { + const value = Number.parseInt(count, 10) || 0; + const absolute = Math.abs(value); + const mod10 = absolute % 10; + const mod100 = absolute % 100; + let suffix = "faktur"; + if (mod10 === 1 && mod100 !== 11) { + suffix = "faktura"; + } else if ([2, 3, 4].includes(mod10) && ![12, 13, 14].includes(mod100)) { + suffix = "faktury"; + } + return `${value} ${suffix}`; +} + +function parseInvoiceIssuedAt(invoice) { + if (!invoice || !invoice.issued_at) { + return null; + } + const normalized = invoice.issued_at.replace(" ", "T"); + const parsed = new Date(normalized); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function parseDateInput(value) { + if (!value) { + return null; + } + const parts = value.split("-").map((part) => Number.parseInt(part, 10)); + if (parts.length !== 3 || parts.some(Number.isNaN)) { + return null; + } + return new Date(parts[0], parts[1] - 1, parts[2]); +} + +function setActiveView(view) { + activeView = view === "dashboard" ? "dashboard" : "invoice-builder"; + setVisibility(invoiceBuilderSection, activeView === "invoice-builder"); + setVisibility(dashboardSection, activeView === "dashboard"); + const showDashboard = activeView === "dashboard"; + appNavButtons.forEach((button) => { + button.classList.toggle("active", button.dataset.view === activeView); + }); + if (showDashboard) { + applyInvoiceFilters(); + } +} + +function updateLoginLabel() { + if (!currentLogin) { + currentLoginLabel.textContent = ""; + return; + } + currentLoginLabel.textContent = `Zalogowany jako ${currentLogin}`; +} + +function updateLogoPreview() { + if (currentLogo && currentLogo.data && currentLogo.mime_type) { + const dataUrl = currentLogo.data_url || `data:${currentLogo.mime_type};base64,${currentLogo.data}`; + logoPreviewImage.src = dataUrl; + logoPreview.classList.remove("hidden"); + removeLogoButton.classList.remove("hidden"); + } else { + logoPreviewImage.removeAttribute("src"); + logoPreview.classList.add("hidden"); + removeLogoButton.classList.add("hidden"); + } +} + +function renderInvoicesTable(invoices) { + invoicesTableBody.innerHTML = ""; + if (!Array.isArray(invoices) || invoices.length === 0) { + invoicesEmpty.classList.remove("hidden"); + return; + } + + invoicesEmpty.classList.add("hidden"); + invoices.forEach((invoice) => { + const row = document.createElement("tr"); + + const numberCell = document.createElement("td"); + numberCell.textContent = invoice.invoice_id || "---"; + row.appendChild(numberCell); + + const issuedCell = document.createElement("td"); + issuedCell.textContent = invoice.issued_at || "-"; + row.appendChild(issuedCell); + + const clientCell = document.createElement("td"); + const clientName = invoice.client?.name || ""; + const clientCity = invoice.client?.city || ""; + clientCell.textContent = clientName ? `${clientName}${clientCity ? ` (${clientCity})` : ""}` : "-"; + row.appendChild(clientCell); + + const grossCell = document.createElement("td"); + grossCell.textContent = formatCurrency(invoice.totals?.gross ?? 0); + row.appendChild(grossCell); + + const actionsCell = document.createElement("td"); + const actionsWrapper = document.createElement("div"); + actionsWrapper.className = "table-actions"; + + const editButton = document.createElement("button"); + editButton.type = "button"; + editButton.textContent = "Edytuj"; + editButton.addEventListener("click", () => { + startInvoiceEdit(invoice.invoice_id); + }); + + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.className = "button secondary"; + deleteButton.textContent = "Usuń"; + deleteButton.addEventListener("click", async () => { + clearFeedback(dashboardFeedback); + const shouldDelete = window.confirm(`Usuńac fakturę ${invoice.invoice_id}?`); + if (!shouldDelete) { + return; + } + await deleteInvoice(invoice.invoice_id); + }); + + actionsWrapper.appendChild(editButton); + actionsWrapper.appendChild(deleteButton); + actionsCell.appendChild(actionsWrapper); + row.appendChild(actionsCell); + + invoicesTableBody.appendChild(row); + }); +} + +function applyInvoiceFilters() { + if (!Array.isArray(invoicesCache)) { + renderInvoicesTable([]); + return; + } + + let filtered = invoicesCache.slice(); + const startDate = parseDateInput(filterStartDate?.value); + const endDate = parseDateInput(filterEndDate?.value); + + if (startDate) { + const startTime = startDate.getTime(); + filtered = filtered.filter((invoice) => { + const issued = parseInvoiceIssuedAt(invoice); + return !issued || issued.getTime() >= startTime; + }); + } + + if (endDate) { + const endBoundary = new Date(endDate); + endBoundary.setHours(23, 59, 59, 999); + const endTime = endBoundary.getTime(); + filtered = filtered.filter((invoice) => { + const issued = parseInvoiceIssuedAt(invoice); + return !issued || issued.getTime() <= endTime; + }); + } + + filtered.sort((a, b) => (b.issued_at || "").localeCompare(a.issued_at || "")); + renderInvoicesTable(filtered); +} + +async function refreshInvoices() { + if (!authToken) { + invoicesCache = []; + renderInvoicesTable([]); + return; + } + clearFeedback(dashboardFeedback); + try { + const data = await apiRequest("/api/invoices", {}, true); + invoicesCache = Array.isArray(data.invoices) ? data.invoices.slice() : []; + invoicesCache.sort((a, b) => (b.issued_at || "").localeCompare(a.issued_at || "")); + applyInvoiceFilters(); + } catch (error) { + console.error(error); + showFeedback(dashboardFeedback, error.message || "Nie udało się pobrać faktur."); + } +} + +function updateSummaryCards(summary) { + const monthSummary = summary?.last_month || { count: 0, gross_total: 0 }; + const quarterSummary = summary?.quarter || { count: 0, gross_total: 0 }; + const yearSummary = summary?.year || { count: 0, gross_total: 0 }; + + summaryMonthCount.textContent = formatInvoicesCount(monthSummary.count); + summaryQuarterCount.textContent = formatInvoicesCount(quarterSummary.count); + summaryYearCount.textContent = formatInvoicesCount(yearSummary.count); + + summaryMonthAmount.textContent = formatCurrency(monthSummary.gross_total ?? 0); + summaryQuarterAmount.textContent = formatCurrency(quarterSummary.gross_total ?? 0); + summaryYearAmount.textContent = formatCurrency(yearSummary.gross_total ?? 0); +} + +function updateSummaryChart(summary) { + if (!invoicesChartCanvas || typeof window.Chart === "undefined") { + return; + } + + const labels = ["Ostatnie 30 dni", "Bieżący kwartał", "Bieżący rok"]; + const counts = [ + Number.parseInt(summary?.last_month?.count ?? 0, 10) || 0, + Number.parseInt(summary?.quarter?.count ?? 0, 10) || 0, + Number.parseInt(summary?.year?.count ?? 0, 10) || 0, + ]; + const amounts = [ + parseNumber(summary?.last_month?.gross_total ?? 0), + parseNumber(summary?.quarter?.gross_total ?? 0), + parseNumber(summary?.year?.gross_total ?? 0), + ]; + + const chartData = { + labels, + datasets: [ + { + label: "Liczba faktur", + data: counts, + backgroundColor: "rgba(26, 115, 232, 0.65)", + yAxisID: "count", + borderRadius: 6, + }, + { + label: "Suma brutto (PLN)", + data: amounts, + type: "line", + fill: false, + borderColor: "rgba(26, 115, 232, 0.65)", + backgroundColor: "rgba(26, 115, 232, 0.35)", + tension: 0.3, + yAxisID: "amount", + }, + ], + }; + + const options = { + responsive: true, + maintainAspectRatio: false, + scales: { + count: { + beginAtZero: true, + position: "left", + ticks: { + precision: 0, + stepSize: 1, + }, + }, + amount: { + beginAtZero: true, + position: "right", + grid: { + drawOnChartArea: false, + }, + ticks: { + callback: (value) => `${new Intl.NumberFormat("pl-PL", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value)} PLN`, + }, + }, + }, + plugins: { + legend: { + position: "bottom", + }, + tooltip: { + callbacks: { + label(context) { + if (context.dataset.yAxisID === "amount") { + return `${context.dataset.label}: ${new Intl.NumberFormat("pl-PL", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(context.parsed.y)} PLN`; + } + return `${context.dataset.label}: ${context.parsed.y}`; + }, + }, + }, + }, + }; + + if (!invoicesChart) { + invoicesChart = new window.Chart(invoicesChartCanvas, { + type: "bar", + data: chartData, + options, + }); + } else { + invoicesChart.data = chartData; + invoicesChart.options = options; + invoicesChart.update(); + } +} + +async function refreshSummary() { + if (!authToken) { + updateSummaryCards({}); + updateSummaryChart({}); + return; + } + clearFeedback(dashboardFeedback); + try { + const data = await apiRequest("/api/invoices/summary", {}, true); + updateSummaryCards(data.summary); + updateSummaryChart(data.summary); + } catch (error) { + console.error(error); + showFeedback(dashboardFeedback, error.message || "Nie udało się pobrać podsumowania."); + } +} + +async function deleteInvoice(invoiceId) { + if (!invoiceId) { + return; + } + try { + await apiRequest(`/api/invoices/${encodeURIComponent(invoiceId)}`, { method: "DELETE" }, true); + invoicesCache = invoicesCache.filter((invoice) => invoice.invoice_id !== invoiceId); + applyInvoiceFilters(); + await refreshSummary(); + } catch (error) { + console.error(error); + showFeedback(dashboardFeedback, error.message || "Nie udało się usunąć faktury."); + } +} + +function startInvoiceEdit(invoiceId) { + if (!invoiceId) { + return; + } + const invoice = invoicesCache.find((item) => item.invoice_id === invoiceId); + if (!invoice) { + showFeedback(dashboardFeedback, "Nie znaleziono wybranej faktury."); + return; + } + + editingInvoiceId = invoiceId; + saveInvoiceButton.textContent = "Zapisz zmiany"; + cancelEditInvoiceButton.classList.remove("hidden"); + setActiveView("invoice-builder"); + + resetInvoiceForm(); + invoiceForm.elements.saleDate.value = invoice.sale_date || ""; + invoiceForm.elements.paymentTerm.value = invoice.payment_term || 14; + + if (invoice.client) { + invoiceForm.elements.clientName.value = invoice.client.name || ""; + invoiceForm.elements.clientTaxId.value = invoice.client.tax_id || ""; + invoiceForm.elements.clientAddress.value = invoice.client.address_line || ""; + invoiceForm.elements.clientPostalCode.value = invoice.client.postal_code || ""; + invoiceForm.elements.clientCity.value = invoice.client.city || ""; + invoiceForm.elements.clientPhone.value = invoice.client.phone || ""; + } + + itemsBody.innerHTML = ""; + if (Array.isArray(invoice.items) && invoice.items.length > 0) { + invoice.items.forEach((item) => { + createItemRow({ + name: item.name, + quantity: item.quantity, + unit_price_gross: item.unit_price_gross ?? item.gross_total, + vat_code: item.vat_code, + unit: item.unit, + }); + }); + } else { + createItemRow(); + } + + const note = invoice.exemption_note || ""; + syncExemptionControlsWithNote(note); + const requiresNote = Array.isArray(invoice.items) + ? invoice.items.some((item) => requiresExemption(item.vat_code)) + : false; + updateExemptionVisibility(requiresNote); + + lastInvoice = invoice; +} + +function exitInvoiceEdit() { + editingInvoiceId = null; + saveInvoiceButton.textContent = "Generuj fakturę"; + cancelEditInvoiceButton.classList.add("hidden"); +} + +async function apiRequest(path, { method = "GET", body, headers = {} } = {}, requireAuth = false) { + const options = { + method, + headers: { + "Content-Type": "application/json", + ...headers, + }, + }; + + if (body !== undefined) { + options.body = JSON.stringify(body); + } + + if (requireAuth) { + if (!authToken) { + throw new Error("Brak tokenu autoryzacyjnego."); + } + options.headers.Authorization = `Bearer ${authToken}`; + } + + const response = await fetch(path, options); + const isJson = response.headers.get("content-type")?.includes("application/json"); + const data = isJson ? await response.json() : {}; + + if (response.status === 401) { + authToken = null; + currentLogin = ""; + sessionStorage.removeItem("invoiceAuthToken"); + sessionStorage.removeItem("invoiceLogin"); + setAppState("auth"); + throw new Error(data.error || "Sesja wygasła. Zaloguj się ponownie."); + } + + if (!response.ok) { + throw new Error(data.error || "Wystapil błąd podczas komunikacji z serwerem."); + } + + return data; +} + +function renderBusinessDisplay(business) { + if (!business) { + businessDisplay.textContent = "Brak zapisanych danych firmy."; + return; + } + + const fallback = (value) => { + if (!value) { + return "---"; + } + const trimmed = value.toString().trim(); + return trimmed || "---"; + }; + + const companyName = fallback(business.company_name); + const ownerName = fallback(business.owner_name); + const addressLine = fallback(business.address_line); + const location = fallback([business.postal_code, business.city].filter(Boolean).join(" ")); + const taxLine = `NIP: ${fallback(business.tax_id)}`; + const bankLine = `Konto: ${fallback(business.bank_account)}`; + + businessDisplay.innerHTML = ` +
+
+ ${companyName} + ${ownerName} +
+
+ ${addressLine} + ${location} +
+
+ ${taxLine} + ${bankLine} +
+
+ `; +} + +function fillBusinessForm(business) { + if (!business) { + return; + } + businessForm.elements.company_name.value = business.company_name || ""; + businessForm.elements.owner_name.value = business.owner_name || ""; + businessForm.elements.address_line.value = business.address_line || ""; + businessForm.elements.postal_code.value = business.postal_code || ""; + businessForm.elements.city.value = business.city || ""; + businessForm.elements.tax_id.value = business.tax_id || ""; + businessForm.elements.bank_account.value = business.bank_account || ""; +} + +function setBusinessFormVisibility(visible, { preserveFeedback = false } = {}) { + setVisibility(businessForm, visible); + if (toggleBusinessFormButton) { + toggleBusinessFormButton.textContent = visible ? "Ukryj formularz" : "Edycja danych"; + } + if (!visible && !preserveFeedback) { + clearFeedback(businessFeedback); + } +} + +function vatSelectElement(initialValue = "23") { + const select = document.createElement("select"); + select.className = "item-vat"; + VAT_OPTIONS.forEach((option) => { + const element = document.createElement("option"); + element.value = option.value; + element.textContent = option.label; + select.appendChild(element); + }); + select.value = VAT_OPTIONS.some((option) => option.value === initialValue) ? initialValue : "23"; + return select; +} + +function unitSelectElement(initialValue = DEFAULT_UNIT) { + const select = document.createElement("select"); + select.className = "item-unit"; + UNIT_OPTIONS.forEach((option) => { + const element = document.createElement("option"); + element.value = option.value; + element.textContent = option.label; + select.appendChild(element); + }); + select.value = UNIT_OPTIONS.some((option) => option.value === initialValue) ? initialValue : DEFAULT_UNIT; + return select; +} + +function createItemRow(initialValues = {}) { + const row = document.createElement("tr"); + + const nameCell = document.createElement("td"); + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.className = "item-name"; + nameInput.placeholder = "Nazwa towaru lub usługi"; + if (initialValues.name) { + nameInput.value = initialValues.name; + } + nameCell.appendChild(nameInput); + + const quantityCell = document.createElement("td"); + const quantityInput = document.createElement("input"); + quantityInput.type = "number"; + quantityInput.className = "item-quantity"; + quantityInput.min = "1"; + quantityInput.step = "1"; + quantityInput.inputMode = "numeric"; + const parsedQuantity = parseIntegerString(initialValues.quantity); + const safeQuantity = Number.isNaN(parsedQuantity) || parsedQuantity <= 0 ? 1 : parsedQuantity; + quantityInput.value = String(safeQuantity); + quantityCell.appendChild(quantityInput); + + const unitCell = document.createElement("td"); + const unitSelect = unitSelectElement(initialValues.unit); + unitCell.appendChild(unitSelect); + + const unitGrossCell = document.createElement("td"); + const unitGrossInput = document.createElement("input"); + unitGrossInput.type = "number"; + unitGrossInput.className = "item-gross"; + unitGrossInput.min = "0.01"; + unitGrossInput.step = "0.01"; + unitGrossInput.placeholder = "Brutto"; + if (initialValues.unit_price_gross) { + unitGrossInput.value = initialValues.unit_price_gross; + } + unitGrossCell.appendChild(unitGrossInput); + + const vatCell = document.createElement("td"); + const vatSelect = vatSelectElement(initialValues.vat_code); + vatCell.appendChild(vatSelect); + + const totalCell = document.createElement("td"); + totalCell.className = "item-total"; + totalCell.textContent = "0.00 PLN"; + + const actionsCell = document.createElement("td"); + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "remove-item"; + removeButton.textContent = "Usuń"; + actionsCell.appendChild(removeButton); + + row.appendChild(nameCell); + row.appendChild(quantityCell); + row.appendChild(unitCell); + row.appendChild(unitGrossCell); + row.appendChild(vatCell); + row.appendChild(totalCell); + row.appendChild(actionsCell); + + const handleChange = () => updateTotals(); + nameInput.addEventListener("input", handleChange); + quantityInput.addEventListener("input", () => { + const sanitized = quantityInput.value.replace(/[^0-9]/g, ""); + quantityInput.value = sanitized; + handleChange(); + }); + quantityInput.addEventListener("blur", () => { + const parsed = parseIntegerString(quantityInput.value); + quantityInput.value = Number.isNaN(parsed) || parsed <= 0 ? "1" : String(parsed); + handleChange(); + }); + unitGrossInput.addEventListener("input", handleChange); + vatSelect.addEventListener("change", handleChange); + unitSelect.addEventListener("change", handleChange); + + removeButton.addEventListener("click", () => { + if (itemsBody.children.length === 1) { + nameInput.value = ""; + quantityInput.value = "1"; + unitGrossInput.value = ""; + vatSelect.value = "23"; + unitSelect.value = DEFAULT_UNIT; + updateTotals(); + return; + } + row.remove(); + updateTotals(); + }); + + itemsBody.appendChild(row); + updateTotals(); +} + +function calculateRowTotals(row) { + const name = row.querySelector(".item-name")?.value.trim() ?? ""; + const quantityRaw = row.querySelector(".item-quantity")?.value; + const quantityParsed = parseIntegerString(quantityRaw); + const quantityValid = Number.isInteger(quantityParsed) && quantityParsed > 0; + const quantity = quantityValid ? quantityParsed : 0; + const unitGross = parseNumber(row.querySelector(".item-gross")?.value); + const vatCode = row.querySelector(".item-vat")?.value ?? "23"; + const rate = VAT_RATE_VALUES[vatCode] ?? 0; + const unit = row.querySelector(".item-unit")?.value || DEFAULT_UNIT; + const unitLabel = UNIT_OPTIONS.some((option) => option.value === unit) ? unit : DEFAULT_UNIT; + + const hasValues = name || quantity > 0 || unitGross > 0; + if (!hasValues) { + return { + valid: false, + vatCode, + vatLabel: vatLabelFromCode(vatCode), + requiresExemption: requiresExemption(vatCode), + quantity, + unitGross, + unitNet: 0, + netTotal: 0, + vatAmount: 0, + grossTotal: 0, + unit: unitLabel, + }; + } + + if (!quantityValid || unitGross <= 0) { + return { + valid: false, + vatCode, + vatLabel: vatLabelFromCode(vatCode), + requiresExemption: requiresExemption(vatCode), + quantity, + unitGross, + unitNet: 0, + netTotal: 0, + vatAmount: 0, + grossTotal: quantity * unitGross, + unit: unitLabel, + }; + } + + const grossTotal = quantity * unitGross; + const netTotal = rate > 0 ? grossTotal / (1 + rate) : grossTotal; + const vatAmount = grossTotal - netTotal; + const unitNet = netTotal / quantity; + + return { + valid: true, + vatCode, + vatLabel: vatLabelFromCode(vatCode), + requiresExemption: requiresExemption(vatCode), + quantity, + unitGross, + unitNet, + netTotal, + vatAmount, + grossTotal, + unit: unitLabel, + }; +} + +function updateTotals() { + let totalNet = 0; + let totalVat = 0; + let totalGross = 0; + const summary = new Map(); + let exemptionNeeded = false; + + const rows = Array.from(itemsBody.querySelectorAll("tr")); + rows.forEach((row) => { + const totals = calculateRowTotals(row); + if (totals.requiresExemption) { + exemptionNeeded = true; + } + const totalCell = row.querySelector(".item-total"); + totalCell.textContent = formatCurrency(totals.grossTotal); + + if (!totals.valid) { + return; + } + + totalNet += totals.netTotal; + totalVat += totals.vatAmount; + totalGross += totals.grossTotal; + + const existing = summary.get(totals.vatLabel) || { net: 0, vat: 0, gross: 0 }; + existing.net += totals.netTotal; + existing.vat += totals.vatAmount; + existing.gross += totals.grossTotal; + summary.set(totals.vatLabel, existing); + }); + + totalNetLabel.textContent = `Suma netto: ${totalNet.toFixed(2)} PLN`; + totalVatLabel.textContent = `Kwota VAT: ${totalVat.toFixed(2)} PLN`; + totalGrossLabel.textContent = `Suma brutto: ${totalGross.toFixed(2)} PLN`; + renderRateSummary(summary); + + updateExemptionVisibility(exemptionNeeded); +} + +function renderRateSummary(summary) { + if (!summary || summary.size === 0) { + rateSummaryContainer.innerHTML = ""; + return; + } + + const entries = Array.from(summary.entries()).sort(([a], [b]) => a.localeCompare(b)); + const markup = entries + .map( + ([label, totals]) => + `
+ ${label} + Netto: ${totals.net.toFixed(2)} PLN + VAT: ${totals.vat.toFixed(2)} PLN + Brutto: ${totals.gross.toFixed(2)} PLN +
` + ) + .join(""); + rateSummaryContainer.innerHTML = `

Podsumowanie stawek

${markup}`; +} + +function collectInvoicePayload() { + const items = []; + const rows = Array.from(itemsBody.querySelectorAll("tr")); + + rows.forEach((row) => { + const name = row.querySelector(".item-name")?.value.trim() ?? ""; + const quantityRaw = row.querySelector(".item-quantity")?.value; + const quantityParsed = parseIntegerString(quantityRaw); + const quantityValid = Number.isInteger(quantityParsed) && quantityParsed > 0; + const quantity = quantityValid ? quantityParsed : 0; + const unitGross = parseNumber(row.querySelector(".item-gross")?.value); + const vatCode = row.querySelector(".item-vat")?.value ?? "23"; + const unitValue = row.querySelector(".item-unit")?.value || DEFAULT_UNIT; + const unit = UNIT_OPTIONS.some((option) => option.value === unitValue) ? unitValue : DEFAULT_UNIT; + + const hasValues = name || quantity > 0 || unitGross > 0; + if (!hasValues) { + return; + } + + if (!name) { + throw new Error("Każda pozycja musi mieć nazwę."); + } + if (!quantityValid) { + throw new Error("Ilość musi byc dodatnia liczba calkowita."); + } + if (unitGross <= 0) { + throw new Error("Cena brutto musi być większa od zera."); + } + + items.push({ + name, + quantity, + unit, + unit_price_gross: unitGross.toFixed(2), + vat_code: vatCode, + }); + }); + + if (items.length === 0) { + throw new Error("Dodaj przynajmniej jedną pozycję."); + } + + const saleDate = invoiceForm.elements.saleDate.value || null; + const paymentTerm = parseInt(invoiceForm.elements.paymentTerm.value) || 14; + const requiresExemptionNote = items.some((item) => item.vat_code === "ZW" || item.vat_code === "0"); + let exemptionNote = ""; + if (requiresExemptionNote) { + const noteFromTextarea = exemptionNoteInput.value.trim(); + if (exemptionReasonSelect) { + const selectedReason = EXEMPTION_REASON_LOOKUP.get(exemptionReasonSelect.value); + if (selectedReason && selectedReason.value !== "custom") { + exemptionNote = selectedReason.note; + } else { + exemptionNote = noteFromTextarea; + } + } else { + exemptionNote = noteFromTextarea; + } + if (!exemptionNote) { + throw new Error("Wybierz lub wpisz podstawę zwolnienia dla pozycji ze stawka ZW/0%."); + } + } + const client = { + name: (invoiceForm.elements.clientName.value || "").trim(), + tax_id: (invoiceForm.elements.clientTaxId.value || "").trim(), + address_line: (invoiceForm.elements.clientAddress.value || "").trim(), + postal_code: (invoiceForm.elements.clientPostalCode.value || "").trim(), + city: (invoiceForm.elements.clientCity.value || "").trim(), + phone: (invoiceForm.elements.clientPhone.value || "").trim(), + }; + + return { + sale_date: saleDate, + payment_term: paymentTerm, + client, + items, + exemption_note: exemptionNote, + }; +} + +function renderInvoicePreview(invoice) { + if (!invoice || !currentBusiness) { + invoiceOutput.innerHTML = "

Brak danych faktury.

"; + return; + } + + const client = invoice.client || {}; + const hasClientData = client.name || client.address_line || client.postal_code || client.city || client.tax_id; + + const itemsRows = (invoice.items || []) + .map((item) => { + const quantityDisplay = formatQuantity(item.quantity); + const unitDisplay = UNIT_OPTIONS.some((option) => option.value === item.unit) ? item.unit : DEFAULT_UNIT; + return ` + + ${item.name} + ${quantityDisplay} + ${unitDisplay} + ${formatCurrency(item.unit_price_net)} + ${formatCurrency(item.net_total)} + ${item.vat_label} + ${formatCurrency(item.vat_amount)} + ${formatCurrency(item.gross_total)} + `; + }) + .join(""); + + const summaryRows = (invoice.summary || []) + .map( + (entry) => + `
+ ${entry.vat_label} + Netto: ${formatCurrency(entry.net_total)} + VAT: ${formatCurrency(entry.vat_total)} + Brutto: ${formatCurrency(entry.gross_total)} +
` + ) + .join(""); + + invoiceOutput.innerHTML = ` +
+ Numer: ${invoice.invoice_id} + Data wystawienia: ${invoice.issued_at} + Data sprzedaży: ${invoice.sale_date} + ${invoice.payment_term ? `Termin płatności: ${invoice.payment_term} dni` : ''} +
+
+
+

Nabywca

+ ${ + hasClientData + ? ` +

${client.name || "---"}

+

${client.address_line || "---"}

+

${client.postal_code || ""} ${client.city || ""}

+

${client.tax_id ? `NIP: ${client.tax_id}` : ""}

+ ${client.phone ? `

Tel: ${client.phone}

` : ''} + ` + : "

Brak danych nabywcy.

" + } +
+
+

Sprzedawca

+

${currentBusiness.company_name}

+

${currentBusiness.owner_name}

+

${currentBusiness.address_line}

+

${currentBusiness.postal_code} ${currentBusiness.city}

+

NIP: ${currentBusiness.tax_id}

+

Konto: ${currentBusiness.bank_account}

+
+
+ + + + + + + + + + + + + + ${itemsRows} +
NazwaIlośćJednostkaCena jedn. nettoWartość netto (pozycja)Stawka VATKwota VAT (pozycja)Wartość brutto
+
+

Podsumowanie stawek

+ ${summaryRows} +
+
+ Netto: ${formatCurrency(invoice.totals.net)} + VAT: ${formatCurrency(invoice.totals.vat)} + Brutto: ${formatCurrency(invoice.totals.gross)} +
+ ${ + invoice.exemption_note + ? `
Podstawa prawna zwolnienia: ${invoice.exemption_note}
` + : "" + } + `; +} + +function drawPartyBox(doc, title, lines, x, y, width) { + const lineHeight = 5; + const wrappedLines = lines.flatMap((line) => doc.splitTextToSize(line, width)); + const boxHeight = wrappedLines.length * lineHeight + 14; + + doc.roundedRect(x - 4, y - 8, width + 8, boxHeight, 2, 2); + doc.setFontSize(11); + doc.text(title, x, y); + doc.setFontSize(10); + + let cursor = y + 5; + wrappedLines.forEach((line) => { + doc.text(line, x, cursor); + cursor += lineHeight; + }); + + return y - 8 + boxHeight; +} + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + const chunk = bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length)); + binary += String.fromCharCode.apply(null, chunk); + } + return btoa(binary); +} + +const PDF_FONT_FILE = "Roboto-VariableFont_wdth,wght.ttf"; +const PDF_FONT_NAME = "RobotoPolish"; + +async function ensurePdfFont() { + if (pdfFontPromise) { + return pdfFontPromise; + } + + if (!window.jspdf || !window.jspdf.jsPDF) { + throw new Error("Biblioteka jsPDF nie została załadowana."); + } + + const { jsPDF } = window.jspdf; + const loadBase64 = async () => { + if (typeof window !== "undefined" && window.PDF_FONT_BASE64) { + return window.PDF_FONT_BASE64; + } + const response = await fetch(`/${encodeURIComponent(PDF_FONT_FILE)}`); + if (!response.ok) { + throw new Error(`Nie udało się pobrać czcionki Roboto (status ${response.status}).`); + } + const buffer = await response.arrayBuffer(); + return arrayBufferToBase64(buffer); + }; + + pdfFontPromise = loadBase64().then((data) => { + pdfFontBase64 = data; + return data; + }); + + return pdfFontPromise; +} + +async function generatePdf(business, invoice, logo) { + if (!window.jspdf || !window.jspdf.jsPDF) { + alert("Biblioteka jsPDF nie została załadowana. Sprawdź połączenie z internetem."); + return; + } + + let fontBase64; + try { + fontBase64 = await ensurePdfFont(); + } catch (error) { + alert(error.message || "Nie udało się przygotować czcionki do PDF."); + return; + } + + const { jsPDF } = window.jspdf; + const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" }); + const marginX = 18; + let cursorY = 20; + const pageWidth = doc.internal.pageSize.getWidth(); + + if (!doc.getFontList()[PDF_FONT_NAME]) { + const embeddedFont = pdfFontBase64 || fontBase64; + doc.addFileToVFS(PDF_FONT_FILE, embeddedFont); + doc.addFont(PDF_FONT_FILE, PDF_FONT_NAME, "normal"); + } + + doc.setFont(PDF_FONT_NAME, "normal"); + if (logo && logo.data && logo.mime_type) { + const format = logo.mime_type === "image/png" ? "PNG" : "JPEG"; + const dataUrl = logo.data_url || `data:${logo.mime_type};base64,${logo.data}`; + try { + let logoWidth = 45; + let logoHeight = 18; + if (doc.getImageProperties) { + const props = doc.getImageProperties(dataUrl); + if (props?.width && props?.height) { + const ratio = props.height / props.width; + logoHeight = logoWidth * ratio; + if (logoHeight > 22) { + logoHeight = 22; + logoWidth = logoHeight / ratio; + } + } + } + const logoX = pageWidth - marginX - logoWidth; + const logoY = 14; + doc.addImage(dataUrl, format, logoX, logoY, logoWidth, logoHeight); + } catch (error) { + console.warn("Nie udało się dodać logo do PDF:", error); + } + } + doc.setFontSize(16); + doc.text(`Faktura ${invoice.invoice_id}`, marginX, cursorY); + doc.setFontSize(10); + doc.text(`Data wystawienia: ${invoice.issued_at}`, marginX, cursorY + 6); + doc.text(`Data sprzedaży: ${invoice.sale_date}`, marginX, cursorY + 12); + + cursorY += 22; + const columnWidth = 85; + const sellerX = marginX + columnWidth + 12; + + const clientLines = invoice.client && (invoice.client.name || invoice.client.address_line || invoice.client.city || invoice.client.tax_id || invoice.client.phone) + ? [ + invoice.client.name || "---", + invoice.client.address_line || "", + `${(invoice.client.postal_code || "").trim()} ${(invoice.client.city || "").trim()}`.trim(), + invoice.client.tax_id ? `NIP: ${invoice.client.tax_id}` : "", + invoice.client.phone ? `Tel: ${invoice.client.phone}` : "", + ].filter((line) => line && line.trim()) + : ["Brak danych nabywcy"]; + + const sellerLines = [ + business.company_name, + business.owner_name, + business.address_line, + `${business.postal_code} ${business.city}`.trim(), + `NIP: ${business.tax_id}`, + `Konto: ${business.bank_account}`, + ]; + + const buyerBottom = drawPartyBox(doc, "NABYWCA", clientLines, marginX, cursorY, columnWidth); + const sellerBottom = drawPartyBox(doc, "SPRZEDAWCA", sellerLines, sellerX, cursorY, columnWidth); + cursorY = Math.max(buyerBottom, sellerBottom) + 12; + + const tableColumns = [ + { key: "name", label: "Nazwa", width: 44 }, + { key: "quantity", label: "Ilość", width: 14 }, + { key: "unit", label: "Jednostka", width: 14 }, + { key: "unitNet", label: "Cena jedn. netto", width: 23 }, + { key: "netTotal", label: "Wartość netto", width: 23 }, + { key: "vatLabel", label: "Stawka VAT", width: 14 }, + { key: "vatAmount", label: "Kwota VAT", width: 21 }, + { key: "grossTotal", label: "Wartość brutto", width: 21 }, + ]; + const lineHeight = 5; + const headerLineHeight = 4.2; + tableColumns.forEach((column) => { + column.headerLines = doc.splitTextToSize(column.label, column.width - 4); + }); + const headerHeight = Math.max(...tableColumns.map((column) => column.headerLines.length * headerLineHeight + 3)); + + const tableWidth = tableColumns.reduce((sum, column) => sum + column.width, 0); + + doc.setFillColor(241, 243, 247); + doc.rect(marginX, cursorY, tableWidth, headerHeight, "F"); + doc.rect(marginX, cursorY, tableWidth, headerHeight); + let offsetX = marginX; + doc.setFontSize(10); + tableColumns.forEach((column) => { + doc.rect(offsetX, cursorY, column.width, headerHeight); + column.headerLines.forEach((line, index) => { + const textY = cursorY + 4 + index * headerLineHeight; + doc.text((line || "").trim(), offsetX + 2, textY); + }); + offsetX += column.width; + }); + cursorY += headerHeight; + + const invoiceItems = Array.isArray(invoice.items) ? invoice.items : []; + invoiceItems.forEach((item) => { + const quantity = formatQuantity(item.quantity); + const unitLabel = UNIT_OPTIONS.some((option) => option.value === item.unit) ? item.unit : DEFAULT_UNIT; + const unitNet = formatCurrency(item.unit_price_net); + const netTotal = formatCurrency(item.net_total); + const vatAmount = formatCurrency(item.vat_amount); + const grossTotal = formatCurrency(item.gross_total); + + const wrapText = (text, width) => + doc + .splitTextToSize(text ?? "", width) + .map((line) => line.trim()); + + const columnData = tableColumns.map((column) => { + switch (column.key) { + case "name": + return wrapText(item.name, column.width - 4); + case "quantity": + return [quantity]; + case "unit": + return [unitLabel]; + case "unitNet": + return [unitNet]; + case "netTotal": + return [netTotal]; + case "vatLabel": + return [item.vat_label]; + case "vatAmount": + return [vatAmount]; + case "grossTotal": + return [grossTotal]; + default: + return [""]; + } + }); + + const rowHeight = Math.max(...columnData.map((data) => data.length)) * lineHeight + 2; + offsetX = marginX; + tableColumns.forEach((column, index) => { + doc.rect(offsetX, cursorY, column.width, rowHeight); + const lines = columnData[index]; + lines.forEach((line, lineIndex) => { + const textY = cursorY + (lineIndex + 1) * lineHeight; + const content = (line || "").trim(); + doc.text(content, offsetX + 2, textY); + }); + offsetX += column.width; + }); + + cursorY += rowHeight; + }); + + cursorY += 10; + doc.setFontSize(11); + doc.text("Podsumowanie stawek:", marginX, cursorY); + cursorY += 6; + + const summaryEntries = Array.isArray(invoice.summary) ? invoice.summary : []; + summaryEntries.forEach((entry) => { + const summaryLine = `${entry.vat_label} – Netto: ${formatCurrency(entry.net_total)} / VAT: ${formatCurrency(entry.vat_total)} / Brutto: ${formatCurrency(entry.gross_total)}`; + const wrapped = doc.splitTextToSize(summaryLine, 170); + wrapped.forEach((line) => { + doc.text((line || "").trim(), marginX, cursorY); + cursorY += lineHeight; + }); + }); + + cursorY += 6; + doc.setFontSize(12); + doc.text(`Suma netto: ${formatCurrency(invoice.totals.net)}`, marginX, cursorY); + doc.text(`Suma VAT: ${formatCurrency(invoice.totals.vat)}`, marginX, cursorY + 6); + doc.text(`Suma brutto: ${formatCurrency(invoice.totals.gross)}`, marginX, cursorY + 12); + if (invoice.payment_term) { + doc.text(`Termin płatności: ${invoice.payment_term} dni`, marginX, cursorY + 18); + } + cursorY += 30; + + if (invoice.exemption_note) { + doc.setFontSize(10); + const noteLines = doc.splitTextToSize(`Podstawa prawna zwolnienia: ${invoice.exemption_note}`, 170); + doc.text(noteLines, marginX, cursorY); + } + + doc.save(`${invoice.invoice_id}.pdf`); +} + +async function loadBusinessData() { + const data = await apiRequest("/api/business", {}, true); + currentBusiness = data.business; + renderBusinessDisplay(currentBusiness); + fillBusinessForm(currentBusiness); + setBusinessFormVisibility(false); +} + +async function loadLogo() { + try { + const data = await apiRequest("/api/logo", {}, true); + currentLogo = data.logo || null; + } catch (error) { + console.error("Nie udało się pobrać logo:", error); + currentLogo = null; + } + updateLogoPreview(); +} + +function resetInvoiceForm() { + invoiceForm.reset(); + customExemptionNote = ""; + updateExemptionVisibility(false); + itemsBody.innerHTML = ""; + createItemRow(); + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + if (invoiceForm.elements.saleDate) { + invoiceForm.elements.saleDate.value = `${year}-${month}-${day}`; + } + updateTotals(); +} + +async function bootstrapApp() { + try { + await loadBusinessData(); + await loadLogo(); + exitInvoiceEdit(); + resetInvoiceForm(); + invoiceResult.classList.add("hidden"); + lastInvoice = null; + await refreshInvoices(); + await refreshSummary(); + updateLoginLabel(); + setAppState("app"); + activeView = "invoice-builder"; + setActiveView(activeView); + } catch (error) { + console.error(error); + authToken = null; + currentLogin = ""; + sessionStorage.removeItem("invoiceAuthToken"); + sessionStorage.removeItem("invoiceLogin"); + showFeedback(loginFeedback, error.message || "Nie udało się pobrać danych konta."); + setAppState("auth"); + } +} + +async function initialize() { + exitInvoiceEdit(); + resetInvoiceForm(); + updateLogoPreview(); + updateSummaryCards({}); + updateSummaryChart({}); + setActiveView("invoice-builder"); + setAppState("auth"); + closeRegisterPanel({ resetForm: true, focusTrigger: false }); + clearFeedback(registerFeedback); + clearFeedback(loginFeedback); + try { + const status = await apiRequest("/api/status"); + if (typeof status.max_logo_size === "number") { + maxLogoSize = status.max_logo_size; + } + if (status.legacy_login_hint) { + legacyLoginHint.textContent = `Zaloguj się korzystajac ze starego loginu "${status.legacy_login_hint}", a nastepnie dodaj adres email.`; + legacyLoginHint.classList.remove("hidden"); + } else { + legacyLoginHint.classList.add("hidden"); + legacyLoginHint.textContent = ""; + } + + if (authToken) { + await bootstrapApp(); + } + } catch (error) { + console.error(error); + showFeedback(registerFeedback, "Nie udało się nawiązać połączenia z serwerem."); + } +} + +if (registerForm && registerFeedback && loginFeedback) { + registerForm.addEventListener("submit", async (event) => { + event.preventDefault(); + clearFeedback(registerFeedback); + clearFeedback(loginFeedback); + + const formData = new FormData(registerForm); + const emailValue = formData.get("email")?.toString().trim() ?? ""; + const password = formData.get("password")?.toString() ?? ""; + const confirmPassword = formData.get("confirm_password")?.toString() ?? ""; + + if (!emailValue) { + showFeedback(registerFeedback, "Podaj adres email."); + return; + } + if (password !== confirmPassword) { + showFeedback(registerFeedback, "Hasła musza byc identyczne."); + return; + } + + if (password.trim().length < 4) { + showFeedback(registerFeedback, "Hasło musi miec co najmniej 4 znaki."); + return; + } + + const payload = { + email: emailValue, + company_name: formData.get("company_name")?.toString().trim(), + owner_name: formData.get("owner_name")?.toString().trim(), + address_line: formData.get("address_line")?.toString().trim(), + postal_code: formData.get("postal_code")?.toString().trim(), + city: formData.get("city")?.toString().trim(), + tax_id: formData.get("tax_id")?.toString().trim(), + bank_account: formData.get("bank_account")?.toString().trim(), + password, + }; + + try { + await apiRequest("/api/setup", { method: "POST", body: payload }); + showFeedback(registerFeedback, "Konto utworzone. Możesz sie zalogowac.", "success"); + if (loginForm && loginForm.elements.email) { + loginForm.elements.email.value = emailValue; + } + registerForm.reset(); + setTimeout(() => { + closeRegisterPanel({ resetForm: true, focusTrigger: false }); + clearFeedback(registerFeedback); + clearFeedback(loginFeedback); + showFeedback(loginFeedback, "Konto utworzone. Zaloguj się haslem.", "success"); + if (loginForm) { + const passwordInput = loginForm.elements.password; + if (passwordInput) { + passwordInput.focus(); + } + } + }, 1600); + } catch (error) { + showFeedback(registerFeedback, error.message || "Nie udało się utworzyć konta."); + } + }); +} + +if (loginForm && loginFeedback) { + const setLoginSubmittingState = (isSubmitting) => { + if (!loginSubmitButton) { + return; + } + if (isSubmitting) { + loginSubmitButton.disabled = true; + loginSubmitButton.setAttribute("data-loading", "true"); + loginSubmitButton.textContent = "Logowanie..."; + } else { + loginSubmitButton.disabled = false; + loginSubmitButton.textContent = loginSubmitButtonDefaultText; + loginSubmitButton.removeAttribute("data-loading"); + } + }; + + loginForm.addEventListener("submit", async (event) => { + event.preventDefault(); + clearFeedback(loginFeedback); + + const emailElement = loginForm.elements.email; + const emailValue = emailElement ? emailElement.value.trim() : ""; + const password = loginForm.elements.password.value; + + if (!emailValue) { + showFeedback(loginFeedback, "Podaj adres email."); + return; + } + + if (!password) { + showFeedback(loginFeedback, "Podaj hasło."); + return; + } + + setLoginSubmittingState(true); + + try { + const response = await apiRequest("/api/login", { method: "POST", body: { email: emailValue, password } }); + authToken = response.token; + currentLogin = response.email || response.login || emailValue; + sessionStorage.setItem("invoiceAuthToken", authToken); + sessionStorage.setItem("invoiceLogin", currentLogin); + loginForm.reset(); + await bootstrapApp(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error || ""); + let feedbackMessage = errorMessage || "Logowanie nie powiodło się."; + if (/nieprawidlowy (login|email) lub hasło/i.test(errorMessage)) { + feedbackMessage = "Podany email lub hasło są nieprawidłowe. Utwórz konto, jeśli jeszcze go nie masz."; + } else if (/brak autoryzacji/i.test(errorMessage) || /brak tokenu autoryzacyjnego/i.test(errorMessage)) { + feedbackMessage = "Sesja wygasła. Zaloguj się ponownie."; + } else if (/failed to fetch|networkerror/i.test(errorMessage) || error instanceof TypeError) { + feedbackMessage = "Nie udało się nawiązać połączenia z serwerem. Sprawdź, czy aplikacja serwerowa jest uruchomiona."; + } + showFeedback(loginFeedback, feedbackMessage); + } finally { + setLoginSubmittingState(false); + } + }); +} + +if (toggleBusinessFormButton && businessForm && businessFeedback) { + toggleBusinessFormButton.addEventListener("click", () => { + const isVisible = !businessForm.classList.contains("hidden"); + if (!isVisible) { + fillBusinessForm(currentBusiness); + setBusinessFormVisibility(true, { preserveFeedback: true }); + } else { + setBusinessFormVisibility(false); + } + }); +} + +if (cancelBusinessUpdateButton && businessForm && businessFeedback && toggleBusinessFormButton) { + cancelBusinessUpdateButton.addEventListener("click", () => { + setBusinessFormVisibility(false); + }); +} + +if (businessForm && businessFeedback) { + businessForm.addEventListener("submit", async (event) => { + event.preventDefault(); + clearFeedback(businessFeedback); + + const formData = new FormData(businessForm); + const payload = { + company_name: formData.get("company_name")?.toString().trim(), + owner_name: formData.get("owner_name")?.toString().trim(), + address_line: formData.get("address_line")?.toString().trim(), + postal_code: formData.get("postal_code")?.toString().trim(), + city: formData.get("city")?.toString().trim(), + tax_id: formData.get("tax_id")?.toString().trim(), + bank_account: formData.get("bank_account")?.toString().trim(), + }; + + try { + const data = await apiRequest("/api/business", { method: "PUT", body: payload }, true); + currentBusiness = data.business; + renderBusinessDisplay(currentBusiness); + fillBusinessForm(currentBusiness); + setBusinessFormVisibility(false, { preserveFeedback: true }); + showFeedback(businessFeedback, "Dane sprzedawcy zaktualizowane.", "success"); + setTimeout(() => clearFeedback(businessFeedback), 2000); + } catch (error) { + showFeedback(businessFeedback, error.message || "Nie udało się zaktualizować danych."); + } + }); +} + +if (exemptionReasonSelect) { + populateExemptionReasons(); + let previousReasonValue = exemptionReasonSelect.value; + applyExemptionReasonSelection({ preserveCustom: true }); + exemptionReasonSelect.addEventListener("change", () => { + if (previousReasonValue === "custom" && exemptionNoteInput) { + customExemptionNote = exemptionNoteInput.value.trim(); + } + previousReasonValue = exemptionReasonSelect.value; + applyExemptionReasonSelection(); + if (exemptionReasonSelect.value === "custom" && exemptionNoteInput) { + exemptionNoteInput.focus(); + } + }); +} + +if (exemptionNoteInput) { + exemptionNoteInput.addEventListener("input", () => { + if (exemptionReasonSelect && exemptionReasonSelect.value === "custom") { + customExemptionNote = exemptionNoteInput.value; + } + }); +} + +if (invoiceForm) { + invoiceForm.addEventListener("submit", async (event) => { + event.preventDefault(); + try { + const payload = collectInvoicePayload(); + let response; + if (editingInvoiceId) { + response = await apiRequest(`/api/invoices/${encodeURIComponent(editingInvoiceId)}`, { method: "PUT", body: payload }, true); + exitInvoiceEdit(); + } else { + response = await apiRequest("/api/invoices", { method: "POST", body: payload }, true); + } + lastInvoice = response.invoice; + renderInvoicePreview(lastInvoice); + if (invoiceResult) { + invoiceResult.classList.remove("hidden"); + } + await refreshInvoices(); + await refreshSummary(); + resetInvoiceForm(); + } catch (error) { + alert(error.message || "Nie udało się zapisać faktury."); + } + }); +} + +if (addItemButton) { + addItemButton.addEventListener("click", () => { + createItemRow(); + }); +} + +if (downloadButton) { + downloadButton.addEventListener("click", async () => { + if (!lastInvoice || !currentBusiness) { + alert("Brak faktury do pobrania. Wygeneruj ją najpierw."); + return; + } + await generatePdf(currentBusiness, lastInvoice, currentLogo); + }); +} + +if (logoutButton) { + logoutButton.addEventListener("click", () => { + authToken = null; + currentLogin = ""; + sessionStorage.removeItem("invoiceAuthToken"); + sessionStorage.removeItem("invoiceLogin"); + lastInvoice = null; + currentBusiness = null; + currentLogo = null; + invoicesCache = []; + exitInvoiceEdit(); + resetInvoiceForm(); + setBusinessFormVisibility(false); + if (invoiceResult) { + invoiceResult.classList.add("hidden"); + } + updateLogoPreview(); + updateLoginLabel(); + renderInvoicesTable([]); + updateSummaryCards({}); + updateSummaryChart({}); + closeRegisterPanel({ resetForm: true, focusTrigger: true }); + clearFeedback(registerFeedback); + clearFeedback(loginFeedback); + clearFeedback(businessFeedback); + clearFeedback(logoFeedback); + clearFeedback(dashboardFeedback); + setAppState("auth"); + }); +} + +appNavButtons.forEach((button) => { + button.addEventListener("click", () => { + setActiveView(button.dataset.view); + }); +}); + +if (filterStartDate) { + filterStartDate.addEventListener("change", applyInvoiceFilters); +} +if (filterEndDate) { + filterEndDate.addEventListener("change", applyInvoiceFilters); +} +if (clearFiltersButton) { + clearFiltersButton.addEventListener("click", () => { + if (filterStartDate) { + filterStartDate.value = ""; + } + if (filterEndDate) { + filterEndDate.value = ""; + } + applyInvoiceFilters(); + }); +} + +if (showRegisterButton) { + showRegisterButton.addEventListener("click", () => { + openRegisterPanel(); + }); +} + +if (backToLoginButton) { + backToLoginButton.addEventListener("click", () => { + closeRegisterPanel({ resetForm: false, focusTrigger: true }); + }); +} + +if (cancelRegisterButton) { + cancelRegisterButton.addEventListener("click", () => { + closeRegisterPanel({ resetForm: true, focusTrigger: true }); + }); +} + +if (logoInput) { + logoInput.addEventListener("change", (event) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + clearFeedback(logoFeedback); + if (file.size > maxLogoSize) { + showFeedback(logoFeedback, `Logo jest zbyt duze. Maksymalny rozmiar to ${(maxLogoSize / 1024).toFixed(0)} KB.`); + logoInput.value = ""; + return; + } + const reader = new FileReader(); + reader.onload = async () => { + try { + const base64 = reader.result?.toString(); + if (!base64) { + throw new Error("Nie udało się odczytać pliku."); + } + const response = await apiRequest( + "/api/logo", + { + method: "POST", + body: { + filename: file.name, + mime_type: file.type, + content: base64, + }, + }, + true + ); + currentLogo = response.logo; + updateLogoPreview(); + showFeedback(logoFeedback, "Logo zapisane.", "success"); + } catch (error) { + showFeedback(logoFeedback, error.message || "Nie udało się zapisać logo."); + } finally { + logoInput.value = ""; + } + }; + reader.onerror = () => { + showFeedback(logoFeedback, "Nie udało się wczytać pliku logo."); + logoInput.value = ""; + }; + reader.readAsDataURL(file); + }); +} + +if (removeLogoButton) { + removeLogoButton.addEventListener("click", async () => { + clearFeedback(logoFeedback); + if (!currentLogo) { + showFeedback(logoFeedback, "Brak logo do usunięcia."); + return; + } + try { + await apiRequest("/api/logo", { method: "DELETE" }, true); + currentLogo = null; + updateLogoPreview(); + showFeedback(logoFeedback, "Logo usunięte.", "success"); + } catch (error) { + showFeedback(logoFeedback, error.message || "Nie udało się usunąć logo."); + } + }); +} + +if (cancelEditInvoiceButton) { + cancelEditInvoiceButton.addEventListener("click", () => { + exitInvoiceEdit(); + resetInvoiceForm(); + }); +} + +initialize().catch((error) => { + console.error(error); + showFeedback(registerFeedback, "Nie udało się uruchomić aplikacji."); +});