From 272445f8359266c2554c0c938946c24f541a585c Mon Sep 17 00:00:00 2001 From: maheshrijal <62394512+maheshrijal@users.noreply.github.com> Date: Fri, 2 Jan 2026 13:58:01 +0530 Subject: [PATCH 01/17] Refactor: Convert to ES modules and add Playwright tests --- .github/workflows/playwright.yml | 36 + .gitignore | 7 +- index.html | 4 +- package-lock.json | 79 ++ package.json | 24 + playwright.config.js | 63 ++ script.js | 944 ------------------ src/js/main.js | 286 ++++++ src/js/modules/calculations.js | 25 + src/js/modules/dom.js | 97 ++ src/js/modules/form.js | 151 +++ src/js/modules/formatters.js | 52 + src/js/modules/history.js | 156 +++ src/js/modules/invoice.js | 112 +++ src/js/modules/items.js | 114 +++ src/js/modules/json-io.js | 72 ++ src/js/modules/pdf.js | 329 ++++++ src/js/modules/storage.js | 82 ++ styles.css | 14 +- tests/rebill.spec.js | 271 +++++ .../invoice-preview-chromium-darwin.png | Bin 0 -> 41717 bytes .../invoice-preview-firefox-darwin.png | Bin 0 -> 54253 bytes .../invoice-preview-webkit-darwin.png | Bin 0 -> 51597 bytes 23 files changed, 1970 insertions(+), 948 deletions(-) create mode 100644 .github/workflows/playwright.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.js delete mode 100644 script.js create mode 100644 src/js/main.js create mode 100644 src/js/modules/calculations.js create mode 100644 src/js/modules/dom.js create mode 100644 src/js/modules/form.js create mode 100644 src/js/modules/formatters.js create mode 100644 src/js/modules/history.js create mode 100644 src/js/modules/invoice.js create mode 100644 src/js/modules/items.js create mode 100644 src/js/modules/json-io.js create mode 100644 src/js/modules/pdf.js create mode 100644 src/js/modules/storage.js create mode 100644 tests/rebill.spec.js create mode 100644 tests/rebill.spec.js-snapshots/invoice-preview-chromium-darwin.png create mode 100644 tests/rebill.spec.js-snapshots/invoice-preview-firefox-darwin.png create mode 100644 tests/rebill.spec.js-snapshots/invoice-preview-webkit-darwin.png diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..0a1c8be --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,36 @@ +name: Playwright Tests + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Playwright Browsers + run: npx playwright install --with-deps + + - name: Run Playwright tests + run: npx playwright test + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index d5acdb6..0b8a7d0 100644 --- a/.gitignore +++ b/.gitignore @@ -58,5 +58,8 @@ node_modules/ *~ # Temporary files -*.tmp -*.temp \ No newline at end of file +*.temp + +# Playwright +playwright-report/ +test-results/ diff --git a/index.html b/index.html index e3e03f8..b7616e6 100644 --- a/index.html +++ b/index.html @@ -35,7 +35,7 @@ - + @@ -204,6 +204,8 @@

Import / Export

+ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ff8984f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,79 @@ +{ + "name": "rebill", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rebill", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@playwright/test": "^1.40.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4221912 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "rebill", + "version": "1.0.0", + "description": "Open-source, privacy-first invoice generator", + "type": "module", + "scripts": { + "dev": "python3 -m http.server 8080", + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:headed": "playwright test --headed", + "test:debug": "playwright test --debug" + }, + "keywords": [ + "invoice", + "generator", + "pdf", + "billing" + ], + "author": "Mahesh Rijal", + "license": "MIT", + "devDependencies": { + "@playwright/test": "^1.40.0" + } +} \ No newline at end of file diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..778912e --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,63 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright configuration for Rebill tests + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './tests', + + /* Run tests in parallel */ + fullyParallel: true, + + /* Fail the build on CI if you accidentally left test.only in the source code */ + forbidOnly: !!process.env.CI, + + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + + /* Opt out of parallel tests on CI */ + workers: process.env.CI ? 1 : undefined, + + /* Reporter to use */ + reporter: [ + ['html', { outputFolder: 'playwright-report' }], + ['list'] + ], + + /* Shared settings for all the projects */ + use: { + /* Base URL to use in actions like `await page.goto('/')` */ + baseURL: 'http://localhost:8080', + + /* Collect trace when retrying the failed test */ + trace: 'on-first-retry', + + /* Take screenshot on failure */ + screenshot: 'only-on-failure', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'python3 -m http.server 8080', + url: 'http://localhost:8080', + reuseExistingServer: !process.env.CI, + timeout: 10000, + }, +}); diff --git a/script.js b/script.js deleted file mode 100644 index 03f967a..0000000 --- a/script.js +++ /dev/null @@ -1,944 +0,0 @@ -const STORAGE_KEY = 'invoice.draft.v1'; -const LAST_NUMBER_KEY = 'invoice.lastNumber'; -const HISTORY_KEY = 'invoice.history'; - -let isApplyingData = false; - -// ===== History Management ===== -function getHistory() { - const raw = localStorage.getItem(HISTORY_KEY); - if (!raw) return []; - try { - return JSON.parse(raw); - } catch (e) { - return []; - } -} - -function saveToHistory(data) { - const history = getHistory(); - const invoiceNumber = data.invoice.number; - - // Check if invoice number already exists - const existing = history.find(h => h.invoiceNumber === invoiceNumber); - if (existing) { - const shouldOverwrite = confirm(`Invoice #${invoiceNumber} already exists in history. Overwrite it?`); - if (!shouldOverwrite) return; - } - - const entry = { - id: Date.now(), - invoiceNumber: invoiceNumber, - customerName: data.billTo.name, - total: data.totals.total, - currency: data.settings.currency, - date: data.invoice.date, - savedAt: new Date().toISOString(), - data: data - }; - - // Remove duplicate if same invoice number exists - const filtered = history.filter(h => h.invoiceNumber !== entry.invoiceNumber); - filtered.unshift(entry); - - // Keep max 50 entries - const trimmed = filtered.slice(0, 50); - try { - localStorage.setItem(HISTORY_KEY, JSON.stringify(trimmed)); - } catch (e) { - alert('History storage is full. Clear some old invoices.'); - return; - } - renderHistoryList(); -} - -function deleteFromHistory(id) { - const history = getHistory(); - const filtered = history.filter(h => h.id !== id); - localStorage.setItem(HISTORY_KEY, JSON.stringify(filtered)); - renderHistoryList(); -} - -function clearHistory() { - if (!confirm('Clear all invoice history?')) return; - localStorage.removeItem(HISTORY_KEY); - renderHistoryList(); -} - -function loadFromHistory(id) { - const history = getHistory(); - const entry = history.find(h => h.id === id); - if (!entry || !entry.data) return; - - applyDataToForm(entry.data); - toggleHistoryPanel(false); -} - -function renderHistoryList() { - const list = document.getElementById('historyList'); - if (!list) return; - - const history = getHistory(); - - if (history.length === 0) { - list.innerHTML = '

No invoices saved yet. Generate a bill to save it here.

'; - return; - } - - list.innerHTML = ''; - history.forEach(entry => { - const item = document.createElement('div'); - item.className = 'history-item'; - item.dataset.id = entry.id; - - const info = document.createElement('div'); - info.className = 'history-item-info'; - - const title = document.createElement('div'); - title.className = 'history-item-title'; - title.textContent = `#${entry.invoiceNumber} - ${entry.customerName || 'No customer'}`; - - const meta = document.createElement('div'); - meta.className = 'history-item-meta'; - meta.textContent = `${formatDate(entry.date)} · ${formatCurrency(entry.total, entry.currency)}`; - - info.appendChild(title); - info.appendChild(meta); - - const deleteBtn = document.createElement('button'); - deleteBtn.type = 'button'; - deleteBtn.className = 'history-item-delete'; - deleteBtn.dataset.id = entry.id; - deleteBtn.title = 'Delete'; - deleteBtn.textContent = '×'; - - item.appendChild(info); - item.appendChild(deleteBtn); - list.appendChild(item); - }); -} - -function toggleHistoryPanel(show) { - const panel = document.getElementById('historyPanel'); - if (!panel) return; - - if (typeof show === 'boolean') { - panel.style.display = show ? 'block' : 'none'; - } else { - panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; - } - - if (panel.style.display === 'block') { - renderHistoryList(); - } -} - -function getValue(id, fallback = '') { - const el = document.getElementById(id); - if (!el) return fallback; - return el.value ?? fallback; -} - -function setValue(id, value) { - const el = document.getElementById(id); - if (!el) return; - el.value = value ?? ''; -} - -function setText(id, value) { - const el = document.getElementById(id); - if (!el) return; - el.textContent = value ?? ''; -} - -function escapeHtml(str) { - if (str == null) return ''; - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function parseNumber(value, fallback = 0) { - const num = parseFloat(value); - return Number.isFinite(num) ? num : fallback; -} - -function formatCurrency(amount, currency, locale) { - const safeAmount = Number.isFinite(amount) ? amount : 0; - try { - return new Intl.NumberFormat(locale || 'en-IN', { - style: 'currency', - currency: currency || 'INR', - minimumFractionDigits: 2, - maximumFractionDigits: 2 - }).format(safeAmount); - } catch (error) { - const suffix = currency ? ` ${currency}` : ''; - return `${safeAmount.toFixed(2)}${suffix}`; - } -} - -function formatDate(dateValue) { - if (!dateValue) return ''; - const date = new Date(dateValue); - if (Number.isNaN(date.getTime())) return dateValue; - const day = String(date.getDate()).padStart(2, '0'); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const year = String(date.getFullYear()); - return `${day}/${month}/${year}`; -} - -function calculateTotals(items, taxRate, discount) { - const subtotal = items.reduce((sum, item) => sum + item.total, 0); - const taxAmount = subtotal * (taxRate / 100); - const total = subtotal + taxAmount - discount; - return { - subtotal, - taxRate, - taxAmount, - discount, - total, - balanceDue: total - }; -} - -function ensureAtLeastOneItemRow() { - const container = document.getElementById('itemsContainer'); - if (!container || container.querySelectorAll('.item-row').length > 0) return; - addItemRow({ - description: '', - quantity: 1, - unitPrice: 0 - }); -} - -function addItemRow(item = {}) { - const container = document.getElementById('itemsContainer'); - if (!container) return; - - const row = document.createElement('div'); - row.className = 'item-row'; - - const descInput = document.createElement('input'); - descInput.type = 'text'; - descInput.className = 'item-description'; - descInput.placeholder = 'Description'; - descInput.value = item.description ?? ''; - - const qtyInput = document.createElement('input'); - qtyInput.type = 'number'; - qtyInput.className = 'item-quantity'; - qtyInput.min = '0'; - qtyInput.step = '1'; - qtyInput.value = item.quantity ?? 1; - - const priceInput = document.createElement('input'); - priceInput.type = 'number'; - priceInput.className = 'item-unit-price'; - priceInput.min = '0'; - priceInput.step = '0.01'; - priceInput.value = item.unitPrice ?? 0; - - const totalSpan = document.createElement('span'); - totalSpan.className = 'item-total'; - totalSpan.textContent = '0.00'; - - const removeBtn = document.createElement('button'); - removeBtn.type = 'button'; - removeBtn.className = 'remove-item'; - removeBtn.title = 'Remove item'; - removeBtn.textContent = '×'; - - row.appendChild(descInput); - row.appendChild(qtyInput); - row.appendChild(priceInput); - row.appendChild(totalSpan); - row.appendChild(removeBtn); - - container.appendChild(row); -} - -function renderItemsForm(items) { - const container = document.getElementById('itemsContainer'); - if (!container) return; - container.innerHTML = ''; - - if (!Array.isArray(items) || items.length === 0) { - addItemRow(); - return; - } - - items.forEach((item) => addItemRow(item)); -} - -function collectItemsFromForm(settings) { - const rows = Array.from(document.querySelectorAll('.item-row')); - const items = rows.map((row) => { - const description = row.querySelector('.item-description')?.value?.trim() ?? ''; - const quantity = parseNumber(row.querySelector('.item-quantity')?.value, 0); - const unitPrice = parseNumber(row.querySelector('.item-unit-price')?.value, 0); - const total = quantity * unitPrice; - const totalEl = row.querySelector('.item-total'); - if (totalEl) { - totalEl.textContent = formatCurrency(total, settings.currency, settings.locale); - } - return { - description, - quantity, - unitPrice, - total - }; - }); - - return items; -} - -function getDataFromForm() { - const settings = { - currency: getValue('currencyCode', 'INR').trim() || 'INR', - locale: 'en-IN' - }; - - const items = collectItemsFromForm(settings); - const taxRate = parseNumber(getValue('taxRate', 0), 0); - const discount = parseNumber(getValue('discountAmount', 0), 0); - const totals = calculateTotals(items, taxRate, discount); - - return { - schemaVersion: 1, - settings, - seller: { - name: getValue('sellerName'), - address: getValue('sellerAddress'), - email: getValue('sellerEmail'), - phone: getValue('sellerPhone') - }, - billTo: { - name: getValue('billToName'), - address: getValue('billToAddress'), - email: getValue('billToEmail'), - phone: getValue('billToPhone') - }, - invoice: { - title: getValue('invoiceTitle', 'INVOICE'), - date: getValue('invoiceDate'), - dueDate: getValue('invoiceDueDate'), - number: getValue('invoiceNumber'), - notes: getValue('invoiceNotes'), - instructions: getValue('invoiceInstructions') - }, - items, - totals, - meta: { - updatedAt: new Date().toISOString(), - showInvoice: isInvoiceVisible() - } - }; -} - -function updateFormTotalsDisplay(data) { - const { totals, settings } = data; - setText('formSubtotal', formatCurrency(totals.subtotal, settings.currency, settings.locale)); - setText('formTaxAmount', formatCurrency(totals.taxAmount, settings.currency, settings.locale)); - setText('formTotal', formatCurrency(totals.total, settings.currency, settings.locale)); -} - -function renderInvoice(data) { - const { settings, seller, billTo, invoice, items, totals } = data; - - setText('displaySellerName', seller.name); - setText('displaySellerAddress', seller.address || ''); - setText('displaySellerEmail', seller.email); - setText('displaySellerPhone', seller.phone); - - setText('displayInvoiceTitle', invoice.title || 'INVOICE'); - - setText('displayBillToName', billTo.name); - setText('displayBillToAddress', billTo.address || ''); - setText('displayBillToEmail', billTo.email); - setText('displayBillToPhone', billTo.phone); - - setText('displayInvoiceDate', formatDate(invoice.date)); - - // Conditionally show/hide due date row - const dueDateRow = document.getElementById('dueDateRow'); - if (dueDateRow) { - if (invoice.dueDate) { - dueDateRow.style.display = ''; - setText('displayInvoiceDueDate', formatDate(invoice.dueDate)); - } else { - dueDateRow.style.display = 'none'; - } - } - - setText('displayInvoiceNumber', invoice.number); - - const itemsBody = document.getElementById('displayItemsBody'); - if (itemsBody) { - itemsBody.innerHTML = ''; - items.forEach((item) => { - const row = document.createElement('tr'); - - const descTd = document.createElement('td'); - descTd.textContent = item.description || ''; - - const qtyTd = document.createElement('td'); - qtyTd.textContent = item.quantity || 0; - - const priceTd = document.createElement('td'); - priceTd.textContent = formatCurrency(item.unitPrice || 0, settings.currency, settings.locale); - - const totalTd = document.createElement('td'); - totalTd.textContent = formatCurrency(item.total || 0, settings.currency, settings.locale); - - row.appendChild(descTd); - row.appendChild(qtyTd); - row.appendChild(priceTd); - row.appendChild(totalTd); - - itemsBody.appendChild(row); - }); - } - - setText('displaySubtotal', formatCurrency(totals.subtotal, settings.currency, settings.locale)); - - // Conditionally show/hide tax row - const taxRow = document.getElementById('taxRow'); - if (taxRow) { - if (totals.taxAmount > 0) { - taxRow.style.display = ''; - setText('displayTaxAmount', formatCurrency(totals.taxAmount, settings.currency, settings.locale)); - } else { - taxRow.style.display = 'none'; - } - } - - // Conditionally show/hide discount row - const discountRow = document.getElementById('discountRow'); - if (discountRow) { - if (totals.discount > 0) { - discountRow.style.display = ''; - setText('displayDiscount', formatCurrency(totals.discount, settings.currency, settings.locale)); - } else { - discountRow.style.display = 'none'; - } - } - - setText('displayBalanceDue', formatCurrency(totals.balanceDue, settings.currency, settings.locale)); - - setText('displayNotesHeading', invoice.notes || 'Thank you for your business'); - setText('displayInstructions', invoice.instructions || ''); - - document.getElementById('invoicePlaceholder').style.display = 'none'; - document.getElementById('invoice').style.display = 'block'; - document.getElementById('downloadBtn').style.display = 'inline-block'; -} - -function isInvoiceVisible() { - const invoice = document.getElementById('invoice'); - return invoice && invoice.style.display === 'block'; -} - -function saveDraft(data) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); -} - -function loadDraft() { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return null; - try { - return JSON.parse(raw); - } catch (error) { - return null; - } -} - -function deepMerge(target, source) { - if (!source || typeof source !== 'object') return target; - - // Dangerous keys that could lead to prototype pollution - const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype']; - - function hasUnsafeKeys(obj, visited = new Set()) { - if (!obj || typeof obj !== 'object' || visited.has(obj)) return false; - visited.add(obj); - return UNSAFE_KEYS.some(k => k in obj) || - Object.values(obj).some(v => v && typeof v === 'object' && hasUnsafeKeys(v, visited)); - } - - Object.keys(source).forEach((key) => { - // Skip prototype pollution vectors - if (UNSAFE_KEYS.includes(key)) return; - if (!Object.prototype.hasOwnProperty.call(source, key)) return; - - const value = source[key]; - if (Array.isArray(value)) { - target[key] = value.slice(); - } else if (value && typeof value === 'object') { - if (hasUnsafeKeys(value)) return; - if (!target[key] || typeof target[key] !== 'object') { - target[key] = {}; - } - deepMerge(target[key], value); - } else { - target[key] = value; - } - }); - return target; -} - -function applyDataToForm(data) { - isApplyingData = true; - - setValue('sellerName', data.seller?.name ?? ''); - setValue('sellerAddress', data.seller?.address ?? ''); - setValue('sellerEmail', data.seller?.email ?? ''); - setValue('sellerPhone', data.seller?.phone ?? ''); - setValue('billToName', data.billTo?.name ?? ''); - setValue('billToAddress', data.billTo?.address ?? ''); - setValue('billToEmail', data.billTo?.email ?? ''); - setValue('billToPhone', data.billTo?.phone ?? ''); - - setValue('invoiceTitle', data.invoice?.title ?? 'INVOICE'); - setValue('invoiceDate', data.invoice?.date ?? ''); - setValue('invoiceDueDate', data.invoice?.dueDate ?? ''); - setValue('invoiceNumber', data.invoice?.number ?? ''); - - setValue('currencyCode', data.settings?.currency ?? 'INR'); - - renderItemsForm(data.items || []); - - setValue('taxRate', data.totals?.taxRate ?? 0); - setValue('discountAmount', data.totals?.discount ?? 0); - - setValue('invoiceNotes', data.invoice?.notes ?? ''); - - const legacyBank = data.seller?.bank; - const legacyInstructions = data.invoice?.terms ?? data.invoice?.instructions ?? ''; - let mergedInstructions = legacyInstructions; - if (!mergedInstructions && legacyBank) { - const lines = []; - if (legacyBank.accountNo) lines.push(`A/c no: ${legacyBank.accountNo}`); - if (legacyBank.ifsc) lines.push(`IFSC: ${legacyBank.ifsc}`); - if (legacyBank.name) lines.push(`Name: ${legacyBank.name}`); - if (lines.length > 0) { - mergedInstructions = `Please send payment to below bank account.\n${lines.join('\n')}`; - } - } - setValue('invoiceInstructions', mergedInstructions); - - isApplyingData = false; - - syncFromForm({ render: data.meta?.showInvoice, showInvoice: data.meta?.showInvoice }); -} - -function syncFromForm(options = {}) { - const data = getDataFromForm(); - if (typeof options.showInvoice === 'boolean') { - data.meta.showInvoice = options.showInvoice; - } - updateFormTotalsDisplay(data); - saveDraft(data); - if (options.render) { - renderInvoice(data); - } - return data; -} - -function validateData(data) { - if (!data.seller.name || !data.seller.address) return false; - if (!data.billTo.name || !data.billTo.address) return false; - if (!data.invoice.date || !data.invoice.number) return false; - if (!Array.isArray(data.items) || data.items.length === 0) return false; - const validItems = data.items.some((item) => item.description && item.quantity > 0 && item.unitPrice >= 0); - return validItems; -} - -function generateBill() { - const data = syncFromForm({ showInvoice: true }); - if (!validateData(data)) { - alert('Please fill in all required fields and add at least one line item.'); - return; - } - data.meta.showInvoice = true; - saveDraft(data); - renderInvoice(data); - localStorage.setItem(LAST_NUMBER_KEY, data.invoice.number); - saveToHistory(data); - document.getElementById('invoice').scrollIntoView({ behavior: 'smooth' }); -} - -async function downloadPDF() { - const { jsPDF } = window.jspdf; - const invoiceElement = document.getElementById('invoice'); - const downloadBtn = document.getElementById('downloadBtn'); - - // Show loading state - const originalBtnText = downloadBtn.textContent; - downloadBtn.textContent = 'Generating...'; - downloadBtn.disabled = true; - - // Store original styles to restore later - const originalStyles = { - formDisplay: document.querySelector('.form-section').style.display, - invoiceHeight: invoiceElement.style.height, - invoiceOverflow: invoiceElement.style.overflow - }; - - try { - // Prepare invoice for capture - document.querySelector('.form-section').style.display = 'none'; - invoiceElement.style.height = 'auto'; - invoiceElement.style.overflow = 'visible'; - document.body.classList.add('pdf-generation'); - - // Add print-optimized styles - const printStyles = document.createElement('style'); - printStyles.id = 'pdf-print-styles'; - printStyles.textContent = ` - .pdf-generation .invoice-section { - background: white !important; - box-shadow: none !important; - padding: 40px !important; - margin: 0 !important; - width: 794px !important; - min-height: auto !important; - height: auto !important; - overflow: visible !important; - font-family: 'Inter', Arial, sans-serif !important; - } - .pdf-generation .invoice-header { margin-bottom: 30px !important; } - .pdf-generation .business-info h1 { font-size: 18px !important; } - .pdf-generation .business-info p { font-size: 12px !important; } - .pdf-generation .invoice-title h1 { font-size: 28px !important; } - .pdf-generation .invoice-table { font-size: 12px !important; } - .pdf-generation .invoice-table th, - .pdf-generation .invoice-table td { padding: 10px !important; } - `; - document.head.appendChild(printStyles); - - // Wait for styles to apply - await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); - - // Capture with high DPI for crisp output - const canvas = await html2canvas(invoiceElement, { - scale: 3, // Higher scale for better quality - useCORS: true, - backgroundColor: '#ffffff', - logging: false, - windowWidth: 794, // A4 width at 96 DPI - onclone: (clonedDoc) => { - // Ensure cloned element is fully visible - const clonedInvoice = clonedDoc.getElementById('invoice'); - if (clonedInvoice) { - clonedInvoice.style.height = 'auto'; - clonedInvoice.style.overflow = 'visible'; - } - } - }); - - // Create PDF with proper A4 dimensions - const pdf = new jsPDF({ - orientation: 'portrait', - unit: 'mm', - format: 'a4', - compress: true - }); - - const pageWidth = pdf.internal.pageSize.getWidth(); - const pageHeight = pdf.internal.pageSize.getHeight(); - const margin = 10; - const contentWidth = pageWidth - (margin * 2); - - // Calculate image dimensions maintaining aspect ratio - const imgWidth = contentWidth; - const imgHeight = (canvas.height * imgWidth) / canvas.width; - - // Handle multi-page if content is taller than one page - const maxHeightPerPage = pageHeight - (margin * 2); - - if (imgHeight <= maxHeightPerPage) { - // Single page - center vertically if short - const yOffset = margin; - pdf.addImage(canvas.toDataURL('image/png', 1.0), 'PNG', margin, yOffset, imgWidth, imgHeight); - } else { - // Multi-page handling for long invoices - let remainingHeight = imgHeight; - let sourceY = 0; - let pageNum = 0; - - while (remainingHeight > 0) { - if (pageNum > 0) pdf.addPage(); - - const sliceHeight = Math.min(remainingHeight, maxHeightPerPage); - const sourceSliceHeight = (sliceHeight / imgHeight) * canvas.height; - - // Create a slice canvas for this page - const sliceCanvas = document.createElement('canvas'); - sliceCanvas.width = canvas.width; - sliceCanvas.height = sourceSliceHeight; - const ctx = sliceCanvas.getContext('2d'); - ctx.drawImage(canvas, 0, sourceY, canvas.width, sourceSliceHeight, 0, 0, canvas.width, sourceSliceHeight); - - pdf.addImage(sliceCanvas.toDataURL('image/png', 1.0), 'PNG', margin, margin, imgWidth, sliceHeight); - - sourceY += sourceSliceHeight; - remainingHeight -= sliceHeight; - pageNum++; - } - } - - // Generate filename - const customerName = getValue('billToName').replace(/[^a-zA-Z0-9\s]/g, '').trim().replace(/\s+/g, '-'); - const invoiceNumber = getValue('invoiceNumber'); - const filename = `Invoice-${invoiceNumber}-${customerName}.pdf`; - - pdf.save(filename); - - // Remove print styles - document.head.removeChild(printStyles); - - } catch (error) { - console.error('Error generating PDF:', error); - alert('Error generating PDF. Please try again.'); - } finally { - // Restore original styles - document.querySelector('.form-section').style.display = originalStyles.formDisplay; - invoiceElement.style.height = originalStyles.invoiceHeight; - invoiceElement.style.overflow = originalStyles.invoiceOverflow; - document.body.classList.remove('pdf-generation'); - - // Remove print styles if still present - const printStyles = document.getElementById('pdf-print-styles'); - if (printStyles) printStyles.remove(); - - // Restore button - downloadBtn.textContent = originalBtnText; - downloadBtn.disabled = false; - } -} - -function exportJsonToTextarea() { - const data = syncFromForm({ render: false }); - const json = JSON.stringify(data, null, 2); - setValue('jsonData', json); -} - -function downloadJsonFile() { - const data = syncFromForm({ render: false }); - const json = JSON.stringify(data, null, 2); - const blob = new Blob([json], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - const invoiceNumber = data.invoice.number || 'draft'; - anchor.href = url; - anchor.download = `invoice-${invoiceNumber}.json`; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - URL.revokeObjectURL(url); -} - -function importJsonData(rawJson) { - let parsed; - try { - parsed = JSON.parse(rawJson); - } catch (error) { - alert('Invalid JSON. Please check the format and try again.'); - return; - } - - const base = getDataFromForm(); - const merged = deepMerge(base, parsed); - merged.meta = merged.meta || {}; - merged.meta.showInvoice = true; - - applyDataToForm(merged); -} - -function handleFileImport(event) { - const file = event.target.files[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = () => { - importJsonData(reader.result); - }; - reader.readAsText(file); -} - -function resetDraft() { - const shouldReset = confirm('Reset the current draft? Your stored draft will be cleared.'); - if (!shouldReset) return; - - localStorage.removeItem(STORAGE_KEY); - document.getElementById('invoice').style.display = 'none'; - document.getElementById('invoicePlaceholder').style.display = 'flex'; - document.getElementById('downloadBtn').style.display = 'none'; - setValue('jsonData', ''); - - // Reset all form fields - setValue('sellerName', ''); - setValue('sellerAddress', ''); - setValue('sellerEmail', ''); - setValue('sellerPhone', ''); - setValue('billToName', ''); - setValue('billToAddress', ''); - setValue('billToEmail', ''); - setValue('billToPhone', ''); - setValue('invoiceTitle', 'INVOICE'); - setValue('invoiceDueDate', ''); - setValue('currencyCode', 'INR'); - setValue('taxRate', '0'); - setValue('discountAmount', '0'); - setValue('invoiceNotes', ''); - setValue('invoiceInstructions', ''); - - const today = new Date().toISOString().split('T')[0]; - setValue('invoiceDate', today); - - const lastNumber = localStorage.getItem(LAST_NUMBER_KEY); - if (lastNumber) { - setValue('invoiceNumber', String(parseNumber(lastNumber, 0) + 1)); - } else { - setValue('invoiceNumber', '1'); - } - - renderItemsForm([]); - ensureAtLeastOneItemRow(); - syncFromForm({ render: false, showInvoice: false }); -} - -function attachEventHandlers() { - const form = document.getElementById('billForm'); - form.addEventListener('input', () => { - if (isApplyingData) return; - const shouldRender = isInvoiceVisible(); - syncFromForm({ render: shouldRender }); - }); - - document.getElementById('addItemBtn').addEventListener('click', () => { - addItemRow({ description: '', quantity: 1, unitPrice: 0 }); - syncFromForm({ render: isInvoiceVisible() }); - }); - - const itemsContainer = document.getElementById('itemsContainer'); - itemsContainer.addEventListener('click', (event) => { - if (!event.target.classList.contains('remove-item')) return; - const row = event.target.closest('.item-row'); - if (row) row.remove(); - ensureAtLeastOneItemRow(); - syncFromForm({ render: isInvoiceVisible() }); - }); - - document.getElementById('exportJsonBtn').addEventListener('click', exportJsonToTextarea); - document.getElementById('downloadJsonBtn').addEventListener('click', downloadJsonFile); - document.getElementById('importJsonBtn').addEventListener('click', () => { - const raw = getValue('jsonData'); - if (!raw.trim()) { - alert('Paste invoice JSON first.'); - return; - } - importJsonData(raw); - }); - - document.getElementById('importFile').addEventListener('change', handleFileImport); - document.getElementById('resetDraftBtn').addEventListener('click', resetDraft); - - // Invoice number increment/decrement buttons - document.getElementById('incrementInvoice').addEventListener('click', () => { - const input = document.getElementById('invoiceNumber'); - const current = parseNumber(input.value, 0); - input.value = current + 1; - syncFromForm({ render: isInvoiceVisible() }); - }); - - document.getElementById('decrementInvoice').addEventListener('click', () => { - const input = document.getElementById('invoiceNumber'); - const current = parseNumber(input.value, 0); - if (current > 1) { - input.value = current - 1; - syncFromForm({ render: isInvoiceVisible() }); - } - }); - - // Drag and drop support for JSON textarea - const jsonTextarea = document.getElementById('jsonData'); - - jsonTextarea.addEventListener('dragover', (e) => { - e.preventDefault(); - jsonTextarea.classList.add('drag-over'); - }); - - jsonTextarea.addEventListener('dragleave', (e) => { - e.preventDefault(); - jsonTextarea.classList.remove('drag-over'); - }); - - jsonTextarea.addEventListener('drop', (e) => { - e.preventDefault(); - jsonTextarea.classList.remove('drag-over'); - - const file = e.dataTransfer.files[0]; - if (!file) return; - - if (!file.name.endsWith('.json') && file.type !== 'application/json') { - alert('Please drop a .json file.'); - return; - } - - const reader = new FileReader(); - reader.onload = () => { - importJsonData(reader.result); - }; - reader.readAsText(file); - }); - - // History panel handlers - document.getElementById('historyToggle').addEventListener('click', () => toggleHistoryPanel()); - document.getElementById('clearHistoryBtn').addEventListener('click', clearHistory); - - document.getElementById('historyList').addEventListener('click', (e) => { - const deleteBtn = e.target.closest('.history-item-delete'); - if (deleteBtn) { - e.stopPropagation(); - const id = parseInt(deleteBtn.dataset.id, 10); - deleteFromHistory(id); - return; - } - - const item = e.target.closest('.history-item'); - if (item) { - const id = parseInt(item.dataset.id, 10); - loadFromHistory(id); - } - }); -} - -document.addEventListener('DOMContentLoaded', () => { - const today = new Date().toISOString().split('T')[0]; - if (!getValue('invoiceDate')) { - setValue('invoiceDate', today); - } - - attachEventHandlers(); - - const storedDraft = loadDraft(); - if (storedDraft) { - applyDataToForm(storedDraft); - if (storedDraft.meta?.showInvoice) { - renderInvoice(storedDraft); - } - return; - } - - const lastNumber = localStorage.getItem(LAST_NUMBER_KEY); - if (lastNumber && !getValue('invoiceNumber')) { - setValue('invoiceNumber', String(parseNumber(lastNumber, 0) + 1)); - } - - ensureAtLeastOneItemRow(); - syncFromForm({ render: false, showInvoice: false }); -}); diff --git a/src/js/main.js b/src/js/main.js new file mode 100644 index 0000000..1503142 --- /dev/null +++ b/src/js/main.js @@ -0,0 +1,286 @@ +/** + * Rebill - Invoice Generator + * Main Application Entry Point + */ + +// Import modules +import { getValue, setValue } from './modules/dom.js'; +import { parseNumber } from './modules/formatters.js'; +import { saveDraft, loadDraft, clearDraft, STORAGE_KEY } from './modules/storage.js'; +import { + getHistory, + saveToHistory, + deleteFromHistory, + clearHistory, + getHistoryEntry, + renderHistoryList, + toggleHistoryPanel +} from './modules/history.js'; +import { addItemRow, renderItemsForm, ensureAtLeastOneItemRow } from './modules/items.js'; +import { renderInvoice, isInvoiceVisible } from './modules/invoice.js'; +import { + getDataFromForm, + updateFormTotalsDisplay, + validateData, + syncFromForm, + applyDataToForm +} from './modules/form.js'; +import { downloadPDF, shareInvoice } from './modules/pdf.js'; +import { + exportJsonToTextarea, + downloadJsonFile, + importJsonData, + handleFileImport +} from './modules/json-io.js'; + +// Constants +const LAST_NUMBER_KEY = 'invoice.lastNumber'; + +// State +let isApplyingData = false; + +/** + * Generate bill from form data + */ +function generateBill() { + const data = syncFromForm({ showInvoice: true }); + if (!validateData(data)) { + alert('Please fill in all required fields and add at least one line item.'); + return; + } + data.meta.showInvoice = true; + saveDraft(data); + renderInvoice(data); + localStorage.setItem(LAST_NUMBER_KEY, data.invoice.number); + saveToHistory(data); + document.getElementById('invoice').scrollIntoView({ behavior: 'smooth' }); +} + +/** + * Load invoice from history by ID + * @param {number} id - History entry ID + */ +function loadFromHistory(id) { + const entry = getHistoryEntry(id); + if (!entry || !entry.data) return; + applyDataToForm(entry.data, (options) => { + isApplyingData = true; + syncFromForm(options); + isApplyingData = false; + }); + toggleHistoryPanel(false); +} + +/** + * Reset the current draft + */ +function resetDraft() { + const shouldReset = confirm('Reset the current draft? Your stored draft will be cleared.'); + if (!shouldReset) return; + + clearDraft(); + document.getElementById('invoice').style.display = 'none'; + document.getElementById('invoicePlaceholder').style.display = 'flex'; + document.getElementById('downloadBtn').style.display = 'none'; + document.getElementById('shareBtn').style.display = 'none'; + setValue('jsonData', ''); + + // Reset all form fields + setValue('sellerName', ''); + setValue('sellerAddress', ''); + setValue('sellerEmail', ''); + setValue('sellerPhone', ''); + setValue('billToName', ''); + setValue('billToAddress', ''); + setValue('billToEmail', ''); + setValue('billToPhone', ''); + setValue('invoiceTitle', 'INVOICE'); + setValue('invoiceDueDate', ''); + setValue('currencyCode', 'INR'); + setValue('taxRate', '0'); + setValue('discountAmount', '0'); + setValue('invoiceNotes', ''); + setValue('invoiceInstructions', ''); + + const today = new Date().toISOString().split('T')[0]; + setValue('invoiceDate', today); + + const lastNumber = localStorage.getItem(LAST_NUMBER_KEY); + if (lastNumber) { + setValue('invoiceNumber', String(parseNumber(lastNumber, 0) + 1)); + } else { + setValue('invoiceNumber', '1'); + } + + renderItemsForm([]); + ensureAtLeastOneItemRow(addItemRow); + syncFromForm({ render: false, showInvoice: false }); +} + +/** + * Attach all event handlers + */ +function attachEventHandlers() { + const form = document.getElementById('billForm'); + form.addEventListener('input', () => { + if (isApplyingData) return; + const shouldRender = isInvoiceVisible(); + syncFromForm({ render: shouldRender }); + }); + + document.getElementById('addItemBtn').addEventListener('click', () => { + addItemRow({ description: '', quantity: 1, unitPrice: 0 }); + syncFromForm({ render: isInvoiceVisible() }); + }); + + const itemsContainer = document.getElementById('itemsContainer'); + itemsContainer.addEventListener('click', (event) => { + if (!event.target.classList.contains('remove-item')) return; + const row = event.target.closest('.item-row'); + if (row) row.remove(); + ensureAtLeastOneItemRow(addItemRow); + syncFromForm({ render: isInvoiceVisible() }); + }); + + document.getElementById('exportJsonBtn').addEventListener('click', exportJsonToTextarea); + document.getElementById('downloadJsonBtn').addEventListener('click', downloadJsonFile); + document.getElementById('importJsonBtn').addEventListener('click', () => { + const raw = getValue('jsonData'); + if (!raw.trim()) { + alert('Paste invoice JSON first.'); + return; + } + importJsonData(raw, (options) => { + isApplyingData = true; + syncFromForm(options); + isApplyingData = false; + }); + }); + + document.getElementById('importFile').addEventListener('change', (event) => { + handleFileImport(event, (options) => { + isApplyingData = true; + syncFromForm(options); + isApplyingData = false; + }); + }); + document.getElementById('resetDraftBtn').addEventListener('click', resetDraft); + + // Invoice number increment/decrement buttons + document.getElementById('incrementInvoice').addEventListener('click', () => { + const input = document.getElementById('invoiceNumber'); + const current = parseNumber(input.value, 0); + input.value = current + 1; + syncFromForm({ render: isInvoiceVisible() }); + }); + + document.getElementById('decrementInvoice').addEventListener('click', () => { + const input = document.getElementById('invoiceNumber'); + const current = parseNumber(input.value, 0); + if (current > 1) { + input.value = current - 1; + syncFromForm({ render: isInvoiceVisible() }); + } + }); + + // Drag and drop support for JSON textarea + const jsonTextarea = document.getElementById('jsonData'); + + jsonTextarea.addEventListener('dragover', (e) => { + e.preventDefault(); + jsonTextarea.classList.add('drag-over'); + }); + + jsonTextarea.addEventListener('dragleave', (e) => { + e.preventDefault(); + jsonTextarea.classList.remove('drag-over'); + }); + + jsonTextarea.addEventListener('drop', (e) => { + e.preventDefault(); + jsonTextarea.classList.remove('drag-over'); + + const file = e.dataTransfer.files[0]; + if (!file) return; + + if (!file.name.endsWith('.json') && file.type !== 'application/json') { + alert('Please drop a .json file.'); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + importJsonData(reader.result, (options) => { + isApplyingData = true; + syncFromForm(options); + isApplyingData = false; + }); + }; + reader.readAsText(file); + }); + + // History panel handlers + document.getElementById('historyToggle').addEventListener('click', () => toggleHistoryPanel()); + document.getElementById('clearHistoryBtn').addEventListener('click', () => { + clearHistory(); + renderHistoryList(); + }); + + document.getElementById('historyList').addEventListener('click', (e) => { + const deleteBtn = e.target.closest('.history-item-delete'); + if (deleteBtn) { + e.stopPropagation(); + const id = parseInt(deleteBtn.dataset.id, 10); + deleteFromHistory(id); + renderHistoryList(); + return; + } + + const item = e.target.closest('.history-item'); + if (item) { + const id = parseInt(item.dataset.id, 10); + loadFromHistory(id); + } + }); +} + +/** + * Initialize the application + */ +function init() { + const today = new Date().toISOString().split('T')[0]; + if (!getValue('invoiceDate')) { + setValue('invoiceDate', today); + } + + attachEventHandlers(); + + const storedDraft = loadDraft(); + if (storedDraft) { + isApplyingData = true; + applyDataToForm(storedDraft, (options) => { + syncFromForm(options); + }); + isApplyingData = false; + if (storedDraft.meta?.showInvoice) { + renderInvoice(storedDraft); + } + return; + } + + const lastNumber = localStorage.getItem(LAST_NUMBER_KEY); + if (lastNumber && !getValue('invoiceNumber')) { + setValue('invoiceNumber', String(parseNumber(lastNumber, 0) + 1)); + } + + ensureAtLeastOneItemRow(addItemRow); + syncFromForm({ render: false, showInvoice: false }); +} + +// Expose functions to global scope for onclick handlers in HTML +window.generateBill = generateBill; +window.downloadPDF = downloadPDF; +window.shareInvoice = shareInvoice; + +// Initialize on DOM ready +document.addEventListener('DOMContentLoaded', init); diff --git a/src/js/modules/calculations.js b/src/js/modules/calculations.js new file mode 100644 index 0000000..4a821ca --- /dev/null +++ b/src/js/modules/calculations.js @@ -0,0 +1,25 @@ +/** + * Calculations Module + * Invoice totals calculation + */ + +/** + * Calculate invoice totals from items + * @param {Array} items - Array of line items with total property + * @param {number} taxRate - Tax rate as percentage + * @param {number} discount - Discount amount + * @returns {Object} Totals object with subtotal, taxRate, taxAmount, discount, total, balanceDue + */ +export function calculateTotals(items, taxRate, discount) { + const subtotal = items.reduce((sum, item) => sum + item.total, 0); + const taxAmount = subtotal * (taxRate / 100); + const total = subtotal + taxAmount - discount; + return { + subtotal, + taxRate, + taxAmount, + discount, + total, + balanceDue: total + }; +} diff --git a/src/js/modules/dom.js b/src/js/modules/dom.js new file mode 100644 index 0000000..19f041e --- /dev/null +++ b/src/js/modules/dom.js @@ -0,0 +1,97 @@ +/** + * DOM Helper Functions + * Utilities for getting/setting form values and creating clickable links + */ + +/** + * Get value from a form element by ID + * @param {string} id - Element ID + * @param {string} fallback - Default value if element not found + * @returns {string} Element value or fallback + */ +export function getValue(id, fallback = '') { + const el = document.getElementById(id); + if (!el) return fallback; + return el.value ?? fallback; +} + +/** + * Set value on a form element by ID + * @param {string} id - Element ID + * @param {*} value - Value to set + */ +export function setValue(id, value) { + const el = document.getElementById(id); + if (!el) return; + el.value = value ?? ''; +} + +/** + * Set text content of an element by ID + * @param {string} id - Element ID + * @param {*} value - Text to set + */ +export function setText(id, value) { + const el = document.getElementById(id); + if (!el) return; + el.textContent = value ?? ''; +} + +/** + * Escape HTML special characters to prevent XSS + * @param {string} str - String to escape + * @returns {string} Escaped string + */ +export function escapeHtml(str) { + if (str == null) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Set clickable email link in an element + * @param {string} id - Element ID + * @param {string} email - Email address + */ +export function setEmailLink(id, email) { + const el = document.getElementById(id); + if (!el) return; + if (email && email.trim()) { + el.innerHTML = ''; + const link = document.createElement('a'); + link.href = `mailto:${escapeHtml(email.trim())}`; + link.textContent = email.trim(); + link.style.color = 'inherit'; + link.style.textDecoration = 'none'; + el.appendChild(link); + } else { + el.textContent = ''; + } +} + +/** + * Set clickable phone link in an element + * @param {string} id - Element ID + * @param {string} phone - Phone number + */ +export function setPhoneLink(id, phone) { + const el = document.getElementById(id); + if (!el) return; + if (phone && phone.trim()) { + el.innerHTML = ''; + const link = document.createElement('a'); + // Remove spaces and special chars for tel: link + const cleanPhone = phone.trim().replace(/[\s\-\(\)]/g, ''); + link.href = `tel:${escapeHtml(cleanPhone)}`; + link.textContent = phone.trim(); + link.style.color = 'inherit'; + link.style.textDecoration = 'none'; + el.appendChild(link); + } else { + el.textContent = ''; + } +} diff --git a/src/js/modules/form.js b/src/js/modules/form.js new file mode 100644 index 0000000..26a5240 --- /dev/null +++ b/src/js/modules/form.js @@ -0,0 +1,151 @@ +/** + * Form Module + * Form data collection and synchronization + */ + +import { getValue, setValue, setText } from './dom.js'; +import { parseNumber, formatCurrency } from './formatters.js'; +import { calculateTotals } from './calculations.js'; +import { collectItemsFromForm, renderItemsForm, addItemRow } from './items.js'; +import { saveDraft, deepMerge } from './storage.js'; +import { renderInvoice, isInvoiceVisible } from './invoice.js'; + +/** + * Get all invoice data from the form + * @returns {Object} Complete invoice data object + */ +export function getDataFromForm() { + const settings = { + currency: getValue('currencyCode', 'INR').trim() || 'INR', + locale: 'en-IN' + }; + + const items = collectItemsFromForm(settings); + const taxRate = parseNumber(getValue('taxRate', 0), 0); + const discount = parseNumber(getValue('discountAmount', 0), 0); + const totals = calculateTotals(items, taxRate, discount); + + return { + schemaVersion: 1, + settings, + seller: { + name: getValue('sellerName'), + address: getValue('sellerAddress'), + email: getValue('sellerEmail'), + phone: getValue('sellerPhone') + }, + billTo: { + name: getValue('billToName'), + address: getValue('billToAddress'), + email: getValue('billToEmail'), + phone: getValue('billToPhone') + }, + invoice: { + title: getValue('invoiceTitle', 'INVOICE'), + date: getValue('invoiceDate'), + dueDate: getValue('invoiceDueDate'), + number: getValue('invoiceNumber'), + notes: getValue('invoiceNotes'), + instructions: getValue('invoiceInstructions') + }, + items, + totals, + meta: { + updatedAt: new Date().toISOString(), + showInvoice: isInvoiceVisible() + } + }; +} + +/** + * Update form totals display + * @param {Object} data - Invoice data with totals + */ +export function updateFormTotalsDisplay(data) { + const { totals, settings } = data; + setText('formSubtotal', formatCurrency(totals.subtotal, settings.currency, settings.locale)); + setText('formTaxAmount', formatCurrency(totals.taxAmount, settings.currency, settings.locale)); + setText('formTotal', formatCurrency(totals.total, settings.currency, settings.locale)); +} + +/** + * Validate invoice data + * @param {Object} data - Invoice data to validate + * @returns {boolean} True if valid + */ +export function validateData(data) { + if (!data.seller.name || !data.seller.address) return false; + if (!data.billTo.name || !data.billTo.address) return false; + if (!data.invoice.date || !data.invoice.number) return false; + if (!Array.isArray(data.items) || data.items.length === 0) return false; + const validItems = data.items.some((item) => item.description && item.quantity > 0 && item.unitPrice >= 0); + return validItems; +} + +/** + * Sync data from form, save draft, and optionally render + * @param {Object} options - { render: boolean, showInvoice: boolean } + * @returns {Object} Current form data + */ +export function syncFromForm(options = {}) { + const data = getDataFromForm(); + if (typeof options.showInvoice === 'boolean') { + data.meta.showInvoice = options.showInvoice; + } + updateFormTotalsDisplay(data); + saveDraft(data); + if (options.render) { + renderInvoice(data); + } + return data; +} + +/** + * Apply data to form fields + * @param {Object} data - Invoice data to apply + * @param {Function} syncCallback - Callback to sync after applying + */ +export function applyDataToForm(data, syncCallback) { + setValue('sellerName', data.seller?.name ?? ''); + setValue('sellerAddress', data.seller?.address ?? ''); + setValue('sellerEmail', data.seller?.email ?? ''); + setValue('sellerPhone', data.seller?.phone ?? ''); + setValue('billToName', data.billTo?.name ?? ''); + setValue('billToAddress', data.billTo?.address ?? ''); + setValue('billToEmail', data.billTo?.email ?? ''); + setValue('billToPhone', data.billTo?.phone ?? ''); + + setValue('invoiceTitle', data.invoice?.title ?? 'INVOICE'); + setValue('invoiceDate', data.invoice?.date ?? ''); + setValue('invoiceDueDate', data.invoice?.dueDate ?? ''); + setValue('invoiceNumber', data.invoice?.number ?? ''); + + setValue('currencyCode', data.settings?.currency ?? 'INR'); + + renderItemsForm(data.items || []); + + setValue('taxRate', data.totals?.taxRate ?? 0); + setValue('discountAmount', data.totals?.discount ?? 0); + + setValue('invoiceNotes', data.invoice?.notes ?? ''); + + const legacyBank = data.seller?.bank; + const legacyInstructions = data.invoice?.terms ?? data.invoice?.instructions ?? ''; + let mergedInstructions = legacyInstructions; + if (!mergedInstructions && legacyBank) { + const lines = []; + if (legacyBank.accountNo) lines.push(`A/c no: ${legacyBank.accountNo}`); + if (legacyBank.ifsc) lines.push(`IFSC: ${legacyBank.ifsc}`); + if (legacyBank.name) lines.push(`Name: ${legacyBank.name}`); + if (lines.length > 0) { + mergedInstructions = `Please send payment to below bank account.\n${lines.join('\n')}`; + } + } + setValue('invoiceInstructions', mergedInstructions); + + if (syncCallback) { + syncCallback({ render: data.meta?.showInvoice, showInvoice: data.meta?.showInvoice }); + } +} + +export { deepMerge }; diff --git a/src/js/modules/formatters.js b/src/js/modules/formatters.js new file mode 100644 index 0000000..d9a77bc --- /dev/null +++ b/src/js/modules/formatters.js @@ -0,0 +1,52 @@ +/** + * Formatting Functions + * Number parsing and currency/date formatting utilities + */ + +/** + * Parse a value as a number with fallback + * @param {*} value - Value to parse + * @param {number} fallback - Default if parsing fails + * @returns {number} Parsed number or fallback + */ +export function parseNumber(value, fallback = 0) { + const num = parseFloat(value); + return Number.isFinite(num) ? num : fallback; +} + +/** + * Format amount as currency string + * @param {number} amount - Amount to format + * @param {string} currency - Currency code (e.g., 'INR', 'USD') + * @param {string} locale - Locale string (e.g., 'en-IN') + * @returns {string} Formatted currency string + */ +export function formatCurrency(amount, currency, locale) { + const safeAmount = Number.isFinite(amount) ? amount : 0; + try { + return new Intl.NumberFormat(locale || 'en-IN', { + style: 'currency', + currency: currency || 'INR', + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(safeAmount); + } catch (error) { + const suffix = currency ? ` ${currency}` : ''; + return `${safeAmount.toFixed(2)}${suffix}`; + } +} + +/** + * Format date value as DD/MM/YYYY + * @param {string|Date} dateValue - Date to format + * @returns {string} Formatted date string + */ +export function formatDate(dateValue) { + if (!dateValue) return ''; + const date = new Date(dateValue); + if (Number.isNaN(date.getTime())) return dateValue; + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = String(date.getFullYear()); + return `${day}/${month}/${year}`; +} diff --git a/src/js/modules/history.js b/src/js/modules/history.js new file mode 100644 index 0000000..77b8d24 --- /dev/null +++ b/src/js/modules/history.js @@ -0,0 +1,156 @@ +/** + * History Module + * Invoice history management in localStorage + */ + +import { formatDate, formatCurrency } from './formatters.js'; + +const HISTORY_KEY = 'invoice.history'; + +/** + * Get all history entries from localStorage + * @returns {Array} Array of history entries + */ +export function getHistory() { + const raw = localStorage.getItem(HISTORY_KEY); + if (!raw) return []; + try { + return JSON.parse(raw); + } catch (e) { + return []; + } +} + +/** + * Save invoice data to history + * @param {Object} data - Invoice data to save + */ +export function saveToHistory(data) { + const history = getHistory(); + const entry = { + id: Date.now(), + date: data.invoice?.date || new Date().toISOString().split('T')[0], + number: data.invoice?.number || '', + customerName: data.billTo?.name || '', + total: data.totals?.total || 0, + currency: data.settings?.currency || 'INR', + data: data + }; + + // Check for duplicate invoice number + const existingIndex = history.findIndex(h => h.number === entry.number && entry.number); + if (existingIndex >= 0) { + history[existingIndex] = entry; + } else { + history.unshift(entry); + } + + // Keep only last 50 entries + const trimmed = history.slice(0, 50); + + try { + localStorage.setItem(HISTORY_KEY, JSON.stringify(trimmed)); + } catch (e) { + console.warn('Failed to save history:', e); + } +} + +/** + * Delete a history entry by ID + * @param {number} id - Entry ID to delete + */ +export function deleteFromHistory(id) { + const history = getHistory().filter(h => h.id !== id); + localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); +} + +/** + * Clear all history entries + */ +export function clearHistory() { + if (!confirm('Clear all invoice history? This cannot be undone.')) return; + localStorage.removeItem(HISTORY_KEY); +} + +/** + * Get a history entry by ID + * @param {number} id - Entry ID + * @returns {Object|null} History entry or null + */ +export function getHistoryEntry(id) { + const history = getHistory(); + return history.find(h => h.id === id) || null; +} + +/** + * Render the history list in the UI + * @param {Function} onLoad - Callback when an entry is loaded + * @param {Function} onDelete - Callback when an entry is deleted + */ +export function renderHistoryList() { + const list = document.getElementById('historyList'); + if (!list) return; + + const history = getHistory(); + list.innerHTML = ''; + + if (history.length === 0) { + const empty = document.createElement('p'); + empty.className = 'history-empty'; + empty.textContent = 'No saved invoices yet'; + list.appendChild(empty); + return; + } + + history.forEach((entry) => { + const item = document.createElement('div'); + item.className = 'history-item'; + item.dataset.id = entry.id; + + const info = document.createElement('div'); + info.className = 'history-item-info'; + + const title = document.createElement('div'); + title.className = 'history-item-title'; + title.textContent = `#${entry.number} - ${entry.customerName || 'Unknown'}`; + + const meta = document.createElement('div'); + meta.className = 'history-item-meta'; + meta.textContent = `${formatDate(entry.date)} · ${formatCurrency(entry.total, entry.currency)}`; + + info.appendChild(title); + info.appendChild(meta); + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'history-item-delete'; + deleteBtn.dataset.id = entry.id; + deleteBtn.title = 'Delete'; + deleteBtn.textContent = '×'; + + item.appendChild(info); + item.appendChild(deleteBtn); + list.appendChild(item); + }); +} + +/** + * Toggle history panel visibility + * @param {boolean} show - Optional explicit show/hide + */ +export function toggleHistoryPanel(show) { + const panel = document.getElementById('historyPanel'); + if (!panel) return; + + if (typeof show === 'boolean') { + panel.style.display = show ? 'block' : 'none'; + } else { + panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; + } + + if (panel.style.display === 'block') { + renderHistoryList(); + } +} + +export { HISTORY_KEY }; diff --git a/src/js/modules/invoice.js b/src/js/modules/invoice.js new file mode 100644 index 0000000..0fee987 --- /dev/null +++ b/src/js/modules/invoice.js @@ -0,0 +1,112 @@ +/** + * Invoice Rendering Module + * Display invoice preview + */ + +import { setText, setEmailLink, setPhoneLink } from './dom.js'; +import { formatCurrency, formatDate } from './formatters.js'; + +/** + * Check if invoice preview is currently visible + * @returns {boolean} True if invoice is visible + */ +export function isInvoiceVisible() { + const invoice = document.getElementById('invoice'); + return invoice && invoice.style.display === 'block'; +} + +/** + * Render invoice preview from data + * @param {Object} data - Complete invoice data object + */ +export function renderInvoice(data) { + const { settings, seller, billTo, invoice, items, totals } = data; + + setText('displaySellerName', seller.name); + setText('displaySellerAddress', seller.address || ''); + setEmailLink('displaySellerEmail', seller.email); + setPhoneLink('displaySellerPhone', seller.phone); + + setText('displayInvoiceTitle', invoice.title || 'INVOICE'); + + setText('displayBillToName', billTo.name); + setText('displayBillToAddress', billTo.address || ''); + setEmailLink('displayBillToEmail', billTo.email); + setPhoneLink('displayBillToPhone', billTo.phone); + + setText('displayInvoiceDate', formatDate(invoice.date)); + + // Conditionally show/hide due date row + const dueDateRow = document.getElementById('dueDateRow'); + if (dueDateRow) { + if (invoice.dueDate) { + dueDateRow.style.display = ''; + setText('displayInvoiceDueDate', formatDate(invoice.dueDate)); + } else { + dueDateRow.style.display = 'none'; + } + } + + setText('displayInvoiceNumber', invoice.number); + + const itemsBody = document.getElementById('displayItemsBody'); + if (itemsBody) { + itemsBody.innerHTML = ''; + items.forEach((item) => { + const row = document.createElement('tr'); + + const descTd = document.createElement('td'); + descTd.textContent = item.description || ''; + + const qtyTd = document.createElement('td'); + qtyTd.textContent = item.quantity || 0; + + const priceTd = document.createElement('td'); + priceTd.textContent = formatCurrency(item.unitPrice || 0, settings.currency, settings.locale); + + const totalTd = document.createElement('td'); + totalTd.textContent = formatCurrency(item.total || 0, settings.currency, settings.locale); + + row.appendChild(descTd); + row.appendChild(qtyTd); + row.appendChild(priceTd); + row.appendChild(totalTd); + + itemsBody.appendChild(row); + }); + } + + setText('displaySubtotal', formatCurrency(totals.subtotal, settings.currency, settings.locale)); + + // Conditionally show/hide tax row + const taxRow = document.getElementById('taxRow'); + if (taxRow) { + if (totals.taxAmount > 0) { + taxRow.style.display = ''; + setText('displayTaxAmount', formatCurrency(totals.taxAmount, settings.currency, settings.locale)); + } else { + taxRow.style.display = 'none'; + } + } + + // Conditionally show/hide discount row + const discountRow = document.getElementById('discountRow'); + if (discountRow) { + if (totals.discount > 0) { + discountRow.style.display = ''; + setText('displayDiscount', formatCurrency(totals.discount, settings.currency, settings.locale)); + } else { + discountRow.style.display = 'none'; + } + } + + setText('displayBalanceDue', formatCurrency(totals.balanceDue, settings.currency, settings.locale)); + + setText('displayNotesHeading', invoice.notes || 'Thank you for your business'); + setText('displayInstructions', invoice.instructions || ''); + + document.getElementById('invoicePlaceholder').style.display = 'none'; + document.getElementById('invoice').style.display = 'block'; + document.getElementById('downloadBtn').style.display = 'inline-block'; + document.getElementById('shareBtn').style.display = 'inline-block'; +} diff --git a/src/js/modules/items.js b/src/js/modules/items.js new file mode 100644 index 0000000..7673eb8 --- /dev/null +++ b/src/js/modules/items.js @@ -0,0 +1,114 @@ +/** + * Items Module + * Line items form management + */ + +import { parseNumber, formatCurrency } from './formatters.js'; + +/** + * Ensure at least one item row exists + * @param {Function} addItemRowFn - Function to add an item row + */ +export function ensureAtLeastOneItemRow(addItemRowFn) { + const container = document.getElementById('itemsContainer'); + if (!container || container.querySelectorAll('.item-row').length > 0) return; + addItemRowFn({ + description: '', + quantity: 1, + unitPrice: 0 + }); +} + +/** + * Add an item row to the form + * @param {Object} item - Item data (description, quantity, unitPrice) + */ +export function addItemRow(item = {}) { + const container = document.getElementById('itemsContainer'); + if (!container) return; + + const row = document.createElement('div'); + row.className = 'item-row'; + + const descInput = document.createElement('input'); + descInput.type = 'text'; + descInput.className = 'item-description'; + descInput.placeholder = 'Description'; + descInput.value = item.description ?? ''; + + const qtyInput = document.createElement('input'); + qtyInput.type = 'number'; + qtyInput.className = 'item-quantity'; + qtyInput.min = '0'; + qtyInput.step = '1'; + qtyInput.value = item.quantity ?? 1; + + const priceInput = document.createElement('input'); + priceInput.type = 'number'; + priceInput.className = 'item-unit-price'; + priceInput.min = '0'; + priceInput.step = '0.01'; + priceInput.value = item.unitPrice ?? 0; + + const totalSpan = document.createElement('span'); + totalSpan.className = 'item-total'; + totalSpan.textContent = '0.00'; + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'remove-item'; + removeBtn.title = 'Remove item'; + removeBtn.textContent = '×'; + + row.appendChild(descInput); + row.appendChild(qtyInput); + row.appendChild(priceInput); + row.appendChild(totalSpan); + row.appendChild(removeBtn); + + container.appendChild(row); +} + +/** + * Render items form from data + * @param {Array} items - Array of item objects + */ +export function renderItemsForm(items) { + const container = document.getElementById('itemsContainer'); + if (!container) return; + container.innerHTML = ''; + + if (!Array.isArray(items) || items.length === 0) { + addItemRow(); + return; + } + + items.forEach((item) => addItemRow(item)); +} + +/** + * Collect items data from the form + * @param {Object} settings - Settings with currency and locale + * @returns {Array} Array of item objects with totals + */ +export function collectItemsFromForm(settings) { + const rows = Array.from(document.querySelectorAll('.item-row')); + const items = rows.map((row) => { + const description = row.querySelector('.item-description')?.value?.trim() ?? ''; + const quantity = parseNumber(row.querySelector('.item-quantity')?.value, 0); + const unitPrice = parseNumber(row.querySelector('.item-unit-price')?.value, 0); + const total = quantity * unitPrice; + const totalEl = row.querySelector('.item-total'); + if (totalEl) { + totalEl.textContent = formatCurrency(total, settings.currency, settings.locale); + } + return { + description, + quantity, + unitPrice, + total + }; + }); + + return items; +} diff --git a/src/js/modules/json-io.js b/src/js/modules/json-io.js new file mode 100644 index 0000000..809ecfe --- /dev/null +++ b/src/js/modules/json-io.js @@ -0,0 +1,72 @@ +/** + * JSON Import/Export Module + * Export and import invoice data as JSON + */ + +import { getValue, setValue } from './dom.js'; +import { getDataFromForm, applyDataToForm, syncFromForm } from './form.js'; +import { deepMerge } from './storage.js'; + +/** + * Export current form data to JSON textarea + */ +export function exportJsonToTextarea() { + const data = syncFromForm({ render: false }); + const json = JSON.stringify(data, null, 2); + setValue('jsonData', json); +} + +/** + * Download form data as JSON file + */ +export function downloadJsonFile() { + const data = syncFromForm({ render: false }); + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + const invoiceNumber = data.invoice.number || 'draft'; + anchor.href = url; + anchor.download = `invoice-${invoiceNumber}.json`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + +/** + * Import JSON data and apply to form + * @param {string} rawJson - JSON string to import + * @param {Function} syncCallback - Callback to sync after import + */ +export function importJsonData(rawJson, syncCallback) { + let parsed; + try { + parsed = JSON.parse(rawJson); + } catch (error) { + alert('Invalid JSON. Please check the format and try again.'); + return; + } + + const base = getDataFromForm(); + const merged = deepMerge(base, parsed); + merged.meta = merged.meta || {}; + merged.meta.showInvoice = true; + + applyDataToForm(merged, syncCallback); +} + +/** + * Handle file import from file input + * @param {Event} event - File input change event + * @param {Function} syncCallback - Callback to sync after import + */ +export function handleFileImport(event, syncCallback) { + const file = event.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + importJsonData(reader.result, syncCallback); + }; + reader.readAsText(file); +} diff --git a/src/js/modules/pdf.js b/src/js/modules/pdf.js new file mode 100644 index 0000000..db080b3 --- /dev/null +++ b/src/js/modules/pdf.js @@ -0,0 +1,329 @@ +/** + * PDF Module + * PDF generation and sharing functionality + */ + +import { getValue } from './dom.js'; +import { getDataFromForm } from './form.js'; + +/** + * Generate PDF and download it + */ +export async function downloadPDF() { + const { jsPDF } = window.jspdf; + const invoiceElement = document.getElementById('invoice'); + const downloadBtn = document.getElementById('downloadBtn'); + + // Show loading state + const originalBtnText = downloadBtn.textContent; + downloadBtn.textContent = 'Generating...'; + downloadBtn.disabled = true; + + // Get invoice data for metadata + const data = getDataFromForm(); + const customerName = data.billTo?.name?.replace(/[^a-zA-Z0-9\s]/g, '').trim().replace(/\s+/g, '-') || 'Customer'; + const invoiceNumber = data.invoice?.number || 'draft'; + const filename = `Invoice-${invoiceNumber}-${customerName}.pdf`; + + // Store original styles to restore later + const originalStyles = { + formDisplay: document.querySelector('.form-section').style.display, + invoiceHeight: invoiceElement.style.height, + invoiceOverflow: invoiceElement.style.overflow + }; + + try { + // Prepare invoice for capture + document.querySelector('.form-section').style.display = 'none'; + invoiceElement.style.height = 'auto'; + invoiceElement.style.overflow = 'visible'; + document.body.classList.add('pdf-generation'); + + // Add print-optimized styles + const printStyles = document.createElement('style'); + printStyles.id = 'pdf-print-styles'; + printStyles.textContent = ` + .pdf-generation .invoice-section { + background: white !important; + box-shadow: none !important; + padding: 40px !important; + margin: 0 !important; + width: 794px !important; + min-height: auto !important; + height: auto !important; + overflow: visible !important; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important; + } + .pdf-generation .invoice-header { margin-bottom: 30px !important; } + .pdf-generation .business-info h1 { font-size: 18px !important; font-weight: 600 !important; } + .pdf-generation .business-info p { font-size: 11px !important; line-height: 1.5 !important; } + .pdf-generation .invoice-title h1 { font-size: 28px !important; font-weight: 700 !important; } + .pdf-generation .invoice-table { font-size: 11px !important; } + .pdf-generation .invoice-table th, + .pdf-generation .invoice-table td { padding: 10px !important; } + .pdf-generation .invoice-table th { font-size: 10px !important; font-weight: 600 !important; } + /* Tabular figures for number alignment */ + .pdf-generation .invoice-table td:nth-child(2), + .pdf-generation .invoice-table td:nth-child(3), + .pdf-generation .invoice-table td:nth-child(4), + .pdf-generation .invoice-totals span, + .pdf-generation .balance-due span { + font-variant-numeric: tabular-nums !important; + } + `; + document.head.appendChild(printStyles); + + // Wait for styles to apply + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + + // Capture with high DPI for crisp output + const canvas = await html2canvas(invoiceElement, { + scale: 3, // Higher scale for better quality + useCORS: true, + backgroundColor: '#ffffff', + logging: false, + windowWidth: 794, // A4 width at 96 DPI + onclone: (clonedDoc) => { + // Ensure cloned element is fully visible + const clonedInvoice = clonedDoc.getElementById('invoice'); + if (clonedInvoice) { + clonedInvoice.style.height = 'auto'; + clonedInvoice.style.overflow = 'visible'; + } + } + }); + + // Create PDF with proper A4 dimensions + const pdf = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: 'a4', + compress: true + }); + + // Set PDF metadata + pdf.setProperties({ + title: `Invoice ${invoiceNumber}`, + subject: `Invoice for ${data.billTo?.name || 'Customer'}`, + author: data.seller?.name || 'Rebill', + creator: 'Rebill - Invoice Generator (rebill.mrjl.dev)', + keywords: 'invoice, receipt, billing' + }); + + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const margin = 10; + const contentWidth = pageWidth - (margin * 2); + + // Calculate image dimensions maintaining aspect ratio + const imgWidth = contentWidth; + const imgHeight = (canvas.height * imgWidth) / canvas.width; + + // Handle multi-page if content is taller than one page + const maxHeightPerPage = pageHeight - (margin * 2); + + if (imgHeight <= maxHeightPerPage) { + // Single page - center vertically if short + const yOffset = margin; + pdf.addImage(canvas.toDataURL('image/png', 1.0), 'PNG', margin, yOffset, imgWidth, imgHeight); + } else { + // Multi-page handling for long invoices + let remainingHeight = imgHeight; + let sourceY = 0; + let pageNum = 0; + + while (remainingHeight > 0) { + if (pageNum > 0) pdf.addPage(); + + const sliceHeight = Math.min(remainingHeight, maxHeightPerPage); + const sourceSliceHeight = (sliceHeight / imgHeight) * canvas.height; + + // Create a slice canvas for this page + const sliceCanvas = document.createElement('canvas'); + sliceCanvas.width = canvas.width; + sliceCanvas.height = sourceSliceHeight; + const ctx = sliceCanvas.getContext('2d'); + ctx.drawImage(canvas, 0, sourceY, canvas.width, sourceSliceHeight, 0, 0, canvas.width, sourceSliceHeight); + + pdf.addImage(sliceCanvas.toDataURL('image/png', 1.0), 'PNG', margin, margin, imgWidth, sliceHeight); + + sourceY += sourceSliceHeight; + remainingHeight -= sliceHeight; + pageNum++; + } + } + + pdf.save(filename); + + // Remove print styles + document.head.removeChild(printStyles); + + } catch (error) { + console.error('Error generating PDF:', error); + alert('Error generating PDF. Please try again.'); + } finally { + // Restore original styles + document.querySelector('.form-section').style.display = originalStyles.formDisplay; + invoiceElement.style.height = originalStyles.invoiceHeight; + invoiceElement.style.overflow = originalStyles.invoiceOverflow; + document.body.classList.remove('pdf-generation'); + + // Remove print styles if still present + const printStyles = document.getElementById('pdf-print-styles'); + if (printStyles) printStyles.remove(); + + // Restore button + downloadBtn.textContent = originalBtnText; + downloadBtn.disabled = false; + } +} + +/** + * Generate PDF as Blob for sharing + * @returns {Promise<{blob: Blob, filename: string}>} PDF blob and filename + */ +export async function generatePDFBlob() { + const { jsPDF } = window.jspdf; + const invoiceElement = document.getElementById('invoice'); + + const data = getDataFromForm(); + const customerName = data.billTo?.name?.replace(/[^a-zA-Z0-9\s]/g, '').trim().replace(/\s+/g, '-') || 'Customer'; + const invoiceNumber = data.invoice?.number || 'draft'; + const filename = `Invoice-${invoiceNumber}-${customerName}.pdf`; + + // Store original styles + const originalStyles = { + formDisplay: document.querySelector('.form-section').style.display, + invoiceHeight: invoiceElement.style.height, + invoiceOverflow: invoiceElement.style.overflow + }; + + try { + // Prepare invoice for capture + document.querySelector('.form-section').style.display = 'none'; + invoiceElement.style.height = 'auto'; + invoiceElement.style.overflow = 'visible'; + document.body.classList.add('pdf-generation'); + + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + + // Capture with html2canvas + const canvas = await html2canvas(invoiceElement, { + scale: 3, + useCORS: true, + backgroundColor: '#ffffff', + logging: false, + windowWidth: 794, + onclone: (clonedDoc) => { + const clonedInvoice = clonedDoc.getElementById('invoice'); + if (clonedInvoice) { + clonedInvoice.style.height = 'auto'; + clonedInvoice.style.overflow = 'visible'; + } + } + }); + + const pdf = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: 'a4', + compress: true + }); + + pdf.setProperties({ + title: `Invoice ${invoiceNumber}`, + subject: `Invoice for ${data.billTo?.name || 'Customer'}`, + author: data.seller?.name || 'Rebill', + creator: 'Rebill - Invoice Generator', + keywords: 'invoice, receipt, billing' + }); + + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const margin = 10; + const contentWidth = pageWidth - (margin * 2); + const imgWidth = contentWidth; + const imgHeight = (canvas.height * imgWidth) / canvas.width; + const maxHeightPerPage = pageHeight - (margin * 2); + + if (imgHeight <= maxHeightPerPage) { + pdf.addImage(canvas.toDataURL('image/png', 1.0), 'PNG', margin, margin, imgWidth, imgHeight); + } else { + let remainingHeight = imgHeight; + let sourceY = 0; + let pageNum = 0; + + while (remainingHeight > 0) { + if (pageNum > 0) pdf.addPage(); + const sliceHeight = Math.min(remainingHeight, maxHeightPerPage); + const sourceSliceHeight = (sliceHeight / imgHeight) * canvas.height; + const sliceCanvas = document.createElement('canvas'); + sliceCanvas.width = canvas.width; + sliceCanvas.height = sourceSliceHeight; + const ctx = sliceCanvas.getContext('2d'); + ctx.drawImage(canvas, 0, sourceY, canvas.width, sourceSliceHeight, 0, 0, canvas.width, sourceSliceHeight); + pdf.addImage(sliceCanvas.toDataURL('image/png', 1.0), 'PNG', margin, margin, imgWidth, sliceHeight); + sourceY += sourceSliceHeight; + remainingHeight -= sliceHeight; + pageNum++; + } + } + + return { blob: pdf.output('blob'), filename }; + } finally { + // Restore original styles + document.querySelector('.form-section').style.display = originalStyles.formDisplay; + invoiceElement.style.height = originalStyles.invoiceHeight; + invoiceElement.style.overflow = originalStyles.invoiceOverflow; + document.body.classList.remove('pdf-generation'); + } +} + +/** + * Share invoice using Web Share API + */ +export async function shareInvoice() { + const shareBtn = document.getElementById('shareBtn'); + if (shareBtn) { + shareBtn.textContent = 'Preparing...'; + shareBtn.disabled = true; + } + + try { + const { blob, filename } = await generatePDFBlob(); + const file = new File([blob], filename, { type: 'application/pdf' }); + + if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) { + await navigator.share({ + files: [file], + title: filename.replace('.pdf', ''), + text: 'Invoice generated with Rebill' + }); + } else { + // Fallback: download the file + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + + // Show a message that sharing isn't available + if (!navigator.share) { + console.log('Web Share API not available, downloaded file instead'); + } + } + } catch (error) { + if (error.name !== 'AbortError') { + console.error('Error sharing invoice:', error); + alert('Error sharing invoice. Please try downloading instead.'); + } + } finally { + if (shareBtn) { + shareBtn.textContent = '📤 Share'; + shareBtn.disabled = false; + } + } +} diff --git a/src/js/modules/storage.js b/src/js/modules/storage.js new file mode 100644 index 0000000..74e3120 --- /dev/null +++ b/src/js/modules/storage.js @@ -0,0 +1,82 @@ +/** + * Storage Module + * Draft save/load and deep merge utilities + */ + +const STORAGE_KEY = 'invoice.draft.v1'; + +/** + * Save draft data to localStorage + * @param {Object} data - Invoice data to save + */ +export function saveDraft(data) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch (e) { + console.warn('Failed to save draft:', e); + } +} + +/** + * Load draft data from localStorage + * @returns {Object|null} Saved draft or null + */ +export function loadDraft() { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch (error) { + return null; + } +} + +/** + * Clear the saved draft + */ +export function clearDraft() { + localStorage.removeItem(STORAGE_KEY); +} + +/** + * Deep merge source object into target + * Includes protection against prototype pollution + * @param {Object} target - Target object + * @param {Object} source - Source object to merge + * @returns {Object} Merged target object + */ +export function deepMerge(target, source) { + if (!source || typeof source !== 'object') return target; + + // Dangerous keys that could lead to prototype pollution + const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype']; + + function hasUnsafeKeys(obj, visited = new Set()) { + if (!obj || typeof obj !== 'object' || visited.has(obj)) return false; + visited.add(obj); + return UNSAFE_KEYS.some(k => k in obj) || + Object.values(obj).some(v => v && typeof v === 'object' && hasUnsafeKeys(v, visited)); + } + + Object.keys(source).forEach((key) => { + // Skip prototype pollution vectors + if (UNSAFE_KEYS.includes(key)) return; + if (!Object.prototype.hasOwnProperty.call(source, key)) return; + + const value = source[key]; + if (Array.isArray(value)) { + target[key] = value.slice(); + } else if (value && typeof value === 'object') { + if (hasUnsafeKeys(value)) return; + if (!target[key] || typeof target[key] !== 'object') { + target[key] = {}; + } + deepMerge(target[key], value); + } else { + target[key] = value; + } + }); + return target; +} + +export { STORAGE_KEY }; diff --git a/styles.css b/styles.css index c247254..3a68851 100644 --- a/styles.css +++ b/styles.css @@ -731,7 +731,19 @@ button:focus-visible { padding: var(--space-10); height: 100vh; overflow-y: auto; - font-family: 'Inter', Arial, sans-serif; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Tabular figures for proper number alignment in invoice */ +.invoice-table td:nth-child(2), +.invoice-table td:nth-child(3), +.invoice-table td:nth-child(4), +.invoice-totals span, +.balance-due span { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum"; } .invoice-header { diff --git a/tests/rebill.spec.js b/tests/rebill.spec.js new file mode 100644 index 0000000..44b93af --- /dev/null +++ b/tests/rebill.spec.js @@ -0,0 +1,271 @@ +// @ts-check +import { test, expect } from '@playwright/test'; + +/** + * Rebill Invoice Generator - E2E Tests + * + * These tests verify the core functionality of the invoice generator, + * with special focus on PDF generation and layout correctness. + */ + +// Test data fixtures +const testData = { + seller: { + name: 'Test Company Ltd', + address: '123 Business Park, Tech City 12345', + email: 'billing@testcompany.com', + phone: '+91 9876543210' + }, + customer: { + name: 'Customer Corp', + address: '456 Client Street, Commerce Town 67890', + email: 'accounts@customercorp.com', + phone: '+91 9876543211' + }, + invoice: { + number: '001', + title: 'INVOICE' + }, + items: [ + { description: 'Web Development Services', quantity: 1, price: 50000 }, + { description: 'UI/UX Design', quantity: 2, price: 15000 } + ] +}; + +test.describe('Invoice Form', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + // Clear any stored draft + await page.evaluate(() => localStorage.clear()); + await page.reload(); + }); + + test('should load the homepage', async ({ page }) => { + await expect(page).toHaveTitle(/Rebill/); + await expect(page.locator('.form-section')).toBeVisible(); + }); + + test('should fill seller information', async ({ page }) => { + await page.fill('#sellerName', testData.seller.name); + await page.fill('#sellerAddress', testData.seller.address); + await page.fill('#sellerEmail', testData.seller.email); + await page.fill('#sellerPhone', testData.seller.phone); + + await expect(page.locator('#sellerName')).toHaveValue(testData.seller.name); + await expect(page.locator('#sellerAddress')).toHaveValue(testData.seller.address); + }); + + test('should add line items', async ({ page }) => { + // Fill first item + await page.fill('.item-description', testData.items[0].description); + await page.fill('.item-quantity', String(testData.items[0].quantity)); + await page.fill('.item-unit-price', String(testData.items[0].price)); + + // Add second item + await page.click('#addItemBtn'); + const itemRows = page.locator('.item-row'); + await expect(itemRows).toHaveCount(2); + }); + + test('should calculate totals correctly', async ({ page }) => { + // Fill one item + await page.fill('.item-description', 'Test Service'); + await page.fill('.item-quantity', '2'); + await page.fill('.item-unit-price', '1000'); + + // Wait for calculation + await page.waitForTimeout(100); + + // Check item total + const itemTotal = page.locator('.item-total').first(); + await expect(itemTotal).toContainText('2,000'); + }); +}); + +test.describe('Invoice Generation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + }); + + test('should generate invoice preview', async ({ page }) => { + // Fill required fields + await page.fill('#sellerName', testData.seller.name); + await page.fill('#sellerAddress', testData.seller.address); + await page.fill('#billToName', testData.customer.name); + await page.fill('#billToAddress', testData.customer.address); + await page.fill('#invoiceNumber', testData.invoice.number); + + // Fill item + await page.fill('.item-description', testData.items[0].description); + await page.fill('.item-quantity', String(testData.items[0].quantity)); + await page.fill('.item-unit-price', String(testData.items[0].price)); + + // Generate invoice + await page.click('button:has-text("Generate Bill")'); + + // Verify preview is visible + await expect(page.locator('#invoice')).toBeVisible(); + await expect(page.locator('#downloadBtn')).toBeVisible(); + }); + + test('should show validation alert for incomplete form', async ({ page }) => { + // Try to generate without filling required fields + page.on('dialog', async dialog => { + expect(dialog.message()).toContain('required fields'); + await dialog.accept(); + }); + + await page.click('button:has-text("Generate Bill")'); + }); +}); + +test.describe('PDF Generation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + }); + + test('should download PDF without errors', async ({ page }) => { + // Fill complete invoice + await page.fill('#sellerName', testData.seller.name); + await page.fill('#sellerAddress', testData.seller.address); + await page.fill('#sellerEmail', testData.seller.email); + await page.fill('#billToName', testData.customer.name); + await page.fill('#billToAddress', testData.customer.address); + await page.fill('#invoiceNumber', testData.invoice.number); + await page.fill('.item-description', testData.items[0].description); + await page.fill('.item-quantity', String(testData.items[0].quantity)); + await page.fill('.item-unit-price', String(testData.items[0].price)); + + // Generate invoice + await page.click('button:has-text("Generate Bill")'); + await expect(page.locator('#invoice')).toBeVisible(); + + // Start waiting for download before clicking + const downloadPromise = page.waitForEvent('download'); + await page.click('#downloadBtn'); + const download = await downloadPromise; + + // Verify download + expect(download.suggestedFilename()).toMatch(/Invoice-.*\.pdf/); + + // Verify file is not empty + const path = await download.path(); + const fs = await import('fs'); + const stats = fs.statSync(path); + expect(stats.size).toBeGreaterThan(10000); // PDF should be > 10KB + }); + + test('invoice preview should not have horizontal cutoff', async ({ page }) => { + // Fill complete invoice + await page.fill('#sellerName', testData.seller.name); + await page.fill('#sellerAddress', testData.seller.address); + await page.fill('#billToName', testData.customer.name); + await page.fill('#billToAddress', testData.customer.address); + await page.fill('#invoiceNumber', testData.invoice.number); + await page.fill('.item-description', testData.items[0].description); + await page.fill('.item-quantity', String(testData.items[0].quantity)); + await page.fill('.item-unit-price', String(testData.items[0].price)); + + // Generate invoice + await page.click('button:has-text("Generate Bill")'); + await expect(page.locator('#invoice')).toBeVisible(); + + // Verify key elements are fully visible (this catches horizontal cutoff) + const invoiceTitle = page.locator('.invoice-title h1'); + await expect(invoiceTitle).toHaveText('INVOICE'); + await expect(invoiceTitle).toBeVisible(); + + // Verify seller name is visible + const sellerName = page.locator('#displaySellerName'); + await expect(sellerName).toHaveText(testData.seller.name); + + // Verify customer name is visible + const customerName = page.locator('#displayBillToName'); + await expect(customerName).toHaveText(testData.customer.name); + + // Verify invoice number is visible + const invoiceNumber = page.locator('#displayInvoiceNumber'); + await expect(invoiceNumber).toHaveText(testData.invoice.number); + + // Verify balance due is visible (right-side element - catches horizontal cutoff) + const balanceDue = page.locator('#displayBalanceDue'); + await expect(balanceDue).toBeVisible(); + await expect(balanceDue).toContainText('₹'); + }); +}); + +test.describe('History Management', () => { + test('should save invoice to history', async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + + // Fill and generate invoice + await page.fill('#sellerName', testData.seller.name); + await page.fill('#sellerAddress', testData.seller.address); + await page.fill('#billToName', testData.customer.name); + await page.fill('#billToAddress', testData.customer.address); + await page.fill('#invoiceNumber', '100'); + await page.fill('.item-description', 'Test Item'); + await page.fill('.item-quantity', '1'); + await page.fill('.item-unit-price', '1000'); + + await page.click('button:has-text("Generate Bill")'); + + // Open history panel + await page.click('#historyToggle'); + await expect(page.locator('#historyPanel')).toBeVisible(); + + // Verify entry exists + await expect(page.locator('.history-item')).toHaveCount(1); + await expect(page.locator('.history-item-title')).toContainText('#100'); + }); +}); + +test.describe('JSON Import/Export', () => { + test('should export and import JSON', async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + + // Fill invoice data + await page.fill('#sellerName', testData.seller.name); + await page.fill('#invoiceNumber', '200'); + + // Export JSON + await page.click('#exportJsonBtn'); + const jsonData = await page.locator('#jsonData').inputValue(); + expect(jsonData).toContain(testData.seller.name); + expect(jsonData).toContain('200'); + + // Parse and verify JSON structure + const parsed = JSON.parse(jsonData); + expect(parsed.seller.name).toBe(testData.seller.name); + expect(parsed.invoice.number).toBe('200'); + }); +}); + +test.describe('Reset Draft', () => { + test('should clear form on reset', async ({ page }) => { + await page.goto('/'); + + // Fill some data + await page.fill('#sellerName', testData.seller.name); + await page.fill('#billToName', testData.customer.name); + + // Click reset and accept dialog + page.on('dialog', async dialog => { + await dialog.accept(); + }); + + await page.click('#resetDraftBtn'); + + // Verify fields are cleared + await expect(page.locator('#sellerName')).toHaveValue(''); + await expect(page.locator('#billToName')).toHaveValue(''); + }); +}); diff --git a/tests/rebill.spec.js-snapshots/invoice-preview-chromium-darwin.png b/tests/rebill.spec.js-snapshots/invoice-preview-chromium-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..c1e26b095b3d856c5cfa0f5ef84110e2988f0da7 GIT binary patch literal 41717 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

JhdKl;gEWU$X=GAGP?$AuCrTxTH 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 literal 0 HcmV?d00001 diff --git a/tests/rebill.spec.js-snapshots/invoice-preview-firefox-darwin.png b/tests/rebill.spec.js-snapshots/invoice-preview-firefox-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..5d6e0c3a3f372a842e1bd9321db6142126c5cc94 GIT binary patch literal 54253 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%0A=#Qnv;RbUJuKmK)F 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 literal 0 HcmV?d00001 diff --git a/tests/rebill.spec.js-snapshots/invoice-preview-webkit-darwin.png b/tests/rebill.spec.js-snapshots/invoice-preview-webkit-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..eb42e178461c0045ac1f09e7055fa52078f33d3f GIT binary patch literal 51597 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 literal 0 HcmV?d00001 From df7e7de2c77f30f89f3a9c3a4398150580467996 Mon Sep 17 00:00:00 2001 From: maheshrijal <62394512+maheshrijal@users.noreply.github.com> Date: Fri, 2 Jan 2026 14:42:16 +0530 Subject: [PATCH 02/17] fix(pdf): improve table layout and alignment - Reduce table border thickness from 1px to 0.5px - Reduce cell padding and margins for more compact rows - Remove table indent columns for proper margin alignment - Align totals section with table right edge --- index.html | 4 +- package-lock.json | 232 ++++++++++++++++++- package.json | 5 +- src/js/modules/pdf.js | 523 +++++++++++++++++++++--------------------- tests/rebill.spec.js | 20 +- 5 files changed, 509 insertions(+), 275 deletions(-) diff --git a/index.html b/index.html index b7616e6..f938cb0 100644 --- a/index.html +++ b/index.html @@ -33,8 +33,8 @@ - - + + diff --git a/package-lock.json b/package-lock.json index ff8984f..edf70c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,203 @@ "version": "1.0.0", "license": "MIT", "devDependencies": { - "@playwright/test": "^1.40.0" + "@playwright/test": "^1.40.0", + "pdf-parse": "^2.4.5" + } + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", + "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", + "dev": true, + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.80", + "@napi-rs/canvas-darwin-arm64": "0.1.80", + "@napi-rs/canvas-darwin-x64": "0.1.80", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", + "@napi-rs/canvas-linux-arm64-musl": "0.1.80", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-musl": "0.1.80", + "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", + "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", + "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", + "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", + "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", + "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", + "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", + "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", + "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", + "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" } }, "node_modules/@playwright/test": { @@ -43,6 +239,40 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/pdf-parse": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", + "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@napi-rs/canvas": "0.1.80", + "pdfjs-dist": "5.4.296" + }, + "bin": { + "pdf-parse": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.16.0 <21 || >=22.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mehmet-kozan" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/playwright": { "version": "1.57.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", diff --git a/package.json b/package.json index 4221912..949a3c4 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "author": "Mahesh Rijal", "license": "MIT", "devDependencies": { - "@playwright/test": "^1.40.0" + "@playwright/test": "^1.40.0", + "pdf-parse": "^2.4.5" } -} \ No newline at end of file +} diff --git a/src/js/modules/pdf.js b/src/js/modules/pdf.js index db080b3..2bfd0ec 100644 --- a/src/js/modules/pdf.js +++ b/src/js/modules/pdf.js @@ -1,281 +1,266 @@ /** * PDF Module - * PDF generation and sharing functionality + * PDF generation and sharing functionality using pdfmake */ -import { getValue } from './dom.js'; import { getDataFromForm } from './form.js'; +import { formatCurrency, formatDate } from './formatters.js'; /** - * Generate PDF and download it + * Generate PDF Document Definition from data + * @param {Object} data - Invoice data + * @returns {Object} pdfmake document definition */ -export async function downloadPDF() { - const { jsPDF } = window.jspdf; - const invoiceElement = document.getElementById('invoice'); - const downloadBtn = document.getElementById('downloadBtn'); - - // Show loading state - const originalBtnText = downloadBtn.textContent; - downloadBtn.textContent = 'Generating...'; - downloadBtn.disabled = true; - - // Get invoice data for metadata - const data = getDataFromForm(); - const customerName = data.billTo?.name?.replace(/[^a-zA-Z0-9\s]/g, '').trim().replace(/\s+/g, '-') || 'Customer'; - const invoiceNumber = data.invoice?.number || 'draft'; - const filename = `Invoice-${invoiceNumber}-${customerName}.pdf`; - - // Store original styles to restore later - const originalStyles = { - formDisplay: document.querySelector('.form-section').style.display, - invoiceHeight: invoiceElement.style.height, - invoiceOverflow: invoiceElement.style.overflow - }; - - try { - // Prepare invoice for capture - document.querySelector('.form-section').style.display = 'none'; - invoiceElement.style.height = 'auto'; - invoiceElement.style.overflow = 'visible'; - document.body.classList.add('pdf-generation'); - - // Add print-optimized styles - const printStyles = document.createElement('style'); - printStyles.id = 'pdf-print-styles'; - printStyles.textContent = ` - .pdf-generation .invoice-section { - background: white !important; - box-shadow: none !important; - padding: 40px !important; - margin: 0 !important; - width: 794px !important; - min-height: auto !important; - height: auto !important; - overflow: visible !important; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important; - } - .pdf-generation .invoice-header { margin-bottom: 30px !important; } - .pdf-generation .business-info h1 { font-size: 18px !important; font-weight: 600 !important; } - .pdf-generation .business-info p { font-size: 11px !important; line-height: 1.5 !important; } - .pdf-generation .invoice-title h1 { font-size: 28px !important; font-weight: 700 !important; } - .pdf-generation .invoice-table { font-size: 11px !important; } - .pdf-generation .invoice-table th, - .pdf-generation .invoice-table td { padding: 10px !important; } - .pdf-generation .invoice-table th { font-size: 10px !important; font-weight: 600 !important; } - /* Tabular figures for number alignment */ - .pdf-generation .invoice-table td:nth-child(2), - .pdf-generation .invoice-table td:nth-child(3), - .pdf-generation .invoice-table td:nth-child(4), - .pdf-generation .invoice-totals span, - .pdf-generation .balance-due span { - font-variant-numeric: tabular-nums !important; - } - `; - document.head.appendChild(printStyles); - - // Wait for styles to apply - await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); - - // Capture with high DPI for crisp output - const canvas = await html2canvas(invoiceElement, { - scale: 3, // Higher scale for better quality - useCORS: true, - backgroundColor: '#ffffff', - logging: false, - windowWidth: 794, // A4 width at 96 DPI - onclone: (clonedDoc) => { - // Ensure cloned element is fully visible - const clonedInvoice = clonedDoc.getElementById('invoice'); - if (clonedInvoice) { - clonedInvoice.style.height = 'auto'; - clonedInvoice.style.overflow = 'visible'; - } - } - }); - - // Create PDF with proper A4 dimensions - const pdf = new jsPDF({ - orientation: 'portrait', - unit: 'mm', - format: 'a4', - compress: true - }); - - // Set PDF metadata - pdf.setProperties({ - title: `Invoice ${invoiceNumber}`, - subject: `Invoice for ${data.billTo?.name || 'Customer'}`, - author: data.seller?.name || 'Rebill', - creator: 'Rebill - Invoice Generator (rebill.mrjl.dev)', - keywords: 'invoice, receipt, billing' - }); - - const pageWidth = pdf.internal.pageSize.getWidth(); - const pageHeight = pdf.internal.pageSize.getHeight(); - const margin = 10; - const contentWidth = pageWidth - (margin * 2); - - // Calculate image dimensions maintaining aspect ratio - const imgWidth = contentWidth; - const imgHeight = (canvas.height * imgWidth) / canvas.width; - - // Handle multi-page if content is taller than one page - const maxHeightPerPage = pageHeight - (margin * 2); - - if (imgHeight <= maxHeightPerPage) { - // Single page - center vertically if short - const yOffset = margin; - pdf.addImage(canvas.toDataURL('image/png', 1.0), 'PNG', margin, yOffset, imgWidth, imgHeight); - } else { - // Multi-page handling for long invoices - let remainingHeight = imgHeight; - let sourceY = 0; - let pageNum = 0; +function generateDocumentDefinition(data) { + const { seller, billTo, invoice, items, totals, settings } = data; + const currency = settings.currency; + const locale = settings.locale; + + // Helper to format currency + const fmt = (amount) => formatCurrency(amount, currency, locale); + + // Prepare table body + const tableBody = []; + + // Header + tableBody.push([ + { text: 'DESCRIPTION', style: 'tableHeader' }, + { text: 'QTY', style: 'tableHeader', alignment: 'center' }, + { text: 'UNIT PRICE', style: 'tableHeader', alignment: 'right' }, + { text: 'TOTAL', style: 'tableHeader', alignment: 'right' } + ]); + + // Items + items.forEach(item => { + tableBody.push([ + { text: item.description || '', style: 'tableCell' }, + { text: item.quantity, style: 'tableCell', alignment: 'center' }, + { text: fmt(item.unitPrice), style: 'tableCell', alignment: 'right' }, + { text: fmt(item.total), style: 'tableCell', alignment: 'right' } + ]); + }); + + // Totals section (rows) + const totalsRows = []; + + // Only show Subtotal if there are adjustments (tax or discount) + if (totals.taxAmount > 0 || totals.discount > 0) { + totalsRows.push([ + { text: 'Subtotal', alignment: 'right' }, + { text: fmt(totals.subtotal), alignment: 'right' } + ]); + + if (totals.taxAmount > 0) { + totalsRows.push([ + { text: `Tax (${totals.taxRate}%)`, alignment: 'right' }, + { text: fmt(totals.taxAmount), alignment: 'right' } + ]); + } - while (remainingHeight > 0) { - if (pageNum > 0) pdf.addPage(); + if (totals.discount > 0) { + totalsRows.push([ + { text: 'Discount', alignment: 'right' }, + { text: `-${fmt(totals.discount)}`, alignment: 'right' } + ]); + } + } - const sliceHeight = Math.min(remainingHeight, maxHeightPerPage); - const sourceSliceHeight = (sliceHeight / imgHeight) * canvas.height; + // Total Row + totalsRows.push([ + { text: 'Total', bold: true, fontSize: 12, alignment: 'right' }, + { text: fmt(totals.total), bold: true, fontSize: 12, alignment: 'right' } + ]); - // Create a slice canvas for this page - const sliceCanvas = document.createElement('canvas'); - sliceCanvas.width = canvas.width; - sliceCanvas.height = sourceSliceHeight; - const ctx = sliceCanvas.getContext('2d'); - ctx.drawImage(canvas, 0, sourceY, canvas.width, sourceSliceHeight, 0, 0, canvas.width, sourceSliceHeight); + const invoiceNumber = invoice.number || 'draft'; + const customerName = billTo.name || 'Customer'; - pdf.addImage(sliceCanvas.toDataURL('image/png', 1.0), 'PNG', margin, margin, imgWidth, sliceHeight); + return { + // Moderate margins: [left, top, right, bottom] + pageMargins: [30, 40, 30, 40], - sourceY += sourceSliceHeight; - remainingHeight -= sliceHeight; - pageNum++; + info: { + title: `Invoice ${invoiceNumber}`, + author: seller.name || 'Rebill', + subject: `Invoice for ${customerName}`, + creator: 'Rebill - Invoice Generator' + }, + content: [ + // Header Section + { + columns: [ + // Seller Info (Left) + { + width: '*', + stack: [ + { text: seller.name || 'Business Name', style: 'businessName' }, + { text: seller.address || '', style: 'normalText' }, + seller.email ? { text: seller.email, style: 'normalText' } : null, + seller.phone ? { text: seller.phone, style: 'normalText' } : null + ].filter(Boolean) + }, + // Invoice Title & Meta (Right) + { + width: 'auto', + alignment: 'right', + stack: [ + { text: invoice.title || 'INVOICE', style: 'invoiceTitle' }, + { + text: [ + { text: 'Invoice #: ', bold: true }, + invoiceNumber + ], + style: 'metaText' + }, + { + text: [ + { text: 'Date: ', bold: true }, + formatDate(invoice.date) + ], + style: 'metaText' + }, + invoice.dueDate ? { + text: [ + { text: 'Due Date: ', bold: true }, + formatDate(invoice.dueDate) + ], + style: 'metaText' + } : null + ].filter(Boolean) + } + ], + columnGap: 20, + marginBottom: 40 + }, + + // Bill To Section + { + text: 'BILL TO', + style: 'sectionLabel', + marginBottom: 5 + }, + { + stack: [ + { text: customerName, style: 'customerName' }, + { text: billTo.address || '', style: 'normalText' }, + billTo.email ? { text: billTo.email, style: 'normalText' } : null, + billTo.phone ? { text: billTo.phone, style: 'normalText' } : null + ].filter(Boolean), + marginBottom: 40 + }, + + // Items Table + { + table: { + headerRows: 1, + widths: ['*', 50, 80, 80], + body: tableBody + }, + layout: { + hLineWidth: (i, node) => 0.5, + vLineWidth: (i, node) => 0.5, + hLineColor: (i, node) => '#E5E7EB', + vLineColor: (i, node) => '#E5E7EB', + paddingLeft: (i) => 8, + paddingRight: (i) => 8, + paddingTop: (i) => 4, + paddingBottom: (i) => 4 + }, + marginBottom: 20 + }, + + // Totals + { + columns: [ + { width: '*', text: '' }, // Spacer + { + width: 'auto', + table: { + widths: ['auto', 100], + body: totalsRows + }, + layout: 'noBorders' + } + ], + marginBottom: 40 + }, + + // Footer Section: Center aligned, standard font size, bold "Thank you" + { + stack: [ + // Notes (Thank you message) + invoice.notes ? { + text: invoice.notes, + style: 'footerNote', + alignment: 'center', + marginBottom: 10 + } : null, + + // Instructions (Bank details etc) + invoice.instructions ? { + text: invoice.instructions, + style: 'footerText', + alignment: 'center' + } : null + ].filter(Boolean) } + ].filter(Boolean), + + styles: { + businessName: { fontSize: 18, bold: true, marginBottom: 4, color: '#111827' }, + invoiceTitle: { fontSize: 24, bold: true, marginBottom: 8, color: '#111827' }, + sectionLabel: { fontSize: 10, bold: true, color: '#6B7280', letterSpacing: 1, marginBottom: 4 }, + customerName: { fontSize: 14, bold: true, marginBottom: 4, color: '#111827' }, + normalText: { fontSize: 10, lineHeight: 1.4, color: '#4B5563' }, + metaText: { fontSize: 10, marginBottom: 4, alignment: 'right', color: '#4B5563' }, + + // Table styles + tableHeader: { fontSize: 10, bold: true, color: '#111827', fillColor: '#F3F4F6', margin: [0, 2, 0, 2] }, + tableCell: { fontSize: 10, color: '#374151', margin: [0, 2, 0, 2] }, + + // Footer styles + footerNote: { fontSize: 14, bold: true, color: '#111827' }, + footerText: { fontSize: 10, color: '#6B7280', lineHeight: 1.5 } + }, + defaultStyle: { + font: 'Roboto' } - - pdf.save(filename); - - // Remove print styles - document.head.removeChild(printStyles); - - } catch (error) { - console.error('Error generating PDF:', error); - alert('Error generating PDF. Please try again.'); - } finally { - // Restore original styles - document.querySelector('.form-section').style.display = originalStyles.formDisplay; - invoiceElement.style.height = originalStyles.invoiceHeight; - invoiceElement.style.overflow = originalStyles.invoiceOverflow; - document.body.classList.remove('pdf-generation'); - - // Remove print styles if still present - const printStyles = document.getElementById('pdf-print-styles'); - if (printStyles) printStyles.remove(); - - // Restore button - downloadBtn.textContent = originalBtnText; - downloadBtn.disabled = false; - } + }; } /** - * Generate PDF as Blob for sharing - * @returns {Promise<{blob: Blob, filename: string}>} PDF blob and filename + * Generate PDF and download it */ -export async function generatePDFBlob() { - const { jsPDF } = window.jspdf; - const invoiceElement = document.getElementById('invoice'); - - const data = getDataFromForm(); - const customerName = data.billTo?.name?.replace(/[^a-zA-Z0-9\s]/g, '').trim().replace(/\s+/g, '-') || 'Customer'; - const invoiceNumber = data.invoice?.number || 'draft'; - const filename = `Invoice-${invoiceNumber}-${customerName}.pdf`; - - // Store original styles - const originalStyles = { - formDisplay: document.querySelector('.form-section').style.display, - invoiceHeight: invoiceElement.style.height, - invoiceOverflow: invoiceElement.style.overflow - }; - - try { - // Prepare invoice for capture - document.querySelector('.form-section').style.display = 'none'; - invoiceElement.style.height = 'auto'; - invoiceElement.style.overflow = 'visible'; - document.body.classList.add('pdf-generation'); - - await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); - - // Capture with html2canvas - const canvas = await html2canvas(invoiceElement, { - scale: 3, - useCORS: true, - backgroundColor: '#ffffff', - logging: false, - windowWidth: 794, - onclone: (clonedDoc) => { - const clonedInvoice = clonedDoc.getElementById('invoice'); - if (clonedInvoice) { - clonedInvoice.style.height = 'auto'; - clonedInvoice.style.overflow = 'visible'; - } - } - }); - - const pdf = new jsPDF({ - orientation: 'portrait', - unit: 'mm', - format: 'a4', - compress: true - }); +export async function downloadPDF() { + const downloadBtn = document.getElementById('downloadBtn'); + let originalBtnText = ''; - pdf.setProperties({ - title: `Invoice ${invoiceNumber}`, - subject: `Invoice for ${data.billTo?.name || 'Customer'}`, - author: data.seller?.name || 'Rebill', - creator: 'Rebill - Invoice Generator', - keywords: 'invoice, receipt, billing' - }); + // Show loading state if button exists + if (downloadBtn) { + originalBtnText = downloadBtn.textContent; + downloadBtn.textContent = 'Generating...'; + downloadBtn.disabled = true; + } - const pageWidth = pdf.internal.pageSize.getWidth(); - const pageHeight = pdf.internal.pageSize.getHeight(); - const margin = 10; - const contentWidth = pageWidth - (margin * 2); - const imgWidth = contentWidth; - const imgHeight = (canvas.height * imgWidth) / canvas.width; - const maxHeightPerPage = pageHeight - (margin * 2); + try { + const data = getDataFromForm(); + const docDefinition = generateDocumentDefinition(data); + const invoiceNumber = data.invoice.number || 'draft'; + // Sanitize filename + const safeNumber = invoiceNumber.replace(/[^a-zA-Z0-9-_]/g, '-'); + const filename = `Invoice-${safeNumber}.pdf`; - if (imgHeight <= maxHeightPerPage) { - pdf.addImage(canvas.toDataURL('image/png', 1.0), 'PNG', margin, margin, imgWidth, imgHeight); - } else { - let remainingHeight = imgHeight; - let sourceY = 0; - let pageNum = 0; - - while (remainingHeight > 0) { - if (pageNum > 0) pdf.addPage(); - const sliceHeight = Math.min(remainingHeight, maxHeightPerPage); - const sourceSliceHeight = (sliceHeight / imgHeight) * canvas.height; - const sliceCanvas = document.createElement('canvas'); - sliceCanvas.width = canvas.width; - sliceCanvas.height = sourceSliceHeight; - const ctx = sliceCanvas.getContext('2d'); - ctx.drawImage(canvas, 0, sourceY, canvas.width, sourceSliceHeight, 0, 0, canvas.width, sourceSliceHeight); - pdf.addImage(sliceCanvas.toDataURL('image/png', 1.0), 'PNG', margin, margin, imgWidth, sliceHeight); - sourceY += sourceSliceHeight; - remainingHeight -= sliceHeight; - pageNum++; - } - } + // Create and download + pdfMake.createPdf(docDefinition).download(filename); - return { blob: pdf.output('blob'), filename }; + } catch (error) { + console.error('Error generating PDF:', error); + alert('Error generating PDF. Please try again.'); } finally { - // Restore original styles - document.querySelector('.form-section').style.display = originalStyles.formDisplay; - invoiceElement.style.height = originalStyles.invoiceHeight; - invoiceElement.style.overflow = originalStyles.invoiceOverflow; - document.body.classList.remove('pdf-generation'); + // Restore button if it exists + if (downloadBtn) { + downloadBtn.textContent = originalBtnText; + downloadBtn.disabled = false; + } } } @@ -290,7 +275,21 @@ export async function shareInvoice() { } try { - const { blob, filename } = await generatePDFBlob(); + const data = getDataFromForm(); + const invoiceNumber = data.invoice.number || 'draft'; + const docDefinition = generateDocumentDefinition(data); + + // Sanitize filename + const safeNumber = invoiceNumber.replace(/[^a-zA-Z0-9-_]/g, '-'); + const filename = `Invoice-${safeNumber}.pdf`; + + // Generate Blob + const blob = await new Promise((resolve) => { + pdfMake.createPdf(docDefinition).getBlob((blob) => { + resolve(blob); + }); + }); + const file = new File([blob], filename, { type: 'application/pdf' }); if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) { @@ -300,17 +299,9 @@ export async function shareInvoice() { text: 'Invoice generated with Rebill' }); } else { - // Fallback: download the file - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - - // Show a message that sharing isn't available + // Fallback: download + pdfMake.createPdf(docDefinition).download(filename); + if (!navigator.share) { console.log('Web Share API not available, downloaded file instead'); } diff --git a/tests/rebill.spec.js b/tests/rebill.spec.js index 44b93af..3442ad2 100644 --- a/tests/rebill.spec.js +++ b/tests/rebill.spec.js @@ -128,7 +128,7 @@ test.describe('PDF Generation', () => { await page.reload(); }); - test('should download PDF without errors', async ({ page }) => { + test('should download PDF without errors and contain correct text', async ({ page }) => { // Fill complete invoice await page.fill('#sellerName', testData.seller.name); await page.fill('#sellerAddress', testData.seller.address); @@ -152,11 +152,23 @@ test.describe('PDF Generation', () => { // Verify download expect(download.suggestedFilename()).toMatch(/Invoice-.*\.pdf/); - // Verify file is not empty + // Verify file is not empty and check content const path = await download.path(); const fs = await import('fs'); - const stats = fs.statSync(path); - expect(stats.size).toBeGreaterThan(10000); // PDF should be > 10KB + const pdfBuffer = fs.readFileSync(path); + + // Dynamic import of pdf-parse + const pdfParse = (await import('pdf-parse')).default; + const data = await pdfParse(pdfBuffer); + + // Verify Content + expect(data.text).toContain('DESCRIPTION'); + expect(data.text).toContain('QTY'); + expect(data.text).toContain('UNIT PRICE'); + expect(data.text).toContain('TOTAL'); + expect(data.text).toContain(testData.items[0].description); + expect(data.text).toContain(testData.seller.name); + expect(data.text).toContain("Total"); }); test('invoice preview should not have horizontal cutoff', async ({ page }) => { From 9adfc0244b5fd09eff97d2ca4d57d2cf36e0c30b Mon Sep 17 00:00:00 2001 From: maheshrijal <62394512+maheshrijal@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:00:30 +0530 Subject: [PATCH 03/17] style: update invoice text colors to dark black for table and footer --- index.html | 15 +++-- src/js/modules/invoice.js | 14 +++- src/js/modules/pdf.js | 21 +++++- styles.css | 133 ++++++++++++++++++++++++-------------- 4 files changed, 125 insertions(+), 58 deletions(-) diff --git a/index.html b/index.html index f938cb0..f7b5c38 100644 --- a/index.html +++ b/index.html @@ -260,13 +260,16 @@

BILL TO

-
-

SUBTOTAL

-

TAX

-

DISCOUNT

+
+

Subtotal +

+

Tax

+

Discount +

-
-

Balance Due

+
+ Total +
diff --git a/src/js/modules/invoice.js b/src/js/modules/invoice.js index 0fee987..a042b8a 100644 --- a/src/js/modules/invoice.js +++ b/src/js/modules/invoice.js @@ -76,7 +76,19 @@ export function renderInvoice(data) { }); } - setText('displaySubtotal', formatCurrency(totals.subtotal, settings.currency, settings.locale)); + // Handle totals breakdown visibility (match PDF logic) + const hasAdjustments = totals.taxAmount > 0 || totals.discount > 0; + + // Subtotal row - only show when there are adjustments + const subtotalRow = document.getElementById('subtotalRow'); + if (subtotalRow) { + if (hasAdjustments) { + subtotalRow.style.display = ''; + setText('displaySubtotal', formatCurrency(totals.subtotal, settings.currency, settings.locale)); + } else { + subtotalRow.style.display = 'none'; + } + } // Conditionally show/hide tax row const taxRow = document.getElementById('taxRow'); diff --git a/src/js/modules/pdf.js b/src/js/modules/pdf.js index 2bfd0ec..4464d27 100644 --- a/src/js/modules/pdf.js +++ b/src/js/modules/pdf.js @@ -178,7 +178,22 @@ function generateDocumentDefinition(data) { widths: ['auto', 100], body: totalsRows }, - layout: 'noBorders' + layout: { + // Add a top border line only above the last row (Total) + hLineWidth: (i, node) => { + const lastRowIndex = node.table.body.length; + return i === lastRowIndex - 1 ? 0.5 : 0; + }, + vLineWidth: () => 0, + hLineColor: () => '#9CA3AF', + paddingLeft: () => 8, + paddingRight: () => 0, + paddingTop: (i, node) => { + const lastRowIndex = node.table.body.length; + return i === lastRowIndex - 1 ? 6 : 2; + }, + paddingBottom: () => 2 + } } ], marginBottom: 40 @@ -215,11 +230,11 @@ function generateDocumentDefinition(data) { // Table styles tableHeader: { fontSize: 10, bold: true, color: '#111827', fillColor: '#F3F4F6', margin: [0, 2, 0, 2] }, - tableCell: { fontSize: 10, color: '#374151', margin: [0, 2, 0, 2] }, + tableCell: { fontSize: 10, color: '#111827', margin: [0, 2, 0, 2] }, // Footer styles footerNote: { fontSize: 14, bold: true, color: '#111827' }, - footerText: { fontSize: 10, color: '#6B7280', lineHeight: 1.5 } + footerText: { fontSize: 10, color: '#111827', lineHeight: 1.5 } }, defaultStyle: { font: 'Roboto' diff --git a/styles.css b/styles.css index 3a68851..0dd894f 100644 --- a/styles.css +++ b/styles.css @@ -751,7 +751,6 @@ button:focus-visible { grid-template-columns: 1fr auto; align-items: start; margin-bottom: 40px; - padding-bottom: 20px; gap: 20px; } @@ -760,16 +759,16 @@ button:focus-visible { } .business-info h1 { - font-size: 20px; - color: #2c3e50; - margin-bottom: 8px; - font-weight: bold; + font-size: 18px; + color: #111827; + margin-bottom: 4px; + font-weight: 700; } .business-info p { margin-bottom: 3px; - color: #666; - font-size: 12px; + color: #4b5563; + font-size: 10px; line-height: 1.4; } @@ -778,29 +777,39 @@ button:focus-visible { } .invoice-title h1 { - font-size: 36px; - color: #2c3e50; - font-weight: bold; - margin: 0; + font-size: 24px; + color: #111827; + font-weight: 700; + margin: 0 0 8px 0; } .invoice-details { display: flex; justify-content: space-between; - margin-bottom: 30px; + margin-bottom: 40px; gap: 20px; } .bill-to h3 { - color: #2c3e50; - margin-bottom: 10px; - font-size: 14px; - font-weight: bold; + color: #6b7280; + margin-bottom: 5px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; } .bill-to p { - margin-bottom: 5px; - color: #666; + margin-bottom: 4px; + color: #4b5563; + font-size: 10px; + line-height: 1.4; +} + +.bill-to p:first-of-type { + font-size: 14px; + font-weight: 700; + color: #111827; + margin-bottom: 4px; } #displaySellerAddress, @@ -810,29 +819,32 @@ button:focus-visible { } .invoice-meta p { - margin-bottom: 5px; - color: #666; + margin-bottom: 4px; + color: #4b5563; + font-size: 10px; } .invoice-table { width: 100%; border-collapse: collapse; - margin-bottom: 30px; + margin-bottom: 20px; } .invoice-table th { - background: #f8f9fa; - padding: 12px; + background: #f3f4f6; + padding: 6px 8px; text-align: left; - border: 1px solid #ddd; - font-weight: bold; - color: #2c3e50; + border: 0.5px solid #e5e7eb; + font-weight: 700; + font-size: 10px; + color: #111827; } .invoice-table td { - padding: 12px; - border: 1px solid #ddd; - color: #666; + padding: 6px 8px; + border: 0.5px solid #e5e7eb; + color: #111827; + font-size: 10px; } .invoice-table th:last-child, @@ -841,46 +853,71 @@ button:focus-visible { } .invoice-totals { - text-align: right; + display: flex; + flex-direction: column; + align-items: flex-end; margin-bottom: 40px; } -.subtotal { - margin-bottom: 10px; - padding: 10px 0; - border-top: 1px solid #ddd; +.totals-breakdown { + margin-bottom: 4px; + text-align: right; } -.subtotal p { - color: #666; +.totals-breakdown p { + margin-bottom: 2px; + font-size: 10px; + color: #4b5563; } -.balance-due { - background: #f8f9fa; - padding: 15px; - border: 2px solid #2c3e50; +.totals-breakdown .totals-label { + color: #6b7280; + margin-right: 8px; } -.balance-due h2 { - color: #2c3e50; - font-size: 18px; +.total-row { + display: inline-flex; + align-items: center; + gap: 8px; + padding-top: 6px; + margin-top: 2px; + border-top: 0.5px solid #9ca3af; +} + +.total-row .total-label { + font-weight: 700; + font-size: 12px; + color: #111827; + padding-left: 8px; +} + +.total-row .total-amount { + font-weight: 700; + font-size: 12px; + color: #111827; + min-width: 100px; + text-align: right; } .invoice-footer { text-align: center; margin-top: 40px; padding-top: 20px; - border-top: 1px solid #eee; + border-top: none; } .invoice-footer h3 { - color: #2c3e50; - margin-bottom: 15px; + color: #111827; + font-size: 14px; + font-weight: 700; + margin-bottom: 10px; } .invoice-footer p { - color: #666; - margin-bottom: 15px; + color: #111827; + font-size: 10px; + line-height: 1.5; + margin-bottom: 0; } .bank-details p { From fbc349dc33a325ed8531f198ddd68f0bec7eac23 Mon Sep 17 00:00:00 2001 From: maheshrijal <62394512+maheshrijal@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:26:46 +0530 Subject: [PATCH 04/17] test: add advanced feature tests and fix pdf-parse dependency --- package-lock.json | 255 ++++---------------------------- package.json | 2 +- tests/advanced-features.spec.js | 172 +++++++++++++++++++++ 3 files changed, 205 insertions(+), 224 deletions(-) create mode 100644 tests/advanced-features.spec.js diff --git a/package-lock.json b/package-lock.json index edf70c3..06f4d1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,202 +10,7 @@ "license": "MIT", "devDependencies": { "@playwright/test": "^1.40.0", - "pdf-parse": "^2.4.5" - } - }, - "node_modules/@napi-rs/canvas": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", - "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", - "dev": true, - "license": "MIT", - "workspaces": [ - "e2e/*" - ], - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.80", - "@napi-rs/canvas-darwin-arm64": "0.1.80", - "@napi-rs/canvas-darwin-x64": "0.1.80", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", - "@napi-rs/canvas-linux-arm64-musl": "0.1.80", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", - "@napi-rs/canvas-linux-x64-gnu": "0.1.80", - "@napi-rs/canvas-linux-x64-musl": "0.1.80", - "@napi-rs/canvas-win32-x64-msvc": "0.1.80" - } - }, - "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", - "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", - "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", - "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", - "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", - "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", - "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", - "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", - "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", - "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", - "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" + "pdf-parse": "^1.1.1" } }, "node_modules/@playwright/test": { @@ -224,6 +29,16 @@ "node": ">=18" } }, + "node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -239,38 +54,32 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-ensure": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz", + "integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==", + "dev": true, + "license": "MIT" + }, "node_modules/pdf-parse": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", - "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.1.tgz", + "integrity": "sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@napi-rs/canvas": "0.1.80", - "pdfjs-dist": "5.4.296" - }, - "bin": { - "pdf-parse": "bin/cli.mjs" + "debug": "^3.1.0", + "node-ensure": "^0.0.0" }, "engines": { - "node": ">=20.16.0 <21 || >=22.3.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/mehmet-kozan" - } - }, - "node_modules/pdfjs-dist": { - "version": "5.4.296", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", - "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=20.16.0 || >=22.3.0" - }, - "optionalDependencies": { - "@napi-rs/canvas": "^0.1.80" + "node": ">=6.8.1" } }, "node_modules/playwright": { diff --git a/package.json b/package.json index 949a3c4..0a874d5 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,6 @@ "license": "MIT", "devDependencies": { "@playwright/test": "^1.40.0", - "pdf-parse": "^2.4.5" + "pdf-parse": "^1.1.1" } } diff --git a/tests/advanced-features.spec.js b/tests/advanced-features.spec.js new file mode 100644 index 0000000..d228978 --- /dev/null +++ b/tests/advanced-features.spec.js @@ -0,0 +1,172 @@ + +// @ts-check +import { test, expect } from '@playwright/test'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const fs = require('fs'); +const pdfParse = require('pdf-parse'); + +/** + * Advanced Features Tests + * + * Covers: + * - Tax and Discount calculations + * - Due Dates + * - Currency Formatting + * - PDF Content Verification for these fields + */ + +const testData = { + seller: { + name: 'Advanced Seller Ltd', + address: '123 Innovation Dr', + }, + customer: { + name: 'Super Client', + address: '456 Market St', + }, + items: [ + { description: 'Premium Widget', quantity: 2, price: 1000 } + ] +}; + +test.describe('Advanced Invoice Features', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + + // Basic Setup + await page.fill('#sellerName', testData.seller.name); + await page.fill('#sellerAddress', testData.seller.address); // Required + await page.fill('#billToName', testData.customer.name); + await page.fill('#billToAddress', testData.customer.address); // Required + await page.fill('.item-description', testData.items[0].description); + await page.fill('.item-quantity', String(testData.items[0].quantity)); + await page.fill('.item-unit-price', String(testData.items[0].price)); + }); + + test('should calculate tax and discount correctly in preview and PDF', async ({ page }) => { + // 1. Add Tax and Discount + await page.fill('#taxRate', '10'); // 10% + await page.fill('#discountAmount', '100'); // 100 flat off + + // Item total: 2 * 1000 = 2000 + // Subtotal: 2000 + // Tax: 10% of 2000 = 200 + // Discount: 100 + // Total: 2000 + 200 - 100 = 2100 + + await page.click('button:has-text("Generate Bill")'); + await expect(page.locator('#invoice')).toBeVisible(); + + // 2. Verify Preview + await expect(page.locator('#subtotalRow')).toBeVisible(); + await expect(page.locator('#displaySubtotal')).toContainText('2,000'); + + await expect(page.locator('#taxRow')).toBeVisible(); + await expect(page.locator('#displayTaxAmount')).toContainText('200'); + + await expect(page.locator('#discountRow')).toBeVisible(); + await expect(page.locator('#displayDiscount')).toContainText('100'); + + await expect(page.locator('#displayBalanceDue')).toContainText('2,100'); + + // 3. Verify PDF + const downloadPromise = page.waitForEvent('download'); + await page.click('#downloadBtn'); + const download = await downloadPromise; + const path = await download.path(); + + const pdfBuffer = fs.readFileSync(path); + const data = await pdfParse(pdfBuffer); + + // Check for calculated values in PDF text + expect(data.text).toContain('Subtotal'); + expect(data.text).toContain('Tax (10%)'); + expect(data.text).toContain('Discount'); + expect(data.text).toContain('2,100'); + }); + + test('should display due date when provided', async ({ page }) => { + const today = new Date().toISOString().split('T')[0]; + // Set info + await page.fill('#invoiceDate', today); + await page.fill('#invoiceDueDate', '2025-12-31'); + + await page.click('button:has-text("Generate Bill")'); + + // Verify Preview + const dueDateRow = page.locator('#dueDateRow'); + await expect(dueDateRow).toBeVisible(); + await expect(page.locator('#displayInvoiceDueDate')).toContainText('31/12/2025'); + + // Verify PDF + const downloadPromise = page.waitForEvent('download'); + await page.click('#downloadBtn'); + const download = await downloadPromise; + const path = await download.path(); + + const pdfBuffer = fs.readFileSync(path); + const data = await pdfParse(pdfBuffer); + + expect(data.text).toContain('Due Date:'); + expect(data.text).toContain('31/12/2025'); + }); + + test('should hide due date if not provided', async ({ page }) => { + // Ensure due date is empty + await page.fill('#invoiceDueDate', ''); + + await page.click('button:has-text("Generate Bill")'); + + // Verify Preview + const dueDateRow = page.locator('#dueDateRow'); + await expect(dueDateRow).toBeHidden(); + + // Verify PDF + const downloadPromise = page.waitForEvent('download'); + await page.click('#downloadBtn'); + const download = await downloadPromise; + const path = await download.path(); + + const pdfBuffer = fs.readFileSync(path); + const data = await pdfParse(pdfBuffer); + + expect(data.text).not.toContain('Due Date:'); + }); + + test('should display correctly with different currency', async ({ page }) => { + // Change to USD + await page.fill('#currencyCode', 'USD'); + + await page.click('button:has-text("Generate Bill")'); + + // Verify Preview + const balance = await page.locator('#displayBalanceDue').innerText(); + expect(balance).toContain('$'); + }); + + test('should include custom notes and instructions in PDF', async ({ page }) => { + const notes = 'My custom notes'; + const instructions = 'Please pay via Bank Transfer'; + + await page.fill('#invoiceNotes', notes); + await page.fill('#invoiceInstructions', instructions); + + await page.click('button:has-text("Generate Bill")'); + + // Verify PDF + const downloadPromise = page.waitForEvent('download'); + await page.click('#downloadBtn'); + const download = await downloadPromise; + const path = await download.path(); + + const pdfBuffer = fs.readFileSync(path); + const data = await pdfParse(pdfBuffer); + + expect(data.text).toContain(notes); + expect(data.text).toContain(instructions); + }); +}); From 0c0f76afdbf34966d07d6db808b93892b0cf3462 Mon Sep 17 00:00:00 2001 From: maheshrijal <62394512+maheshrijal@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:42:11 +0530 Subject: [PATCH 05/17] feat: improve invoice preview readability and add PR test reporting --- .github/workflows/playwright.yml | 10 ++++++++++ .gitignore | 1 + playwright.config.js | 3 ++- styles.css | 24 ++++++++++++------------ 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 0a1c8be..6fd7864 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -11,6 +11,10 @@ jobs: timeout-minutes: 60 runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: - uses: actions/checkout@v4 @@ -28,6 +32,12 @@ jobs: - name: Run Playwright tests run: npx playwright test + - name: Publish Test Report + uses: daun/playwright-report-summary@v3 + if: always() + with: + report-file: test-results.json + - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: diff --git a/.gitignore b/.gitignore index 0b8a7d0..3504795 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ node_modules/ # Playwright playwright-report/ test-results/ +test-results.json diff --git a/playwright.config.js b/playwright.config.js index 778912e..0af62ca 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -22,7 +22,8 @@ export default defineConfig({ /* Reporter to use */ reporter: [ ['html', { outputFolder: 'playwright-report' }], - ['list'] + ['list'], + ['json', { outputFile: 'test-results.json' }] ], /* Shared settings for all the projects */ diff --git a/styles.css b/styles.css index 0dd894f..540857c 100644 --- a/styles.css +++ b/styles.css @@ -768,7 +768,7 @@ button:focus-visible { .business-info p { margin-bottom: 3px; color: #4b5563; - font-size: 10px; + font-size: 13px; line-height: 1.4; } @@ -793,7 +793,7 @@ button:focus-visible { .bill-to h3 { color: #6b7280; margin-bottom: 5px; - font-size: 10px; + font-size: 11px; font-weight: 700; letter-spacing: 0.05em; } @@ -801,7 +801,7 @@ button:focus-visible { .bill-to p { margin-bottom: 4px; color: #4b5563; - font-size: 10px; + font-size: 13px; line-height: 1.4; } @@ -821,7 +821,7 @@ button:focus-visible { .invoice-meta p { margin-bottom: 4px; color: #4b5563; - font-size: 10px; + font-size: 13px; } .invoice-table { @@ -832,19 +832,19 @@ button:focus-visible { .invoice-table th { background: #f3f4f6; - padding: 6px 8px; + padding: 8px 10px; text-align: left; border: 0.5px solid #e5e7eb; font-weight: 700; - font-size: 10px; + font-size: 11px; color: #111827; } .invoice-table td { - padding: 6px 8px; + padding: 8px 10px; border: 0.5px solid #e5e7eb; color: #111827; - font-size: 10px; + font-size: 13px; } .invoice-table th:last-child, @@ -865,8 +865,8 @@ button:focus-visible { } .totals-breakdown p { - margin-bottom: 2px; - font-size: 10px; + margin-bottom: 4px; + font-size: 13px; color: #4b5563; } @@ -893,7 +893,7 @@ button:focus-visible { .total-row .total-amount { font-weight: 700; - font-size: 12px; + font-size: 16px; color: #111827; min-width: 100px; text-align: right; @@ -915,7 +915,7 @@ button:focus-visible { .invoice-footer p { color: #111827; - font-size: 10px; + font-size: 12px; line-height: 1.5; margin-bottom: 0; } From 677b8bbe1a3a06818f7cf25994907ef3ea8223d9 Mon Sep 17 00:00:00 2001 From: maheshrijal <62394512+maheshrijal@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:49:21 +0530 Subject: [PATCH 06/17] Refactor CSS into modules --- index.html | 2 +- src/css/base/reset.css | 14 + src/css/base/typography.css | 19 + src/css/base/variables.css | 84 ++ src/css/components/buttons.css | 116 +++ src/css/components/forms.css | 328 ++++++++ src/css/components/history.css | 108 +++ src/css/components/invoice.css | 238 ++++++ src/css/layout/grid.css | 22 + src/css/layout/responsive.css | 339 ++++++++ src/css/main.css | 22 + src/css/utilities/animations.css | 12 + src/css/utilities/helpers.css | 33 + styles.css | 1311 ------------------------------ 14 files changed, 1336 insertions(+), 1312 deletions(-) create mode 100644 src/css/base/reset.css create mode 100644 src/css/base/typography.css create mode 100644 src/css/base/variables.css create mode 100644 src/css/components/buttons.css create mode 100644 src/css/components/forms.css create mode 100644 src/css/components/history.css create mode 100644 src/css/components/invoice.css create mode 100644 src/css/layout/grid.css create mode 100644 src/css/layout/responsive.css create mode 100644 src/css/main.css create mode 100644 src/css/utilities/animations.css create mode 100644 src/css/utilities/helpers.css delete mode 100644 styles.css diff --git a/index.html b/index.html index f7b5c38..3249b3d 100644 --- a/index.html +++ b/index.html @@ -32,7 +32,7 @@ href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E🧾%3C/text%3E%3C/svg%3E"> - + diff --git a/src/css/base/reset.css b/src/css/base/reset.css new file mode 100644 index 0000000..dea289c --- /dev/null +++ b/src/css/base/reset.css @@ -0,0 +1,14 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + background: var(--background); + min-height: 100vh; +} \ No newline at end of file diff --git a/src/css/base/typography.css b/src/css/base/typography.css new file mode 100644 index 0000000..99940bc --- /dev/null +++ b/src/css/base/typography.css @@ -0,0 +1,19 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&display=swap'); + +body { + font-family: 'Inter', 'Manrope', system-ui, -apple-system, sans-serif; + font-size: 14px; + line-height: 1.6; + color: var(--text-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + color: var(--text-primary); +} \ No newline at end of file diff --git a/src/css/base/variables.css b/src/css/base/variables.css new file mode 100644 index 0000000..ae42513 --- /dev/null +++ b/src/css/base/variables.css @@ -0,0 +1,84 @@ +:root { + /* Primary palette - Indigo/Violet */ + --primary-50: #eef2ff; + --primary-100: #e0e7ff; + --primary-200: #c7d2fe; + --primary-300: #a5b4fc; + --primary-400: #818cf8; + --primary-500: #6366f1; + --primary-600: #4f46e5; + --primary-700: #4338ca; + --primary-800: #3730a3; + --primary-900: #312e81; + + /* Neutral palette */ + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-300: #d1d5db; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-600: #4b5563; + --gray-700: #374151; + --gray-800: #1f2937; + --gray-900: #111827; + + /* Semantic colors */ + --success-50: #ecfdf5; + --success-500: #10b981; + --success-600: #059669; + --error-50: #fef2f2; + --error-500: #ef4444; + --error-600: #dc2626; + + /* Surface & backgrounds */ + --surface: #ffffff; + --surface-elevated: rgba(255, 255, 255, 0.8); + --surface-glass: rgba(255, 255, 255, 0.7); + --background: linear-gradient(135deg, #f5f7ff 0%, #f0f4ff 50%, #e8ecff 100%); + --background-subtle: #f8fafc; + + /* Text colors */ + --text-primary: var(--gray-900); + --text-secondary: var(--gray-600); + --text-muted: var(--gray-500); + --text-inverse: #ffffff; + + /* Borders */ + --border-light: var(--gray-200); + --border-medium: var(--gray-300); + --border-focus: var(--primary-400); + + /* Shadows */ + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + --shadow-glow: 0 0 20px rgba(99, 102, 241, 0.15); + --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); + + /* Spacing scale */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + + /* Border radius */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 20px; + --radius-full: 9999px; + + /* Transitions */ + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1); +} \ No newline at end of file diff --git a/src/css/components/buttons.css b/src/css/components/buttons.css new file mode 100644 index 0000000..cd120e3 --- /dev/null +++ b/src/css/components/buttons.css @@ -0,0 +1,116 @@ +/* ===== Buttons ===== */ +button { + font-family: inherit; + font-size: 0.875rem; + font-weight: 600; + padding: var(--space-3) var(--space-5); + border: none; + border-radius: var(--radius-md); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + transition: all var(--transition-base); + outline: none; +} + +button:focus-visible { + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.4); +} + +.button-row { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + align-items: center; + margin-top: var(--space-4); +} + +.button-row input[type="file"].visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.file-input-label { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + cursor: pointer; + font-family: inherit; + font-size: 0.875rem; + font-weight: 600; + padding: var(--space-3) var(--space-5); + background: transparent; + color: var(--text-secondary); + border: 1.5px solid var(--border-light); + border-radius: var(--radius-md); + transition: all var(--transition-base); +} + +.file-input-label:hover { + background: var(--gray-50); + border-color: var(--gray-300); + color: var(--text-primary); +} + +/* Primary Button */ +.primary { + background: linear-gradient(135deg, var(--primary-500) 0%, var(--primary-600) 100%); + color: var(--text-inverse); + box-shadow: var(--shadow-sm), 0 2px 8px rgba(99, 102, 241, 0.25); +} + +.primary:hover { + background: linear-gradient(135deg, var(--primary-600) 0%, var(--primary-700) 100%); + box-shadow: var(--shadow-md), 0 4px 12px rgba(99, 102, 241, 0.35); + transform: translateY(-1px); +} + +.primary:active { + transform: translateY(0); + box-shadow: var(--shadow-sm); +} + +/* Secondary Button */ +.secondary { + background: var(--gray-800); + color: var(--text-inverse); + box-shadow: var(--shadow-sm); +} + +.secondary:hover { + background: var(--gray-900); + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} + +.secondary:active { + transform: translateY(0); +} + +/* Ghost Button */ +.ghost { + background: transparent; + color: var(--text-secondary); + border: 1.5px solid var(--border-light); +} + +.ghost:hover { + background: var(--gray-50); + border-color: var(--gray-300); + color: var(--text-primary); + transform: none; + box-shadow: none; +} + +.ghost:active { + background: var(--gray-100); +} \ No newline at end of file diff --git a/src/css/components/forms.css b/src/css/components/forms.css new file mode 100644 index 0000000..ceb329e --- /dev/null +++ b/src/css/components/forms.css @@ -0,0 +1,328 @@ +/* ===== Form Section (Generator) ===== */ +.form-section { + background: var(--surface-glass); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + padding: var(--space-8) var(--space-10); + border-right: 1px solid var(--border-light); + height: 100vh; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--gray-300) transparent; +} + +.form-section::-webkit-scrollbar { + width: 6px; +} + +.form-section::-webkit-scrollbar-track { + background: transparent; +} + +.form-section::-webkit-scrollbar-thumb { + background: var(--gray-300); + border-radius: var(--radius-full); +} + +.form-section::-webkit-scrollbar-thumb:hover { + background: var(--gray-400); +} + +.form-section h2 { + font-family: 'Manrope', sans-serif; + font-size: 1.5rem; + font-weight: 800; + color: var(--text-primary); + margin-bottom: var(--space-6); + padding-bottom: var(--space-4); + border-bottom: 2px solid var(--primary-100); + letter-spacing: -0.02em; + display: flex; + align-items: center; + gap: var(--space-3); +} + +.form-section h2::before { + content: ''; + display: inline-block; + width: 4px; + height: 24px; + background: linear-gradient(180deg, var(--primary-500), var(--primary-600)); + border-radius: var(--radius-full); +} + +/* ===== Form Blocks ===== */ +.form-block { + padding-bottom: var(--space-5); + margin-bottom: var(--space-5); + border-bottom: 1px solid var(--gray-100); + animation: fadeInUp var(--transition-slow) ease-out; +} + +.form-block:last-of-type { + border-bottom: none; + margin-bottom: 0; +} + +.form-block h3 { + font-family: 'Manrope', sans-serif; + font-size: 0.875rem; + font-weight: 700; + color: var(--primary-700); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: var(--space-4); + display: flex; + align-items: center; + gap: var(--space-2); +} + +/* ===== Form Grid ===== */ +.form-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-4); +} + +.form-group { + margin-bottom: 0; +} + +.form-group.span-2 { + grid-column: span 2; +} + +/* ===== Form Labels ===== */ +.form-group label { + display: block; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--space-2); +} + +/* ===== Form Inputs ===== */ +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: var(--space-3) var(--space-4); + font-family: inherit; + font-size: 0.9375rem; + color: var(--text-primary); + background: var(--surface); + border: 1.5px solid var(--border-light); + border-radius: var(--radius-md); + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast), + background-color var(--transition-fast); + outline: none; +} + +.form-group input::placeholder, +.form-group textarea::placeholder { + color: var(--gray-400); +} + +.form-group input:hover, +.form-group textarea:hover, +.form-group select:hover { + border-color: var(--gray-300); +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + border-color: var(--primary-400); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12); + background: var(--surface); +} + +.form-group textarea { + resize: vertical; + min-height: 80px; + line-height: 1.5; +} + +.form-group textarea.drag-over { + border-color: var(--primary-400); + background: var(--primary-50); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); +} + +.form-group select { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 40px; +} + +/* ===== Line Items ===== */ +.items-header { + display: grid; + grid-template-columns: 2fr 0.7fr 1fr 1fr 40px; + gap: var(--space-3); + align-items: center; + padding: var(--space-2) var(--space-3); + background: var(--gray-50); + border-radius: var(--radius-md); + margin-bottom: var(--space-3); +} + +.items-header span { + font-size: 0.7rem; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.item-row { + display: grid; + grid-template-columns: 2fr 0.7fr 1fr 1fr 40px; + gap: var(--space-3); + align-items: center; + padding: var(--space-2) 0; + margin-bottom: var(--space-2); + animation: fadeInUp var(--transition-fast) ease-out; +} + +.item-row input { + width: 100%; + padding: var(--space-2) var(--space-3); + font-size: 0.875rem; + border: 1.5px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--surface); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.item-row input:focus { + border-color: var(--primary-400); + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1); + outline: none; +} + +.item-total { + font-weight: 600; + color: var(--text-primary); + font-size: 0.9rem; + text-align: right; + padding-right: var(--space-2); +} + +.remove-item { + width: 32px; + height: 32px; + min-width: 32px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--error-50); + color: var(--error-500); + border: 1px solid transparent; + border-radius: var(--radius-sm); + font-size: 1.1rem; + font-weight: 600; + line-height: 1; + cursor: pointer; + transition: all var(--transition-fast); +} + +.remove-item:hover { + background: var(--error-500); + color: white; + transform: none; + box-shadow: none; +} + +/* ===== Number Input with Buttons ===== */ +.number-input-wrapper { + display: flex; + align-items: center; + gap: 0; +} + +.number-input-wrapper input { + flex: 1; + border-radius: 0; + text-align: center; + -moz-appearance: textfield; +} + +.number-input-wrapper input::-webkit-outer-spin-button, +.number-input-wrapper input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.number-btn { + width: 36px; + height: 38px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--gray-100); + color: var(--text-secondary); + border: 1.5px solid var(--border-light); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-fast); +} + +.number-btn.decrement { + border-radius: var(--radius-md) 0 0 var(--radius-md); + border-right: none; +} + +.number-btn.increment { + border-radius: 0 var(--radius-md) var(--radius-md) 0; + border-left: none; +} + +.number-btn:hover { + background: var(--primary-100); + color: var(--primary-700); +} + +.number-btn:active { + background: var(--primary-200); +} + +/* ===== Totals Preview ===== */ +.totals-preview { + background: linear-gradient(135deg, var(--primary-50) 0%, var(--success-50) 100%); + padding: var(--space-4); + border-radius: var(--radius-md); + border: 1px solid var(--primary-100); + margin-top: var(--space-3); +} + +.totals-preview p { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-2); + font-size: 0.9rem; +} + +.totals-preview p:last-child { + margin-bottom: 0; + padding-top: var(--space-2); + border-top: 1px dashed var(--primary-200); + font-weight: 700; + color: var(--primary-700); +} + +.totals-preview strong { + color: var(--text-secondary); + font-weight: 600; +} \ No newline at end of file diff --git a/src/css/components/history.css b/src/css/components/history.css new file mode 100644 index 0000000..59848e2 --- /dev/null +++ b/src/css/components/history.css @@ -0,0 +1,108 @@ +/* ===== History Panel ===== */ +.history-panel { + background: var(--gray-50); + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + padding: var(--space-4); + margin-bottom: var(--space-5); + animation: fadeInUp var(--transition-fast) ease-out; +} + +.history-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-3); +} + +.history-header h3 { + font-size: 0.9rem; + font-weight: 700; + color: var(--text-primary); + margin: 0; + text-transform: none; + letter-spacing: normal; +} + +button.small { + font-size: 0.75rem; + padding: var(--space-2) var(--space-3); +} + +.history-list { + max-height: 200px; + overflow-y: auto; +} + +.history-empty { + color: var(--text-muted); + font-size: 0.85rem; + text-align: center; + padding: var(--space-4) 0; + margin: 0; +} + +.history-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-3); + background: var(--surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + margin-bottom: var(--space-2); + cursor: pointer; + transition: all var(--transition-fast); +} + +.history-item:hover { + border-color: var(--primary-300); + background: var(--primary-50); +} + +.history-item:last-child { + margin-bottom: 0; +} + +.history-item-info { + flex: 1; + min-width: 0; +} + +.history-item-title { + font-weight: 600; + font-size: 0.85rem; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.history-item-meta { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 2px; +} + +.history-item-delete { + width: 24px; + height: 24px; + min-width: 24px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + color: var(--text-muted); + border: none; + border-radius: var(--radius-sm); + font-size: 0.9rem; + cursor: pointer; + transition: all var(--transition-fast); + margin-left: var(--space-2); +} + +.history-item-delete:hover { + background: var(--error-50); + color: var(--error-500); +} \ No newline at end of file diff --git a/src/css/components/invoice.css b/src/css/components/invoice.css new file mode 100644 index 0000000..3cb9c1e --- /dev/null +++ b/src/css/components/invoice.css @@ -0,0 +1,238 @@ +/* ===== Invoice Placeholder ===== */ +.invoice-placeholder { + background: linear-gradient(135deg, var(--primary-50) 0%, #f0f4ff 100%); + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: var(--space-8); +} + +.placeholder-content { + max-width: 320px; +} + +.placeholder-icon { + font-size: 4rem; + margin-bottom: var(--space-4); + opacity: 0.6; +} + +.placeholder-content h3 { + font-family: 'Manrope', sans-serif; + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: var(--space-3); +} + +.placeholder-content p { + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.6; +} + +.placeholder-content strong { + color: var(--primary-600); +} + +/* ===== Invoice Preview Section ===== */ +.invoice-section { + background: white; + padding: var(--space-10); + height: 100vh; + overflow-y: auto; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Tabular figures for proper number alignment in invoice */ +.invoice-table td:nth-child(2), +.invoice-table td:nth-child(3), +.invoice-table td:nth-child(4), +.invoice-totals span, +.balance-due span { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum"; +} + +.invoice-header { + display: grid; + grid-template-columns: 1fr auto; + align-items: start; + margin-bottom: 40px; + gap: 20px; +} + +.business-info { + text-align: left; +} + +.business-info h1 { + font-size: 18px; + color: #111827; + margin-bottom: 4px; + font-weight: 700; +} + +.business-info p { + margin-bottom: 3px; + color: #4b5563; + font-size: 13px; + line-height: 1.4; +} + +.invoice-title { + text-align: right; +} + +.invoice-title h1 { + font-size: 24px; + color: #111827; + font-weight: 700; + margin: 0 0 8px 0; +} + +.invoice-details { + display: flex; + justify-content: space-between; + margin-bottom: 40px; + gap: 20px; +} + +.bill-to h3 { + color: #6b7280; + margin-bottom: 5px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; +} + +.bill-to p { + margin-bottom: 4px; + color: #4b5563; + font-size: 13px; + line-height: 1.4; +} + +.bill-to p:first-of-type { + font-size: 14px; + font-weight: 700; + color: #111827; + margin-bottom: 4px; +} + +#displaySellerAddress, +#displayBillToAddress, +#displayInstructions { + white-space: pre-line; +} + +.invoice-meta p { + margin-bottom: 4px; + color: #4b5563; + font-size: 13px; +} + +.invoice-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; +} + +.invoice-table th { + background: #f3f4f6; + padding: 8px 10px; + text-align: left; + border: 0.5px solid #e5e7eb; + font-weight: 700; + font-size: 11px; + color: #111827; +} + +.invoice-table td { + padding: 8px 10px; + border: 0.5px solid #e5e7eb; + color: #111827; + font-size: 13px; +} + +.invoice-table th:last-child, +.invoice-table td:last-child { + text-align: right; +} + +.invoice-totals { + display: flex; + flex-direction: column; + align-items: flex-end; + margin-bottom: 40px; +} + +.totals-breakdown { + margin-bottom: 4px; + text-align: right; +} + +.totals-breakdown p { + margin-bottom: 4px; + font-size: 13px; + color: #4b5563; +} + +.totals-breakdown .totals-label { + color: #6b7280; + margin-right: 8px; +} + +.total-row { + display: inline-flex; + align-items: center; + gap: 8px; + padding-top: 6px; + margin-top: 2px; + border-top: 0.5px solid #9ca3af; +} + +.total-row .total-label { + font-weight: 700; + font-size: 12px; + color: #111827; + padding-left: 8px; +} + +.total-row .total-amount { + font-weight: 700; + font-size: 16px; + color: #111827; + min-width: 100px; + text-align: right; +} + +.invoice-footer { + text-align: center; + margin-top: 40px; + padding-top: 20px; + border-top: none; +} + +.invoice-footer h3 { + color: #111827; + font-size: 14px; + font-weight: 700; + margin-bottom: 10px; +} + +.invoice-footer p { + color: #111827; + font-size: 12px; + line-height: 1.5; + margin-bottom: 0; +} + +.bank-details p { + margin-bottom: 5px; + color: #666; +} \ No newline at end of file diff --git a/src/css/layout/grid.css b/src/css/layout/grid.css new file mode 100644 index 0000000..72a0d45 --- /dev/null +++ b/src/css/layout/grid.css @@ -0,0 +1,22 @@ +/* ===== Layout ===== */ +.container { + display: grid; + grid-template-columns: 1fr 1fr; + min-height: 100vh; + gap: 0; +} + +/* ===== Header Row ===== */ +.header-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); + margin-bottom: var(--space-4); +} + +.header-row h2 { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} \ No newline at end of file diff --git a/src/css/layout/responsive.css b/src/css/layout/responsive.css new file mode 100644 index 0000000..0db3f87 --- /dev/null +++ b/src/css/layout/responsive.css @@ -0,0 +1,339 @@ +/* ===== Responsive: Tablet ===== */ +@media (max-width: 1024px) { + .container { + grid-template-columns: 1fr; + min-height: auto; + } + + .form-section { + height: auto; + max-height: none; + border-right: none; + border-bottom: 1px solid var(--border-light); + padding: var(--space-6); + } + + .invoice-section { + height: auto; + min-height: 600px; + } + + .header-row { + flex-wrap: wrap; + } + + .header-row h2 { + flex: 1; + min-width: 200px; + } +} + +/* ===== Responsive: Mobile ===== */ +@media (max-width: 640px) { + body { + font-size: 15px; + } + + .container { + gap: 0; + } + + .form-section { + padding: var(--space-5); + } + + .form-section h2 { + font-size: 1.25rem; + margin-bottom: var(--space-5); + } + + .form-grid { + grid-template-columns: 1fr; + gap: var(--space-3); + } + + .form-group.span-2 { + grid-column: auto; + } + + .form-group input, + .form-group textarea, + .form-group select { + padding: var(--space-4); + font-size: 1rem; + min-height: 48px; + } + + /* Mobile line items - card style */ + .items-header { + display: none; + } + + .item-row { + grid-template-columns: 1fr; + gap: var(--space-3); + background: var(--gray-50); + padding: var(--space-4); + border-radius: var(--radius-md); + margin-bottom: var(--space-3); + position: relative; + } + + .item-row input { + padding: var(--space-3) var(--space-4); + font-size: 1rem; + min-height: 48px; + } + + .item-row input[type="number"] { + text-align: left; + } + + .item-row .item-description::before { + content: 'Description'; + } + + .item-row input::placeholder { + color: var(--gray-400); + } + + .item-total { + text-align: left; + font-size: 1rem; + padding: var(--space-2) 0; + border-top: 1px dashed var(--gray-200); + } + + .item-total::before { + content: 'Line Total: '; + color: var(--text-muted); + font-weight: 500; + } + + .remove-item { + position: absolute; + top: var(--space-3); + right: var(--space-3); + width: 32px; + height: 32px; + } + + .button-row { + flex-direction: column; + gap: var(--space-3); + } + + .button-row button { + width: 100%; + min-height: 48px; + } + + .button-row input[type="file"] { + width: 100%; + } + + /* Invoice preview mobile */ + .invoice-section { + padding: var(--space-5); + min-height: auto; + } + + .invoice-header { + grid-template-columns: 1fr; + text-align: left; + } + + .invoice-title { + text-align: left; + margin-top: var(--space-3); + } + + .invoice-title h1 { + font-size: 28px; + } + + .invoice-details { + flex-direction: column; + gap: var(--space-5); + } + + .invoice-meta { + margin-top: 0; + } + + .invoice-table { + font-size: 0.875rem; + } + + .invoice-table th, + .invoice-table td { + padding: var(--space-2) var(--space-3); + } + + /* Header row mobile */ + .header-row { + flex-direction: column; + align-items: stretch; + gap: var(--space-3); + } + + .header-row h2 { + font-size: 1.1rem; + } + + #historyToggle { + width: 100%; + min-height: 44px; + } + + /* History panel mobile */ + .history-panel { + padding: var(--space-3); + } + + .history-header { + flex-wrap: wrap; + gap: var(--space-2); + } + + .history-item { + padding: var(--space-3); + } + + .history-item-title { + font-size: 0.9rem; + } + + /* Number input wrapper mobile */ + .number-input-wrapper { + width: 100%; + } + + .number-btn { + width: 48px; + height: 48px; + font-size: 1.2rem; + } + + /* File input label mobile */ + .file-input-label { + width: 100%; + min-height: 48px; + justify-content: center; + } + + /* Open source badge mobile */ + .open-source-badge { + font-size: 0.55rem; + padding: 2px 6px; + } +} + +/* ===== Print Styles ===== */ +@media print { + @page { + margin: 0.5in; + size: A4; + } + + * { + -webkit-print-color-adjust: exact !important; + print-color-adjust: exact !important; + } + + html, + body { + background: white !important; + margin: 0 !important; + padding: 0 !important; + font-size: 12px !important; + } + + .container { + display: block !important; + max-width: none !important; + margin: 0 !important; + padding: 0 !important; + grid-template-columns: none !important; + gap: 0 !important; + } + + .form-section { + display: none !important; + } + + .invoice-section { + box-shadow: none !important; + padding: 20px !important; + margin: 0 !important; + min-height: auto !important; + width: 100% !important; + border-radius: 0 !important; + } + + .invoice-header { + margin-bottom: 30px !important; + border-bottom: none !important; + padding-bottom: 15px !important; + } + + .business-info h1 { + font-size: 16px !important; + margin-bottom: 5px !important; + } + + .business-info p { + font-size: 11px !important; + margin-bottom: 2px !important; + } + + .invoice-title h1 { + font-size: 28px !important; + } + + .invoice-details { + margin-bottom: 20px !important; + } + + .bill-to h3 { + font-size: 12px !important; + margin-bottom: 8px !important; + } + + .bill-to p, + .invoice-meta p { + font-size: 11px !important; + margin-bottom: 3px !important; + } + + .invoice-table { + margin-bottom: 20px !important; + font-size: 11px !important; + } + + .invoice-table th, + .invoice-table td { + padding: 8px !important; + font-size: 11px !important; + } + + .invoice-totals { + margin-bottom: 30px !important; + font-size: 11px !important; + } + + .balance-due h2 { + font-size: 14px !important; + } + + .invoice-footer h3 { + font-size: 13px !important; + margin-bottom: 10px !important; + } + + .invoice-footer p, + .bank-details p { + font-size: 11px !important; + margin-bottom: 3px !important; + } +} \ No newline at end of file diff --git a/src/css/main.css b/src/css/main.css new file mode 100644 index 0000000..5497d5a --- /dev/null +++ b/src/css/main.css @@ -0,0 +1,22 @@ +/* Main CSS Entry Point */ + +/* Base */ +@import 'base/reset.css'; +@import 'base/variables.css'; +@import 'base/typography.css'; + +/* Utilities (Loaded early for helpers) */ +@import 'utilities/animations.css'; +@import 'utilities/helpers.css'; + +/* Layout */ +@import 'layout/grid.css'; + +/* Components */ +@import 'components/buttons.css'; +@import 'components/forms.css'; +@import 'components/history.css'; +@import 'components/invoice.css'; + +/* Responsive (Loaded last to override) */ +@import 'layout/responsive.css'; \ No newline at end of file diff --git a/src/css/utilities/animations.css b/src/css/utilities/animations.css new file mode 100644 index 0000000..fdb8324 --- /dev/null +++ b/src/css/utilities/animations.css @@ -0,0 +1,12 @@ +/* ===== Animations ===== */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} \ No newline at end of file diff --git a/src/css/utilities/helpers.css b/src/css/utilities/helpers.css new file mode 100644 index 0000000..64c754e --- /dev/null +++ b/src/css/utilities/helpers.css @@ -0,0 +1,33 @@ +/* ===== Helpers ===== */ + +/* ===== Open Source Badge ===== */ +.open-source-badge { + font-size: 0.6rem; + font-weight: 600; + color: var(--primary-600); + background: var(--primary-50); + padding: 2px 8px; + border-radius: var(--radius-full); + text-decoration: none; + margin-left: var(--space-2); + vertical-align: middle; + transition: all var(--transition-fast); +} + +.open-source-badge:hover { + background: var(--primary-100); + color: var(--primary-700); +} + +/* ===== Focus visible for accessibility ===== */ +:focus-visible { + outline: 2px solid var(--primary-400); + outline-offset: 2px; +} + +input:focus-visible, +textarea:focus-visible, +select:focus-visible, +button:focus-visible { + outline: none; +} \ No newline at end of file diff --git a/styles.css b/styles.css deleted file mode 100644 index 540857c..0000000 --- a/styles.css +++ /dev/null @@ -1,1311 +0,0 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&display=swap'); - -/* ===== Design System Tokens ===== */ -:root { - /* Primary palette - Indigo/Violet */ - --primary-50: #eef2ff; - --primary-100: #e0e7ff; - --primary-200: #c7d2fe; - --primary-300: #a5b4fc; - --primary-400: #818cf8; - --primary-500: #6366f1; - --primary-600: #4f46e5; - --primary-700: #4338ca; - --primary-800: #3730a3; - --primary-900: #312e81; - - /* Neutral palette */ - --gray-50: #f9fafb; - --gray-100: #f3f4f6; - --gray-200: #e5e7eb; - --gray-300: #d1d5db; - --gray-400: #9ca3af; - --gray-500: #6b7280; - --gray-600: #4b5563; - --gray-700: #374151; - --gray-800: #1f2937; - --gray-900: #111827; - - /* Semantic colors */ - --success-50: #ecfdf5; - --success-500: #10b981; - --success-600: #059669; - --error-50: #fef2f2; - --error-500: #ef4444; - --error-600: #dc2626; - - /* Surface & backgrounds */ - --surface: #ffffff; - --surface-elevated: rgba(255, 255, 255, 0.8); - --surface-glass: rgba(255, 255, 255, 0.7); - --background: linear-gradient(135deg, #f5f7ff 0%, #f0f4ff 50%, #e8ecff 100%); - --background-subtle: #f8fafc; - - /* Text colors */ - --text-primary: var(--gray-900); - --text-secondary: var(--gray-600); - --text-muted: var(--gray-500); - --text-inverse: #ffffff; - - /* Borders */ - --border-light: var(--gray-200); - --border-medium: var(--gray-300); - --border-focus: var(--primary-400); - - /* Shadows */ - --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05); - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); - --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); - --shadow-glow: 0 0 20px rgba(99, 102, 241, 0.15); - --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); - - /* Spacing scale */ - --space-1: 4px; - --space-2: 8px; - --space-3: 12px; - --space-4: 16px; - --space-5: 20px; - --space-6: 24px; - --space-8: 32px; - --space-10: 40px; - --space-12: 48px; - - /* Border radius */ - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 14px; - --radius-xl: 20px; - --radius-full: 9999px; - - /* Transitions */ - --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); - --transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1); - --transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1); - --transition-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1); -} - -/* ===== Base Reset & Typography ===== */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -html { - scroll-behavior: smooth; -} - -body { - font-family: 'Inter', 'Manrope', system-ui, -apple-system, sans-serif; - font-size: 14px; - line-height: 1.6; - color: var(--text-primary); - background: var(--background); - min-height: 100vh; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -/* ===== Layout ===== */ -.container { - display: grid; - grid-template-columns: 1fr 1fr; - min-height: 100vh; - gap: 0; -} - -/* ===== Form Section (Generator) ===== */ -.form-section { - background: var(--surface-glass); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - padding: var(--space-8) var(--space-10); - border-right: 1px solid var(--border-light); - height: 100vh; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: var(--gray-300) transparent; -} - -.form-section::-webkit-scrollbar { - width: 6px; -} - -.form-section::-webkit-scrollbar-track { - background: transparent; -} - -.form-section::-webkit-scrollbar-thumb { - background: var(--gray-300); - border-radius: var(--radius-full); -} - -.form-section::-webkit-scrollbar-thumb:hover { - background: var(--gray-400); -} - -.form-section h2 { - font-family: 'Manrope', sans-serif; - font-size: 1.5rem; - font-weight: 800; - color: var(--text-primary); - margin-bottom: var(--space-6); - padding-bottom: var(--space-4); - border-bottom: 2px solid var(--primary-100); - letter-spacing: -0.02em; - display: flex; - align-items: center; - gap: var(--space-3); -} - -.form-section h2::before { - content: ''; - display: inline-block; - width: 4px; - height: 24px; - background: linear-gradient(180deg, var(--primary-500), var(--primary-600)); - border-radius: var(--radius-full); -} - -/* ===== Header Row ===== */ -.header-row { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: var(--space-4); - margin-bottom: var(--space-4); -} - -.header-row h2 { - margin-bottom: 0; - padding-bottom: 0; - border-bottom: none; -} - -/* ===== History Panel ===== */ -.history-panel { - background: var(--gray-50); - border: 1px solid var(--border-light); - border-radius: var(--radius-md); - padding: var(--space-4); - margin-bottom: var(--space-5); - animation: fadeInUp var(--transition-fast) ease-out; -} - -.history-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--space-3); -} - -.history-header h3 { - font-size: 0.9rem; - font-weight: 700; - color: var(--text-primary); - margin: 0; - text-transform: none; - letter-spacing: normal; -} - -button.small { - font-size: 0.75rem; - padding: var(--space-2) var(--space-3); -} - -.history-list { - max-height: 200px; - overflow-y: auto; -} - -.history-empty { - color: var(--text-muted); - font-size: 0.85rem; - text-align: center; - padding: var(--space-4) 0; - margin: 0; -} - -.history-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: var(--space-3); - background: var(--surface); - border: 1px solid var(--border-light); - border-radius: var(--radius-sm); - margin-bottom: var(--space-2); - cursor: pointer; - transition: all var(--transition-fast); -} - -.history-item:hover { - border-color: var(--primary-300); - background: var(--primary-50); -} - -.history-item:last-child { - margin-bottom: 0; -} - -.history-item-info { - flex: 1; - min-width: 0; -} - -.history-item-title { - font-weight: 600; - font-size: 0.85rem; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.history-item-meta { - font-size: 0.75rem; - color: var(--text-muted); - margin-top: 2px; -} - -.history-item-delete { - width: 24px; - height: 24px; - min-width: 24px; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - background: transparent; - color: var(--text-muted); - border: none; - border-radius: var(--radius-sm); - font-size: 0.9rem; - cursor: pointer; - transition: all var(--transition-fast); - margin-left: var(--space-2); -} - -.history-item-delete:hover { - background: var(--error-50); - color: var(--error-500); -} - -/* ===== Form Blocks ===== */ -.form-block { - padding-bottom: var(--space-5); - margin-bottom: var(--space-5); - border-bottom: 1px solid var(--gray-100); - animation: fadeInUp var(--transition-slow) ease-out; -} - -.form-block:last-of-type { - border-bottom: none; - margin-bottom: 0; -} - -.form-block h3 { - font-family: 'Manrope', sans-serif; - font-size: 0.875rem; - font-weight: 700; - color: var(--primary-700); - text-transform: uppercase; - letter-spacing: 0.08em; - margin-bottom: var(--space-4); - display: flex; - align-items: center; - gap: var(--space-2); -} - -/* ===== Form Grid ===== */ -.form-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--space-4); -} - -.form-group { - margin-bottom: 0; -} - -.form-group.span-2 { - grid-column: span 2; -} - -/* ===== Form Labels ===== */ -.form-group label { - display: block; - font-size: 0.75rem; - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: var(--space-2); -} - -/* ===== Form Inputs ===== */ -.form-group input, -.form-group textarea, -.form-group select { - width: 100%; - padding: var(--space-3) var(--space-4); - font-family: inherit; - font-size: 0.9375rem; - color: var(--text-primary); - background: var(--surface); - border: 1.5px solid var(--border-light); - border-radius: var(--radius-md); - transition: - border-color var(--transition-fast), - box-shadow var(--transition-fast), - background-color var(--transition-fast); - outline: none; -} - -.form-group input::placeholder, -.form-group textarea::placeholder { - color: var(--gray-400); -} - -.form-group input:hover, -.form-group textarea:hover, -.form-group select:hover { - border-color: var(--gray-300); -} - -.form-group input:focus, -.form-group textarea:focus, -.form-group select:focus { - border-color: var(--primary-400); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12); - background: var(--surface); -} - -.form-group textarea { - resize: vertical; - min-height: 80px; - line-height: 1.5; -} - -.form-group textarea.drag-over { - border-color: var(--primary-400); - background: var(--primary-50); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); -} - -.form-group select { - cursor: pointer; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 12px center; - padding-right: 40px; -} - -/* ===== Line Items ===== */ -.items-header { - display: grid; - grid-template-columns: 2fr 0.7fr 1fr 1fr 40px; - gap: var(--space-3); - align-items: center; - padding: var(--space-2) var(--space-3); - background: var(--gray-50); - border-radius: var(--radius-md); - margin-bottom: var(--space-3); -} - -.items-header span { - font-size: 0.7rem; - font-weight: 700; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.item-row { - display: grid; - grid-template-columns: 2fr 0.7fr 1fr 1fr 40px; - gap: var(--space-3); - align-items: center; - padding: var(--space-2) 0; - margin-bottom: var(--space-2); - animation: fadeInUp var(--transition-fast) ease-out; -} - -.item-row input { - width: 100%; - padding: var(--space-2) var(--space-3); - font-size: 0.875rem; - border: 1.5px solid var(--border-light); - border-radius: var(--radius-sm); - background: var(--surface); - transition: border-color var(--transition-fast), box-shadow var(--transition-fast); -} - -.item-row input:focus { - border-color: var(--primary-400); - box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1); - outline: none; -} - -.item-total { - font-weight: 600; - color: var(--text-primary); - font-size: 0.9rem; - text-align: right; - padding-right: var(--space-2); -} - -.remove-item { - width: 32px; - height: 32px; - min-width: 32px; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - background: var(--error-50); - color: var(--error-500); - border: 1px solid transparent; - border-radius: var(--radius-sm); - font-size: 1.1rem; - font-weight: 600; - line-height: 1; - cursor: pointer; - transition: all var(--transition-fast); -} - -.remove-item:hover { - background: var(--error-500); - color: white; - transform: none; - box-shadow: none; -} - -/* ===== Number Input with Buttons ===== */ -.number-input-wrapper { - display: flex; - align-items: center; - gap: 0; -} - -.number-input-wrapper input { - flex: 1; - border-radius: 0; - text-align: center; - -moz-appearance: textfield; -} - -.number-input-wrapper input::-webkit-outer-spin-button, -.number-input-wrapper input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - -.number-btn { - width: 36px; - height: 38px; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - background: var(--gray-100); - color: var(--text-secondary); - border: 1.5px solid var(--border-light); - font-size: 1rem; - font-weight: 600; - cursor: pointer; - transition: all var(--transition-fast); -} - -.number-btn.decrement { - border-radius: var(--radius-md) 0 0 var(--radius-md); - border-right: none; -} - -.number-btn.increment { - border-radius: 0 var(--radius-md) var(--radius-md) 0; - border-left: none; -} - -.number-btn:hover { - background: var(--primary-100); - color: var(--primary-700); -} - -.number-btn:active { - background: var(--primary-200); -} - -/* ===== Totals Preview ===== */ -.totals-preview { - background: linear-gradient(135deg, var(--primary-50) 0%, var(--success-50) 100%); - padding: var(--space-4); - border-radius: var(--radius-md); - border: 1px solid var(--primary-100); - margin-top: var(--space-3); -} - -.totals-preview p { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--space-2); - font-size: 0.9rem; -} - -.totals-preview p:last-child { - margin-bottom: 0; - padding-top: var(--space-2); - border-top: 1px dashed var(--primary-200); - font-weight: 700; - color: var(--primary-700); -} - -.totals-preview strong { - color: var(--text-secondary); - font-weight: 600; -} - -/* ===== Buttons ===== */ -button { - font-family: inherit; - font-size: 0.875rem; - font-weight: 600; - padding: var(--space-3) var(--space-5); - border: none; - border-radius: var(--radius-md); - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--space-2); - transition: all var(--transition-base); - outline: none; -} - -button:focus-visible { - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.4); -} - -.button-row { - display: flex; - flex-wrap: wrap; - gap: var(--space-3); - align-items: center; - margin-top: var(--space-4); -} - -.button-row input[type="file"].visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - border: 0; -} - -.file-input-label { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--space-2); - cursor: pointer; - font-family: inherit; - font-size: 0.875rem; - font-weight: 600; - padding: var(--space-3) var(--space-5); - background: transparent; - color: var(--text-secondary); - border: 1.5px solid var(--border-light); - border-radius: var(--radius-md); - transition: all var(--transition-base); -} - -.file-input-label:hover { - background: var(--gray-50); - border-color: var(--gray-300); - color: var(--text-primary); -} - -/* Primary Button */ -.primary { - background: linear-gradient(135deg, var(--primary-500) 0%, var(--primary-600) 100%); - color: var(--text-inverse); - box-shadow: var(--shadow-sm), 0 2px 8px rgba(99, 102, 241, 0.25); -} - -.primary:hover { - background: linear-gradient(135deg, var(--primary-600) 0%, var(--primary-700) 100%); - box-shadow: var(--shadow-md), 0 4px 12px rgba(99, 102, 241, 0.35); - transform: translateY(-1px); -} - -.primary:active { - transform: translateY(0); - box-shadow: var(--shadow-sm); -} - -/* Secondary Button */ -.secondary { - background: var(--gray-800); - color: var(--text-inverse); - box-shadow: var(--shadow-sm); -} - -.secondary:hover { - background: var(--gray-900); - box-shadow: var(--shadow-md); - transform: translateY(-1px); -} - -.secondary:active { - transform: translateY(0); -} - -/* Ghost Button */ -.ghost { - background: transparent; - color: var(--text-secondary); - border: 1.5px solid var(--border-light); -} - -.ghost:hover { - background: var(--gray-50); - border-color: var(--gray-300); - color: var(--text-primary); - transform: none; - box-shadow: none; -} - -.ghost:active { - background: var(--gray-100); -} - -/* ===== Invoice Placeholder ===== */ -.invoice-placeholder { - background: linear-gradient(135deg, var(--primary-50) 0%, #f0f4ff 100%); - height: 100vh; - display: flex; - align-items: center; - justify-content: center; - text-align: center; - padding: var(--space-8); -} - -.placeholder-content { - max-width: 320px; -} - -.placeholder-icon { - font-size: 4rem; - margin-bottom: var(--space-4); - opacity: 0.6; -} - -.placeholder-content h3 { - font-family: 'Manrope', sans-serif; - font-size: 1.25rem; - font-weight: 700; - color: var(--text-primary); - margin-bottom: var(--space-3); -} - -.placeholder-content p { - font-size: 0.9rem; - color: var(--text-secondary); - line-height: 1.6; -} - -.placeholder-content strong { - color: var(--primary-600); -} - -/* ===== Invoice Preview Section ===== */ -.invoice-section { - background: white; - padding: var(--space-10); - height: 100vh; - overflow-y: auto; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -/* Tabular figures for proper number alignment in invoice */ -.invoice-table td:nth-child(2), -.invoice-table td:nth-child(3), -.invoice-table td:nth-child(4), -.invoice-totals span, -.balance-due span { - font-variant-numeric: tabular-nums; - font-feature-settings: "tnum"; -} - -.invoice-header { - display: grid; - grid-template-columns: 1fr auto; - align-items: start; - margin-bottom: 40px; - gap: 20px; -} - -.business-info { - text-align: left; -} - -.business-info h1 { - font-size: 18px; - color: #111827; - margin-bottom: 4px; - font-weight: 700; -} - -.business-info p { - margin-bottom: 3px; - color: #4b5563; - font-size: 13px; - line-height: 1.4; -} - -.invoice-title { - text-align: right; -} - -.invoice-title h1 { - font-size: 24px; - color: #111827; - font-weight: 700; - margin: 0 0 8px 0; -} - -.invoice-details { - display: flex; - justify-content: space-between; - margin-bottom: 40px; - gap: 20px; -} - -.bill-to h3 { - color: #6b7280; - margin-bottom: 5px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.05em; -} - -.bill-to p { - margin-bottom: 4px; - color: #4b5563; - font-size: 13px; - line-height: 1.4; -} - -.bill-to p:first-of-type { - font-size: 14px; - font-weight: 700; - color: #111827; - margin-bottom: 4px; -} - -#displaySellerAddress, -#displayBillToAddress, -#displayInstructions { - white-space: pre-line; -} - -.invoice-meta p { - margin-bottom: 4px; - color: #4b5563; - font-size: 13px; -} - -.invoice-table { - width: 100%; - border-collapse: collapse; - margin-bottom: 20px; -} - -.invoice-table th { - background: #f3f4f6; - padding: 8px 10px; - text-align: left; - border: 0.5px solid #e5e7eb; - font-weight: 700; - font-size: 11px; - color: #111827; -} - -.invoice-table td { - padding: 8px 10px; - border: 0.5px solid #e5e7eb; - color: #111827; - font-size: 13px; -} - -.invoice-table th:last-child, -.invoice-table td:last-child { - text-align: right; -} - -.invoice-totals { - display: flex; - flex-direction: column; - align-items: flex-end; - margin-bottom: 40px; -} - -.totals-breakdown { - margin-bottom: 4px; - text-align: right; -} - -.totals-breakdown p { - margin-bottom: 4px; - font-size: 13px; - color: #4b5563; -} - -.totals-breakdown .totals-label { - color: #6b7280; - margin-right: 8px; -} - -.total-row { - display: inline-flex; - align-items: center; - gap: 8px; - padding-top: 6px; - margin-top: 2px; - border-top: 0.5px solid #9ca3af; -} - -.total-row .total-label { - font-weight: 700; - font-size: 12px; - color: #111827; - padding-left: 8px; -} - -.total-row .total-amount { - font-weight: 700; - font-size: 16px; - color: #111827; - min-width: 100px; - text-align: right; -} - -.invoice-footer { - text-align: center; - margin-top: 40px; - padding-top: 20px; - border-top: none; -} - -.invoice-footer h3 { - color: #111827; - font-size: 14px; - font-weight: 700; - margin-bottom: 10px; -} - -.invoice-footer p { - color: #111827; - font-size: 12px; - line-height: 1.5; - margin-bottom: 0; -} - -.bank-details p { - margin-bottom: 5px; - color: #666; -} - -/* ===== Open Source Badge ===== */ -.open-source-badge { - font-size: 0.6rem; - font-weight: 600; - color: var(--primary-600); - background: var(--primary-50); - padding: 2px 8px; - border-radius: var(--radius-full); - text-decoration: none; - margin-left: var(--space-2); - vertical-align: middle; - transition: all var(--transition-fast); -} - -.open-source-badge:hover { - background: var(--primary-100); - color: var(--primary-700); -} - -/* ===== Animations ===== */ -@keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(8px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -/* ===== Print Styles ===== */ -@media print { - @page { - margin: 0.5in; - size: A4; - } - - * { - -webkit-print-color-adjust: exact !important; - print-color-adjust: exact !important; - } - - html, - body { - background: white !important; - margin: 0 !important; - padding: 0 !important; - font-size: 12px !important; - } - - .container { - display: block !important; - max-width: none !important; - margin: 0 !important; - padding: 0 !important; - grid-template-columns: none !important; - gap: 0 !important; - } - - .form-section { - display: none !important; - } - - .invoice-section { - box-shadow: none !important; - padding: 20px !important; - margin: 0 !important; - min-height: auto !important; - width: 100% !important; - border-radius: 0 !important; - } - - .invoice-header { - margin-bottom: 30px !important; - border-bottom: none !important; - padding-bottom: 15px !important; - } - - .business-info h1 { - font-size: 16px !important; - margin-bottom: 5px !important; - } - - .business-info p { - font-size: 11px !important; - margin-bottom: 2px !important; - } - - .invoice-title h1 { - font-size: 28px !important; - } - - .invoice-details { - margin-bottom: 20px !important; - } - - .bill-to h3 { - font-size: 12px !important; - margin-bottom: 8px !important; - } - - .bill-to p, - .invoice-meta p { - font-size: 11px !important; - margin-bottom: 3px !important; - } - - .invoice-table { - margin-bottom: 20px !important; - font-size: 11px !important; - } - - .invoice-table th, - .invoice-table td { - padding: 8px !important; - font-size: 11px !important; - } - - .invoice-totals { - margin-bottom: 30px !important; - font-size: 11px !important; - } - - .balance-due h2 { - font-size: 14px !important; - } - - .invoice-footer h3 { - font-size: 13px !important; - margin-bottom: 10px !important; - } - - .invoice-footer p, - .bank-details p { - font-size: 11px !important; - margin-bottom: 3px !important; - } -} - -/* ===== Responsive: Tablet ===== */ -@media (max-width: 1024px) { - .container { - grid-template-columns: 1fr; - min-height: auto; - } - - .form-section { - height: auto; - max-height: none; - border-right: none; - border-bottom: 1px solid var(--border-light); - padding: var(--space-6); - } - - .invoice-section { - height: auto; - min-height: 600px; - } - - .header-row { - flex-wrap: wrap; - } - - .header-row h2 { - flex: 1; - min-width: 200px; - } -} - -/* ===== Responsive: Mobile ===== */ -@media (max-width: 640px) { - body { - font-size: 15px; - } - - .container { - gap: 0; - } - - .form-section { - padding: var(--space-5); - } - - .form-section h2 { - font-size: 1.25rem; - margin-bottom: var(--space-5); - } - - .form-grid { - grid-template-columns: 1fr; - gap: var(--space-3); - } - - .form-group.span-2 { - grid-column: auto; - } - - .form-group input, - .form-group textarea, - .form-group select { - padding: var(--space-4); - font-size: 1rem; - min-height: 48px; - } - - /* Mobile line items - card style */ - .items-header { - display: none; - } - - .item-row { - grid-template-columns: 1fr; - gap: var(--space-3); - background: var(--gray-50); - padding: var(--space-4); - border-radius: var(--radius-md); - margin-bottom: var(--space-3); - position: relative; - } - - .item-row input { - padding: var(--space-3) var(--space-4); - font-size: 1rem; - min-height: 48px; - } - - .item-row input[type="number"] { - text-align: left; - } - - .item-row .item-description::before { - content: 'Description'; - } - - .item-row input::placeholder { - color: var(--gray-400); - } - - .item-total { - text-align: left; - font-size: 1rem; - padding: var(--space-2) 0; - border-top: 1px dashed var(--gray-200); - } - - .item-total::before { - content: 'Line Total: '; - color: var(--text-muted); - font-weight: 500; - } - - .remove-item { - position: absolute; - top: var(--space-3); - right: var(--space-3); - width: 32px; - height: 32px; - } - - .button-row { - flex-direction: column; - gap: var(--space-3); - } - - .button-row button { - width: 100%; - min-height: 48px; - } - - .button-row input[type="file"] { - width: 100%; - } - - /* Invoice preview mobile */ - .invoice-section { - padding: var(--space-5); - min-height: auto; - } - - .invoice-header { - grid-template-columns: 1fr; - text-align: left; - } - - .invoice-title { - text-align: left; - margin-top: var(--space-3); - } - - .invoice-title h1 { - font-size: 28px; - } - - .invoice-details { - flex-direction: column; - gap: var(--space-5); - } - - .invoice-meta { - margin-top: 0; - } - - .invoice-table { - font-size: 0.875rem; - } - - .invoice-table th, - .invoice-table td { - padding: var(--space-2) var(--space-3); - } - - /* Header row mobile */ - .header-row { - flex-direction: column; - align-items: stretch; - gap: var(--space-3); - } - - .header-row h2 { - font-size: 1.1rem; - } - - #historyToggle { - width: 100%; - min-height: 44px; - } - - /* History panel mobile */ - .history-panel { - padding: var(--space-3); - } - - .history-header { - flex-wrap: wrap; - gap: var(--space-2); - } - - .history-item { - padding: var(--space-3); - } - - .history-item-title { - font-size: 0.9rem; - } - - /* Number input wrapper mobile */ - .number-input-wrapper { - width: 100%; - } - - .number-btn { - width: 48px; - height: 48px; - font-size: 1.2rem; - } - - /* File input label mobile */ - .file-input-label { - width: 100%; - min-height: 48px; - justify-content: center; - } - - /* Open source badge mobile */ - .open-source-badge { - font-size: 0.55rem; - padding: 2px 6px; - } -} - -/* ===== Focus visible for accessibility ===== */ -:focus-visible { - outline: 2px solid var(--primary-400); - outline-offset: 2px; -} - -input:focus-visible, -textarea:focus-visible, -select:focus-visible, -button:focus-visible { - outline: none; -} \ No newline at end of file From 11e82f7b6360e00c1ebef8fa4d4ce962bac705e7 Mon Sep 17 00:00:00 2001 From: Mahesh Rijal <62394512+maheshrijal@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:56:59 +0530 Subject: [PATCH 07/17] Update src/js/modules/form.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/js/modules/form.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/modules/form.js b/src/js/modules/form.js index 26a5240..59bde9e 100644 --- a/src/js/modules/form.js +++ b/src/js/modules/form.js @@ -6,7 +6,7 @@ import { getValue, setValue, setText } from './dom.js'; import { parseNumber, formatCurrency } from './formatters.js'; import { calculateTotals } from './calculations.js'; -import { collectItemsFromForm, renderItemsForm, addItemRow } from './items.js'; +import { collectItemsFromForm, renderItemsForm } from './items.js'; import { saveDraft, deepMerge } from './storage.js'; import { renderInvoice, isInvoiceVisible } from './invoice.js'; From 289bb1fb9b0a55a6c1d46578e9fda7fbe2c3df9c Mon Sep 17 00:00:00 2001 From: Mahesh Rijal <62394512+maheshrijal@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:57:08 +0530 Subject: [PATCH 08/17] Update src/js/main.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/js/main.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/main.js b/src/js/main.js index 1503142..5fbae8c 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -6,7 +6,7 @@ // Import modules import { getValue, setValue } from './modules/dom.js'; import { parseNumber } from './modules/formatters.js'; -import { saveDraft, loadDraft, clearDraft, STORAGE_KEY } from './modules/storage.js'; +import { saveDraft, loadDraft, clearDraft } from './modules/storage.js'; import { getHistory, saveToHistory, From 1aba3fcbc6cac3bc597daeeb410291a8cf30a438 Mon Sep 17 00:00:00 2001 From: Mahesh Rijal <62394512+maheshrijal@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:57:20 +0530 Subject: [PATCH 09/17] Update src/js/modules/json-io.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/js/modules/json-io.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/modules/json-io.js b/src/js/modules/json-io.js index 809ecfe..ab46828 100644 --- a/src/js/modules/json-io.js +++ b/src/js/modules/json-io.js @@ -3,7 +3,7 @@ * Export and import invoice data as JSON */ -import { getValue, setValue } from './dom.js'; +import { setValue } from './dom.js'; import { getDataFromForm, applyDataToForm, syncFromForm } from './form.js'; import { deepMerge } from './storage.js'; From d34f918439dd96f5f3982d1b96766871e143c198 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:15:27 +0530 Subject: [PATCH 10/17] Remove unused imports from main.js (#3) * Initial plan * Remove unused imports getDataFromForm and updateFormTotalsDisplay from main.js Co-authored-by: maheshrijal <62394512+maheshrijal@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: maheshrijal <62394512+maheshrijal@users.noreply.github.com> --- src/js/main.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/js/main.js b/src/js/main.js index 5fbae8c..a62a670 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -19,8 +19,6 @@ import { import { addItemRow, renderItemsForm, ensureAtLeastOneItemRow } from './modules/items.js'; import { renderInvoice, isInvoiceVisible } from './modules/invoice.js'; import { - getDataFromForm, - updateFormTotalsDisplay, validateData, syncFromForm, applyDataToForm From 77868e400389a318955b6c34a411a1e7f31e0548 Mon Sep 17 00:00:00 2001 From: maheshrijal <62394512+maheshrijal@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:20:46 +0530 Subject: [PATCH 11/17] style: refactor duplicate detection logic in history module --- src/js/modules/history.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/modules/history.js b/src/js/modules/history.js index 77b8d24..ffce9a2 100644 --- a/src/js/modules/history.js +++ b/src/js/modules/history.js @@ -38,7 +38,7 @@ export function saveToHistory(data) { }; // Check for duplicate invoice number - const existingIndex = history.findIndex(h => h.number === entry.number && entry.number); + const existingIndex = history.findIndex(h => entry.number && h.number === entry.number); if (existingIndex >= 0) { history[existingIndex] = entry; } else { From 42e5e65ac24527aa4a48e82435a9de26b869fc1e Mon Sep 17 00:00:00 2001 From: maheshrijal <62394512+maheshrijal@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:22:06 +0530 Subject: [PATCH 12/17] fix(logic): add error handling to deleteFromHistory --- src/js/modules/history.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/js/modules/history.js b/src/js/modules/history.js index ffce9a2..b1a63d6 100644 --- a/src/js/modules/history.js +++ b/src/js/modules/history.js @@ -61,7 +61,11 @@ export function saveToHistory(data) { */ export function deleteFromHistory(id) { const history = getHistory().filter(h => h.id !== id); - localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); + try { + localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); + } catch (e) { + console.warn('Failed to delete from history:', e); + } } /** From 3b37b3cb2684332203a3ecedaf2ff41b8f13543f Mon Sep 17 00:00:00 2001 From: maheshrijal <62394512+maheshrijal@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:22:56 +0530 Subject: [PATCH 13/17] style: rename shadowed variable in pdf generation --- src/js/modules/pdf.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/js/modules/pdf.js b/src/js/modules/pdf.js index 4464d27..c4430d6 100644 --- a/src/js/modules/pdf.js +++ b/src/js/modules/pdf.js @@ -300,8 +300,8 @@ export async function shareInvoice() { // Generate Blob const blob = await new Promise((resolve) => { - pdfMake.createPdf(docDefinition).getBlob((blob) => { - resolve(blob); + pdfMake.createPdf(docDefinition).getBlob((pdfBlob) => { + resolve(pdfBlob); }); }); From 1317d09168379326101db99434c5fb3d219b9fe7 Mon Sep 17 00:00:00 2001 From: maheshrijal <62394512+maheshrijal@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:32:33 +0530 Subject: [PATCH 14/17] style: strengthen form validation to check all items --- src/js/modules/form.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/modules/form.js b/src/js/modules/form.js index 59bde9e..206a220 100644 --- a/src/js/modules/form.js +++ b/src/js/modules/form.js @@ -78,7 +78,7 @@ export function validateData(data) { if (!data.billTo.name || !data.billTo.address) return false; if (!data.invoice.date || !data.invoice.number) return false; if (!Array.isArray(data.items) || data.items.length === 0) return false; - const validItems = data.items.some((item) => item.description && item.quantity > 0 && item.unitPrice >= 0); + const validItems = data.items.every((item) => item.description && item.quantity > 0 && item.unitPrice >= 0); return validItems; } From 6ff343df850742769ff4e90569afae43f6a64e38 Mon Sep 17 00:00:00 2001 From: maheshrijal <62394512+maheshrijal@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:36:34 +0530 Subject: [PATCH 15/17] style: fix race condition in test dialog handling --- tests/rebill.spec.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/rebill.spec.js b/tests/rebill.spec.js index 3442ad2..b0302bf 100644 --- a/tests/rebill.spec.js +++ b/tests/rebill.spec.js @@ -112,12 +112,11 @@ test.describe('Invoice Generation', () => { test('should show validation alert for incomplete form', async ({ page }) => { // Try to generate without filling required fields - page.on('dialog', async dialog => { - expect(dialog.message()).toContain('required fields'); - await dialog.accept(); - }); - + const dialogPromise = page.waitForEvent('dialog'); await page.click('button:has-text("Generate Bill")'); + const dialog = await dialogPromise; + expect(dialog.message()).toContain('required fields'); + await dialog.accept(); }); }); From 1efc14a4f769b7d09b05a60b6703c7a53e10435a Mon Sep 17 00:00:00 2001 From: maheshrijal <62394512+maheshrijal@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:39:04 +0530 Subject: [PATCH 16/17] style: respect currency in item total and fix test deadlock --- src/js/modules/items.js | 5 ++++- tests/rebill.spec.js | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/js/modules/items.js b/src/js/modules/items.js index 7673eb8..c4bd18b 100644 --- a/src/js/modules/items.js +++ b/src/js/modules/items.js @@ -3,6 +3,7 @@ * Line items form management */ +import { getValue } from './dom.js'; import { parseNumber, formatCurrency } from './formatters.js'; /** @@ -52,7 +53,9 @@ export function addItemRow(item = {}) { const totalSpan = document.createElement('span'); totalSpan.className = 'item-total'; - totalSpan.textContent = '0.00'; + const currentCurrency = getValue('currencyCode', 'INR'); + const initialTotal = (item.quantity ?? 1) * (item.unitPrice ?? 0); + totalSpan.textContent = formatCurrency(initialTotal, currentCurrency); const removeBtn = document.createElement('button'); removeBtn.type = 'button'; diff --git a/tests/rebill.spec.js b/tests/rebill.spec.js index b0302bf..ece4d3e 100644 --- a/tests/rebill.spec.js +++ b/tests/rebill.spec.js @@ -113,10 +113,12 @@ test.describe('Invoice Generation', () => { test('should show validation alert for incomplete form', async ({ page }) => { // Try to generate without filling required fields const dialogPromise = page.waitForEvent('dialog'); - await page.click('button:has-text("Generate Bill")'); + // Do not await click immediately as it might be blocked by the dialog + const clickPromise = page.click('button:has-text("Generate Bill")'); const dialog = await dialogPromise; expect(dialog.message()).toContain('required fields'); await dialog.accept(); + await clickPromise; }); }); From 44235ce9bbd532372909aa118104c9888970d840 Mon Sep 17 00:00:00 2001 From: maheshrijal <62394512+maheshrijal@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:48:04 +0530 Subject: [PATCH 17/17] fix: resolve validation deadlock and strictness in tests --- src/js/modules/form.js | 2 +- tests/rebill.spec.js | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/js/modules/form.js b/src/js/modules/form.js index 206a220..59bde9e 100644 --- a/src/js/modules/form.js +++ b/src/js/modules/form.js @@ -78,7 +78,7 @@ export function validateData(data) { if (!data.billTo.name || !data.billTo.address) return false; if (!data.invoice.date || !data.invoice.number) return false; if (!Array.isArray(data.items) || data.items.length === 0) return false; - const validItems = data.items.every((item) => item.description && item.quantity > 0 && item.unitPrice >= 0); + const validItems = data.items.some((item) => item.description && item.quantity > 0 && item.unitPrice >= 0); return validItems; } diff --git a/tests/rebill.spec.js b/tests/rebill.spec.js index ece4d3e..8bdbef6 100644 --- a/tests/rebill.spec.js +++ b/tests/rebill.spec.js @@ -112,13 +112,13 @@ test.describe('Invoice Generation', () => { test('should show validation alert for incomplete form', async ({ page }) => { // Try to generate without filling required fields - const dialogPromise = page.waitForEvent('dialog'); - // Do not await click immediately as it might be blocked by the dialog - const clickPromise = page.click('button:has-text("Generate Bill")'); - const dialog = await dialogPromise; - expect(dialog.message()).toContain('required fields'); - await dialog.accept(); - await clickPromise; + await Promise.all([ + page.waitForEvent('dialog').then(async dialog => { + expect(dialog.message()).toContain('required fields'); + await dialog.accept(); + }), + page.click('button:has-text("Generate Bill")') + ]); }); });