/* ============================================================
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 = `
${bodyHtml}
${footerHtml ? `` : ''}
`;
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,
};