Spaces:
Sleeping
Sleeping
| 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 = ` | |
| <div class="business-display-grid"> | |
| <div class="business-display-item business-display-item--name"> | |
| <strong>${companyName}</strong> | |
| <span>${ownerName}</span> | |
| </div> | |
| <div class="business-display-item"> | |
| <span>${addressLine}</span> | |
| <span>${location}</span> | |
| </div> | |
| <div class="business-display-item"> | |
| <span>${taxLine}</span> | |
| <span>${bankLine}</span> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| 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 = ` | |
| <strong>${client.name || "Bez nazwy"}</strong> | |
| <span>${[client.tax_id, client.city].filter(Boolean).join(" • ")}</span> | |
| `; | |
| 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]) => | |
| `<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 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 = "<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) => { | |
| const quantityDisplay = formatQuantity(item.quantity); | |
| const unitDisplay = UNIT_OPTIONS.some((option) => option.value === item.unit) ? item.unit : DEFAULT_UNIT; | |
| return ` | |
| <tr> | |
| <td>${item.name}</td> | |
| <td>${quantityDisplay}</td> | |
| <td>${unitDisplay}</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 sprzedaży:</strong> ${invoice.sale_date}</span> | |
| ${invoice.payment_term ? `<span><strong>Termin płatności:</strong> ${invoice.payment_term} dni</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> | |
| ${client.phone ? `<p>Tel: ${client.phone}</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>Jednostka</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, 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."); | |
| }); | |