- 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
205 lines
6.8 KiB
JavaScript
205 lines
6.8 KiB
JavaScript
/* Slim Uiltjies — IndexedDB layer
|
|
Vanilla IndexedDB. Stores:
|
|
- business (single object)
|
|
- parents (id, name, phone, email, address, notes, createdAt)
|
|
- children (id, parentId, name, plan, monthlyFee, startDate, endDate, notes)
|
|
- invoices (id, childId, parentId, period YYYY-MM, items[], totals{}, status, issueDate, dueDate, notes)
|
|
- payments (id, invoiceId, date, amount, method, receivedBy, notes)
|
|
- meta (seeded flag, version)
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const DB_NAME = 'slimuiltjies';
|
|
const DB_VERSION = 1;
|
|
const STORES = ['business', 'parents', 'children', 'invoices', 'payments', 'meta'];
|
|
|
|
let _db = null;
|
|
|
|
function open() {
|
|
if (_db) return Promise.resolve(_db);
|
|
return new Promise((resolve, reject) => {
|
|
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
req.onupgradeneeded = (e) => {
|
|
const db = e.target.result;
|
|
STORES.forEach((s) => {
|
|
if (!db.objectStoreNames.contains(s)) {
|
|
const store = db.createObjectStore(s, { keyPath: 'id', autoIncrement: s !== 'business' && s !== 'meta' });
|
|
if (s === 'children') store.createIndex('parentId', 'parentId');
|
|
if (s === 'invoices') {
|
|
store.createIndex('childId', 'childId');
|
|
store.createIndex('parentId', 'parentId');
|
|
store.createIndex('period', 'period');
|
|
store.createIndex('status', 'status');
|
|
}
|
|
if (s === 'payments') store.createIndex('invoiceId', 'invoiceId');
|
|
if (s === 'parents') store.createIndex('name', 'name');
|
|
}
|
|
});
|
|
};
|
|
req.onsuccess = () => { _db = req.result; resolve(_db); };
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
function tx(store, mode = 'readonly') {
|
|
return open().then((db) => db.transaction(store, mode).objectStore(store));
|
|
}
|
|
|
|
function p(req) {
|
|
return new Promise((resolve, reject) => {
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async function add(store, value) {
|
|
const t = await tx(store, 'readwrite');
|
|
const r = await p(t.add(value));
|
|
scheduleSync();
|
|
return r;
|
|
}
|
|
|
|
async function put(store, value) {
|
|
const t = await tx(store, 'readwrite');
|
|
const r = await p(t.put(value));
|
|
scheduleSync();
|
|
return r;
|
|
}
|
|
|
|
async function get(store, id) {
|
|
const t = await tx(store);
|
|
return p(t.get(id));
|
|
}
|
|
|
|
async function getAll(store) {
|
|
const t = await tx(store);
|
|
return p(t.getAll());
|
|
}
|
|
|
|
async function del(store, id) {
|
|
const t = await tx(store, 'readwrite');
|
|
const r = await p(t.delete(id));
|
|
scheduleSync();
|
|
return r;
|
|
}
|
|
|
|
async function clear(store) {
|
|
const t = await tx(store, 'readwrite');
|
|
const r = await p(t.clear());
|
|
scheduleSync();
|
|
return r;
|
|
}
|
|
|
|
function scheduleSync() {
|
|
try {
|
|
if (window.SUAPI && window.SUAPI.schedulePush) window.SUAPI.schedulePush();
|
|
} catch {}
|
|
}
|
|
|
|
async function byIndex(store, indexName, value) {
|
|
const t = await tx(store);
|
|
return p(t.index(indexName).getAll(value));
|
|
}
|
|
|
|
// Convenience: business info is a single object with id='business'
|
|
async function getBusiness() {
|
|
return get('business', 'business');
|
|
}
|
|
async function saveBusiness(b) {
|
|
return put('business', { ...b, id: 'business' });
|
|
}
|
|
async function uploadLogo(dataUrl) {
|
|
const b = await getBusiness();
|
|
const updated = { ...(b || { id: 'business', name: '' }), logoDataUrl: dataUrl };
|
|
return saveBusiness(updated);
|
|
}
|
|
async function removeLogo() {
|
|
const b = await getBusiness();
|
|
if (!b) return;
|
|
const updated = { ...b };
|
|
delete updated.logoDataUrl;
|
|
return saveBusiness(updated);
|
|
}
|
|
|
|
async function getMeta(key) {
|
|
const r = await get('meta', key);
|
|
return r ? r.value : null;
|
|
}
|
|
async function setMeta(key, value) {
|
|
return put('meta', { id: key, value });
|
|
}
|
|
|
|
// Export / import (full backup as JSON)
|
|
async function exportAll() {
|
|
const out = {};
|
|
for (const s of STORES) out[s] = await getAll(s);
|
|
return out;
|
|
}
|
|
|
|
async function importAll(data, { replace = false } = {}) {
|
|
for (const s of STORES) {
|
|
if (!data[s]) continue;
|
|
if (replace) await clear(s);
|
|
for (const rec of data[s]) {
|
|
if (s === 'business') {
|
|
await put(s, { ...rec, id: 'business' });
|
|
} else if (s === 'meta') {
|
|
await put(s, rec);
|
|
} else {
|
|
await put(s, rec);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// High-level helpers
|
|
async function getParent(id) { return get('parents', Number(id)); }
|
|
async function getChild(id) { return get('children', Number(id)); }
|
|
async function getChildrenByParent(parentId) { return byIndex('children', 'parentId', Number(parentId)); }
|
|
async function getInvoicesByParent(parentId) { return byIndex('invoices', 'parentId', Number(parentId)); }
|
|
async function getInvoicesByChild(childId) { return byIndex('invoices', 'childId', Number(childId)); }
|
|
async function getInvoicesByPeriod(period) { return byIndex('invoices', 'period', period); }
|
|
async function getPaymentsByInvoice(invoiceId) { return byIndex('payments', 'invoiceId', Number(invoiceId)); }
|
|
async function getAllPayments() { return getAll('payments'); }
|
|
|
|
// Aggregate for dashboards
|
|
async function dashboardStats() {
|
|
const [parents, children, invoices, payments] = await Promise.all([
|
|
getAll('parents'), getAll('children'), getAll('invoices'), getAll('payments')
|
|
]);
|
|
const now = new Date();
|
|
const thisPeriod = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
|
|
const totalPaid = payments.reduce((s, p) => s + (Number(p.amount) || 0), 0);
|
|
const monthPayments = payments
|
|
.filter((p) => (p.date || '').startsWith(thisPeriod))
|
|
.reduce((s, p) => s + (Number(p.amount) || 0), 0);
|
|
const outstanding = invoices
|
|
.filter((i) => i.status !== 'paid')
|
|
.reduce((s, i) => s + (Number(i.balance) || 0), 0);
|
|
const monthInvoices = invoices.filter((i) => i.period === thisPeriod);
|
|
|
|
return {
|
|
parents: parents.length,
|
|
children: children.length,
|
|
invoices: invoices.length,
|
|
monthInvoices: monthInvoices.length,
|
|
paid: invoices.filter((i) => i.status === 'paid').length,
|
|
unpaid: invoices.filter((i) => i.status === 'unpaid').length,
|
|
partial: invoices.filter((i) => i.status === 'partial').length,
|
|
totalReceived: totalPaid,
|
|
receivedThisMonth: monthPayments,
|
|
outstanding,
|
|
};
|
|
}
|
|
|
|
window.SUDB = {
|
|
open, add, put, get, getAll, del, clear, byIndex,
|
|
getBusiness, saveBusiness, uploadLogo, removeLogo, getMeta, setMeta,
|
|
getParent, getChild, getChildrenByParent,
|
|
getInvoicesByParent, getInvoicesByChild, getInvoicesByPeriod,
|
|
getPaymentsByInvoice, getAllPayments,
|
|
dashboardStats, exportAll, importAll
|
|
};
|
|
})();
|