- 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
1827 lines
77 KiB
JavaScript
1827 lines
77 KiB
JavaScript
/* 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,
|
||
};
|
||
})();
|