diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..6fd7864 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,46 @@ +name: Playwright Tests + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + + permissions: + contents: read + pull-requests: write + + 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 + + - 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: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index d5acdb6..3504795 100644 --- a/.gitignore +++ b/.gitignore @@ -58,5 +58,9 @@ node_modules/ *~ # Temporary files -*.tmp -*.temp \ No newline at end of file +*.temp + +# Playwright +playwright-report/ +test-results/ +test-results.json diff --git a/index.html b/index.html index e3e03f8..3249b3d 100644 --- a/index.html +++ b/index.html @@ -32,10 +32,10 @@ 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"> - - - - + + + +
@@ -204,6 +204,8 @@SUBTOTAL
-TAX
-DISCOUNT
+Subtotal +
+Tax
+Discount +
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/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/src/js/main.js b/src/js/main.js new file mode 100644 index 0000000..a62a670 --- /dev/null +++ b/src/js/main.js @@ -0,0 +1,284 @@ +/** + * 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 } 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 { + 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..59bde9e --- /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 } 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..b1a63d6 --- /dev/null +++ b/src/js/modules/history.js @@ -0,0 +1,160 @@ +/** + * 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 => entry.number && h.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); + try { + localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); + } catch (e) { + console.warn('Failed to delete from history:', e); + } +} + +/** + * 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..a042b8a --- /dev/null +++ b/src/js/modules/invoice.js @@ -0,0 +1,124 @@ +/** + * 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); + }); + } + + // 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'); + 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..c4bd18b --- /dev/null +++ b/src/js/modules/items.js @@ -0,0 +1,117 @@ +/** + * Items Module + * Line items form management + */ + +import { getValue } from './dom.js'; +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'; + 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'; + 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..ab46828 --- /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 { 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..c4430d6 --- /dev/null +++ b/src/js/modules/pdf.js @@ -0,0 +1,335 @@ +/** + * PDF Module + * PDF generation and sharing functionality using pdfmake + */ + +import { getDataFromForm } from './form.js'; +import { formatCurrency, formatDate } from './formatters.js'; + +/** + * Generate PDF Document Definition from data + * @param {Object} data - Invoice data + * @returns {Object} pdfmake document definition + */ +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' } + ]); + } + + if (totals.discount > 0) { + totalsRows.push([ + { text: 'Discount', alignment: 'right' }, + { text: `-${fmt(totals.discount)}`, alignment: 'right' } + ]); + } + } + + // Total Row + totalsRows.push([ + { text: 'Total', bold: true, fontSize: 12, alignment: 'right' }, + { text: fmt(totals.total), bold: true, fontSize: 12, alignment: 'right' } + ]); + + const invoiceNumber = invoice.number || 'draft'; + const customerName = billTo.name || 'Customer'; + + return { + // Moderate margins: [left, top, right, bottom] + pageMargins: [30, 40, 30, 40], + + 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: { + // 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 + }, + + // 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: '#111827', margin: [0, 2, 0, 2] }, + + // Footer styles + footerNote: { fontSize: 14, bold: true, color: '#111827' }, + footerText: { fontSize: 10, color: '#111827', lineHeight: 1.5 } + }, + defaultStyle: { + font: 'Roboto' + } + }; +} + +/** + * Generate PDF and download it + */ +export async function downloadPDF() { + const downloadBtn = document.getElementById('downloadBtn'); + let originalBtnText = ''; + + // Show loading state if button exists + if (downloadBtn) { + originalBtnText = downloadBtn.textContent; + downloadBtn.textContent = 'Generating...'; + downloadBtn.disabled = true; + } + + 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`; + + // Create and download + pdfMake.createPdf(docDefinition).download(filename); + + } catch (error) { + console.error('Error generating PDF:', error); + alert('Error generating PDF. Please try again.'); + } finally { + // Restore button if it exists + if (downloadBtn) { + downloadBtn.textContent = originalBtnText; + downloadBtn.disabled = false; + } + } +} + +/** + * 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 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((pdfBlob) => { + resolve(pdfBlob); + }); + }); + + 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 + pdfMake.createPdf(docDefinition).download(filename); + + 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 deleted file mode 100644 index c247254..0000000 --- a/styles.css +++ /dev/null @@ -1,1262 +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', Arial, sans-serif; -} - -.invoice-header { - display: grid; - grid-template-columns: 1fr auto; - align-items: start; - margin-bottom: 40px; - padding-bottom: 20px; - gap: 20px; -} - -.business-info { - text-align: left; -} - -.business-info h1 { - font-size: 20px; - color: #2c3e50; - margin-bottom: 8px; - font-weight: bold; -} - -.business-info p { - margin-bottom: 3px; - color: #666; - font-size: 12px; - line-height: 1.4; -} - -.invoice-title { - text-align: right; -} - -.invoice-title h1 { - font-size: 36px; - color: #2c3e50; - font-weight: bold; - margin: 0; -} - -.invoice-details { - display: flex; - justify-content: space-between; - margin-bottom: 30px; - gap: 20px; -} - -.bill-to h3 { - color: #2c3e50; - margin-bottom: 10px; - font-size: 14px; - font-weight: bold; -} - -.bill-to p { - margin-bottom: 5px; - color: #666; -} - -#displaySellerAddress, -#displayBillToAddress, -#displayInstructions { - white-space: pre-line; -} - -.invoice-meta p { - margin-bottom: 5px; - color: #666; -} - -.invoice-table { - width: 100%; - border-collapse: collapse; - margin-bottom: 30px; -} - -.invoice-table th { - background: #f8f9fa; - padding: 12px; - text-align: left; - border: 1px solid #ddd; - font-weight: bold; - color: #2c3e50; -} - -.invoice-table td { - padding: 12px; - border: 1px solid #ddd; - color: #666; -} - -.invoice-table th:last-child, -.invoice-table td:last-child { - text-align: right; -} - -.invoice-totals { - text-align: right; - margin-bottom: 40px; -} - -.subtotal { - margin-bottom: 10px; - padding: 10px 0; - border-top: 1px solid #ddd; -} - -.subtotal p { - color: #666; -} - -.balance-due { - background: #f8f9fa; - padding: 15px; - border: 2px solid #2c3e50; -} - -.balance-due h2 { - color: #2c3e50; - font-size: 18px; -} - -.invoice-footer { - text-align: center; - margin-top: 40px; - padding-top: 20px; - border-top: 1px solid #eee; -} - -.invoice-footer h3 { - color: #2c3e50; - margin-bottom: 15px; -} - -.invoice-footer p { - color: #666; - margin-bottom: 15px; -} - -.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 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); + }); +}); diff --git a/tests/rebill.spec.js b/tests/rebill.spec.js new file mode 100644 index 0000000..8bdbef6 --- /dev/null +++ b/tests/rebill.spec.js @@ -0,0 +1,284 @@ +// @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 + 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")') + ]); + }); +}); + +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 and contain correct text', 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 and check content + const path = await download.path(); + const fs = await import('fs'); + 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 }) => { + // 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 0000000..c1e26b0 Binary files /dev/null and b/tests/rebill.spec.js-snapshots/invoice-preview-chromium-darwin.png differ 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 0000000..5d6e0c3 Binary files /dev/null and b/tests/rebill.spec.js-snapshots/invoice-preview-firefox-darwin.png differ 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 0000000..eb42e17 Binary files /dev/null and b/tests/rebill.spec.js-snapshots/invoice-preview-webkit-darwin.png differ