- 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
361 lines
11 KiB
JavaScript
361 lines
11 KiB
JavaScript
/* Slim Uiltjies — Main app
|
|
Routing, initialisation, splash handling, hash parsing, PIN login.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const V = window.SUViews;
|
|
const DB = window.SUDB;
|
|
|
|
// ============================================================
|
|
// Auth — simple PIN gate (session-only)
|
|
// ============================================================
|
|
// Set the test PIN here. Replace this with a config lookup once
|
|
// a backend is wired up.
|
|
const APP_PIN = '847291';
|
|
const AUTH_KEY = 'su_authenticated';
|
|
const PIN_HIDDEN = '••••••';
|
|
|
|
function isAuthenticated() {
|
|
try { return sessionStorage.getItem(AUTH_KEY) === '1'; } catch { return false; }
|
|
}
|
|
|
|
function setAuthenticated(yes) {
|
|
try {
|
|
if (yes) sessionStorage.setItem(AUTH_KEY, '1');
|
|
else sessionStorage.removeItem(AUTH_KEY);
|
|
} catch {}
|
|
}
|
|
|
|
function logout() {
|
|
setAuthenticated(false);
|
|
try { window.SUAPI && window.SUAPI.logout(); } catch {}
|
|
location.hash = '';
|
|
location.reload();
|
|
}
|
|
|
|
// Expose for views to call (Settings → Log out button)
|
|
window.SU_LOGOUT = logout;
|
|
|
|
function showLoginScreen() {
|
|
const ls = document.getElementById('loginScreen');
|
|
const app = document.getElementById('app');
|
|
if (ls) ls.classList.remove('hidden');
|
|
if (app) app.classList.add('hidden');
|
|
// Mount the owl into the login card
|
|
const o = document.getElementById('loginOwl');
|
|
if (o && window.SUIcons) o.innerHTML = window.SUIcons.brandOwl(96);
|
|
setTimeout(() => document.getElementById('pinInput')?.focus(), 80);
|
|
// Show server status hint
|
|
if (window.SUAPI) {
|
|
window.SUAPI.health().then((ok) => {
|
|
const hint = document.getElementById('serverHint');
|
|
if (hint) hint.textContent = ok ? '🟢 Connected to server' : '⚪️ Server offline — local mode';
|
|
});
|
|
}
|
|
}
|
|
|
|
function hideLoginScreen() {
|
|
document.getElementById('loginScreen')?.classList.add('hidden');
|
|
}
|
|
|
|
function bindLoginScreen() {
|
|
const input = document.getElementById('pinInput');
|
|
const btn = document.getElementById('loginBtn');
|
|
const err = document.getElementById('pinError');
|
|
const pinDisplay = document.getElementById('pinDisplay');
|
|
const pinReveal = document.getElementById('pinReveal');
|
|
|
|
if (pinDisplay) pinDisplay.textContent = PIN_HIDDEN;
|
|
|
|
if (pinReveal) {
|
|
pinReveal.addEventListener('click', () => {
|
|
if (pinDisplay.textContent === PIN_HIDDEN) {
|
|
pinDisplay.textContent = APP_PIN;
|
|
pinReveal.textContent = '🙈';
|
|
} else {
|
|
pinDisplay.textContent = PIN_HIDDEN;
|
|
pinReveal.textContent = '👁';
|
|
}
|
|
});
|
|
}
|
|
|
|
async function attempt() {
|
|
const pin = (input.value || '').trim();
|
|
err.classList.remove('show');
|
|
if (!pin) return;
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = 'Checking…';
|
|
try {
|
|
// 1. Try server auth
|
|
const res = await window.SUAPI.auth(pin);
|
|
// 2. If online, pull server state into local DB
|
|
if (res.online) {
|
|
await window.SUDB.open();
|
|
const pulled = await window.SUAPI.pullAndMerge();
|
|
if (!pulled) {
|
|
// First run — push local seed up
|
|
await window.SUAPI.schedulePush();
|
|
}
|
|
} else {
|
|
// Offline fallback — verify against local constant
|
|
if (pin !== APP_PIN) throw new Error('Invalid PIN');
|
|
}
|
|
setAuthenticated(true);
|
|
hideLoginScreen();
|
|
bootApp();
|
|
} catch (e) {
|
|
err.classList.add('show');
|
|
input.classList.remove('shake');
|
|
void input.offsetWidth;
|
|
input.classList.add('shake');
|
|
input.value = '';
|
|
input.focus();
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Unlock';
|
|
}
|
|
}
|
|
|
|
btn.addEventListener('click', attempt);
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') { e.preventDefault(); attempt(); }
|
|
});
|
|
input.addEventListener('paste', (e) => {
|
|
const t = e.clipboardData?.getData('text') || '';
|
|
if (t) { e.preventDefault(); input.value = t.trim(); attempt(); }
|
|
});
|
|
}
|
|
|
|
function parseHash() {
|
|
const h = (location.hash || '#home').slice(1);
|
|
const [path, qs] = h.split('?');
|
|
const parts = path.split('/').filter(Boolean);
|
|
const query = {};
|
|
if (qs) qs.split('&').forEach((kv) => {
|
|
const [k, v] = kv.split('=');
|
|
query[decodeURIComponent(k)] = decodeURIComponent(v || '');
|
|
});
|
|
return { parts, query };
|
|
}
|
|
|
|
function setTab(name) {
|
|
document.querySelectorAll('.tab, .sidebar-link').forEach((t) => {
|
|
t.classList.toggle('active', t.getAttribute('data-tab') === name);
|
|
});
|
|
}
|
|
|
|
function setSubtitle(text) {
|
|
const el = document.getElementById('headerSubtitle');
|
|
if (el) el.textContent = text;
|
|
}
|
|
|
|
function showBack(show) {
|
|
document.getElementById('backBtn').style.visibility = show ? 'visible' : 'hidden';
|
|
}
|
|
|
|
async function render() {
|
|
const { parts, query } = parseHash();
|
|
const root = document.getElementById('view');
|
|
if (!root) return;
|
|
|
|
// Update header actions slot (desktop primary CTA)
|
|
const headerActions = V.headerActionsFor(location.hash);
|
|
const ha = document.getElementById('headerActions');
|
|
if (ha) ha.innerHTML = headerActions;
|
|
const ha2 = document.querySelector('#view [data-action="bulk-invoice"]');
|
|
// Bind header-action buttons (data-action only)
|
|
if (ha) {
|
|
ha.querySelectorAll('[data-action]').forEach((b) => {
|
|
b.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
const a = b.getAttribute('data-action');
|
|
if (a === 'bulk-invoice') openBulkInvoice();
|
|
});
|
|
});
|
|
}
|
|
|
|
// Determine view
|
|
const top = parts[0] || 'home';
|
|
|
|
if (!parts.length || top === 'home') {
|
|
setTab('home'); setSubtitle('Dashboard'); showBack(false);
|
|
root.innerHTML = await V.home.render();
|
|
V.home.bind(root);
|
|
return;
|
|
}
|
|
|
|
if (top === 'parents' || (top === 'parent' && parts.length > 1)) {
|
|
setTab('parents'); showBack(top === 'parent');
|
|
if (top === 'parent') {
|
|
setSubtitle(parts[1] === 'new' ? 'New Parent' : 'Parent');
|
|
root.innerHTML = await V.parentDetail.render(parts[1], query);
|
|
V.parentDetail.bind(root, parts[1]);
|
|
} else {
|
|
setSubtitle('Parents'); showBack(false);
|
|
root.innerHTML = await V.parents.render();
|
|
V.parents.bind(root);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (top === 'children' || (top === 'child' && parts.length > 1)) {
|
|
setTab('children'); showBack(top === 'child');
|
|
if (top === 'child') {
|
|
setSubtitle(parts[1] === 'new' ? 'New Child' : 'Child');
|
|
root.innerHTML = await V.childDetail.render(parts[1], query);
|
|
V.childDetail.bind(root, parts[1]);
|
|
} else {
|
|
setSubtitle('Children'); showBack(false);
|
|
root.innerHTML = await V.children.render();
|
|
V.children.bind(root);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (top === 'invoices') {
|
|
setTab('invoices'); setSubtitle('Invoices'); showBack(false);
|
|
root.innerHTML = await V.invoices.render(query);
|
|
V.invoices.bind(root, query);
|
|
return;
|
|
}
|
|
|
|
if (top === 'invoice') {
|
|
const id = parts[1];
|
|
if (!id) { location.hash = '#invoices'; return; }
|
|
if (id === 'new') {
|
|
setTab('invoices'); setSubtitle('New Invoice'); showBack(true);
|
|
root.innerHTML = await V.invoiceForm.render('new', query);
|
|
V.invoiceForm.bind(root, 'new', query);
|
|
} else if (parts[2] === 'edit') {
|
|
setTab('invoices'); setSubtitle('Edit Invoice'); showBack(true);
|
|
root.innerHTML = await V.invoiceForm.render(id, query);
|
|
V.invoiceForm.bind(root, id, query);
|
|
} else {
|
|
setTab('invoices'); setSubtitle('Invoice'); showBack(true);
|
|
root.innerHTML = await V.invoiceDetail.render(id);
|
|
V.invoiceDetail.bind(root, id);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (top === 'statement') {
|
|
const parentId = parts[1];
|
|
if (!parentId) { location.hash = '#parents'; return; }
|
|
setTab('parents'); setSubtitle('Statement'); showBack(true);
|
|
root.innerHTML = await V.statement.render(parentId, query);
|
|
V.statement.bind(root, parentId, query);
|
|
return;
|
|
}
|
|
|
|
if (top === 'settings') {
|
|
setTab('settings'); setSubtitle('Settings'); showBack(false);
|
|
root.innerHTML = await V.settings.render();
|
|
V.settings.bind(root);
|
|
return;
|
|
}
|
|
|
|
// Action shortcut from manifest
|
|
if (top === 'action') {
|
|
if (parts[1] === 'new-invoice') location.hash = '#invoice/new';
|
|
else location.hash = '#home';
|
|
return;
|
|
}
|
|
|
|
// Fallback
|
|
setTab('home'); setSubtitle('Dashboard'); showBack(false);
|
|
root.innerHTML = await V.home.render();
|
|
V.home.bind(root);
|
|
}
|
|
|
|
function bindChrome() {
|
|
document.getElementById('backBtn').addEventListener('click', () => {
|
|
history.length > 1 ? history.back() : (location.hash = '#home');
|
|
});
|
|
|
|
document.getElementById('menuBtn').addEventListener('click', () => {
|
|
location.hash = '#settings';
|
|
});
|
|
|
|
// Sidebar logout (desktop)
|
|
const sidebarLogout = document.getElementById('sidebarLogout');
|
|
if (sidebarLogout) {
|
|
sidebarLogout.addEventListener('click', () => {
|
|
if (confirm('Log out? You will need your PIN to get back in.')) logout();
|
|
});
|
|
}
|
|
|
|
// Sync status indicator updates
|
|
if (window.SUAPI && window.SUAPI.on) {
|
|
window.SUAPI.on((event, data) => {
|
|
const dot = document.getElementById('syncStatus');
|
|
const ind = document.getElementById('syncIndicator');
|
|
if (event === 'online') {
|
|
const ok = data === true;
|
|
if (dot) dot.textContent = ok ? '🟢' : '⚪️';
|
|
if (ind) ind.textContent = ok ? '🟢 Connected' : '⚪️ Offline';
|
|
} else if (event === 'syncing') {
|
|
if (data) {
|
|
if (dot) dot.textContent = '🔄';
|
|
if (ind) ind.textContent = '🔄 Syncing…';
|
|
}
|
|
} else if (event === 'synced') {
|
|
if (dot) dot.textContent = '🟢';
|
|
if (ind) ind.textContent = '🟢 Just synced';
|
|
setTimeout(() => {
|
|
if (ind) ind.textContent = '🟢 Connected';
|
|
}, 1500);
|
|
}
|
|
});
|
|
}
|
|
|
|
window.addEventListener('hashchange', render);
|
|
}
|
|
|
|
async function bootApp() {
|
|
window.SUIcons.mount();
|
|
|
|
// Seed sample data on first run
|
|
await window.SUSeed.seed({ force: false });
|
|
|
|
const splash = document.getElementById('splash');
|
|
const app = document.getElementById('app');
|
|
|
|
if (splash) {
|
|
// First-time splash fade (only if splash still in DOM)
|
|
splash.classList.add('fade-out');
|
|
setTimeout(() => splash.remove(), 350);
|
|
setTimeout(() => {
|
|
app.classList.remove('hidden');
|
|
bindChrome();
|
|
render();
|
|
}, 600);
|
|
} else {
|
|
// Splash was already removed (login flow). Just show app.
|
|
app.classList.remove('hidden');
|
|
bindChrome();
|
|
render();
|
|
}
|
|
}
|
|
|
|
// Top-level init — gate behind PIN
|
|
function init() {
|
|
if (!isAuthenticated()) {
|
|
// Hide splash, show login
|
|
document.getElementById('splash')?.classList.add('fade-out');
|
|
setTimeout(() => document.getElementById('splash')?.remove(), 400);
|
|
showLoginScreen();
|
|
bindLoginScreen();
|
|
return;
|
|
}
|
|
bootApp();
|
|
}
|
|
|
|
// Wait for DOM
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|