HR-ATS-Portal/js/app.js

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