KLEINUILTIES-WEBAPP/js/invoice.js
Slim Uiltjies App 1939bf2ef4 Initial commit: Slim Uiltjies AfterCare invoice system
- Progressive Web App with Express + JSON backend
- PIN-based auth, multi-device sync
- Invoice + Statement PDF generation (branded, Grandstander + Nunito)
- Mobile + Desktop responsive layouts
- n8n backup integration
- Custom logo upload
- Settings for business info, fees, banking
- Bulk invoice generation
- Web Share API integration
2026-08-08 09:25:17 +02:00

277 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Slim Uiltjies — Invoice & Statement rendering
Builds the on-brand invoice / statement HTML that matches the original receipt.
*/
(function () {
'use strict';
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
function fmtCurrency(n, currency = 'R') {
const v = Number(n) || 0;
return `${currency}${v.toLocaleString('en-ZA', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
}
function fmtDate(d) {
if (!d) return '';
const [y, m, day] = String(d).split('-').map(Number);
return `${day} ${months[m - 1]} ${y}`;
}
function formatPeriod(p) {
if (!p) return '';
const [y, m] = String(p).split('-').map(Number);
return `${months[m - 1]} ${y}`;
}
function statusBadge(status) {
const map = {
paid: { cls: 'invoice-status-paid', label: 'PAID' },
unpaid: { cls: 'invoice-status-unpaid', label: 'UNPAID' },
partial: { cls: 'invoice-status-partial', label: 'PARTIAL' },
};
const s = map[status] || map.unpaid;
return `<span class="invoice-status-pill ${s.cls}">${s.label}</span>`;
}
function paymentBlock(payments) {
if (!payments || payments.length === 0) return '';
const rows = payments.map((p) => `
<div class="invoice-total-row">
<span>${fmtDate(p.date)} · ${(p.method || '').toUpperCase()}</span>
<span>${fmtCurrency(p.amount)}</span>
</div>
`).join('');
const total = payments.reduce((s, p) => s + Number(p.amount || 0), 0);
return `
<div class="invoice-payment-info">
<div class="label">Payments received</div>
${rows}
<div class="invoice-total-row grand" style="border-top-color:#1F8A4D">
<span>Total received</span>
<span>${fmtCurrency(total)}</span>
</div>
</div>
`;
}
function renderInvoiceHTML({ invoice, parent, child, payments, business, type = 'invoice' }) {
const items = (invoice.items || []).map((it) => `
<tr>
<td class="desc">${escape(it.description)}</td>
<td style="text-align:center">${it.quantity || 1}</td>
<td class="amount">${fmtCurrency(it.unitPrice, business.currency)}</td>
<td class="amount">${fmtCurrency(it.total, business.currency)}</td>
</tr>
`).join('');
const dueText = invoice.dueDate ? `Due ${fmtDate(invoice.dueDate)}` : '';
const logoHtml = business.logoDataUrl
? `<div class="invoice-logo" style="background-image:url('${business.logoDataUrl.replace(/'/g, "\\'")}');background-size:cover;background-position:center"></div>`
: `<div class="invoice-logo">${window.SUIcons.brandOwl(92)}</div>`;
return `
<div class="invoice-doc">
<div class="invoice-header">
<div class="invoice-brand">
<div class="invoice-brand-info">
${logoHtml}
<div>
<h1 class="invoice-business-name">${escape(business.name)}</h1>
<span class="invoice-tagline">"${escape(business.tagline || 'A safe place to learn, grow and shine!')}"</span>
</div>
</div>
<div class="invoice-meta">
<div class="invoice-no">${escape(invoice.number || 'INVOICE')}</div>
<div class="invoice-meta-row"><strong>Issued:</strong> ${fmtDate(invoice.issueDate)}</div>
${dueText ? `<div class="invoice-meta-row"><strong>${escape(dueText)}</strong></div>` : ''}
${statusBadge(invoice.status)}
</div>
</div>
</div>
<h2 class="invoice-title">${type === 'statement' ? 'STATEMENT OF PAYMENTS RECEIVED' : 'INVOICE'}</h2>
<div class="invoice-body">
<div class="invoice-info-grid">
<div class="invoice-info-block">
<div class="label">Parent / Guardian</div>
<div class="value">${escape(parent.name)}</div>
${parent.phone ? `<div style="font-size:12px;color:var(--ink-soft);margin-top:2px">${escape(parent.phone)}</div>` : ''}
${parent.email ? `<div style="font-size:12px;color:var(--ink-soft)">${escape(parent.email)}</div>` : ''}
${parent.address ? `<div style="font-size:12px;color:var(--ink-soft)">${escape(parent.address)}</div>` : ''}
</div>
<div class="invoice-info-block right">
<div class="label">Child & Period</div>
<div class="value">${escape(child.name)}</div>
<div style="font-size:12px;color:var(--ink-soft);margin-top:2px">${child.plan === 'full' ? 'Full Day' : (child.plan === 'half' ? 'Half Day' : escape(child.plan))}</div>
<div style="font-size:12px;color:var(--ink-soft)">${formatPeriod(invoice.period)}</div>
</div>
</div>
${paymentBlock(payments)}
<table class="invoice-table">
<thead>
<tr>
<th style="width:55%">Description</th>
<th style="width:10%;text-align:center">Qty</th>
<th style="width:17.5%" class="amount">Unit</th>
<th style="width:17.5%" class="amount">Total</th>
</tr>
</thead>
<tbody>${items}</tbody>
</table>
<div class="invoice-totals">
<div class="invoice-totals-box">
<div class="invoice-total-row">
<span>Subtotal</span>
<span>${fmtCurrency(invoice.subtotal, business.currency)}</span>
</div>
${invoice.paid > 0 ? `
<div class="invoice-total-row">
<span>Paid</span>
<span style="color:#1F8A4D">− ${fmtCurrency(invoice.paid, business.currency)}</span>
</div>` : ''}
<div class="invoice-total-row grand">
<span>${invoice.balance > 0 ? 'Balance Due' : 'Total Paid'}</span>
<span>${fmtCurrency(invoice.balance > 0 ? invoice.balance : invoice.total, business.currency)}</span>
</div>
</div>
</div>
${invoice.notes ? `
<div class="invoice-notes">
<div class="label">Notes</div>
<div class="value">${escape(invoice.notes)}</div>
</div>` : ''}
${business.bankName || business.accountNumber ? `
<div class="invoice-notes">
<div class="label">Banking Details</div>
<div class="value">
${business.bankName ? `<div>Bank: <strong>${escape(business.bankName)}</strong></div>` : ''}
${business.accountHolder ? `<div>Account holder: <strong>${escape(business.accountHolder)}</strong></div>` : ''}
${business.accountNumber ? `<div>Account no: <strong>${escape(business.accountNumber)}</strong></div>` : ''}
${business.branchCode ? `<div>Branch code: <strong>${escape(business.branchCode)}</strong></div>` : ''}
${business.referenceNote ? `<div style="margin-top:4px">${escape(business.referenceNote)}</div>` : ''}
</div>
</div>` : ''}
</div>
<div class="invoice-footer">
${business.footerNote ? business.footerNote.split('\n').map((l) => `<div class="invoice-footer-row">${escape(l)}</div>`).join('') : ''}
<div class="invoice-footer-row" style="margin-top:8px">
${business.address ? escape(business.address) + ' · ' : ''}
${business.phone ? escape(business.phone) : ''}
${business.email ? ' · ' + escape(business.email) : ''}
</div>
${business.facebook ? `<div class="invoice-footer-row">${escape(business.facebook)}</div>` : ''}
</div>
</div>`;
}
// Statement of payments received — modeled on the original receipt
function renderStatementHTML({ parent, children, payments, business, periodStart, periodEnd }) {
// Aggregate payments by child + period for display
const rows = [];
let total = 0;
const childMap = {};
children.forEach((c) => { childMap[c.id] = c; });
const sorted = payments.slice().sort((a, b) => (a.date || '').localeCompare(b.date || ''));
for (const p of sorted) {
const child = childMap[p.childName ? -1 : (p.invoiceChildId || 0)]; // we'll fall back below
// We need the child for each payment — we'll resolve by looking up the invoice
rows.push(p);
total += Number(p.amount) || 0;
}
const rowsHtml = rows.map((p) => `
<tr>
<td>${fmtDate(p.date)}</td>
<td>${escape(p.childName || '')}</td>
<td>${escape((p.method || '').toUpperCase())}</td>
<td class="right">${fmtCurrency(p.amount, business.currency)}</td>
<td style="font-style:italic">${escape(p.receivedBy || '')}</td>
</tr>
`).join('');
return `
<div class="invoice-doc">
<div class="invoice-header">
<div class="invoice-brand">
<div class="invoice-brand-info">
<div class="invoice-logo">${window.SUIcons.brandOwl(92)}</div>
<div>
<h1 class="invoice-business-name">${escape(business.name)}</h1>
<span class="invoice-tagline">"${escape(business.tagline || 'A safe place to learn, grow and shine!')}"</span>
</div>
</div>
<div class="invoice-meta">
<div class="invoice-meta-label">Statement Period</div>
<div style="font-weight:700;font-size:13px">${formatPeriod(periodStart)} — ${formatPeriod(periodEnd)}</div>
</div>
</div>
</div>
<h2 class="invoice-title">STATEMENT OF THE PROOFS OF<br/><span class="invoice-title-pink">PAYMENTS RECEIVED</span></h2>
<div class="invoice-body">
<div class="invoice-info-grid">
<div class="invoice-info-block">
<div class="label">Parent / Guardian</div>
<div class="value">${escape(parent.name)}</div>
${parent.phone ? `<div style="font-size:12px;color:var(--ink-soft);margin-top:2px">${escape(parent.phone)}</div>` : ''}
${parent.email ? `<div style="font-size:12px;color:var(--ink-soft)">${escape(parent.email)}</div>` : ''}
</div>
<div class="invoice-info-block right">
<div class="label">Children</div>
${children.map((c) => `<div style="font-weight:700">${escape(c.name)}</div>`).join('')}
</div>
</div>
<table class="statement-table">
<thead>
<tr>
<th style="width:15%">Date</th>
<th style="width:25%">Child</th>
<th style="width:15%">Method</th>
<th style="width:20%" class="right">Amount</th>
<th style="width:25%">Received by</th>
</tr>
</thead>
<tbody>${rowsHtml || '<tr><td colspan="5" style="text-align:center;color:var(--ink-soft);padding:20px">No payments in this period</td></tr>'}</tbody>
</table>
<div style="text-align:right;margin-top:8px">
<span class="statement-total">TOTAL RECEIVED: ${fmtCurrency(total, business.currency)}</span>
</div>
</div>
<div class="invoice-footer">
${business.footerNote ? business.footerNote.split('\n').map((l) => `<div class="invoice-footer-row">${escape(l)}</div>`).join('') : ''}
<div class="invoice-footer-row" style="margin-top:8px">
${business.phone ? escape(business.phone) : ''}
${business.facebook ? ' · ' + escape(business.facebook) : ''}
</div>
</div>
</div>`;
}
function escape(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
window.SUInvoice = {
renderInvoiceHTML,
renderStatementHTML,
fmtCurrency,
fmtDate,
formatPeriod,
escape,
};
})();