KLEINUILTIES-WEBAPP/js/views.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

1827 lines
77 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Slim Uiltjies — Views
One render function per screen. Each returns the HTML to drop into the view container,
and binds its own event handlers after rendering.
*/
(function () {
'use strict';
const { fmtCurrency, fmtDate, formatPeriod, escape } = window.SUInvoice;
const DB = window.SUDB;
const Share = window.SUShare;
const PDF = window.SUPDF;
// ============================================
// Helpers
// ============================================
function el(html) {
const t = document.createElement('template');
t.innerHTML = html.trim();
return t.content.firstElementChild;
}
function toast(message, type = 'info') {
const root = document.getElementById('toastRoot');
const node = document.createElement('div');
node.className = `toast toast-${type}`;
node.textContent = message;
root.appendChild(node);
setTimeout(() => {
node.style.opacity = '0';
node.style.transform = 'translateY(20px)';
node.style.transition = 'opacity 0.2s, transform 0.2s';
setTimeout(() => node.remove(), 220);
}, 2400);
}
function openModal({ title, body, footer }) {
const root = document.getElementById('modalRoot');
root.innerHTML = `
<div class="modal-backdrop" id="modalBackdrop">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-head">
<h3 class="modal-title">${title}</h3>
<button class="btn btn-icon btn-ghost" id="modalClose" aria-label="Close">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">${body}</div>
${footer ? `<div class="modal-foot">${footer}</div>` : ''}
</div>
</div>`;
const close = () => root.innerHTML = '';
document.getElementById('modalBackdrop').addEventListener('click', (e) => {
if (e.target.id === 'modalBackdrop') close();
});
document.getElementById('modalClose').addEventListener('click', close);
return { close, root };
}
function initialsOf(name) {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function planLabel(p) { return p === 'full' ? 'Full Day' : (p === 'half' ? 'Half Day' : (p || '')); }
function statusBadge(s) {
const map = {
paid: { cls: 'badge-green', label: 'Paid' },
unpaid: { cls: 'badge-pink', label: 'Unpaid' },
partial: { cls: 'badge-yellow', label: 'Partial' },
};
const t = map[s] || map.unpaid;
return `<span class="badge ${t.cls}">${t.label}</span>`;
}
function nextReceiptNumber() {
return DB.getMeta('nextReceipt').then((n) => {
const num = (n || 1);
return num;
});
}
async function bumpReceipt() {
const n = await DB.getMeta('nextReceipt');
await DB.setMeta('nextReceipt', (n || 1) + 1);
return n || 1;
}
// ============================================
// HOME
// ============================================
async function homeView() {
const stats = await DB.dashboardStats();
const business = await DB.getBusiness();
const greeting = greetingFor();
const recentInvoices = (await DB.getAll('invoices'))
.sort((a, b) => (b.issueDate || '').localeCompare(a.issueDate || ''))
.slice(0, 5);
const childMap = {};
(await DB.getAll('children')).forEach((c) => { childMap[c.id] = c; });
const parentMap = {};
(await DB.getAll('parents')).forEach((p) => { parentMap[p.id] = p; });
const recentHtml = recentInvoices.map((inv) => {
const child = childMap[inv.childId] || {};
const parent = parentMap[inv.parentId] || {};
return `
<a class="list-item" data-invoice="${inv.id}">
<div class="avatar avatar-blue">${initialsOf(child.name || '?')}</div>
<div class="list-item-body">
<p class="list-item-title">${escape(child.name)} · ${formatPeriod(inv.period)}</p>
<p class="list-item-sub">${escape(parent.name)}</p>
</div>
<div class="list-item-meta">
${fmtCurrency(inv.total, business.currency)}<br/>
${statusBadge(inv.status)}
</div>
</a>`;
}).join('');
return `
<div class="hero">
<div class="hero-greeting">${greeting}</div>
<h1 class="hero-title">${escape(business.name)}</h1>
<p class="hero-sub">${escape(business.tagline || 'A safe place to learn, grow and shine!')}</p>
</div>
<div class="stats">
<div class="stat">
<div class="stat-icon bg-purple-soft">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<div class="stat-label">Parents</div>
<div class="stat-value">${stats.parents}</div>
</div>
<div class="stat">
<div class="stat-icon bg-pink-soft">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 1 0-16 0"/></svg>
</div>
<div class="stat-label">Children</div>
<div class="stat-value">${stats.children}</div>
</div>
<div class="stat">
<div class="stat-icon bg-green-soft">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
</div>
<div class="stat-label">This month</div>
<div class="stat-value stat-value-sm">${fmtCurrency(stats.receivedThisMonth, business.currency)}</div>
</div>
<div class="stat">
<div class="stat-icon bg-yellow-soft">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<div class="stat-label">Outstanding</div>
<div class="stat-value stat-value-sm">${fmtCurrency(stats.outstanding, business.currency)}</div>
</div>
</div>
<div class="home-grid">
<div class="card">
<div class="card-header">
<h3 class="card-title">Quick actions</h3>
</div>
<div class="grid-2">
<button class="btn btn-primary btn-block" data-action="new-invoice">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Invoice
</button>
<button class="btn btn-soft btn-block" data-action="new-parent">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Parent
</button>
<button class="btn btn-yellow btn-block" data-action="new-child">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Child
</button>
<button class="btn btn-outline btn-block" data-action="bulk-invoice">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Generate Month
</button>
</div>
</div>
<div>
<div class="section-head" style="margin-top:0">
<h3 class="section-title">Recent invoices</h3>
<a href="#invoices" class="btn btn-ghost btn-sm">View all →</a>
</div>
<div class="list">
${recentHtml || `<div class="empty" style="padding:2rem 1rem"><span class="empty-emoji">📭</span><p class="empty-sub">No invoices yet</p></div>`}
</div>
</div>
</div>
`;
}
function greetingFor() {
const h = new Date().getHours();
if (h < 12) return 'Good morning';
if (h < 17) return 'Good afternoon';
return 'Good evening';
}
function bindHome(root) {
root.querySelectorAll('[data-action]').forEach((b) => {
b.addEventListener('click', () => {
const a = b.getAttribute('data-action');
if (a === 'new-invoice') location.hash = '#invoice/new';
else if (a === 'new-parent') location.hash = '#parent/new';
else if (a === 'new-child') location.hash = '#child/new';
else if (a === 'bulk-invoice') openBulkInvoice();
});
});
root.querySelectorAll('[data-invoice]').forEach((a) => {
a.addEventListener('click', () => {
location.hash = `#invoice/${a.getAttribute('data-invoice')}`;
});
});
}
// ============================================
// PARENTS
// ============================================
async function parentsView() {
const parents = await DB.getAll('parents');
const childMap = {};
(await DB.getAll('children')).forEach((c) => { childMap[c.parentId] = childMap[c.parentId] || []; childMap[c.parentId].push(c); });
if (parents.length === 0) {
return `
<div class="empty">
<span class="empty-emoji">👨‍👩‍👧</span>
<h3 class="empty-title">No parents yet</h3>
<p class="empty-sub">Add your first parent to start creating invoices.</p>
<a class="btn btn-primary" href="#parent/new">+ Add Parent</a>
</div>`;
}
const sorted = parents.slice().sort((a, b) => a.name.localeCompare(b.name));
const html = sorted.map((p) => {
const kids = childMap[p.id] || [];
return `
<a class="list-item" data-parent="${p.id}">
<div class="avatar">${initialsOf(p.name)}</div>
<div class="list-item-body">
<p class="list-item-title">${escape(p.name)}</p>
<p class="list-item-sub">${kids.length} child${kids.length === 1 ? '' : 'ren'}${p.phone ? ' · ' + escape(p.phone) : ''}</p>
</div>
<svg class="list-item-arrow" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</a>`;
}).join('');
return `
<div class="page-title">
<h1>Parents <span class="count">${parents.length}</span></h1>
</div>
<div class="list">${html}</div>
<button class="fab" id="fabAdd" aria-label="Add parent">+</button>
`;
}
function bindParents(root) {
root.querySelectorAll('[data-parent]').forEach((a) => {
a.addEventListener('click', () => location.hash = `#parent/${a.getAttribute('data-parent')}`);
});
const fab = root.querySelector('#fabAdd');
if (fab) fab.addEventListener('click', () => location.hash = '#parent/new');
}
async function parentDetailView(id) {
const parent = id === 'new' ? { id: null, name: '', phone: '', email: '', address: '', notes: '' } : await DB.getParent(id);
if (!parent) return notFound();
const isNew = !parent.id;
const children = !isNew ? await DB.getChildrenByParent(parent.id) : [];
const childHtml = children.map((c) => `
<a class="list-item" data-child="${c.id}">
<div class="avatar avatar-pink avatar-sm">${initialsOf(c.name)}</div>
<div class="list-item-body">
<p class="list-item-title">${escape(c.name)}</p>
<p class="list-item-sub">${planLabel(c.plan)} · ${fmtCurrency(c.monthlyFee)}/mo</p>
</div>
<svg class="list-item-arrow" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</a>
`).join('') || `<p class="text-muted text-sm">No children linked yet.</p>`;
return `
<div class="card">
<div class="card-header">
<div>
<h2 class="card-title">${isNew ? 'New Parent' : escape(parent.name)}</h2>
${isNew ? '' : `<p class="card-subtitle">${children.length} child${children.length === 1 ? '' : 'ren'} linked</p>`}
</div>
</div>
<form class="form" id="parentForm">
<div class="field">
<label class="label">Parent / Guardian name *</label>
<input class="input" name="name" value="${escape(parent.name)}" required />
</div>
<div class="form-row">
<div class="field">
<label class="label">Phone</label>
<input class="input" name="phone" value="${escape(parent.phone || '')}" placeholder="082 123 4567" />
</div>
<div class="field">
<label class="label">Email</label>
<input class="input" type="email" name="email" value="${escape(parent.email || '')}" />
</div>
</div>
<div class="field">
<label class="label">Address</label>
<input class="input" name="address" value="${escape(parent.address || '')}" />
</div>
<div class="field">
<label class="label">Notes</label>
<textarea class="textarea" name="notes" rows="2">${escape(parent.notes || '')}</textarea>
</div>
<div class="row" style="gap:0.6rem;margin-top:0.5rem">
<button type="submit" class="btn btn-primary" style="flex:1">${isNew ? 'Add Parent' : 'Save Changes'}</button>
${isNew ? '' : `<button type="button" class="btn btn-danger" id="deleteParent">Delete</button>`}
</div>
</form>
</div>
${isNew ? '' : `
<div class="section-head">
<h3 class="section-title">Children</h3>
<a class="btn btn-soft btn-sm" href="#child/new?parent=${parent.id}">+ Add Child</a>
</div>
<div class="list">${childHtml}</div>`}
`;
}
function bindParentDetail(root, id) {
const form = root.querySelector('#parentForm');
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(form);
const data = Object.fromEntries(fd.entries());
if (!data.name || !data.name.trim()) return toast('Name is required', 'error');
const isNew = id === 'new';
const rec = isNew
? { id: Date.now(), name: data.name.trim(), phone: data.phone, email: data.email, address: data.address, notes: data.notes, createdAt: new Date().toISOString() }
: { ...(await DB.getParent(id)), name: data.name.trim(), phone: data.phone, email: data.email, address: data.address, notes: data.notes };
await DB.put('parents', rec);
toast(isNew ? 'Parent added' : 'Parent updated', 'success');
location.hash = isNew ? `#parent/${rec.id}` : `#parent/${id}`;
});
const del = root.querySelector('#deleteParent');
if (del) {
del.addEventListener('click', async () => {
if (!confirm(`Delete ${(await DB.getParent(id)).name}? This will also delete their children and invoices.`)) return;
const kids = await DB.getChildrenByParent(Number(id));
for (const k of kids) await DB.del('children', k.id);
const invs = await DB.getInvoicesByParent(Number(id));
for (const inv of invs) {
const pays = await DB.getPaymentsByInvoice(inv.id);
for (const p of pays) await DB.del('payments', p.id);
await DB.del('invoices', inv.id);
}
await DB.del('parents', Number(id));
toast('Parent deleted', 'success');
location.hash = '#parents';
});
}
root.querySelectorAll('[data-child]').forEach((a) => {
a.addEventListener('click', () => location.hash = `#child/${a.getAttribute('data-child')}`);
});
}
// ============================================
// CHILDREN
// ============================================
async function childrenView() {
const [children, parents, business] = await Promise.all([DB.getAll('children'), DB.getAll('parents'), DB.getBusiness()]);
const parentMap = {};
parents.forEach((p) => { parentMap[p.id] = p; });
if (children.length === 0) {
return `
<div class="empty">
<span class="empty-emoji">🧒</span>
<h3 class="empty-title">No children yet</h3>
<p class="empty-sub">Add a child to start generating invoices.</p>
<a class="btn btn-primary" href="#child/new">Add Child</a>
</div>`;
}
const sorted = children.slice().sort((a, b) => a.name.localeCompare(b.name));
const html = sorted.map((c) => {
const p = parentMap[c.parentId];
return `
<a class="list-item" data-child="${c.id}">
<div class="avatar avatar-pink">${initialsOf(c.name)}</div>
<div class="list-item-body">
<p class="list-item-title">${escape(c.name)}</p>
<p class="list-item-sub">${planLabel(c.plan)} · ${fmtCurrency(c.monthlyFee, business.currency)}/mo · ${escape(p ? p.name : 'No parent')}</p>
</div>
<svg class="list-item-arrow" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</a>`;
}).join('');
return `
<div class="page-title">
<h1>Children <span class="count">${children.length}</span></h1>
</div>
<div class="list">${html}</div>
<button class="fab" id="fabAdd" aria-label="Add child">+</button>
`;
}
function bindChildren(root) {
root.querySelectorAll('[data-child]').forEach((a) => {
a.addEventListener('click', () => location.hash = `#child/${a.getAttribute('data-child')}`);
});
const fab = root.querySelector('#fabAdd');
if (fab) fab.addEventListener('click', () => location.hash = '#child/new');
}
async function childDetailView(id, query) {
const isNew = id === 'new';
const parents = await DB.getAll('parents');
const preParentId = (query && query.parent) ? Number(query.parent) : (parents[0] ? parents[0].id : '');
const child = isNew ? { id: null, name: '', parentId: preParentId, plan: 'full', monthlyFee: '', startDate: new Date().toISOString().slice(0, 10), notes: '' } : await DB.getChild(id);
if (!child && !isNew) return notFound();
const business = await DB.getBusiness();
const parentOptions = parents.map((p) => `<option value="${p.id}" ${p.id === (child.parentId || Number(query && query.parent)) ? 'selected' : ''}>${escape(p.name)}</option>`).join('');
return `
<div class="card">
<div class="card-header">
<h2 class="card-title">${isNew ? 'New Child' : escape(child.name)}</h2>
</div>
<form class="form" id="childForm">
<div class="field">
<label class="label">Child's name *</label>
<input class="input" name="name" value="${escape(child.name)}" required />
</div>
<div class="field">
<label class="label">Parent / Guardian *</label>
<select class="select" name="parentId" required>
<option value="">Select parent…</option>
${parentOptions}
</select>
${parents.length === 0 ? `<p class="hint">No parents yet — <a href="#parent/new">add a parent</a> first.</p>` : ''}
</div>
<div class="form-row">
<div class="field">
<label class="label">Aftercare plan</label>
<div class="segmented" id="planSeg">
<button type="button" data-val="full" class="${child.plan === 'full' ? 'active' : ''}">Full Day</button>
<button type="button" data-val="half" class="${child.plan === 'half' ? 'active' : ''}">Half Day</button>
</div>
<input type="hidden" name="plan" value="${child.plan}" />
</div>
<div class="field">
<label class="label">Monthly fee *</label>
<input class="input" type="number" name="monthlyFee" value="${child.monthlyFee}" min="0" step="0.01" required />
<p class="hint">Default: ${fmtCurrency(business.defaultFullDayFee || 0, business.currency)} (full) / ${fmtCurrency(business.defaultHalfDayFee || 0, business.currency)} (half)</p>
</div>
</div>
<div class="field">
<label class="label">Start date</label>
<input class="input" type="date" name="startDate" value="${child.startDate || ''}" />
</div>
<div class="field">
<label class="label">Notes</label>
<textarea class="textarea" name="notes" rows="2">${escape(child.notes || '')}</textarea>
</div>
<div class="row" style="gap:0.6rem;margin-top:0.5rem">
<button type="submit" class="btn btn-primary" style="flex:1">${isNew ? 'Add Child' : 'Save Changes'}</button>
${isNew ? '' : `<button type="button" class="btn btn-danger" id="deleteChild">Delete</button>`}
</div>
</form>
</div>
`;
}
function bindChildDetail(root, id) {
const form = root.querySelector('#childForm');
if (!form) return;
const seg = root.querySelector('#planSeg');
if (seg) {
seg.querySelectorAll('button').forEach((b) => {
b.addEventListener('click', () => {
seg.querySelectorAll('button').forEach((x) => x.classList.remove('active'));
b.classList.add('active');
form.querySelector('[name=plan]').value = b.getAttribute('data-val');
});
});
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(form);
const data = Object.fromEntries(fd.entries());
if (!data.name || !data.name.trim()) return toast('Name is required', 'error');
if (!data.parentId) return toast('Parent is required', 'error');
const isNew = id === 'new';
const rec = isNew
? { id: Date.now(), name: data.name.trim(), parentId: Number(data.parentId), plan: data.plan, monthlyFee: Number(data.monthlyFee) || 0, startDate: data.startDate, notes: data.notes, createdAt: new Date().toISOString() }
: { ...(await DB.getChild(id)), name: data.name.trim(), parentId: Number(data.parentId), plan: data.plan, monthlyFee: Number(data.monthlyFee) || 0, startDate: data.startDate, notes: data.notes };
await DB.put('children', rec);
toast(isNew ? 'Child added' : 'Child updated', 'success');
location.hash = isNew ? `#child/${rec.id}` : `#child/${id}`;
});
const del = root.querySelector('#deleteChild');
if (del) {
del.addEventListener('click', async () => {
if (!confirm('Delete this child? Their invoices will also be deleted.')) return;
const invs = await DB.getInvoicesByChild(Number(id));
for (const inv of invs) {
const pays = await DB.getPaymentsByInvoice(inv.id);
for (const p of pays) await DB.del('payments', p.id);
await DB.del('invoices', inv.id);
}
await DB.del('children', Number(id));
toast('Child deleted', 'success');
location.hash = '#children';
});
}
}
// ============================================
// INVOICES
// ============================================
async function invoicesView(query) {
const [invoices, children, parents, business] = await Promise.all([
DB.getAll('invoices'), DB.getAll('children'), DB.getAll('parents'), DB.getBusiness()
]);
const childMap = {}; children.forEach((c) => childMap[c.id] = c);
const parentMap = {}; parents.forEach((p) => parentMap[p.id] = p);
// Filter
let filtered = invoices;
const filterStatus = query && query.status;
const filterParent = query && query.parent;
if (filterStatus && filterStatus !== 'all') filtered = filtered.filter((i) => i.status === filterStatus);
if (filterParent) filtered = filtered.filter((i) => i.parentId === Number(filterParent));
filtered.sort((a, b) => (b.issueDate || '').localeCompare(a.issueDate || ''));
const total = filtered.reduce((s, i) => s + Number(i.total || 0), 0);
const balance = filtered.reduce((s, i) => s + Number(i.balance || 0), 0);
const tabs = `
<div class="segmented" id="statusFilter" style="margin-bottom:1rem">
<button class="${(!filterStatus || filterStatus === 'all') ? 'active' : ''}" data-status="all">All</button>
<button class="${filterStatus === 'unpaid' ? 'active' : ''}" data-status="unpaid">Unpaid</button>
<button class="${filterStatus === 'partial' ? 'active' : ''}" data-status="partial">Partial</button>
<button class="${filterStatus === 'paid' ? 'active' : ''}" data-status="paid">Paid</button>
</div>`;
if (filtered.length === 0) {
return `
${tabs}
<div class="empty">
<span class="empty-emoji">📄</span>
<h3 class="empty-title">No invoices</h3>
<p class="empty-sub">Create your first invoice to get started.</p>
<a class="btn btn-primary" href="#invoice/new">+ New Invoice</a>
</div>
<button class="fab" id="fabAdd" aria-label="New invoice">+</button>
`;
}
const list = filtered.map((inv) => {
const c = childMap[inv.childId] || {};
const p = parentMap[inv.parentId] || {};
return `
<a class="list-item" data-invoice="${inv.id}">
<div class="avatar avatar-blue">${initialsOf(c.name || '?')}</div>
<div class="list-item-body">
<p class="list-item-title">${escape(c.name || 'Unknown')} · ${formatPeriod(inv.period)}</p>
<p class="list-item-sub">${escape(p.name || 'No parent')} · ${inv.number}</p>
</div>
<div class="list-item-meta">
${fmtCurrency(inv.total, business.currency)}<br/>
${statusBadge(inv.status)}
</div>
</a>`;
}).join('');
return `
<div class="page-title">
<h1>Invoices <span class="count">${filtered.length}</span></h1>
</div>
${tabs}
<div class="card-flat" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<div>
<div class="text-xs text-muted">Filtered total</div>
<div style="font-family:var(--font-display);font-weight:700;font-size:1.25rem">${fmtCurrency(total, business.currency)}</div>
</div>
<div style="text-align:right">
<div class="text-xs text-muted">Outstanding</div>
<div style="font-family:var(--font-display);font-weight:700;font-size:1.25rem;color:${balance > 0 ? 'var(--pink)' : '#1F8A4D'}">${fmtCurrency(balance, business.currency)}</div>
</div>
</div>
<div class="list">${list}</div>
<button class="fab" id="fabAdd" aria-label="New invoice">+</button>
`;
}
function bindInvoices(root, query) {
const seg = root.querySelector('#statusFilter');
if (seg) {
seg.querySelectorAll('button').forEach((b) => {
b.addEventListener('click', () => {
const status = b.getAttribute('data-status');
const params = new URLSearchParams(query || {});
if (status === 'all') params.delete('status');
else params.set('status', status);
const qs = params.toString();
location.hash = qs ? `#invoices?${qs}` : '#invoices';
});
});
}
root.querySelectorAll('[data-invoice]').forEach((a) => {
a.addEventListener('click', () => location.hash = `#invoice/${a.getAttribute('data-invoice')}`);
});
const fab = root.querySelector('#fabAdd');
if (fab) fab.addEventListener('click', () => location.hash = '#invoice/new');
}
// New / edit invoice
async function invoiceFormView(id, query) {
const isNew = id === 'new';
const business = await DB.getBusiness();
const [parents, children, invoices] = await Promise.all([DB.getAll('parents'), DB.getAll('children'), DB.getAll('invoices')]);
let inv;
if (isNew) {
const preChildId = query && query.child ? Number(query.child) : (children[0] && children[0].id);
const preChild = children.find((c) => c.id === preChildId);
const now = new Date();
const period = query && query.period ? query.period : `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const number = await nextReceiptNumber();
inv = {
id: null,
number: `${business.receiptPrefix || 'SU'}-${String(number).padStart(4, '0')}`,
childId: preChildId,
parentId: preChild ? preChild.parentId : (parents[0] && parents[0].id),
period,
issueDate: new Date().toISOString().slice(0, 10),
dueDate: addDays(25, period),
items: [{ description: `AfterCare Fees — ${formatPeriod(period)}`, quantity: 1, unitPrice: preChild ? preChild.monthlyFee : (business.defaultFullDayFee || 500), total: preChild ? preChild.monthlyFee : (business.defaultFullDayFee || 500) }],
subtotal: preChild ? preChild.monthlyFee : (business.defaultFullDayFee || 500),
total: preChild ? preChild.monthlyFee : (business.defaultFullDayFee || 500),
paid: 0, balance: preChild ? preChild.monthlyFee : (business.defaultFullDayFee || 500),
status: 'unpaid',
notes: '',
};
} else {
inv = await DB.getAll('invoices').then((arr) => arr.find((i) => i.id === Number(id)));
if (!inv) return notFound();
}
const childOptions = children.map((c) => {
const p = parents.find((pp) => pp.id === c.parentId);
return `<option value="${c.id}" ${c.id === inv.childId ? 'selected' : ''}>${escape(c.name)} — ${escape(p ? p.name : 'No parent')}</option>`;
}).join('');
const periodOptions = last12Periods().map((p) => `<option value="${p}" ${p === inv.period ? 'selected' : ''}>${formatPeriod(p)}</option>`).join('');
return `
<div class="card">
<div class="card-header">
<h2 class="card-title">${isNew ? 'New Invoice' : escape(inv.number)}</h2>
${isNew ? '' : statusBadge(inv.status)}
</div>
<form class="form" id="invoiceForm">
<div class="field">
<label class="label">Invoice number</label>
<input class="input" name="number" value="${escape(inv.number)}" />
</div>
<div class="form-row">
<div class="field">
<label class="label">Child *</label>
<select class="select" name="childId" required>
<option value="">Select…</option>
${childOptions}
</select>
</div>
<div class="field">
<label class="label">Period *</label>
<select class="select" name="period" required>
${periodOptions}
</select>
</div>
</div>
<div class="form-row">
<div class="field">
<label class="label">Issue date</label>
<input class="input" type="date" name="issueDate" value="${inv.issueDate || ''}" />
</div>
<div class="field">
<label class="label">Due date</label>
<input class="input" type="date" name="dueDate" value="${inv.dueDate || ''}" />
</div>
</div>
<div class="field">
<label class="label">Description</label>
<input class="input" name="description" value="${escape(inv.items[0] ? inv.items[0].description : '')}" />
</div>
<div class="form-row">
<div class="field">
<label class="label">Amount *</label>
<input class="input" type="number" name="amount" value="${inv.items[0] ? inv.items[0].unitPrice : 0}" step="0.01" required />
</div>
<div class="field">
<label class="label">Already paid</label>
<input class="input" type="number" name="paid" value="${inv.paid || 0}" step="0.01" min="0" />
</div>
</div>
<div class="field">
<label class="label">Notes</label>
<textarea class="textarea" name="notes" rows="2">${escape(inv.notes || '')}</textarea>
</div>
<div class="row" style="gap:0.6rem;margin-top:0.5rem">
<button type="submit" class="btn btn-primary" style="flex:1">${isNew ? 'Create Invoice' : 'Save'}</button>
${isNew ? '' : `<button type="button" class="btn btn-danger" id="deleteInvoice">Delete</button>`}
</div>
</form>
</div>
${isNew ? '' : `
<div class="card">
<div class="card-header">
<h3 class="card-title">Actions</h3>
</div>
<div class="col">
<button class="btn btn-primary btn-block" id="shareInvoice">📤 Share / Save PDF</button>
<button class="btn btn-soft btn-block" id="addPayment">💰 Record Payment</button>
<button class="btn btn-outline btn-block" id="viewStatement">📋 View Statement</button>
<button class="btn btn-outline btn-block" id="printInvoice">🖨️ Print</button>
</div>
</div>`}
`;
}
function addDays(dayOfMonth, period) {
const [y, m] = period.split('-').map(Number);
return `${y}-${String(m).padStart(2, '0')}-${String(dayOfMonth).padStart(2, '0')}`;
}
function last12Periods() {
const out = [];
const now = new Date();
for (let i = -1; i < 12; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
out.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
}
return out.reverse();
}
function bindInvoiceForm(root, id, query) {
const form = root.querySelector('#invoiceForm');
if (!form) return;
const isNew = id === 'new';
// When child changes, auto-fill amount + period description
form.querySelector('[name=childId]').addEventListener('change', async () => {
const childId = Number(form.querySelector('[name=childId]').value);
const child = await DB.getChild(childId);
if (!child) return;
form.querySelector('[name=amount]').value = child.monthlyFee;
const period = form.querySelector('[name=period]').value;
form.querySelector('[name=description]').value = `AfterCare Fees — ${formatPeriod(period)}${child.plan === 'half' ? ' (Half Day)' : ''}`;
});
form.querySelector('[name=period]').addEventListener('change', () => {
const period = form.querySelector('[name=period]').value;
const cur = form.querySelector('[name=description]').value;
const newDesc = cur.replace(/AfterCare Fees — [A-Za-z]+ \d{4}.*$/, `AfterCare Fees — ${formatPeriod(period)}`);
form.querySelector('[name=description]').value = newDesc || `AfterCare Fees — ${formatPeriod(period)}`;
form.querySelector('[name=dueDate]').value = addDays(25, period);
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(form);
const data = Object.fromEntries(fd.entries());
const childId = Number(data.childId);
const child = await DB.getChild(childId);
if (!child) return toast('Select a child', 'error');
const amount = Number(data.amount) || 0;
const paid = Number(data.paid) || 0;
const balance = Math.max(0, amount - paid);
const status = balance <= 0 ? 'paid' : (paid > 0 ? 'partial' : 'unpaid');
let invId;
if (isNew) {
invId = Date.now();
await bumpReceipt();
const newInv = {
id: invId,
number: data.number,
childId,
parentId: child.parentId,
period: data.period,
issueDate: data.issueDate,
dueDate: data.dueDate,
items: [{ description: data.description, quantity: 1, unitPrice: amount, total: amount }],
subtotal: amount,
total: amount,
paid,
balance,
status,
notes: data.notes,
createdAt: new Date().toISOString(),
};
await DB.put('invoices', newInv);
if (paid > 0) {
await DB.put('payments', {
id: Date.now() + 1,
invoiceId: invId,
date: data.issueDate,
amount: paid,
method: 'cash',
receivedBy: 'Syl-Marie',
notes: 'Opening balance',
createdAt: new Date().toISOString(),
});
}
toast('Invoice created', 'success');
location.hash = `#invoice/${invId}`;
} else {
const oldInv = await DB.getAll('invoices').then((arr) => arr.find((i) => i.id === Number(id)));
const updated = {
...oldInv,
number: data.number,
childId,
parentId: child.parentId,
period: data.period,
issueDate: data.issueDate,
dueDate: data.dueDate,
items: [{ description: data.description, quantity: 1, unitPrice: amount, total: amount }],
subtotal: amount,
total: amount,
paid,
balance,
status,
notes: data.notes,
};
await DB.put('invoices', updated);
toast('Invoice updated', 'success');
location.hash = `#invoice/${id}`;
}
});
const del = root.querySelector('#deleteInvoice');
if (del) {
del.addEventListener('click', async () => {
if (!confirm('Delete this invoice?')) return;
const pays = await DB.getPaymentsByInvoice(Number(id));
for (const p of pays) await DB.del('payments', p.id);
await DB.del('invoices', Number(id));
toast('Invoice deleted', 'success');
location.hash = '#invoices';
});
}
const share = root.querySelector('#shareInvoice');
if (share) share.addEventListener('click', () => shareInvoiceById(Number(id)));
const print = root.querySelector('#printInvoice');
if (print) print.addEventListener('click', () => printInvoiceById(Number(id)));
const stmt = root.querySelector('#viewStatement');
if (stmt) stmt.addEventListener('click', async () => {
const inv = await DB.getAll('invoices').then((arr) => arr.find((i) => i.id === Number(id)));
location.hash = `#statement/${inv.parentId}`;
});
const addPay = root.querySelector('#addPayment');
if (addPay) addPay.addEventListener('click', () => openPaymentModal(Number(id)));
}
// ============================================
// INVOICE DETAIL (view)
// ============================================
async function invoiceDetailView(id) {
const inv = await DB.getAll('invoices').then((arr) => arr.find((i) => i.id === Number(id)));
if (!inv) return notFound();
const [parent, child, payments, business] = await Promise.all([
DB.getParent(inv.parentId), DB.getChild(inv.childId),
DB.getPaymentsByInvoice(Number(id)), DB.getBusiness()
]);
const node = document.createElement('div');
node.innerHTML = window.SUInvoice.renderInvoiceHTML({
invoice: inv, parent, child, payments, business, type: 'invoice'
});
setTimeout(() => {
const wrap = document.getElementById('renderRoot');
wrap.innerHTML = '';
wrap.appendChild(node.firstElementChild);
}, 50);
// Return a viewer-friendly HTML
const itemsHtml = inv.items.map((it) => `
<tr>
<td>${escape(it.description)}</td>
<td style="text-align:center">${it.quantity || 1}</td>
<td style="text-align:right">${fmtCurrency(it.unitPrice, business.currency)}</td>
<td style="text-align:right">${fmtCurrency(it.total, business.currency)}</td>
</tr>
`).join('');
const paymentsHtml = payments.map((p) => `
<tr>
<td>${fmtDate(p.date)}</td>
<td>${(p.method || '').toUpperCase()}</td>
<td style="text-align:right;font-weight:700">${fmtCurrency(p.amount, business.currency)}</td>
<td style="font-style:italic">${escape(p.receivedBy || '')}</td>
</tr>
`).join('') || `<tr><td colspan="4" style="text-align:center;color:var(--ink-soft);padding:1rem">No payments recorded yet</td></tr>`;
return `
<div class="card">
<div class="card-header">
<div>
<h2 class="card-title">${escape(inv.number)}</h2>
<p class="card-subtitle">${escape(child.name)} · ${escape(parent.name)} · ${formatPeriod(inv.period)}</p>
</div>
${statusBadge(inv.status)}
</div>
<div style="background:var(--purple-tint);border-radius:var(--radius);padding:1rem;text-align:center;margin-bottom:1rem">
<div class="text-xs text-muted">Total</div>
<div style="font-family:var(--font-display);font-weight:700;font-size:2rem;color:var(--purple-deep)">${fmtCurrency(inv.total, business.currency)}</div>
${inv.balance > 0 ? `<div class="text-sm" style="color:var(--pink);font-weight:700">Balance: ${fmtCurrency(inv.balance, business.currency)}</div>` : '<div class="text-sm text-green" style="font-weight:700">✓ Fully paid</div>'}
</div>
<div class="col" style="gap:0.5rem">
<button class="btn btn-primary btn-block btn-lg" id="shareInvoice">📤 Share / Save PDF</button>
<button class="btn btn-soft btn-block" id="previewInvoice">👁️ Preview PDF</button>
<div class="form-row">
<button class="btn btn-soft btn-block" id="editInvoice">✏️ Edit</button>
<button class="btn btn-green btn-block" id="addPayment">💰 Record Payment</button>
</div>
<button class="btn btn-outline btn-block" id="viewStatement">📋 View Statement</button>
<button class="btn btn-outline btn-block" id="printInvoice">🖨️ Print</button>
</div>
</div>
<div class="card">
<h3 class="card-title" style="margin-bottom:0.75rem">Items</h3>
<table class="invoice-table">
<thead><tr><th>Description</th><th style="text-align:center">Qty</th><th style="text-align:right">Unit</th><th style="text-align:right">Total</th></tr></thead>
<tbody>${itemsHtml}</tbody>
</table>
</div>
<div class="card">
<h3 class="card-title" style="margin-bottom:0.75rem">Payments</h3>
<table class="invoice-table">
<thead><tr><th>Date</th><th>Method</th><th style="text-align:right">Amount</th><th>By</th></tr></thead>
<tbody>${paymentsHtml}</tbody>
</table>
</div>
`;
}
function bindInvoiceDetail(root, id) {
const share = root.querySelector('#shareInvoice');
if (share) share.addEventListener('click', () => shareInvoiceById(Number(id)));
const preview = root.querySelector('#previewInvoice');
if (preview) preview.addEventListener('click', () => previewInvoiceById(Number(id)));
const edit = root.querySelector('#editInvoice');
if (edit) edit.addEventListener('click', () => location.hash = `#invoice/${id}/edit`);
const print = root.querySelector('#printInvoice');
if (print) print.addEventListener('click', () => printInvoiceById(Number(id)));
const stmt = root.querySelector('#viewStatement');
if (stmt) stmt.addEventListener('click', async () => {
const inv = await DB.getAll('invoices').then((arr) => arr.find((i) => i.id === Number(id)));
location.hash = `#statement/${inv.parentId}`;
});
const add = root.querySelector('#addPayment');
if (add) add.addEventListener('click', () => openPaymentModal(Number(id)));
}
async function openPaymentModal(invoiceId) {
const inv = await DB.getAll('invoices').then((arr) => arr.find((i) => i.id === invoiceId));
if (!inv) return;
const business = await DB.getBusiness();
const balance = Number(inv.balance) || 0;
const body = `
<form class="form" id="paymentForm">
<div class="card-soft">
<div class="text-xs text-muted">Remaining balance</div>
<div style="font-family:var(--font-display);font-weight:700;font-size:1.4rem;color:var(--pink)">${fmtCurrency(balance, business.currency)}</div>
</div>
<div class="form-row">
<div class="field">
<label class="label">Date *</label>
<input class="input" type="date" name="date" value="${new Date().toISOString().slice(0,10)}" required />
</div>
<div class="field">
<label class="label">Amount *</label>
<input class="input" type="number" name="amount" value="${balance}" step="0.01" required />
</div>
</div>
<div class="form-row">
<div class="field">
<label class="label">Method</label>
<select class="select" name="method">
<option value="cash">Cash</option>
<option value="eft">EFT</option>
<option value="other">Other</option>
</select>
</div>
<div class="field">
<label class="label">Received by</label>
<input class="input" name="receivedBy" value="Syl-Marie" />
</div>
</div>
<div class="field">
<label class="label">Notes</label>
<input class="input" name="notes" />
</div>
</form>`;
const m = openModal({
title: 'Record Payment',
body,
footer: `<button class="btn btn-outline" id="pmCancel">Cancel</button><button class="btn btn-primary" id="pmSave">Save Payment</button>`,
});
m.root.querySelector('#pmCancel').addEventListener('click', m.close);
m.root.querySelector('#pmSave').addEventListener('click', async () => {
const form = m.root.querySelector('#paymentForm');
const fd = new FormData(form);
const amount = Number(fd.get('amount'));
if (!amount || amount <= 0) return toast('Amount required', 'error');
await DB.put('payments', {
id: Date.now(),
invoiceId,
date: fd.get('date'),
amount,
method: fd.get('method'),
receivedBy: fd.get('receivedBy'),
notes: fd.get('notes'),
createdAt: new Date().toISOString(),
});
// Recalculate invoice totals
const pays = await DB.getPaymentsByInvoice(invoiceId);
const paid = pays.reduce((s, p) => s + Number(p.amount || 0), 0);
const balance = Math.max(0, Number(inv.total) - paid);
const status = balance <= 0 ? 'paid' : (paid > 0 ? 'partial' : 'unpaid');
const updated = { ...inv, paid, balance, status };
await DB.put('invoices', updated);
toast('Payment recorded', 'success');
m.close();
location.hash = `#invoice/${invoiceId}`;
});
}
async function shareInvoiceById(id) {
const inv = await DB.getAll('invoices').then((arr) => arr.find((i) => i.id === id));
if (!inv) return;
const [parent, child, payments, business] = await Promise.all([
DB.getParent(inv.parentId), DB.getChild(inv.childId),
DB.getPaymentsByInvoice(id), DB.getBusiness()
]);
const html = window.SUInvoice.renderInvoiceHTML({ invoice: inv, parent, child, payments, business });
showShareSheet({ html, filename: `${inv.number}.pdf`, title: `Invoice ${inv.number}`, parent, child, business });
}
async function printInvoiceById(id) {
const inv = await DB.getAll('invoices').then((arr) => arr.find((i) => i.id === id));
if (!inv) return;
const [parent, child, payments, business] = await Promise.all([
DB.getParent(inv.parentId), DB.getChild(inv.childId),
DB.getPaymentsByInvoice(id), DB.getBusiness()
]);
const html = window.SUInvoice.renderInvoiceHTML({ invoice: inv, parent, child, payments, business });
stageForPrint(html);
}
// Open the PDF in a new tab — useful for previewing before sending
async function previewInvoiceById(id) {
const inv = await DB.getAll('invoices').then((arr) => arr.find((i) => i.id === id));
if (!inv) return;
const [parent, child, payments, business] = await Promise.all([
DB.getParent(inv.parentId), DB.getChild(inv.childId),
DB.getPaymentsByInvoice(id), DB.getBusiness()
]);
const html = window.SUInvoice.renderInvoiceHTML({ invoice: inv, parent, child, payments, business });
stageForPrint(html);
}
// ============================================
// STATEMENT (statement of payments)
// ============================================
async function statementView(parentId, query) {
const parent = await DB.getParent(Number(parentId));
if (!parent) return notFound();
const [children, invoices, allPayments, business] = await Promise.all([
DB.getChildrenByParent(parent.id), DB.getInvoicesByParent(parent.id), DB.getAllPayments(), DB.getBusiness()
]);
const childMap = {};
children.forEach((c) => childMap[c.id] = c);
const invoiceMap = {};
invoices.forEach((i) => invoiceMap[i.id] = i);
const periodStart = (query && query.from) || `${new Date().getFullYear()}-01`;
const periodEnd = (query && query.to) || `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
const filteredPayments = allPayments
.filter((p) => invoiceMap[p.invoiceId])
.filter((p) => {
const m = (p.date || '').slice(0, 7);
return m >= periodStart && m <= periodEnd;
})
.map((p) => ({
...p,
childName: childMap[invoiceMap[p.invoiceId].childId] ? childMap[invoiceMap[p.invoiceId].childId].name : '',
}))
.sort((a, b) => (a.date || '').localeCompare(b.date || ''));
const total = filteredPayments.reduce((s, p) => s + Number(p.amount || 0), 0);
const periodOptions = last12Periods().map((p) => `<option value="${p}">${formatPeriod(p)}</option>`).join('');
const rowsHtml = filteredPayments.map((p) => `
<tr>
<td>${fmtDate(p.date)}</td>
<td>${escape(p.childName)}</td>
<td><span class="badge badge-muted">${escape((p.method || '').toUpperCase())}</span></td>
<td style="text-align:right;font-weight:700">${fmtCurrency(p.amount, business.currency)}</td>
<td style="font-style:italic">${escape(p.receivedBy || '')}</td>
</tr>
`).join('') || `<tr><td colspan="5" style="text-align:center;color:var(--ink-soft);padding:2rem">No payments in this period</td></tr>`;
return `
<div class="card">
<div class="card-header">
<div>
<h2 class="card-title">${escape(parent.name)}</h2>
<p class="card-subtitle">${children.length} child${children.length === 1 ? '' : 'ren'} · ${filteredPayments.length} payment${filteredPayments.length === 1 ? '' : 's'}</p>
</div>
</div>
<div class="form-row">
<div class="field">
<label class="label">From</label>
<select class="select" id="stmtFrom">${periodOptions.replace(`value="${periodStart}"`, `value="${periodStart}" selected`)}</select>
</div>
<div class="field">
<label class="label">To</label>
<select class="select" id="stmtTo">${periodOptions.replace(`value="${periodEnd}"`, `value="${periodEnd}" selected`)}</select>
</div>
</div>
</div>
<div class="card">
<h3 class="card-title" style="margin-bottom:0.75rem">Payments</h3>
<table class="invoice-table">
<thead><tr><th>Date</th><th>Child</th><th>Method</th><th style="text-align:right">Amount</th><th>By</th></tr></thead>
<tbody>${rowsHtml}</tbody>
</table>
<div style="text-align:right;margin-top:1rem">
<span class="statement-total">TOTAL: ${fmtCurrency(total, business.currency)}</span>
</div>
</div>
<div class="card">
<h3 class="card-title" style="margin-bottom:0.75rem">Actions</h3>
<div class="col">
<button class="btn btn-primary btn-block btn-lg" id="shareStatement">📤 Share Statement PDF</button>
<button class="btn btn-soft btn-block" id="previewStatement">👁️ Preview PDF</button>
<button class="btn btn-outline btn-block" id="printStatement">🖨️ Print</button>
<button class="btn btn-soft btn-block" id="newInvoiceForParent">+ New Invoice</button>
</div>
</div>
`;
}
function bindStatement(root, parentId, query) {
const upd = () => {
const from = root.querySelector('#stmtFrom').value;
const to = root.querySelector('#stmtTo').value;
location.hash = `#statement/${parentId}?from=${from}&to=${to}`;
};
const from = root.querySelector('#stmtFrom');
const to = root.querySelector('#stmtTo');
if (from) from.addEventListener('change', upd);
if (to) to.addEventListener('change', upd);
const share = root.querySelector('#shareStatement');
if (share) share.addEventListener('click', () => shareStatementById(parentId, query));
const preview = root.querySelector('#previewStatement');
if (preview) preview.addEventListener('click', () => printStatementById(parentId, query));
const print = root.querySelector('#printStatement');
if (print) print.addEventListener('click', () => printStatementById(parentId, query));
const ni = root.querySelector('#newInvoiceForParent');
if (ni) ni.addEventListener('click', () => location.hash = `#invoice/new?parent=${parentId}`);
}
async function shareStatementById(parentId, query) {
const parent = await DB.getParent(Number(parentId));
if (!parent) return;
const [children, invoices, allPayments, business] = await Promise.all([
DB.getChildrenByParent(parent.id), DB.getInvoicesByParent(parent.id), DB.getAllPayments(), DB.getBusiness()
]);
const childMap = {}; children.forEach((c) => childMap[c.id] = c);
const invoiceMap = {}; invoices.forEach((i) => invoiceMap[i.id] = i);
const periodStart = (query && query.from) || `${new Date().getFullYear()}-01`;
const periodEnd = (query && query.to) || `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
const payments = allPayments
.filter((p) => invoiceMap[p.invoiceId])
.filter((p) => {
const m = (p.date || '').slice(0, 7);
return m >= periodStart && m <= periodEnd;
})
.map((p) => ({
...p,
childName: childMap[invoiceMap[p.invoiceId].childId] ? childMap[invoiceMap[p.invoiceId].childId].name : '',
}))
.sort((a, b) => (a.date || '').localeCompare(b.date || ''));
const html = window.SUInvoice.renderStatementHTML({ parent, children, payments, business, periodStart, periodEnd });
showShareSheet({ html, filename: `Statement-${parent.name.replace(/\s+/g, '_')}-${periodStart}_to_${periodEnd}.pdf`, title: `Statement — ${parent.name}`, parent, business });
}
async function printStatementById(parentId, query) {
await shareStatementById(parentId, query);
}
// ============================================
// SETTINGS
// ============================================
async function settingsView() {
const b = await DB.getBusiness();
return `
<div class="page-title">
<h1>Settings</h1>
</div>
<div class="settings-grid">
<div class="card">
<div class="card-header">
<h3 class="card-title">Business info</h3>
</div>
<form class="form" id="bizForm">
<div class="field">
<label class="label">Business name</label>
<input class="input" name="name" value="${escape(b.name)}" />
</div>
<div class="field">
<label class="label">Tagline</label>
<input class="input" name="tagline" value="${escape(b.tagline || '')}" />
</div>
<div class="field">
<label class="label">Address</label>
<input class="input" name="address" value="${escape(b.address || '')}" />
</div>
<div class="form-row">
<div class="field">
<label class="label">Phone</label>
<input class="input" name="phone" value="${escape(b.phone || '')}" />
</div>
<div class="field">
<label class="label">Email</label>
<input class="input" type="email" name="email" value="${escape(b.email || '')}" />
</div>
</div>
<div class="field">
<label class="label">Facebook page</label>
<input class="input" name="facebook" value="${escape(b.facebook || '')}" />
</div>
<div class="field">
<label class="label">Footer note</label>
<textarea class="textarea" name="footerNote" rows="2">${escape(b.footerNote || '')}</textarea>
</div>
<div class="field">
<label class="label">Business logo <span class="text-xs text-muted">(optional — replaces owl in app + PDF)</span></label>
<div class="row" style="gap:0.75rem;align-items:center">
<div id="logoPreview" style="width:64px;height:64px;border-radius:12px;background:var(--purple-tint);overflow:hidden;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:var(--ink-soft);font-size:0.7rem">
${b.logoDataUrl ? `<img src="${b.logoDataUrl}" style="width:100%;height:100%;object-fit:cover" />` : 'Owl'}
</div>
<div style="flex:1">
<label class="btn btn-soft btn-sm" style="cursor:pointer;display:inline-block;margin-bottom:0.4rem">
📷 Upload logo
<input type="file" accept="image/*" id="logoUpload" hidden />
</label>
${b.logoDataUrl ? `<button type="button" class="btn btn-outline btn-sm" id="logoRemove" style="margin-left:0.4rem">Remove</button>` : ''}
</div>
</div>
<p class="hint">Square images work best. Max ~500 KB.</p>
</div>
<button type="submit" class="btn btn-primary btn-block">Save Business Info</button>
</form>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">Default fees & receipt</h3>
</div>
<form class="form" id="feeForm">
<div class="form-row">
<div class="field">
<label class="label">Full day fee (${escape(b.currency)})</label>
<input class="input" type="number" name="defaultFullDayFee" value="${b.defaultFullDayFee || 0}" />
</div>
<div class="field">
<label class="label">Half day fee (${escape(b.currency)})</label>
<input class="input" type="number" name="defaultHalfDayFee" value="${b.defaultHalfDayFee || 0}" />
</div>
</div>
<div class="field">
<label class="label">Receipt prefix</label>
<input class="input" name="receiptPrefix" value="${escape(b.receiptPrefix || 'SU')}" />
</div>
<button type="submit" class="btn btn-primary btn-block">Save Fees</button>
</form>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">Banking details</h3>
</div>
<p class="text-sm text-muted mb-2">Shown on invoices so parents know where to pay.</p>
<form class="form" id="bankForm">
<div class="field">
<label class="label">Bank name</label>
<input class="input" name="bankName" value="${escape(b.bankName || '')}" />
</div>
<div class="field">
<label class="label">Account holder</label>
<input class="input" name="accountHolder" value="${escape(b.accountHolder || '')}" />
</div>
<div class="form-row">
<div class="field">
<label class="label">Account number</label>
<input class="input" name="accountNumber" value="${escape(b.accountNumber || '')}" />
</div>
<div class="field">
<label class="label">Branch code</label>
<input class="input" name="branchCode" value="${escape(b.branchCode || '')}" />
</div>
</div>
<div class="field">
<label class="label">Payment reference note</label>
<input class="input" name="referenceNote" value="${escape(b.referenceNote || '')}" placeholder="Use child name as payment reference" />
</div>
<button type="submit" class="btn btn-primary btn-block">Save Banking</button>
</form>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">Data & backup</h3>
</div>
<p class="text-sm text-muted mb-2">Backup, restore, or auto-sync to n8n.</p>
<form class="form" id="n8nForm">
<div class="field">
<label class="label">n8n webhook URL <span class="text-xs text-muted">(optional — auto-backup after every save)</span></label>
<input class="input" id="n8nUrl" name="n8nUrl" placeholder="https://n8n.example.com/webhook/slimuiltjies-backup" />
</div>
<div class="field">
<label class="label">Auth token <span class="text-xs text-muted">(if your webhook needs one)</span></label>
<input class="input" type="password" id="n8nToken" name="n8nToken" placeholder="Bearer token" />
</div>
<div class="row" style="gap:0.5rem">
<button type="submit" class="btn btn-primary" style="flex:1">Save backup settings</button>
<button type="button" class="btn btn-soft" id="testBackup">Test</button>
</div>
<div class="text-xs text-muted" id="n8nStatus" style="margin-top:0.5rem"></div>
</form>
<div class="divider"></div>
<div class="col">
<button class="btn btn-outline btn-block" id="exportData">📤 Export backup (JSON)</button>
<label class="btn btn-outline btn-block" style="cursor:pointer">
📥 Import backup
<input type="file" accept="application/json" id="importData" hidden />
</label>
<button class="btn btn-soft btn-block" id="reseed">✨ Load sample data</button>
<button class="btn btn-danger btn-block" id="resetData">⚠️ Reset all data</button>
</div>
</div>
</div>
<div class="card" style="margin-top:1rem">
<div class="card-header">
<h3 class="card-title">About</h3>
</div>
<div class="text-sm text-muted">
<p><strong>Slim Uiltjies AfterCare · Invoices</strong></p>
<p>A safe place to learn, grow and shine!</p>
<p style="margin-top:0.5rem">A Progressive Web App — install to your home screen for offline use.</p>
</div>
</div>
`;
}
function bindSettings(root) {
const biz = root.querySelector('#bizForm');
biz && biz.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(biz);
const cur = await DB.getBusiness();
await DB.saveBusiness({ ...cur, ...Object.fromEntries(fd.entries()) });
toast('Business info saved', 'success');
});
const fee = root.querySelector('#feeForm');
fee && fee.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(fee);
const cur = await DB.getBusiness();
const data = Object.fromEntries(fd.entries());
await DB.saveBusiness({
...cur,
defaultFullDayFee: Number(data.defaultFullDayFee) || 0,
defaultHalfDayFee: Number(data.defaultHalfDayFee) || 0,
receiptPrefix: data.receiptPrefix || 'SU',
});
toast('Fees saved', 'success');
});
const bank = root.querySelector('#bankForm');
bank && bank.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(bank);
const cur = await DB.getBusiness();
await DB.saveBusiness({ ...cur, ...Object.fromEntries(fd.entries()) });
toast('Banking details saved', 'success');
});
const exp = root.querySelector('#exportData');
exp && exp.addEventListener('click', async () => {
const data = await DB.exportAll();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const filename = `slimuiltjies-backup-${new Date().toISOString().slice(0, 10)}.json`;
Share.downloadBlob(blob, filename);
toast('Backup downloaded', 'success');
});
const imp = root.querySelector('#importData');
imp && imp.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (!confirm('Import will merge data. Continue?')) return;
await DB.importAll(data, { replace: false });
toast('Data imported', 'success');
location.hash = '#home';
} catch (e) {
toast('Import failed: ' + e.message, 'error');
}
});
const reset = root.querySelector('#resetData');
reset && reset.addEventListener('click', async () => {
if (!confirm('Delete ALL data? This cannot be undone.')) return;
if (!confirm('Really sure? Type OK in your head if so.')) return;
for (const s of ['parents', 'children', 'invoices', 'payments', 'business', 'meta']) {
await DB.clear(s);
}
toast('All data cleared', 'success');
location.hash = '#home';
location.reload();
});
const reseed = root.querySelector('#reseed');
reseed && reseed.addEventListener('click', async () => {
if (!confirm('Replace existing data with sample data?')) return;
await window.SUSeed.seed({ force: true });
toast('Sample data loaded', 'success');
location.hash = '#home';
});
const logoutBtn = root.querySelector('#logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', () => {
if (confirm('Log out? You will need your PIN to get back in.')) {
try { window.SU_LOGOUT(); } catch {}
}
});
}
// Logo upload
const logoUpload = root.querySelector('#logoUpload');
const logoRemove = root.querySelector('#logoRemove');
const logoPreview = root.querySelector('#logoPreview');
if (logoUpload) {
logoUpload.addEventListener('change', async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 1024 * 1024) return toast('Logo must be under 1 MB', 'error');
const reader = new FileReader();
reader.onload = async () => {
const dataUrl = reader.result;
try {
// Compress via canvas if image
const img = new Image();
img.onload = async () => {
const canvas = document.createElement('canvas');
const max = 400;
const scale = Math.min(1, max / Math.max(img.width, img.height));
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const compressed = canvas.toDataURL('image/png', 0.9);
await window.SUDB.uploadLogo(compressed);
toast('Logo uploaded', 'success');
if (logoPreview) logoPreview.innerHTML = `<img src="${compressed}" style="width:100%;height:100%;object-fit:cover" />`;
// Refresh the brand mark in the header
if (window.SUIcons) window.SUIcons.mount();
};
img.src = dataUrl;
} catch (e) {
toast('Upload failed: ' + e.message, 'error');
}
};
reader.readAsDataURL(file);
});
}
if (logoRemove) {
logoRemove.addEventListener('click', async () => {
await window.SUDB.removeLogo();
toast('Logo removed', 'success');
if (logoPreview) logoPreview.innerHTML = 'Owl';
if (window.SUIcons) window.SUIcons.mount();
});
}
// n8n backup config
const n8nForm = root.querySelector('#n8nForm');
const n8nUrlInput = root.querySelector('#n8nUrl');
const n8nTokenInput = root.querySelector('#n8nToken');
const n8nStatus = root.querySelector('#n8nStatus');
if (n8nUrlInput && window.SUAPI) {
const s = window.SUAPI.n8nStatus();
if (s.url) n8nUrlInput.value = s.url;
if (s.lastBackup) {
n8nStatus.innerHTML = `Last backup: ${new Date(s.lastBackup).toLocaleString()}`;
} else if (s.lastError) {
n8nStatus.innerHTML = `<span style="color:var(--red)">Last error: ${escape(s.lastError)}</span>`;
}
}
n8nForm && n8nForm.addEventListener('submit', (e) => {
e.preventDefault();
const url = n8nUrlInput.value.trim();
const token = n8nTokenInput.value.trim();
if (window.SUAPI) window.SUAPI.configureN8n(url, token);
toast(url ? 'n8n backup configured' : 'n8n backup disabled', 'success');
n8nStatus.textContent = url ? `Will back up to ${new URL(url).hostname}` : '';
});
const testBackup = root.querySelector('#testBackup');
if (testBackup) {
testBackup.addEventListener('click', async () => {
const url = n8nUrlInput.value.trim();
const token = n8nTokenInput.value.trim();
if (!url) return toast('Enter a webhook URL first', 'error');
window.SUAPI.configureN8n(url, token);
n8nStatus.textContent = 'Sending test…';
const state = await window.SUDB.exportAll();
const res = await window.SUAPI.n8nBackup(state);
if (res.ok) {
toast('Backup sent ✓', 'success');
n8nStatus.innerHTML = `Last backup: ${new Date(res.at).toLocaleString()}`;
} else if (res.skipped) {
toast('Already in progress', 'info');
} else {
toast('Backup failed: ' + res.error, 'error');
n8nStatus.innerHTML = `<span style="color:var(--red)">${escape(res.error)}</span>`;
}
});
}
}
// ============================================
// BULK INVOICE GENERATION
// ============================================
async function openBulkInvoice() {
const business = await DB.getBusiness();
const [children, parents, invoices] = await Promise.all([DB.getAll('children'), DB.getAll('parents'), DB.getAll('invoices')]);
const parentMap = {}; parents.forEach((p) => parentMap[p.id] = p);
const now = new Date();
const period = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const existing = new Set(invoices.filter((i) => i.period === period).map((i) => i.childId));
const eligible = children.filter((c) => !existing.has(c.id));
const body = `
<div class="card-soft">
<p class="text-sm">Generate monthly invoices for all children who don't yet have one for <strong>${formatPeriod(period)}</strong>.</p>
</div>
${eligible.length === 0 ? '<div class="empty"><p class="empty-sub">All children already have an invoice for this period ✓</p></div>' : `
<form class="form" id="bulkForm">
<div class="card-flat" style="padding:0.5rem 0.75rem">
${eligible.map((c) => {
const p = parentMap[c.parentId];
return `<label class="check" style="margin-bottom:0.4rem"><input type="checkbox" name="child" value="${c.id}" checked /><span class="check-box"></span><span class="check-label" style="flex:1">${escape(c.name)} <span class="text-muted text-sm">· ${escape(p ? p.name : '')} · ${fmtCurrency(c.monthlyFee, business.currency)}</span></span></label>`;
}).join('')}
</div>
</form>`}`;
const footer = `<button class="btn btn-outline" id="bulkCancel">Cancel</button>${eligible.length === 0 ? '' : '<button class="btn btn-primary" id="bulkGenerate">Generate ' + eligible.length + ' invoice' + (eligible.length === 1 ? '' : 's') + '</button>'}`;
const m = openModal({ title: `Generate ${formatPeriod(period)}`, body, footer });
const cancel = m.root.querySelector('#bulkCancel');
if (cancel) cancel.addEventListener('click', m.close);
const gen = m.root.querySelector('#bulkGenerate');
if (gen) gen.addEventListener('click', async () => {
const form = m.root.querySelector('#bulkForm');
const ids = Array.from(form.querySelectorAll('input[name=child]:checked')).map((i) => Number(i.value));
if (ids.length === 0) return toast('Select at least one child', 'error');
let nextNum = await nextReceiptNumber();
for (const id of ids) {
const child = await DB.getChild(id);
if (!child) continue;
const inv = {
id: Date.now() + Math.floor(Math.random() * 1000),
number: `${business.receiptPrefix || 'SU'}-${String(nextNum++).padStart(4, '0')}`,
childId: child.id,
parentId: child.parentId,
period,
issueDate: `${period}-01`,
dueDate: addDays(25, period),
items: [{ description: `AfterCare Fees — ${formatPeriod(period)}${child.plan === 'half' ? ' (Half Day)' : ''}`, quantity: 1, unitPrice: child.monthlyFee, total: child.monthlyFee }],
subtotal: child.monthlyFee,
total: child.monthlyFee,
paid: 0,
balance: child.monthlyFee,
status: 'unpaid',
notes: '',
createdAt: new Date().toISOString(),
};
await DB.put('invoices', inv);
}
await DB.setMeta('nextReceipt', nextNum);
toast(`Generated ${ids.length} invoice${ids.length === 1 ? '' : 's'}`, 'success');
m.close();
location.hash = '#invoices';
});
}
// ============================================
// SHARE SHEET (reusable)
// ============================================
function showShareSheet({ html, filename, title, parent, child, business }) {
const wrap = document.getElementById('renderRoot');
wrap.innerHTML = html;
// wait for fonts/images
setTimeout(async () => {
const node = wrap.firstElementChild;
if (!node) return;
const body = `
<div class="text-center mb-2">
<div style="font-size:3rem;margin-bottom:0.5rem">📤</div>
<h3 style="margin-bottom:0.25rem">Share this ${title.includes('Statement') ? 'statement' : 'invoice'}</h3>
<p class="text-sm text-muted">The PDF will look exactly like the preview.</p>
</div>
<div class="card-soft text-sm">
<strong>Tip:</strong> Use the buttons below to send via WhatsApp, email, or print. On phones, "Share PDF" opens your phone's native share sheet so you can send it to any app.
</div>`;
const footer = `
<button class="btn btn-outline" id="ssCancel">Close</button>
<button class="btn btn-soft" id="ssDownload"><3E>️ Save PDF</button>
<button class="btn btn-pink" id="ssWhatsApp">💬 WhatsApp</button>
<button class="btn btn-primary" id="ssShare">📤 Share PDF</button>`;
const m = openModal({ title: 'Share', body, footer });
m.root.querySelector('#ssCancel').addEventListener('click', m.close);
m.root.querySelector('#ssDownload').addEventListener('click', async () => {
const blob = await PDF.nodeToPdfBlob(node, { filename });
Share.downloadBlob(blob, filename);
toast('PDF downloaded', 'success');
});
m.root.querySelector('#ssShare').addEventListener('click', async () => {
const btn = m.root.querySelector('#ssShare');
btn.disabled = true; btn.textContent = '⏳ Generating…';
try {
const blob = await PDF.nodeToPdfBlob(node, { filename });
blob.name = filename;
const result = await Share.shareFile(blob, {
title,
text: buildShareText({ parent, child, business, filename }),
});
if (result.method === 'download') toast('PDF saved — open it to share', 'success');
} catch (e) {
toast('Share failed: ' + e.message, 'error');
} finally {
btn.disabled = false; btn.innerHTML = '<27> Share PDF';
}
});
m.root.querySelector('#ssWhatsApp').addEventListener('click', async () => {
// Try sharing the PDF first (so the file goes with the message)
try {
const blob = await PDF.nodeToPdfBlob(node, { filename });
blob.name = filename;
const file = new File([blob], filename, { type: 'application/pdf' });
if (navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({ files: [file], title, text: buildShareText({ parent, child, business, filename }) });
return;
}
} catch (e) {
// fall through
}
// WhatsApp text fallback
const phone = parent && parent.phone ? parent.phone.replace(/[^0-9+]/g, '') : '';
const text = buildShareText({ parent, child, business, filename });
const url = phone ? `https://wa.me/${phone}?text=${encodeURIComponent(text)}` : `https://wa.me/?text=${encodeURIComponent(text)}`;
window.open(url, '_blank');
});
wrap.innerHTML = '';
}, 80);
}
function buildShareText({ parent, child, business, filename }) {
const lines = [];
lines.push(`Hi ${parent ? parent.name : ''},`);
lines.push('');
lines.push(`Please find attached the invoice from ${business.name}.`);
if (child) lines.push(`For: ${child.name}`);
lines.push('');
lines.push('Thank you!');
lines.push(business.name);
if (business.phone) lines.push(business.phone);
return lines.join('\n');
}
function stageForPrint(html) {
const wrap = document.getElementById('renderRoot');
wrap.innerHTML = html;
setTimeout(() => {
Share.openPrintDialog();
setTimeout(() => { wrap.innerHTML = ''; }, 1000);
}, 200);
}
function notFound() {
return `<div class="empty"><span class="empty-emoji">🔍</span><h3 class="empty-title">Not found</h3><p class="empty-sub">This item doesn't exist anymore.</p><a class="btn btn-primary" href="#home">Go home</a></div>`;
}
// ============================================================
// Header actions — desktop primary CTA slot
// Returns HTML for the header action button(s) based on the current
// hash. Returns '' when no action is appropriate.
// ============================================================
function headerActionsFor(hash) {
const h = (hash || '').toLowerCase();
// Home — primary CTA: new invoice (the most common action)
if (h === '#home' || h === '') {
return `<a class="btn btn-primary btn-sm" href="#invoice/new">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Invoice
</a>`;
}
if (h === '#invoices') {
return `<a class="btn btn-primary btn-sm" href="#invoice/new">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Invoice
</a>
<button class="btn btn-soft btn-sm" data-action="bulk-invoice">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Generate Month
</button>`;
}
if (h === '#parents') {
return `<a class="btn btn-primary btn-sm" href="#parent/new">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Parent
</a>`;
}
if (h === '#children') {
return `<a class="btn btn-primary btn-sm" href="#child/new">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Child
</a>`;
}
// Detail pages — Share/Back/etc come from the page itself
return '';
}
function bindHeaderActions(root) {
root.querySelectorAll('[data-action="bulk-invoice"]').forEach((b) => {
b.addEventListener('click', () => openBulkInvoice());
});
}
// Export
window.SUViews = {
home: { render: homeView, bind: bindHome },
parents: { render: parentsView, bind: bindParents },
parentDetail: { render: parentDetailView, bind: bindParentDetail },
children: { render: childrenView, bind: bindChildren },
childDetail: { render: childDetailView, bind: bindChildDetail },
invoices: { render: invoicesView, bind: bindInvoices },
invoiceForm: { render: invoiceFormView, bind: bindInvoiceForm },
invoiceDetail: { render: invoiceDetailView, bind: bindInvoiceDetail },
statement: { render: statementView, bind: bindStatement },
settings: { render: settingsView, bind: bindSettings },
headerActionsFor,
bindHeaderActions,
};
})();