276 lines
13 KiB
JavaScript
276 lines
13 KiB
JavaScript
/* ============================================================
|
|
app.js — Core: router, sidebar, topbar, theme, search, init
|
|
============================================================ */
|
|
window.Router = {};
|
|
window.App = {};
|
|
|
|
const ROUTES = {
|
|
dashboard: { title: 'Dashboard' }, inbox: { title: 'Recruitment Inbox' }, jobs: { title: 'Jobs' }, candidates: { title: 'Candidates' },
|
|
talentpool: { title: 'Talent Pool' }, pipeline: { title: 'Pipeline' }, interviews: { title: 'Interviews' },
|
|
assessments: { title: 'Assessments' }, offers: { title: 'Offers' }, managers: { title: 'Hiring Managers' },
|
|
calendar: { title: 'Calendar' }, reports: { title: 'Reports' }, analytics: { title: 'Analytics' },
|
|
notifications: { title: 'Notifications' }, settings: { title: 'Settings' }, help: { title: 'Help' },
|
|
import: { title: 'CV Import' }, jobboard: { title: 'Job Board' }, recruiterhub: { title: 'Recruiter Hub' },
|
|
aiassistant: { title: 'AI Assistant' }, aistudio: { title: 'AI Studio' }, rbac: { title: 'Access Control' },
|
|
tasks: { title: 'Tasks' }
|
|
};
|
|
|
|
let currentRoute = 'dashboard';
|
|
|
|
Router.go = function (route) {
|
|
if (!ROUTES[route]) route = 'dashboard';
|
|
location.hash = route;
|
|
};
|
|
Router.reload = function () { Router.render(currentRoute); };
|
|
|
|
Router.render = function (route) {
|
|
currentRoute = route;
|
|
const view = (window.Views[route] || window.Views.dashboard)();
|
|
const main = document.getElementById('main-content');
|
|
main.innerHTML = view.html;
|
|
main.scrollTop = 0;
|
|
if (view.onMount) view.onMount();
|
|
|
|
// Expose the route so CSS can react to it (e.g. hide the AI launcher on
|
|
// the AI Assistant page, where it sat on top of the chat send button).
|
|
// Deliberately NOT `data-route`: initNav() binds a click handler to every
|
|
// [data-route] element, and <html> matching that would fire on any click.
|
|
document.documentElement.setAttribute('data-view', route);
|
|
|
|
// active nav
|
|
document.querySelectorAll('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.route === route));
|
|
document.title = 'TalentFlow · ' + (ROUTES[route] ? ROUTES[route].title : 'ATS');
|
|
|
|
// close mobile sidebar + AI dock
|
|
if (App.setNavOpen) App.setNavOpen(false);
|
|
else {
|
|
document.getElementById('sidebar').classList.remove('mobile-open');
|
|
document.getElementById('scrim').classList.remove('open');
|
|
}
|
|
const dock = document.getElementById('aiDock');
|
|
if (dock) dock.classList.remove('open');
|
|
};
|
|
|
|
function handleHash() {
|
|
const route = (location.hash || '#dashboard').slice(1);
|
|
Router.render(ROUTES[route] ? route : 'dashboard');
|
|
}
|
|
|
|
// ---------------- Theme ----------------
|
|
// `persist` is false when the OS drives the change, so following the
|
|
// system stays the default until the user makes an explicit choice.
|
|
App.applyTheme = function (theme, persist) {
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
if (persist) { try { localStorage.setItem('tf-theme', theme); } catch (e) {} }
|
|
const btn = document.getElementById('themeToggle');
|
|
if (btn) {
|
|
btn.setAttribute('aria-pressed', theme === 'dark' ? 'true' : 'false');
|
|
btn.setAttribute('title', theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode');
|
|
btn.setAttribute('aria-label', btn.getAttribute('title'));
|
|
}
|
|
};
|
|
App.setTheme = function (theme) {
|
|
App.applyTheme(theme, true);
|
|
// re-render current view so canvas charts pick up new theme colors
|
|
Router.render(currentRoute);
|
|
};
|
|
App.toggleTheme = function () {
|
|
const cur = document.documentElement.getAttribute('data-theme');
|
|
App.setTheme(cur === 'dark' ? 'light' : 'dark');
|
|
};
|
|
|
|
// ---------------- Toast passthrough ----------------
|
|
App.toast = function (msg, type, title) { UI.toast(msg, type, title); };
|
|
|
|
// ---------------- Badges ----------------
|
|
App.updateBadges = function () {
|
|
const openJobs = DB.jobs.filter(j => j.status === 'Open').length;
|
|
setBadge('navJobsBadge', openJobs);
|
|
const unread = DB.notifications.filter(n => n.unread).length;
|
|
setBadge('navNotifBadge', unread);
|
|
const inboxUnread = DB.inbox.filter(i => i.unread).length + DB.emails.filter(e => e.unread).length;
|
|
setBadge('navInboxBadge', inboxUnread);
|
|
const openTasks = DB.tasks.filter(t => !t.done).length;
|
|
setBadge('navTaskBadge', openTasks);
|
|
};
|
|
function setBadge(id, n) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.textContent = n;
|
|
el.style.display = n ? '' : 'none';
|
|
}
|
|
App.markAllNotifsRead = function () {
|
|
DB.notifications.forEach(n => n.unread = false);
|
|
App.updateBadges();
|
|
App.renderNotifDropdown();
|
|
UI.toast('All notifications marked as read', 'success');
|
|
};
|
|
|
|
// ---------------- Topbar dropdown content ----------------
|
|
App.renderNotifDropdown = function () {
|
|
const list = document.getElementById('notifList');
|
|
list.className = 'dd-scroll';
|
|
list.innerHTML = DB.notifications.slice(0, 6).map(n => `
|
|
<div class="notif-row ${n.unread ? 'unread' : ''}">
|
|
<span class="notif-icn ${n.color}">${UI.icon(n.icon)}</span>
|
|
<div class="notif-body"><div class="notif-title">${n.title}</div><div class="notif-text">${n.text}</div><div class="notif-time">${n.time}</div></div>
|
|
</div>`).join('');
|
|
};
|
|
App.renderMessages = function () {
|
|
const list = document.getElementById('messagesList');
|
|
list.className = 'dd-scroll';
|
|
list.innerHTML = DB.messages.map(m => `
|
|
<div class="notif-row ${m.unread ? 'unread' : ''}">
|
|
${UI.avatar(m.name, m.initials, m.color)}
|
|
<div class="notif-body"><div class="notif-title">${m.name}</div><div class="notif-text">${m.text}</div><div class="notif-time">${m.time} ago</div></div>
|
|
</div>`).join('');
|
|
};
|
|
|
|
// ---------------- Global search ----------------
|
|
App.search = function (q) {
|
|
const box = document.getElementById('searchResults');
|
|
q = q.trim().toLowerCase();
|
|
if (!q) { box.classList.remove('open'); return; }
|
|
|
|
const jobs = DB.jobs.filter(j => (j.title + j.id + j.department).toLowerCase().includes(q)).slice(0, 4);
|
|
const cands = DB.candidates.filter(c => (c.name + c.email + c.jobTitle).toLowerCase().includes(q)).slice(0, 4);
|
|
const mgrs = DB.managers.filter(m => m.name.toLowerCase().includes(q)).slice(0, 3);
|
|
|
|
let html = '';
|
|
if (jobs.length) html += `<div class="search-group-label">Jobs</div>` + jobs.map(j =>
|
|
`<div class="search-item" onclick="App.searchGo('jobs',()=>Jobs.view('${j.id}'))"><span class="kpi-icn i-indigo" style="width:32px;height:32px;border-radius:8px">${UI.icon('briefcase')}</span><div><div class="si-title">${j.title}</div><div class="si-sub">${j.id} · ${j.department}</div></div></div>`).join('');
|
|
if (cands.length) html += `<div class="search-group-label">Candidates</div>` + cands.map(c =>
|
|
`<div class="search-item" onclick="App.searchGo('candidates',()=>Candidates.openProfile('${c.id}'))">${UI.avatar(c.name, c.initials, c.color)}<div><div class="si-title">${c.name}</div><div class="si-sub">${c.jobTitle}</div></div></div>`).join('');
|
|
if (mgrs.length) html += `<div class="search-group-label">Hiring Managers</div>` + mgrs.map(m =>
|
|
`<div class="search-item" onclick="App.searchGo('managers',()=>Views._mgrDetail('${m.id}'))">${UI.avatar(m.name, m.initials, m.color)}<div><div class="si-title">${m.name}</div><div class="si-sub">${m.title}</div></div></div>`).join('');
|
|
if (!html) html = `<div class="search-empty">No results for "${q}"</div>`;
|
|
|
|
box.innerHTML = html;
|
|
box.classList.add('open');
|
|
};
|
|
App.searchGo = function (route, cb) {
|
|
document.getElementById('searchResults').classList.remove('open');
|
|
document.getElementById('globalSearch').value = '';
|
|
Router.go(route);
|
|
if (cb) setTimeout(cb, 120);
|
|
};
|
|
|
|
// ---------------- Dropdown behavior ----------------
|
|
function initDropdowns() {
|
|
document.querySelectorAll('.dropdown').forEach(dd => {
|
|
const toggle = dd.querySelector('[data-dd-toggle]');
|
|
toggle.addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
const wasOpen = dd.classList.contains('open');
|
|
document.querySelectorAll('.dropdown.open').forEach(o => o.classList.remove('open'));
|
|
if (!wasOpen) dd.classList.add('open');
|
|
});
|
|
dd.querySelector('[data-dd-panel]').addEventListener('click', e => e.stopPropagation());
|
|
});
|
|
document.addEventListener('click', () => {
|
|
document.querySelectorAll('.dropdown.open').forEach(o => o.classList.remove('open'));
|
|
document.getElementById('searchResults').classList.remove('open');
|
|
});
|
|
}
|
|
|
|
// ---------------- Nav link interception ----------------
|
|
function initNav() {
|
|
document.querySelectorAll('[data-route]').forEach(el => {
|
|
el.addEventListener('click', e => {
|
|
if (el.tagName === 'A') { /* href hash handles it */ }
|
|
const route = el.dataset.route;
|
|
if (route) { e.preventDefault(); Router.go(route); document.querySelectorAll('.dropdown.open').forEach(o => o.classList.remove('open')); }
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------- Init ----------------
|
|
function init() {
|
|
// Theme: an explicit past choice wins; otherwise follow the OS and keep
|
|
// following it until the user picks a side themselves.
|
|
const mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
|
|
let saved = null;
|
|
try { saved = localStorage.getItem('tf-theme'); } catch (e) {}
|
|
App.applyTheme(saved || (mq && mq.matches ? 'dark' : 'light'), false);
|
|
if (mq && !saved) {
|
|
const onSystemChange = e => {
|
|
let s = null;
|
|
try { s = localStorage.getItem('tf-theme'); } catch (err) {}
|
|
if (s) return; // user has chosen; stop following
|
|
App.applyTheme(e.matches ? 'dark' : 'light', false);
|
|
Router.render(currentRoute); // recolour canvas charts
|
|
};
|
|
mq.addEventListener ? mq.addEventListener('change', onSystemChange)
|
|
: mq.addListener(onSystemChange);
|
|
}
|
|
|
|
document.getElementById('themeToggle').addEventListener('click', App.toggleTheme);
|
|
|
|
// Canvas charts are drawn at a fixed pixel size, so they blur when the
|
|
// window changes width. Re-render on resize — but never while a modal or
|
|
// the AI dock is open, since that would discard what the user is doing.
|
|
// Width only: on iOS/Android the address bar collapsing fires `resize` with
|
|
// a height change on nearly every scroll, and re-rendering there would tear
|
|
// the view out from under the user mid-gesture.
|
|
let resizeTimer, lastW = window.innerWidth;
|
|
window.addEventListener('resize', () => {
|
|
if (window.innerWidth === lastW) return;
|
|
lastW = window.innerWidth;
|
|
clearTimeout(resizeTimer);
|
|
resizeTimer = setTimeout(() => {
|
|
const busy = document.getElementById('modalRoot').classList.contains('open')
|
|
|| document.getElementById('aiDock').classList.contains('open');
|
|
if (!busy) Router.render(currentRoute);
|
|
}, 220);
|
|
}, { passive: true });
|
|
window.addEventListener('orientationchange', () => {
|
|
lastW = -1; // force the next resize through
|
|
});
|
|
|
|
// AI Assistant floating dock
|
|
document.getElementById('aiFab').addEventListener('click', () => AI._dockOpen());
|
|
|
|
// sidebar collapse (desktop)
|
|
document.getElementById('sidebarCollapse').addEventListener('click', () => {
|
|
document.getElementById('sidebar').classList.toggle('collapsed');
|
|
});
|
|
// Mobile nav drawer. `nav-open` on <html> is what CSS keys off — the FAB
|
|
// sits before .scrim in the DOM, so no sibling selector can reach it, and
|
|
// :has() would exclude older Safari/Firefox.
|
|
App.setNavOpen = function (open) {
|
|
document.getElementById('sidebar').classList.toggle('mobile-open', open);
|
|
document.getElementById('scrim').classList.toggle('open', open);
|
|
document.documentElement.classList.toggle('nav-open', open);
|
|
// Stop the page behind the drawer from scrolling under the user's finger.
|
|
document.body.style.overflow = open ? 'hidden' : '';
|
|
};
|
|
document.getElementById('mobileMenu').addEventListener('click', () => {
|
|
App.setNavOpen(!document.getElementById('sidebar').classList.contains('mobile-open'));
|
|
});
|
|
document.getElementById('scrim').addEventListener('click', () => App.setNavOpen(false));
|
|
|
|
// search
|
|
const search = document.getElementById('globalSearch');
|
|
search.addEventListener('input', () => App.search(search.value));
|
|
search.addEventListener('click', e => e.stopPropagation());
|
|
document.addEventListener('keydown', e => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); search.focus(); }
|
|
if (e.key === 'Escape') { UI.closeModal(); document.getElementById('searchResults').classList.remove('open'); document.getElementById('aiDock').classList.remove('open'); App.setNavOpen(false); }
|
|
});
|
|
|
|
initDropdowns();
|
|
initNav();
|
|
App.renderNotifDropdown();
|
|
App.renderMessages();
|
|
App.updateBadges();
|
|
|
|
window.addEventListener('hashchange', handleHash);
|
|
handleHash();
|
|
|
|
// redraw charts on resize (debounced)
|
|
let rt;
|
|
window.addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => Router.reload(), 250); });
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|