278 lines
11 KiB
JavaScript
278 lines
11 KiB
JavaScript
|
|
/* 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) => ({
|
|||
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|||
|
|
}[c]));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.SUInvoice = {
|
|||
|
|
renderInvoiceHTML,
|
|||
|
|
renderStatementHTML,
|
|||
|
|
fmtCurrency,
|
|||
|
|
fmtDate,
|
|||
|
|
formatPeriod,
|
|||
|
|
escape,
|
|||
|
|
};
|
|||
|
|
})();
|