KLEINUILTIES-WEBAPP/js/app.js
Slim Uiltjies App 1939bf2ef4 Initial commit: Slim Uiltjies AfterCare invoice system
- Progressive Web App with Express + JSON backend
- PIN-based auth, multi-device sync
- Invoice + Statement PDF generation (branded, Grandstander + Nunito)
- Mobile + Desktop responsive layouts
- n8n backup integration
- Custom logo upload
- Settings for business info, fees, banking
- Bulk invoice generation
- Web Share API integration
2026-08-08 09:25:17 +02:00

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();
}
})();