Test_DB / main.js
Antoni09's picture
Upload 10 files
9c4e7c0 verified
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.");
});