const APP_PATHNAME = typeof window !== "undefined" && window.location && typeof window.location.pathname === "string" ? window.location.pathname.replace(/\/$/, "") : ""; 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 heroPanel = document.getElementById("hero-panel"); 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 clientSearchInput = document.getElementById("client-search"); const clientSuggestionsContainer = document.getElementById("client-suggestions"); const loginBadge = document.getElementById("login-badge"); 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 = ""; let clientLookupTimeout = null; 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); setVisibility(heroPanel, false); } else { setVisibility(authSection, true); setVisibility(registerSection, false); setVisibility(appSection, false); setVisibility(heroPanel, true); } } 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; } const parsed = Number.parseFloat(normalized.replace(",", ".")); if (!Number.isFinite(parsed) || Math.floor(parsed) !== parsed) { return Number.NaN; } return 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 (!currentLoginLabel) { return; } if (!currentLogin) { currentLoginLabel.textContent = ""; if (loginBadge) { loginBadge.classList.add("hidden"); } return; } currentLoginLabel.textContent = currentLogin; if (loginBadge) { loginBadge.classList.remove("hidden"); } } 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 pdfButton = document.createElement("button"); pdfButton.type = "button"; pdfButton.className = "button secondary"; pdfButton.dataset.download = invoice.invoice_id; pdfButton.textContent = "PDF"; 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(pdfButton); 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); } if (invoicesTableBody) { invoicesTableBody.addEventListener("click", async (event) => { const target = event.target; if (!(target instanceof HTMLElement)) { return; } const pdfTrigger = target.closest("[data-download]"); if (pdfTrigger) { const invoiceId = pdfTrigger.getAttribute("data-download"); if (!invoiceId) { return; } const invoiceData = invoicesCache.find((invoice) => invoice.invoice_id === invoiceId); if (!invoiceData || !currentBusiness) { showFeedback(dashboardFeedback, "Nie udało się przygotować PDF. Odśwież dane i spróbuj ponownie."); return; } try { await generatePdf(currentBusiness, invoiceData, currentLogo); } catch (error) { console.error(error); showFeedback(dashboardFeedback, "Nie udało się wygenerować PDF-a."); } } }); } 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) { setClientFormValues(invoice.client); } 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"); } function buildApiUrl(path = "") { if (!path) { return APP_PATHNAME || "/"; } if (/^https?:\/\//i.test(path)) { return path; } return path.startsWith("/") ? `${APP_PATHNAME}${path}` || "/" : `${APP_PATHNAME}/${path}`.replace(/\/{2,}/g, "/"); } 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 url = buildApiUrl(path); const response = await fetch(url, 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 setClientFormValues(client = {}) { if (!invoiceForm) { return; } invoiceForm.elements.clientName.value = client.name || ""; invoiceForm.elements.clientTaxId.value = client.tax_id || ""; invoiceForm.elements.clientAddress.value = client.address_line || ""; invoiceForm.elements.clientPostalCode.value = client.postal_code || ""; invoiceForm.elements.clientCity.value = client.city || ""; invoiceForm.elements.clientPhone.value = client.phone || ""; } function hideClientSuggestions() { if (!clientSuggestionsContainer) { return; } clientSuggestionsContainer.classList.add("hidden"); clientSuggestionsContainer.innerHTML = ""; } function selectClientFromLookup(client) { setClientFormValues(client); if (clientSearchInput) { const summary = [client.name, client.tax_id].filter(Boolean).join(" • "); clientSearchInput.value = summary || client.name || client.tax_id || ""; } hideClientSuggestions(); } function renderClientSuggestions(clients) { if (!clientSuggestionsContainer) { return; } clientSuggestionsContainer.innerHTML = ""; if (!Array.isArray(clients) || clients.length === 0) { const empty = document.createElement("p"); empty.className = "client-suggestions-empty"; empty.textContent = "Brak dopasowanych klientów."; clientSuggestionsContainer.appendChild(empty); clientSuggestionsContainer.classList.remove("hidden"); return; } const fragment = document.createDocumentFragment(); clients.forEach((client) => { const button = document.createElement("button"); button.type = "button"; button.className = "client-suggestion"; button.setAttribute("role", "option"); button.innerHTML = ` ${client.name || "Bez nazwy"} ${[client.tax_id, client.city].filter(Boolean).join(" • ")} `; button.addEventListener("click", () => { selectClientFromLookup(client); }); fragment.appendChild(button); }); clientSuggestionsContainer.appendChild(fragment); clientSuggestionsContainer.classList.remove("hidden"); } async function requestClientSuggestions(term) { const query = (term || "").trim(); if (!clientSuggestionsContainer || !clientSearchInput) { return; } if (!authToken || query.length < 2) { hideClientSuggestions(); return; } try { const data = await apiRequest(`/api/clients?q=${encodeURIComponent(query)}`, {}, true); renderClientSuggestions(data.clients || []); } catch (error) { console.error(error); hideClientSuggestions(); } } function handleClientSearchInput(event) { const term = event.target.value || ""; if (clientLookupTimeout) { window.clearTimeout(clientLookupTimeout); } if (!term.trim()) { hideClientSuggestions(); return; } clientLookupTimeout = window.setTimeout(() => { requestClientSuggestions(term); }, 250); } 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}
Nazwa Ilość Jednostka Cena jedn. netto Wartość netto (pozycja) Stawka VAT Kwota 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, options = {}) { const lineHeight = 5; const wrappedLines = lines.flatMap((line) => doc.splitTextToSize(line, width)); const boxHeight = wrappedLines.length * lineHeight + 18; const bgColor = options.bgColor || PDF_COLORS.surface; const plain = options.plain || false; if (!plain) { doc.setDrawColor(...PDF_COLORS.border); doc.setFillColor(...bgColor); doc.roundedRect(x - 4, y - 10, width + 8, boxHeight, 2.5, 2.5, "FD"); } doc.setFontSize(11); doc.setTextColor(...PDF_COLORS.muted); doc.text(title.toUpperCase(), x, y - 2); doc.setFontSize(10); doc.setTextColor(...PDF_COLORS.text); let cursor = y + 4; wrappedLines.forEach((line) => { doc.text(line, x, cursor); cursor += lineHeight; }); return y - 10 + 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"; const PDF_COLORS = { accent: [37, 99, 235], accentMuted: [226, 236, 255], text: [16, 24, 40], muted: [102, 112, 133], border: [215, 222, 236], surface: [249, 251, 255], }; 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"); doc.setTextColor(...PDF_COLORS.text); doc.setFontSize(18); doc.text("Faktura", marginX, cursorY + 2); doc.setFontSize(13); doc.text(invoice.invoice_id, marginX, cursorY + 10); doc.setFontSize(10); doc.setTextColor(...PDF_COLORS.muted); const metaLines = [ `Data wystawienia: ${invoice.issued_at}`, `Data sprzedaży: ${invoice.sale_date}`, ]; if (invoice.payment_term) { metaLines.push(`Termin płatności: ${invoice.payment_term} dni`); } metaLines.forEach((line, index) => { doc.text(line, marginX, cursorY + 18 + index * 5); }); cursorY += 18 + metaLines.length * 5 + 6; 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}`, ]; let logoBottom = cursorY; 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 = 40; let logoHeight = 16; if (doc.getImageProperties) { const props = doc.getImageProperties(dataUrl); if (props?.width && props?.height) { const ratio = props.height / props.width; logoHeight = logoWidth * ratio; if (logoHeight > 20) { logoHeight = 20; logoWidth = logoHeight / ratio; } } } const logoX = sellerX; const logoY = Math.max(cursorY - logoHeight - 12, 18); doc.addImage(dataUrl, format, logoX, logoY, logoWidth, logoHeight); logoBottom = logoY + logoHeight; } catch (error) { console.warn("Nie udało się dodać logo nad sprzedawcą:", error); } } const buyerBottom = drawPartyBox(doc, "NABYWCA", clientLines, marginX, cursorY, columnWidth, { plain: true }); const sellerBottom = drawPartyBox(doc, "SPRZEDAWCA", sellerLines, sellerX, cursorY, columnWidth, { plain: true, }); cursorY = Math.max(buyerBottom, sellerBottom, logoBottom) + 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(...PDF_COLORS.accentMuted); doc.setDrawColor(...PDF_COLORS.border); doc.rect(marginX, cursorY, tableWidth, headerHeight, "F"); doc.rect(marginX, cursorY, tableWidth, headerHeight); let offsetX = marginX; doc.setFontSize(10); doc.setTextColor(...PDF_COLORS.text); const rightAlignedColumns = new Set(["quantity", "unit", "unitNet", "netTotal", "vatAmount", "grossTotal"]); tableColumns.forEach((column) => { doc.rect(offsetX, cursorY, column.width, headerHeight); column.headerLines.forEach((line, index) => { const textY = cursorY + 4 + index * headerLineHeight; const textX = rightAlignedColumns.has(column.key) ? offsetX + column.width - 2 : offsetX + 2; doc.text((line || "").trim(), textX, textY, { align: rightAlignedColumns.has(column.key) ? "right" : "left", }); }); offsetX += column.width; }); cursorY += headerHeight; const withPercent = (value) => { if (!value) { return "-"; } if (/%$/.test(value)) { return value; } if (/^\d+(\.\d+)?$/.test(value)) { return `${value}%`; } return value; }; const invoiceItems = Array.isArray(invoice.items) ? invoice.items : []; invoiceItems.forEach((item, rowIndex) => { 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) => { const available = Math.max(width - 4, 6); return doc .splitTextToSize(text ?? "", available) .map((line) => line.trim()); }; const columnData = tableColumns.map((column) => { switch (column.key) { case "name": return wrapText(item.name, column.width - 4); case "quantity": return wrapText(quantity, column.width); case "unit": return wrapText(unitLabel, column.width); case "unitNet": return wrapText(unitNet, column.width); case "netTotal": return wrapText(netTotal, column.width); case "vatLabel": return wrapText(withPercent(item.vat_label), column.width); case "vatAmount": return wrapText(vatAmount, column.width); case "grossTotal": return wrapText(grossTotal, column.width); default: return wrapText("", column.width); } }); const rowHeight = Math.max(...columnData.map((data) => data.length)) * lineHeight + 2; offsetX = marginX; if (rowIndex % 2 === 1) { doc.setFillColor(248, 250, 253); doc.rect(marginX, cursorY, tableWidth, rowHeight, "F"); } 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(); const alignRight = rightAlignedColumns.has(column.key); const textX = alignRight ? offsetX + column.width - 2 : offsetX + 2; doc.text(content, textX, textY, { align: alignRight ? "right" : "left" }); }); 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 : []; doc.setFontSize(10); doc.setTextColor(...PDF_COLORS.text); summaryEntries.forEach((entry) => { const summaryLine = `${withPercent(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 += 2; }); cursorY += 6; const totals = [ { label: "Netto", value: formatCurrency(invoice.totals.net), variant: "muted" }, { label: "VAT", value: formatCurrency(invoice.totals.vat), variant: "muted" }, { label: "Brutto", value: formatCurrency(invoice.totals.gross), variant: "accent" }, ]; const chipWidth = 54; const chipHeight = 20; doc.setFontSize(10); totals.forEach((chip, index) => { const x = marginX + index * (chipWidth + 12); if (chip.variant === "accent") { doc.setFillColor(...PDF_COLORS.accent); doc.setTextColor(255, 255, 255); } else { doc.setFillColor(...PDF_COLORS.surface); doc.setTextColor(...PDF_COLORS.muted); } doc.roundedRect(x, cursorY, chipWidth, chipHeight, 4, 4, "F"); doc.text(chip.label.toUpperCase(), x + 3, cursorY + 6); doc.setFontSize(chip.variant === "accent" ? 12 : 11); if (chip.variant === "accent") { doc.setTextColor(255, 255, 255); } else { doc.setTextColor(...PDF_COLORS.text); } doc.text(chip.value, x + 3, cursorY + 14); doc.setFontSize(10); }); cursorY += chipHeight + 12; if (invoice.exemption_note) { doc.setFontSize(10); doc.setTextColor(...PDF_COLORS.text); 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); if (legacyLoginHint) { 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, password, confirm_password: confirmPassword, 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 { await apiRequest("/api/register", { 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) { 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 { await apiRequest("/api/business", { method: "POST", body: payload }, true); await loadBusinessData(); 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(); }); } if (clientSearchInput) { clientSearchInput.addEventListener("input", handleClientSearchInput); clientSearchInput.addEventListener("focus", () => { if ((clientSearchInput.value || "").trim().length >= 2) { requestClientSuggestions(clientSearchInput.value); } }); } document.addEventListener("click", (event) => { if (!clientSuggestionsContainer || !clientSearchInput) { return; } if ( clientSuggestionsContainer.contains(event.target) || clientSearchInput === event.target || clientSearchInput.contains(event.target) ) { return; } hideClientSuggestions(); }); initialize().catch((error) => { console.error(error); showFeedback(registerFeedback, "Nie udało się uruchomić aplikacji."); });