HR-ATS-Portal/js/ui.js

253 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* ============================================================
ui.js — Reusable UI primitives & helpers
Exposes global `UI`
============================================================ */
(function () {
'use strict';
const ICONS = {
'user-plus': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/>',
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
'check': '<polyline points="20 6 9 17 4 12"/>',
'check-circle': '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>',
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
'x-circle': '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>',
'file': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
'message': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
'info': '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
'alert': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
'edit': '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z"/>',
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
'more': '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
'filter': '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>',
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'mail': '<path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><polyline points="22,6 12,13 2,6"/>',
'phone': '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.98.36 1.94.7 2.85a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.87.57 2.85.7A2 2 0 0 1 22 16.92z"/>',
'map': '<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>',
'briefcase': '<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>',
'trending-up': '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
'trending-down': '<polyline points="23 18 13.5 8.5 8.5 13.5 1 6"/><polyline points="17 18 23 18 23 12"/>',
'users': '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
'award': '<circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/>',
'dollar': '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>',
'target': '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
'send': '<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>',
'video': '<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/>',
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
'chevron-left': '<polyline points="15 18 9 12 15 6"/>',
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
'refresh': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
'copy': '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
'linkedin': '<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/>',
'inbox': '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
'sparkles': '<path d="M12 3l1.9 5.8L20 10l-6.1 1.2L12 17l-1.9-5.8L4 10l6.1-1.2z"/><path d="M19 3v4M21 5h-4M5 17v4M7 19H3"/>',
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
'grid': '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
'bookmark': '<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>',
'paperclip': '<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
'external': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
'shield': '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
'lock': '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'flame': '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
'bell': '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
'layers': '<polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/>',
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
'check-square': '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
'arrow-right': '<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>'
};
function icon(name, cls) { return `<svg class="${cls || ''}" viewBox="0 0 24 24">${ICONS[name] || ICONS['info']}</svg>`; }
function avatar(name, initials, color, cls) {
const bg = color || DB.avatarColor(name || '');
const init = initials || DB.initials(name || '?');
return `<span class="avatar ${cls || ''}" style="background:${bg}">${init}</span>`;
}
// ---------- badge helpers ----------
const statusMap = {
'Open': 'b-green', 'Closed': 'b-gray', 'On Hold': 'b-amber', 'Draft': 'b-blue',
'Applied': 'b-blue', 'Screening': 'b-purple', 'Assessment': 'b-amber', 'Interview': 'b-indigo',
'Offer': 'b-teal', 'Hired': 'b-green', 'Rejected': 'b-red',
'Scheduled': 'b-blue', 'Completed': 'b-green', 'Cancelled': 'b-red', 'No Show': 'b-amber',
'Sent': 'b-blue', 'Accepted': 'b-green', 'Negotiating': 'b-amber', 'Declined': 'b-red', 'Expired': 'b-gray',
'In Progress': 'b-amber', 'Pending': 'b-gray', 'Active': 'b-green', 'Invited': 'b-amber',
'Strong Hire': 'b-green', 'Hire': 'b-teal', 'Lean Hire': 'b-amber', 'No Hire': 'b-red'
};
function badge(text, cls) { return `<span class="badge ${cls || statusMap[text] || 'b-gray'}">${text}</span>`; }
function scoreChip(score) {
// Theme tokens, not fixed hex — the old greens/blues dropped to ~2.6:1 on dark cards.
const color = score >= 85 ? 'var(--success)' : score >= 70 ? 'var(--warning)' : score >= 55 ? 'var(--info)' : 'var(--danger)';
return `<span class="score"><span class="score-ring" style="--pct:${score};--sc-color:${color}"><span style="color:${color}">${score}</span></span></span>`;
}
function pbar(pct, cls) {
const c = pct >= 80 ? 'green' : pct >= 50 ? '' : pct >= 30 ? 'amber' : 'red';
return `<div class="pbar"><div class="pbar-fill ${cls || c}" style="width:${pct}%"></div></div>`;
}
function avatarStack(names, max) {
max = max || 3;
const shown = names.slice(0, max);
const extra = names.length - max;
let html = '<div class="avatar-stack">';
shown.forEach(n => html += avatar(n, DB.initials(n)));
if (extra > 0) html += `<span class="more-count">+${extra}</span>`;
return html + '</div>';
}
// ---------- Modal ----------
function modal({ title, subtitle, body, footer, size }) {
const root = document.getElementById('modalRoot');
root.innerHTML = `
<div class="modal-backdrop" data-close></div>
<div class="modal ${size || ''}" role="dialog" aria-modal="true">
<div class="modal-head">
<div><h2>${title}</h2>${subtitle ? `<p>${subtitle}</p>` : ''}</div>
<button class="modal-close" data-close aria-label="Close">${icon('x')}</button>
</div>
<div class="modal-body">${body}</div>
${footer ? `<div class="modal-foot">${footer}</div>` : ''}
</div>`;
root.classList.add('open');
document.body.style.overflow = 'hidden';
root.querySelectorAll('[data-close]').forEach(el => el.addEventListener('click', closeModal));
return root;
}
function closeModal() {
const root = document.getElementById('modalRoot');
root.classList.remove('open');
root.innerHTML = '';
document.body.style.overflow = '';
}
// ---------- Toast ----------
function toast(msg, type = 'info', title) {
const root = document.getElementById('toastRoot');
const cfg = {
success: { i: 'check-circle', c: 'i-green', t: 'Success' },
error: { i: 'x-circle', c: 'i-red', t: 'Error' },
info: { i: 'info', c: 'i-blue', t: 'Notice' },
warning: { i: 'alert', c: 'i-amber', t: 'Warning' }
}[type] || { i: 'info', c: 'i-blue', t: 'Notice' };
const el = document.createElement('div');
el.className = 'toast';
el.innerHTML = `
<span class="toast-icn ${cfg.c}">${icon(cfg.i)}</span>
<div class="toast-body"><div class="toast-title">${title || cfg.t}</div><div class="toast-msg">${msg}</div></div>
<button class="toast-close">${icon('x')}</button>`;
root.appendChild(el);
const remove = () => { el.classList.add('out'); setTimeout(() => el.remove(), 300); };
el.querySelector('.toast-close').onclick = remove;
setTimeout(remove, 4200);
}
// ---------- Sortable / paginated table ----------
function dataTable(config) {
// config: { columns:[{key,label,sortable,render,align}], rows, pageSize, empty }
const state = { sortKey: null, sortDir: 1, page: 1, rows: config.rows };
const pageSize = config.pageSize || 10;
const id = 'tbl_' + Math.random().toString(36).slice(2, 8);
function sorted() {
let r = state.rows;
if (state.sortKey) {
const col = config.columns.find(c => c.key === state.sortKey);
r = [...r].sort((a, b) => {
let va = col.sortValue ? col.sortValue(a) : a[state.sortKey];
let vb = col.sortValue ? col.sortValue(b) : b[state.sortKey];
if (typeof va === 'string') { va = va.toLowerCase(); vb = (vb || '').toLowerCase(); }
if (va < vb) return -1 * state.sortDir;
if (va > vb) return 1 * state.sortDir;
return 0;
});
}
return r;
}
function render() {
const rows = sorted();
const total = rows.length;
const pages = Math.max(1, Math.ceil(total / pageSize));
if (state.page > pages) state.page = pages;
const start = (state.page - 1) * pageSize;
const pageRows = rows.slice(start, start + pageSize);
const thead = config.columns.map(c => {
const sortedCls = state.sortKey === c.key ? (state.sortDir === 1 ? 'sorted-asc' : 'sorted-desc') : '';
const ind = c.sortable ? `<span class="sort-ind">${state.sortKey === c.key ? (state.sortDir === 1 ? '▲' : '▼') : '⇅'}</span>` : '';
return `<th class="${c.sortable ? 'sortable ' : ''}${sortedCls}" data-sort="${c.key}" style="text-align:${c.align || 'left'}">${c.label}${ind}</th>`;
}).join('');
let tbody;
if (!pageRows.length) {
tbody = `<tr><td colspan="${config.columns.length}">
<div class="empty-state">${icon('search')}<h3>No results found</h3><p>${config.empty || 'Try adjusting your filters or search.'}</p></div></td></tr>`;
} else {
tbody = pageRows.map(row => `<tr>${config.columns.map(c =>
`<td style="text-align:${c.align || 'left'}">${c.render ? c.render(row) : (row[c.key] ?? '')}</td>`).join('')}</tr>`).join('');
}
const from = total ? start + 1 : 0, to = Math.min(start + pageSize, total);
const pager = pageButtons(state.page, pages);
const el = document.getElementById(id);
el.innerHTML = `
<div class="table-wrap"><table class="data">
<thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table></div>
<div class="pagination">
<span class="page-info">Showing <b>${from}${to}</b> of <b>${total}</b></span>
<div class="page-controls">${pager}</div>
</div>`;
el.querySelectorAll('th.sortable').forEach(th => th.onclick = () => {
const k = th.dataset.sort;
if (state.sortKey === k) state.sortDir *= -1; else { state.sortKey = k; state.sortDir = 1; }
render();
});
el.querySelectorAll('[data-page]').forEach(b => b.onclick = () => {
const p = b.dataset.page;
if (p === 'prev') state.page = Math.max(1, state.page - 1);
else if (p === 'next') state.page = Math.min(pages, state.page + 1);
else state.page = +p;
render();
});
if (config.onRender) config.onRender(el);
}
function pageButtons(cur, pages) {
let btns = `<button class="page-btn" data-page="prev" ${cur === 1 ? 'disabled' : ''}>${icon('chevron-left')}</button>`;
const list = [];
for (let i = 1; i <= pages; i++) {
if (i === 1 || i === pages || Math.abs(i - cur) <= 1) list.push(i);
else if (list[list.length - 1] !== '…') list.push('…');
}
list.forEach(i => btns += i === '…' ? `<span class="page-btn" style="cursor:default">…</span>` : `<button class="page-btn ${i === cur ? 'active' : ''}" data-page="${i}">${i}</button>`);
btns += `<button class="page-btn" data-page="next" ${cur === pages ? 'disabled' : ''}>${icon('chevron-right')}</button>`;
return btns;
}
// public API
return {
html: `<div id="${id}" class="dt"></div>`,
mount: render,
update(rows) { state.rows = rows; state.page = 1; render(); }
};
}
function fieldError(inputEl, msg) {
inputEl.classList.add('err');
let err = inputEl.parentElement.querySelector('.field-error');
if (err) { err.textContent = msg; err.classList.add('show'); }
}
function clearErrors(form) {
form.querySelectorAll('.err').forEach(e => e.classList.remove('err'));
form.querySelectorAll('.field-error').forEach(e => e.classList.remove('show'));
}
window.UI = { icon, avatar, badge, scoreChip, pbar, avatarStack, modal, closeModal, toast, dataTable, fieldError, clearErrors, ICONS };
})();