Spaces:
Sleeping
Sleeping
| 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 = ` | |
| <p><strong>${business.company_name}</strong></p> | |
| <p>${business.owner_name}</p> | |
| <p>${business.address_line}</p> | |
| <p>${business.postal_code} ${business.city}</p> | |
| <p>NIP: ${business.tax_id}</p> | |
| <p>Konto: ${business.bank_account}</p> | |
| `; | |
| } | |
| 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]) => | |
| `<div class="rate-summary-item"> | |
| <span>${label}</span> | |
| <span>Netto: ${totals.net.toFixed(2)} PLN</span> | |
| <span>VAT: ${totals.vat.toFixed(2)} PLN</span> | |
| <span>Brutto: ${totals.gross.toFixed(2)} PLN</span> | |
| </div>` | |
| ) | |
| .join(""); | |
| rateSummaryContainer.innerHTML = `<h4>Podsumowanie stawek</h4>${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 = "<p>Brak danych faktury.</p>"; | |
| 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) => ` | |
| <tr> | |
| <td>${item.name}</td> | |
| <td>${parseNumber(item.quantity).toFixed(2)}</td> | |
| <td>${formatCurrency(item.unit_price_net)}</td> | |
| <td>${formatCurrency(item.net_total)}</td> | |
| <td>${item.vat_label}</td> | |
| <td>${formatCurrency(item.vat_amount)}</td> | |
| <td>${formatCurrency(item.gross_total)}</td> | |
| </tr>` | |
| ) | |
| .join(""); | |
| const summaryRows = (invoice.summary || []) | |
| .map( | |
| (entry) => | |
| `<div class="rate-summary-item"> | |
| <span>${entry.vat_label}</span> | |
| <span>Netto: ${formatCurrency(entry.net_total)}</span> | |
| <span>VAT: ${formatCurrency(entry.vat_total)}</span> | |
| <span>Brutto: ${formatCurrency(entry.gross_total)}</span> | |
| </div>` | |
| ) | |
| .join(""); | |
| invoiceOutput.innerHTML = ` | |
| <div class="invoice-preview-meta"> | |
| <span><strong>Numer:</strong> ${invoice.invoice_id}</span> | |
| <span><strong>Data wystawienia:</strong> ${invoice.issued_at}</span> | |
| <span><strong>Data sprzedazy:</strong> ${invoice.sale_date}</span> | |
| </div> | |
| <div class="invoice-preview-header"> | |
| <div class="invoice-preview-card"> | |
| <h4>Nabywca</h4> | |
| ${ | |
| hasClientData | |
| ? ` | |
| <p>${client.name || "---"}</p> | |
| <p>${client.address_line || "---"}</p> | |
| <p>${client.postal_code || ""} ${client.city || ""}</p> | |
| <p>${client.tax_id ? `NIP: ${client.tax_id}` : ""}</p> | |
| ` | |
| : "<p>Brak danych nabywcy.</p>" | |
| } | |
| </div> | |
| <div class="invoice-preview-card"> | |
| <h4>Sprzedawca</h4> | |
| <p>${currentBusiness.company_name}</p> | |
| <p>${currentBusiness.owner_name}</p> | |
| <p>${currentBusiness.address_line}</p> | |
| <p>${currentBusiness.postal_code} ${currentBusiness.city}</p> | |
| <p>NIP: ${currentBusiness.tax_id}</p> | |
| <p>Konto: ${currentBusiness.bank_account}</p> | |
| </div> | |
| </div> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Nazwa</th> | |
| <th>Ilość</th> | |
| <th>Cena jedn. netto</th> | |
| <th>Wartość netto (pozycja)</th> | |
| <th>Stawka VAT</th> | |
| <th>Kwota VAT (pozycja)</th> | |
| <th>Wartość brutto</th> | |
| </tr> | |
| </thead> | |
| <tbody>${itemsRows}</tbody> | |
| </table> | |
| <div class="rate-summary"> | |
| <h4>Podsumowanie stawek</h4> | |
| ${summaryRows} | |
| </div> | |
| <div class="invoice-preview-summary"> | |
| <span>Netto: ${formatCurrency(invoice.totals.net)}</span> | |
| <span>VAT: ${formatCurrency(invoice.totals.vat)}</span> | |
| <span>Brutto: ${formatCurrency(invoice.totals.gross)}</span> | |
| </div> | |
| ${ | |
| invoice.exemption_note | |
| ? `<div class="invoice-preview-note"><strong>Podstawa prawna zwolnienia:</strong> ${invoice.exemption_note}</div>` | |
| : "" | |
| } | |
| `; | |
| } | |
| 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."); | |
| }); | |