/* ============================================================ page.js — Shared helpers for pages running inside iframes ============================================================ */ // Compute app root from current URL — works for pages/ and pages/reports/ const _pIdx = window.location.pathname.split('/').indexOf('pages'); const BASE_URL = _pIdx > 0 ? window.location.pathname.split('/').slice(0, _pIdx).join('/') : ''; // Bridge to parent frame — only used for toast + nav const bridge = window.parent?.AppBridge || { navigate: (l, u) => window.location.href = u, toast: (m, t) => alert(m), getToken: () => localStorage.getItem('pa_token') || '', getUser: () => JSON.parse(localStorage.getItem('pa_user') || '{}'), }; const token = () => bridge.getToken(); const user = () => bridge.getUser(); // Toast — shows in parent frame (has the toast container) const toast = (msg, type = 'info') => bridge.toast(msg, type); // Navigate — opens new tab in parent shell const nav = (label, url) => bridge.navigate(label, BASE_URL + '/' + url); // ── LOCAL Modal (renders inside iframe so onclick refs work) ── const modal = (title, bodyHtml, footerHtml = '') => { closeModal(); // remove any existing // Inject minimal modal CSS if not already present if (!document.getElementById('_modal-styles')) { const s = document.createElement('style'); s.id = '_modal-styles'; s.textContent = ` #_modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1000;display:flex;align-items:center;justify-content:center;padding:20px;backdrop-filter:blur(3px);animation:_mfade .18s ease} @keyframes _mfade{from{opacity:0}to{opacity:1}} #_modal-box{background:var(--bg-card,#fff);border:1px solid var(--border,#e5e7eb);border-radius:10px;width:100%;max-width:640px;max-height:90vh;overflow-y:auto;box-shadow:0 8px 40px rgba(0,0,0,.18);animation:_mup .2s ease} @keyframes _mup{from{transform:translateY(16px);opacity:0}to{transform:translateY(0);opacity:1}} #_modal-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border,#e5e7eb)} #_modal-title{font-size:15px;font-weight:600;color:var(--text-primary,#1a1d23)} #_modal-close{width:28px;height:28px;background:var(--bg-raised,#f8f9fa);border:1px solid var(--border,#e5e7eb);border-radius:50%;cursor:pointer;display:grid;place-items:center;color:var(--text-secondary,#5c636e);font-size:14px;flex-shrink:0;transition:.15s} #_modal-close:hover{background:#fee2e2;color:#dc2626;border-color:#fca5a5} #_modal-body{padding:20px} #_modal-footer{display:flex;gap:10px;justify-content:flex-end;padding:14px 20px;border-top:1px solid var(--border,#e5e7eb)} `; document.head.appendChild(s); } const overlay = document.createElement('div'); overlay.id = '_modal-overlay'; overlay.innerHTML = `
${title}
${bodyHtml}
${footerHtml ? `` : ''}
`; overlay.addEventListener('click', e => { if (e.target === overlay) closeModal(); }); document.body.appendChild(overlay); }; const closeModal = () => { document.getElementById('_modal-overlay')?.remove(); }; // Formatting const fmt = { currency: (v) => 'R\u00a0' + parseFloat(v || 0).toLocaleString('en-ZA', { minimumFractionDigits: 2, maximumFractionDigits: 2 }), date: (v) => v && v !== '0000-00-00' ? new Date(v).toLocaleDateString('en-ZA') : '—', upper: (v) => (v || '').toUpperCase(), number: (v) => parseFloat(v || 0).toLocaleString('en-ZA'), }; const statusBadge = (s) => { const map = { DRAFT: 'draft', SENT: 'sent', PAID: 'paid', OVERDUE: 'overdue', CONVERTED: 'converted' }; const cls = map[(s || '').toUpperCase()] || 'draft'; return `${s || 'DRAFT'}`; }; // ── API helpers ── const apiGet = async (url) => { const sep = url.includes('?') ? '&' : '?'; const res = await fetch(BASE_URL + url + sep + 'token=' + encodeURIComponent(token())); const json = await res.json(); if (!json.success) throw new Error(json.error || 'Error'); return json.data; }; const apiPost = async (url, data) => { const fd = new FormData(); fd.set('token', token()); Object.entries(data).forEach(([k, v]) => fd.set(k, v ?? '')); const res = await fetch(BASE_URL + url, { method: 'POST', body: fd }); const json = await res.json(); if (!json.success) throw new Error(json.error || 'Error'); return json; }; const apiPostForm = async (url, fd) => { fd.set('token', token()); const res = await fetch(BASE_URL + url, { method: 'POST', body: fd }); const json = await res.json(); if (!json.success) throw new Error(json.error || 'Error'); return json; }; // ── UI helpers ── const spinner = (id) => { const el = document.getElementById(id); if (el) el.innerHTML = ''; }; const renderEmpty = (msg = 'No records found.') => `

${msg}

`; const confirmDialog = (msg) => window.confirm(msg); // ── Debounce ── const debounce = (fn, ms = 300) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; // ── Populate