/* ============================================================ ProArt Accounting — Main App JS Token-based auth, tab management, API helpers ============================================================ */ const App = (() => { // ── State ── let token = localStorage.getItem('pa_token') || ''; let user = JSON.parse(localStorage.getItem('pa_user') || '{}'); let tabs = []; let activeTab = null; let tabCounter = 0; // ── API ── const api = async (url, opts = {}) => { const sep = url.includes('?') ? '&' : '?'; const fullUrl = url.includes('http') ? url : `${BASE_URL}${url}`; const method = opts.method || 'GET'; let fetchUrl = fullUrl; let body = null; if (method === 'GET') { fetchUrl = fullUrl + sep + 'token=' + encodeURIComponent(token); } else { if (opts.body instanceof FormData) { opts.body.set('token', token); body = opts.body; } else { const fd = new FormData(); fd.set('token', token); if (opts.data) Object.entries(opts.data).forEach(([k, v]) => fd.set(k, v ?? '')); body = fd; } } try { const res = await fetch(fetchUrl, { method, body, headers: opts.headers || {} }); const json = await res.json(); if (!res.ok || json.redirect === 'login') { if (res.status === 401) { App.logout(); return null; } throw new Error(json.error || 'Request failed'); } return json; } catch (e) { if (e.message !== 'Failed to fetch') toast(e.message, 'error'); throw e; } }; const get = (url) => api(url, { method: 'GET' }); const post = (url, data) => api(url, { method: 'POST', data }); const postForm = (url, formData) => api(url, { method: 'POST', body: formData }); // ── Auth ── const isLoggedIn = () => !!token; const setAuth = (tok, usr) => { token = tok; user = usr; localStorage.setItem('pa_token', tok); localStorage.setItem('pa_user', JSON.stringify(usr)); }; const logout = async () => { try { await get(`${BASE_URL}/api/logout.php`); } catch (e) { } localStorage.removeItem('pa_token'); localStorage.removeItem('pa_user'); token = ''; user = {}; window.location.href = BASE_URL + '/login.php'; }; // ── Toast ── const toast = (msg, type = 'info', duration = 3500) => { const icons = { success: '✓', error: '✕', info: '◆' }; const el = document.createElement('div'); el.className = `toast ${type}`; el.innerHTML = `${icons[type]} ${msg}`; document.getElementById('toast-container').appendChild(el); setTimeout(() => { el.classList.add('fadeOut'); setTimeout(() => el.remove(), 350); }, duration); }; // ── Modal ── const modal = (title, bodyHtml, footerHtml = '') => { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.id = 'active-modal'; overlay.innerHTML = ` `; overlay.addEventListener('click', e => { if (e.target === overlay) App.closeModal(); }); document.body.appendChild(overlay); return overlay; }; const closeModal = () => { const m = document.getElementById('active-modal'); if (m) m.remove(); }; // ── Tabs ── const initTabs = () => { renderTabs(); }; const openTab = (label, url) => { // Check if tab with same url exists const exists = tabs.find(t => t.url === url); if (exists) { activateTab(exists.id); return; } const id = ++tabCounter; const tab = { id, label, url }; tabs.push(tab); // Create iframe const frame = document.createElement('iframe'); frame.id = `frame-${id}`; frame.className = 'page-frame'; frame.src = url + (url.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(token); document.getElementById('page-area').appendChild(frame); renderTabs(); activateTab(id); }; const activateTab = (id) => { activeTab = id; // Show correct frame document.querySelectorAll('.page-frame').forEach(f => f.classList.remove('active')); const frame = document.getElementById(`frame-${id}`); if (frame) frame.classList.add('active'); // Update tab highlights renderTabs(); // Update breadcrumb const tab = tabs.find(t => t.id === id); if (tab) { document.getElementById('breadcrumb').innerHTML = `ProArt ${tab.label}`; } }; const closeTab = (id, e) => { e.stopPropagation(); const idx = tabs.findIndex(t => t.id === id); tabs.splice(idx, 1); const frame = document.getElementById(`frame-${id}`); if (frame) frame.remove(); if (activeTab === id) { const next = tabs[idx] || tabs[idx - 1]; if (next) activateTab(next.id); else activeTab = null; } renderTabs(); }; const renderTabs = () => { const bar = document.getElementById('tab-bar'); bar.innerHTML = tabs.map(t => `
${t.label} ${tabs.length > 0 ? `` : ''}
`).join('') + `
+
`; }; // Reload the active tab's iframe const reloadActive = () => { const frame = document.getElementById(`frame-${activeTab}`); if (frame) { const src = frame.src; frame.src = ''; frame.src = src; } }; // Navigate inside an existing iframe from within the iframe itself const navigate = (label, url) => openTab(label, url); // ── Sidebar ── const initSidebar = () => { const sidebar = document.getElementById('sidebar'); const collapsed = localStorage.getItem('pa_sidebar') === 'collapsed'; if (collapsed) sidebar.classList.add('collapsed'); document.getElementById('sidebar-toggle').addEventListener('click', () => { sidebar.classList.toggle('collapsed'); localStorage.setItem('pa_sidebar', sidebar.classList.contains('collapsed') ? 'collapsed' : 'open'); }); // Group toggles document.querySelectorAll('.nav-group-header').forEach(h => { h.addEventListener('click', () => { const sub = h.nextElementSibling; if (!sub) return; const isOpen = sub.classList.contains('open'); document.querySelectorAll('.nav-sub').forEach(s => s.classList.remove('open')); document.querySelectorAll('.nav-group-header').forEach(x => x.classList.remove('open')); if (!isOpen) { sub.classList.add('open'); h.classList.add('open'); } }); }); // Nav links document.querySelectorAll('.nav-sub-item[data-url]').forEach(el => { el.addEventListener('click', () => { document.querySelectorAll('.nav-sub-item').forEach(x => x.classList.remove('active')); el.classList.add('active'); openTab(el.dataset.label || el.textContent.trim(), el.dataset.url); }); }); }; // ── Init ── const init = () => { if (!isLoggedIn()) { window.location.href = BASE_URL + '/login.php'; return; } // Render user info const uEl = document.getElementById('topbar-user-name'); if (uEl) uEl.textContent = user.username || 'User'; const avEl = document.getElementById('topbar-avatar'); if (avEl) avEl.textContent = (user.username || 'U')[0].toUpperCase(); initSidebar(); initTabs(); // Open dashboard by default openTab('Dashboard', BASE_URL + '/pages/dashboard.php'); }; // ── Helpers (for pages inside iframes) ── const fmt = { currency: (v) => 'R ' + parseFloat(v || 0).toLocaleString('en-ZA', { minimumFractionDigits: 2, maximumFractionDigits: 2 }), date: (v) => v ? new Date(v).toLocaleDateString('en-ZA') : '—', upper: (v) => (v || '').toUpperCase(), }; const statusBadge = (s) => { const map = { DRAFT: 'draft', SENT: 'sent', PAID: 'paid', OVERDUE: 'overdue', CONVERTED: 'converted' }; const cls = map[(s || '').toUpperCase()] || 'draft'; return `${s || 'DRAFT'}`; }; return { get, post, postForm, isLoggedIn, setAuth, logout, toast, modal, closeModal, openTab, activateTab, closeTab, navigate, reloadActive, init, fmt, statusBadge, getToken: () => token, getUser: () => user, }; })(); // Expose App on window so iframes can reach it via window.parent.App window.App = App; // Global shortcuts for iframe pages — App is local, reference directly window.AppBridge = { navigate: (label, url) => App.openTab(label, url), toast: (...a) => App.toast(...a), modal: (...a) => App.modal(...a), closeModal: () => App.closeModal(), getToken: () => App.getToken(), getUser: () => App.getUser(), fmt: App.fmt, };