Initial commit of TalentFlow ATS dashboard.
Add the app source, docs, and a .gitignore that excludes OS junk, backups, and local tooling. Co-authored-by: Cursor <cursoragent@cursor.com>main
commit
889d48ef7c
|
|
@ -0,0 +1,42 @@
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
Icon
?
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Editor / IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Claude / local AI tooling
|
||||||
|
.claude/
|
||||||
|
.audit.js
|
||||||
|
|
||||||
|
# Backups
|
||||||
|
.backup-prebrand/
|
||||||
|
*.bak
|
||||||
|
*.backup
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Environment / secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Logs & temp
|
||||||
|
*.log
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
.cache/
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,36 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Static dev server for the ATS dashboard.
|
||||||
|
|
||||||
|
Identical to `python3 -m http.server` except it disables caching. The stdlib
|
||||||
|
server answers conditional requests from Last-Modified, which has one-second
|
||||||
|
granularity — so a file edited twice within the same second keeps serving the
|
||||||
|
stale copy and the browser never sees the change.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from functools import partial
|
||||||
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
|
||||||
|
class NoCacheHandler(SimpleHTTPRequestHandler):
|
||||||
|
def end_headers(self):
|
||||||
|
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||||
|
self.send_header("Pragma", "no-cache")
|
||||||
|
self.send_header("Expires", "0")
|
||||||
|
super().end_headers()
|
||||||
|
|
||||||
|
def send_header(self, keyword, value):
|
||||||
|
# Drop the validator entirely so conditional GETs can't 304.
|
||||||
|
if keyword.lower() == "last-modified":
|
||||||
|
return
|
||||||
|
super().send_header(keyword, value)
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 4173
|
||||||
|
directory = sys.argv[2] if len(sys.argv) > 2 else "."
|
||||||
|
handler = partial(NoCacheHandler, directory=directory)
|
||||||
|
print(f"Serving {directory} on http://localhost:{port} (no-cache)")
|
||||||
|
ThreadingHTTPServer(("127.0.0.1", port), handler).serve_forever()
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,287 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<!-- viewport-fit=cover lets the layout reach under the iOS notch/home bar;
|
||||||
|
the safe-area insets in styles.css keep content clear of them.
|
||||||
|
No maximum-scale/user-scalable — pinch-zoom must stay available. -->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="format-detection" content="telephone=no" />
|
||||||
|
<title>TalentFlow · Applicant Tracking System</title>
|
||||||
|
<meta name="theme-color" content="#004d43" media="(prefers-color-scheme: light)" />
|
||||||
|
<meta name="theme-color" content="#0e1d1f" media="(prefers-color-scheme: dark)" />
|
||||||
|
|
||||||
|
<!-- Utopia brand type: Belleza (main headings) + Inter as the metric-
|
||||||
|
compatible stand-in for Neue Montreal, which is a licensed face.
|
||||||
|
If Neue Montreal is installed locally it wins via the CSS stack. -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="css/styles.css" />
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<div class="brand-logo">
|
||||||
|
<!-- Utopia Brands emblem — vector traced from the brand guideline.
|
||||||
|
The upright left edge is the abstract 'u'; the curve is the leaf. -->
|
||||||
|
<svg class="brand-mark" viewBox="0 0 100 64.46" aria-hidden="true">
|
||||||
|
<path d="M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="brand-text">
|
||||||
|
<span class="brand-name">TalentFlow</span>
|
||||||
|
<span class="brand-sub">Utopia Brands · ATS</span>
|
||||||
|
</div>
|
||||||
|
<button class="sidebar-collapse-btn" id="sidebarCollapse" title="Collapse sidebar" aria-label="Collapse sidebar">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav" id="sidebarNav">
|
||||||
|
<div class="nav-section-label">Workspace</div>
|
||||||
|
<a class="nav-item" data-route="dashboard" href="#dashboard">
|
||||||
|
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-route="inbox" href="#inbox">
|
||||||
|
<svg viewBox="0 0 24 24"><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"/></svg>
|
||||||
|
<span>Recruitment Inbox</span>
|
||||||
|
<span class="nav-badge nav-badge-alert" id="navInboxBadge">0</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-route="jobs" href="#jobs">
|
||||||
|
<svg viewBox="0 0 24 24"><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"/></svg>
|
||||||
|
<span>Jobs</span>
|
||||||
|
<span class="nav-badge" id="navJobsBadge">0</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-route="candidates" href="#candidates">
|
||||||
|
<svg viewBox="0 0 24 24"><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"/></svg>
|
||||||
|
<span>Candidates</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-route="talentpool" href="#talentpool">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="M22 4L12 14.01l-3-3"/></svg>
|
||||||
|
<span>Talent Pool</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-route="pipeline" href="#pipeline">
|
||||||
|
<svg viewBox="0 0 24 24"><rect x="2" y="4" width="6" height="16" rx="1"/><rect x="9" y="4" width="6" height="10" rx="1"/><rect x="16" y="4" width="6" height="13" rx="1"/></svg>
|
||||||
|
<span>Pipeline</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-section-label">Recruiting</div>
|
||||||
|
<a class="nav-item" data-route="import" href="#import">
|
||||||
|
<svg viewBox="0 0 24 24"><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"/></svg>
|
||||||
|
<span>CV Import</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-route="jobboard" href="#jobboard">
|
||||||
|
<svg viewBox="0 0 24 24"><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"/></svg>
|
||||||
|
<span>Job Board</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-route="recruiterhub" href="#recruiterhub">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||||
|
<span>Recruiter Hub</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-route="tasks" href="#tasks">
|
||||||
|
<svg viewBox="0 0 24 24"><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"/></svg>
|
||||||
|
<span>Tasks</span>
|
||||||
|
<span class="nav-badge" id="navTaskBadge">0</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-route="aiassistant" href="#aiassistant">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 3l1.9 5.8L20 10l-6.1 1.2L12 17l-1.9-5.8L4 10l6.1-1.2z"/></svg>
|
||||||
|
<span>AI Assistant</span>
|
||||||
|
<span class="nav-badge nav-badge-ai">AI</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-section-label">Hiring</div>
|
||||||
|
<a class="nav-item" data-route="interviews" href="#interviews">
|
||||||
|
<svg viewBox="0 0 24 24"><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"/></svg>
|
||||||
|
<span>Interviews</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-route="assessments" href="#assessments">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||||
|
<span>Assessments</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-route="offers" href="#offers">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M9 15l2 2 4-4"/></svg>
|
||||||
|
<span>Offers</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-route="managers" href="#managers">
|
||||||
|
<svg viewBox="0 0 24 24"><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"/></svg>
|
||||||
|
<span>Hiring Managers</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-route="calendar" href="#calendar">
|
||||||
|
<svg viewBox="0 0 24 24"><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"/></svg>
|
||||||
|
<span>Calendar</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-section-label">Insights</div>
|
||||||
|
<a class="nav-item" data-route="reports" href="#reports">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>
|
||||||
|
<span>Reports</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-route="analytics" href="#analytics">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M21 21H3V3"/><path d="M7 14l4-4 3 3 5-6"/></svg>
|
||||||
|
<span>Analytics</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-route="aistudio" href="#aistudio">
|
||||||
|
<svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||||
|
<span>AI Studio</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-route="notifications" href="#notifications">
|
||||||
|
<svg viewBox="0 0 24 24"><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"/></svg>
|
||||||
|
<span>Notifications</span>
|
||||||
|
<span class="nav-badge nav-badge-alert" id="navNotifBadge">0</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-section-label">System</div>
|
||||||
|
<a class="nav-item" data-route="rbac" href="#rbac">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
|
<span>Access Control</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-route="settings" href="#settings">
|
||||||
|
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
<span>Settings</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav-item" data-route="help" href="#help">
|
||||||
|
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||||
|
<span>Help</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="usage-card">
|
||||||
|
<div class="usage-top"><span>Seats used</span><span id="usageCount">14 / 20</span></div>
|
||||||
|
<div class="usage-bar"><div class="usage-fill" style="width:70%"></div></div>
|
||||||
|
<button class="btn btn-ghost btn-block" onclick="App.toast('Upgrade flow coming soon','info')">Upgrade plan</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<div class="main-wrap">
|
||||||
|
<!-- Topbar -->
|
||||||
|
<header class="topbar">
|
||||||
|
<button class="icon-btn menu-toggle" id="mobileMenu" aria-label="Toggle menu">
|
||||||
|
<svg viewBox="0 0 24 24"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="topbar-search">
|
||||||
|
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
<input type="text" id="globalSearch" placeholder="Search jobs, candidates, managers…" autocomplete="off" />
|
||||||
|
<div class="search-results" id="searchResults"></div>
|
||||||
|
<kbd class="search-kbd">⌘K</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<button class="icon-btn" id="themeToggle" title="Toggle theme" aria-label="Toggle theme">
|
||||||
|
<svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||||
|
<svg class="icon-moon" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="dropdown" id="messagesDropdown">
|
||||||
|
<button class="icon-btn" data-dd-toggle title="Messages" aria-label="Messages">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
<span class="dot dot-blue"></span>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu dropdown-menu-wide" data-dd-panel>
|
||||||
|
<div class="dropdown-head">Messages</div>
|
||||||
|
<div id="messagesList"></div>
|
||||||
|
<div class="dropdown-foot"><a href="#notifications" data-route="notifications">Open inbox</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dropdown" id="notifDropdown">
|
||||||
|
<button class="icon-btn" data-dd-toggle title="Notifications" aria-label="Notifications">
|
||||||
|
<svg viewBox="0 0 24 24"><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"/></svg>
|
||||||
|
<span class="dot dot-red"></span>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu dropdown-menu-wide" data-dd-panel>
|
||||||
|
<div class="dropdown-head">Notifications <button class="link-btn" onclick="App.markAllNotifsRead()">Mark all read</button></div>
|
||||||
|
<div id="notifList"></div>
|
||||||
|
<div class="dropdown-foot"><a href="#notifications" data-route="notifications">View all</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar-divider"></div>
|
||||||
|
|
||||||
|
<div class="dropdown" id="profileDropdown">
|
||||||
|
<button class="profile-btn" data-dd-toggle>
|
||||||
|
<span class="avatar avatar-grad">AA</span>
|
||||||
|
<span class="profile-meta">
|
||||||
|
<span class="profile-name">Asfand Ahmed</span>
|
||||||
|
<span class="profile-role">Talent Lead</span>
|
||||||
|
</span>
|
||||||
|
<svg class="chev" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu" data-dd-panel>
|
||||||
|
<div class="dropdown-profile">
|
||||||
|
<span class="avatar avatar-grad avatar-lg">AA</span>
|
||||||
|
<div>
|
||||||
|
<div class="dp-name">Asfand Ahmed</div>
|
||||||
|
<div class="dp-email">asfand.ahmed@utopiabrands.com</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<a class="dropdown-link" href="#settings" data-route="settings"><svg viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>My Profile</a>
|
||||||
|
<a class="dropdown-link" href="#settings" data-route="settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Settings</a>
|
||||||
|
<a class="dropdown-link" href="#help" data-route="help"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>Help Center</a>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<button class="dropdown-link danger" onclick="App.toast('Signed out (demo)','info')"><svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5"/><line x1="21" y1="12" x2="9" y2="12"/></svg>Sign out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<main class="content" id="main-content"></main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Assistant floating launcher -->
|
||||||
|
<button class="ai-fab" id="aiFab" title="AI Recruiter Assistant" aria-label="Open AI Assistant">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 3l1.9 5.8L20 10l-6.1 1.2L12 17l-1.9-5.8L4 10l6.1-1.2z"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- AI Assistant slide-over dock -->
|
||||||
|
<div class="ai-dock" id="aiDock">
|
||||||
|
<div class="ai-dock-inner" id="aiDockInner"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal root -->
|
||||||
|
<div class="modal-root" id="modalRoot"></div>
|
||||||
|
<!-- Toast root -->
|
||||||
|
<div class="toast-root" id="toastRoot"></div>
|
||||||
|
<!-- Mobile overlay -->
|
||||||
|
<div class="scrim" id="scrim"></div>
|
||||||
|
|
||||||
|
<script src="js/data.js"></script>
|
||||||
|
<script src="js/charts.js"></script>
|
||||||
|
<script src="js/ui.js"></script>
|
||||||
|
<script src="js/dashboard.js"></script>
|
||||||
|
<script src="js/jobs.js"></script>
|
||||||
|
<script src="js/candidates.js"></script>
|
||||||
|
<script src="js/pipeline.js"></script>
|
||||||
|
<script src="js/interviews.js"></script>
|
||||||
|
<script src="js/assessments.js"></script>
|
||||||
|
<script src="js/offers.js"></script>
|
||||||
|
<script src="js/reports.js"></script>
|
||||||
|
<script src="js/analytics.js"></script>
|
||||||
|
<script src="js/settings.js"></script>
|
||||||
|
<script src="js/misc.js"></script>
|
||||||
|
<script src="js/inbox.js"></script>
|
||||||
|
<script src="js/import.js"></script>
|
||||||
|
<script src="js/jobboard.js"></script>
|
||||||
|
<script src="js/recruiterhub.js"></script>
|
||||||
|
<script src="js/aiassistant.js"></script>
|
||||||
|
<script src="js/rbac.js"></script>
|
||||||
|
<script src="js/tasks.js"></script>
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
/* ============================================================
|
||||||
|
aiassistant.js — ChatGPT-style AI Recruiter Assistant (UI only)
|
||||||
|
+ AI Studio (future modules gallery). API-ready, no AI wired.
|
||||||
|
============================================================ */
|
||||||
|
window.Views = window.Views || {};
|
||||||
|
window.AI = {};
|
||||||
|
|
||||||
|
// simulated (non-AI) canned responses keyed by intent — clearly a UI stub
|
||||||
|
AI._reply = function (prompt) {
|
||||||
|
const p = prompt.toLowerCase();
|
||||||
|
if (p.includes('rank')) {
|
||||||
|
const top = [...DB.candidates].sort((a, b) => b.aiScore - a.aiScore).slice(0, 5);
|
||||||
|
return `<p>Here are the top-ranked candidates by ATS match score:</p><ul>${top.map((c, i) => `<li><b>${i + 1}. ${c.name}</b> — ${c.aiScore}% match · ${c.jobTitle} · ${c.recommendation}</li>`).join('')}</ul><p class="text-muted">This is a UI preview. Connect an LLM endpoint to generate live rankings from resume + JD embeddings.</p>`;
|
||||||
|
}
|
||||||
|
if (p.includes('compare')) {
|
||||||
|
const two = DB.candidates.slice(0, 2);
|
||||||
|
return `<p>Comparing <b>${two[0].name}</b> vs <b>${two[1].name}</b>:</p><ul><li><b>Experience:</b> ${two[0].experience}y vs ${two[1].experience}y</li><li><b>ATS Score:</b> ${two[0].aiScore}% vs ${two[1].aiScore}%</li><li><b>Recommendation:</b> ${two[0].recommendation} vs ${two[1].recommendation}</li></ul><p><b>Suggested:</b> ${two[0].aiScore >= two[1].aiScore ? two[0].name : two[1].name} appears stronger on core criteria.</p>`;
|
||||||
|
}
|
||||||
|
if (p.includes('job description') || p.includes('jd')) {
|
||||||
|
return `<p><b>Senior Product Designer</b></p><p>We're looking for a Senior Product Designer to craft intuitive, delightful experiences across our platform. You'll own end-to-end design, from research to polished UI, and partner closely with product and engineering.</p><p><b>Responsibilities:</b> lead design for key initiatives, run user research, build and maintain design systems, mentor peers.</p><p><b>Requirements:</b> 5+ years product design, strong portfolio, fluency in Figma, systems thinking.</p>`;
|
||||||
|
}
|
||||||
|
if (p.includes('interview question')) {
|
||||||
|
return `<p>Here are role-specific interview questions:</p><ul><li>Walk me through how you'd design a system to handle 1M concurrent users.</li><li>Describe a technically challenging project and the tradeoffs you made.</li><li>How do you approach debugging a production incident under time pressure?</li><li>Tell me about a time you disagreed with a teammate on an approach.</li></ul>`;
|
||||||
|
}
|
||||||
|
if (p.includes('summar')) {
|
||||||
|
const c = DB.candidates[0];
|
||||||
|
return `<p><b>Resume summary — ${c.name}</b></p><p>${c.experience} years of experience, currently ${c.currentTitle} at ${c.currentCompany}. Strong in ${c.skills.slice(0, 3).join(', ')}. ATS match ${c.aiScore}% for ${c.jobTitle}. ${c.recommendation}.</p>`;
|
||||||
|
}
|
||||||
|
if (p.includes('email')) {
|
||||||
|
return `<p><b>Subject:</b> Interview Invitation — Next Steps</p><p>Hi [Candidate],</p><p>Thank you for applying. We were impressed by your background and would love to invite you to an interview. Please share your availability for this week.</p><p>Best regards,<br>Talent Team</p>`;
|
||||||
|
}
|
||||||
|
if (p.includes('offer letter')) {
|
||||||
|
return `<p><b>Offer Letter</b></p><p>Dear [Candidate], We are pleased to offer you the position of Product Manager at a base salary of $160,000, plus equity and benefits. This offer is contingent on standard background checks.</p><p>We're excited about the possibility of you joining the team.</p>`;
|
||||||
|
}
|
||||||
|
if (p.includes('skill gap')) {
|
||||||
|
return `<p><b>Skill Gap Analysis — Engineering pipeline</b></p><ul><li><span class="skill-pill skill-missing">Kubernetes</span> under-represented (only 22% of pipeline)</li><li><span class="skill-pill skill-missing">System Design</span> gap at senior level</li><li><span class="skill-pill skill-matched">React</span> well covered</li></ul><p>Consider sourcing candidates with cloud-native infra experience.</p>`;
|
||||||
|
}
|
||||||
|
if (p.includes('pipeline')) {
|
||||||
|
const total = DB.candidates.length;
|
||||||
|
return `<p><b>Pipeline health analysis</b></p><ul><li>${total} active candidates across 6 stages</li><li>Conversion Applied → Interview: ~28%</li><li>Bottleneck detected at <b>Assessment</b> stage (longest dwell time)</li><li>Offer acceptance trending at 82%</li></ul><p>Recommendation: accelerate assessment turnaround to improve velocity.</p>`;
|
||||||
|
}
|
||||||
|
if (p.includes('productivity') || p.includes('recruiter')) {
|
||||||
|
return `<p><b>Team productivity this month</b></p><ul><li>Top performer: ${[...DB.recruiters].sort((a, b) => b.hires - a.hires)[0].name}</li><li>Avg time-to-hire: 27 days (3 days faster than last month)</li><li>Interview completion rate: 91%</li></ul>`;
|
||||||
|
}
|
||||||
|
if (p.includes('recommend') || p.includes('suggest')) {
|
||||||
|
const c = [...DB.candidates].sort((a, b) => b.aiScore - a.aiScore)[0];
|
||||||
|
return `<p><b>Top recommendation:</b> ${c.name} (${c.aiScore}% match) for ${c.jobTitle}. Strong on ${c.matchedSkills.slice(0, 2).join(' & ')}. I'd prioritise scheduling a screen this week.</p>`;
|
||||||
|
}
|
||||||
|
return `<p>I can help with ranking candidates, comparing profiles, drafting JDs, interview questions, emails, offer letters, skill-gap and pipeline analysis, and more.</p><p class="text-muted">This is a fully-designed interface. Wire an AI endpoint (Claude / OpenAI) into <code>AI.send()</code> to make responses live.</p>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
AI._chatHtml = function (compact) {
|
||||||
|
const promptsHtml = DB.aiPrompts.slice(0, compact ? 6 : 12).map(p =>
|
||||||
|
`<button class="prompt-chip" onclick="AI.usePrompt(this, ${JSON.stringify(p.prompt).replace(/"/g, '"')})">${UI.icon(p.icon)} ${p.text}</button>`).join('');
|
||||||
|
return `
|
||||||
|
<div class="chat-wrap" ${compact ? 'style="height:100%"' : ''}>
|
||||||
|
<div class="chat-scroll" id="chatScroll">
|
||||||
|
<div class="ai-hero">
|
||||||
|
<div class="ai-logo">${UI.icon('sparkles')}</div>
|
||||||
|
<h2 style="font-size:${compact ? '18' : '22'}px;margin-bottom:6px">AI Recruiter Assistant</h2>
|
||||||
|
<p class="text-muted">Ask anything about your candidates, jobs, and pipeline</p>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:8px;justify-content:center;max-width:720px;margin:0 auto 10px">${promptsHtml}</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding-top:12px">
|
||||||
|
<div class="chat-input-bar">
|
||||||
|
<textarea id="chatInput" rows="1" placeholder="Message AI Assistant…"></textarea>
|
||||||
|
<button class="chat-send" id="chatSend">${UI.icon('arrow-right')}</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted text-sm" style="text-align:center;margin-top:8px">UI preview · responses are simulated. ${UI.icon('lock')} API-ready for backend integration.</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
AI._bindChat = function () {
|
||||||
|
const input = document.getElementById('chatInput');
|
||||||
|
const send = document.getElementById('chatSend');
|
||||||
|
if (!input) return;
|
||||||
|
input.oninput = () => { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 140) + 'px'; };
|
||||||
|
input.onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); AI.send(); } };
|
||||||
|
send.onclick = AI.send;
|
||||||
|
AI._started = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
AI.usePrompt = function (btn, prompt) {
|
||||||
|
const input = document.getElementById('chatInput');
|
||||||
|
input.value = prompt;
|
||||||
|
AI.send();
|
||||||
|
};
|
||||||
|
|
||||||
|
AI.send = function () {
|
||||||
|
const input = document.getElementById('chatInput');
|
||||||
|
const scroll = document.getElementById('chatScroll');
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
if (!AI._started) { scroll.innerHTML = ''; AI._started = true; }
|
||||||
|
|
||||||
|
// user message
|
||||||
|
scroll.insertAdjacentHTML('beforeend', `
|
||||||
|
<div class="chat-msg"><div class="chat-av user">${UI.icon('users')}</div>
|
||||||
|
<div class="chat-bubble"><div class="chat-role">You</div><div class="chat-text">${text.replace(/</g, '<')}</div></div></div>`);
|
||||||
|
input.value = ''; input.style.height = 'auto';
|
||||||
|
scroll.scrollTop = scroll.scrollHeight;
|
||||||
|
|
||||||
|
// typing indicator
|
||||||
|
const typingId = 'typing_' + Math.random().toString(36).slice(2, 7);
|
||||||
|
scroll.insertAdjacentHTML('beforeend', `
|
||||||
|
<div class="chat-msg" id="${typingId}"><div class="chat-av ai">${UI.icon('sparkles')}</div>
|
||||||
|
<div class="chat-bubble"><div class="chat-role">AI Assistant</div><div class="chat-typing"><span></span><span></span><span></span></div></div></div>`);
|
||||||
|
scroll.scrollTop = scroll.scrollHeight;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const t = document.getElementById(typingId);
|
||||||
|
if (t) t.querySelector('.chat-bubble').innerHTML = `<div class="chat-role">AI Assistant</div><div class="chat-text">${AI._reply(text)}</div>`;
|
||||||
|
scroll.scrollTop = scroll.scrollHeight;
|
||||||
|
}, 850 + Math.random() * 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- Full page ----------------
|
||||||
|
Views.aiassistant = function () {
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">AI Assistant</h1><p class="page-sub">Your recruiting copilot — powered by AI (interface preview)</p></div>
|
||||||
|
<div class="page-head-actions">
|
||||||
|
<span class="integration-status pending"><span class="pulse"></span>Model endpoint · Not connected</span>
|
||||||
|
<button class="btn btn-secondary" onclick="AI.newChat()">${UI.icon('plus')} New Chat</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><div class="card-body" id="aiChatMount">${AI._chatHtml(false)}</div></div>
|
||||||
|
</div>`;
|
||||||
|
return { html, onMount() { AI._bindChat(); } };
|
||||||
|
};
|
||||||
|
AI.newChat = function () {
|
||||||
|
const mount = document.getElementById('aiChatMount');
|
||||||
|
if (mount) { mount.innerHTML = AI._chatHtml(false); AI._bindChat(); }
|
||||||
|
else { AI._dockOpen(true); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- Floating dock ----------------
|
||||||
|
AI._dockOpen = function (force) {
|
||||||
|
const dock = document.getElementById('aiDock');
|
||||||
|
const inner = document.getElementById('aiDockInner');
|
||||||
|
const willOpen = force || !dock.classList.contains('open');
|
||||||
|
if (willOpen) {
|
||||||
|
inner.innerHTML = `
|
||||||
|
<div class="card-head" style="border-radius:0"><div><h3>${UI.icon('sparkles')} AI Assistant</h3></div>
|
||||||
|
<div class="flex items-center gap-8">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="Router.go('aiassistant');AI._dockClose()">Expand</button>
|
||||||
|
<button class="modal-close" onclick="AI._dockClose()">${UI.icon('x')}</button></div></div>
|
||||||
|
<div style="flex:1;padding:16px;overflow:hidden;display:flex" id="dockChatMount">${AI._chatHtml(true)}</div>`;
|
||||||
|
dock.classList.add('open');
|
||||||
|
AI._bindChat();
|
||||||
|
} else { AI._dockClose(); }
|
||||||
|
};
|
||||||
|
AI._dockClose = function () { document.getElementById('aiDock').classList.remove('open'); };
|
||||||
|
|
||||||
|
// ---------------- AI Studio (future modules) ----------------
|
||||||
|
Views.aistudio = function () {
|
||||||
|
const cards = DB.aiModules.map(m => `
|
||||||
|
<div class="card" style="cursor:pointer" onclick="AI.moduleDetail('${m.name.replace(/'/g, "\\'")}')">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center" style="justify-content:space-between;margin-bottom:12px">
|
||||||
|
<span class="kpi-icn ${m.cls}" style="width:46px;height:46px;border-radius:13px">${UI.icon(m.icon)}</span>
|
||||||
|
${UI.badge(m.status, m.status === 'Beta' ? 'b-indigo' : 'b-gray')}
|
||||||
|
</div>
|
||||||
|
<div class="lr-title" style="font-size:15px">${m.name}</div>
|
||||||
|
<div class="lr-sub" style="margin-top:5px;line-height:1.5">${m.desc}</div>
|
||||||
|
<div style="margin-top:14px;color:var(--primary);font-weight:600;font-size:13px">${m.status === 'Beta' ? 'Try it' : 'Join waitlist'} ${UI.icon('arrow-right')}</div>
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">AI Studio</h1><p class="page-sub">Next-generation AI modules — designed and API-ready for backend integration</p></div>
|
||||||
|
<div class="page-head-actions"><span class="integration-status pending"><span class="pulse"></span>${DB.aiModules.filter(m => m.status === 'Beta').length} in Beta</span>
|
||||||
|
<button class="btn btn-primary" onclick="AI._dockOpen(true)">${UI.icon('sparkles')} Open Assistant</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card brand-hero mb-18">
|
||||||
|
<div class="card-body" style="display:flex;align-items:center;gap:20px;flex-wrap:wrap">
|
||||||
|
<div class="ai-logo" style="margin:0;width:56px;height:56px">${UI.icon('sparkles')}</div>
|
||||||
|
<div style="flex:1;min-width:220px"><h2 style="font-size:19px;margin-bottom:4px">Everything is API-ready</h2>
|
||||||
|
<p style="opacity:.88">Each module below ships with a complete, production-grade interface. Connect your model endpoint to activate them — no UI work required.</p></div>
|
||||||
|
<button class="btn btn-on-brand" onclick="UI.toast('Integration guide opened','info')">${UI.icon('external')} Integration Guide</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid g-3">${cards}</div>
|
||||||
|
</div>`;
|
||||||
|
return { html };
|
||||||
|
};
|
||||||
|
AI.moduleDetail = function (name) {
|
||||||
|
const m = DB.aiModules.find(x => x.name === name);
|
||||||
|
UI.modal({
|
||||||
|
title: m.name, subtitle: m.status + ' · AI Module',
|
||||||
|
body: `<div class="flex items-center gap-16" style="margin-bottom:18px"><span class="kpi-icn ${m.cls}" style="width:56px;height:56px;border-radius:16px">${UI.icon(m.icon)}</span>
|
||||||
|
<div><div class="fw-600" style="font-size:16px">${m.name}</div><div class="text-muted">${m.desc}</div></div></div>
|
||||||
|
<div class="card" style="box-shadow:none;background:var(--bg-sunken)"><div class="card-body">
|
||||||
|
<div class="form-section-title" style="margin-top:0">API Contract (preview)</div>
|
||||||
|
<div class="resume-thumb" style="max-height:none">POST /api/ai/${m.name.toLowerCase().replace(/ /g, '-')}
|
||||||
|
{
|
||||||
|
"context": { "jobId": "JOB-1001", "candidateIds": [...] },
|
||||||
|
"options": { "model": "claude-opus", "stream": true }
|
||||||
|
}
|
||||||
|
|
||||||
|
→ 200 OK
|
||||||
|
{
|
||||||
|
"result": { ... },
|
||||||
|
"usage": { "tokens": 1240 }
|
||||||
|
}</div>
|
||||||
|
</div></div>
|
||||||
|
<p class="text-muted text-sm" style="margin-top:14px">${UI.icon('lock')} This feature's UI is complete. Backend wiring is the only remaining step.</p>`,
|
||||||
|
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Close</button>
|
||||||
|
<button class="btn btn-primary" onclick="UI.closeModal();AI._dockOpen(true)">${UI.icon('sparkles')} Try in Assistant</button>`,
|
||||||
|
size: 'modal-lg'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
/* ============================================================
|
||||||
|
analytics.js — Analytics dashboard (many charts)
|
||||||
|
============================================================ */
|
||||||
|
window.Views = window.Views || {};
|
||||||
|
|
||||||
|
Views.analytics = function () {
|
||||||
|
const a = DB.analytics;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">Analytics</h1><p class="page-sub">Deep-dive metrics across your recruitment funnel</p></div>
|
||||||
|
<div class="page-head-actions">
|
||||||
|
<div class="pill-tabs"><span class="pill-tab">Week</span><span class="pill-tab active">Month</span><span class="pill-tab">Quarter</span></div>
|
||||||
|
<button class="btn btn-secondary" onclick="UI.toast('Analytics exported','success')">${UI.icon('download')} Export</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid g-2 mb-18">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Hiring Trend</h3><span class="ch-sub">Hires vs applications</span></div></div>
|
||||||
|
<div class="card-body"><div class="chart-wrap"><canvas id="anTrend" height="260"></canvas></div>
|
||||||
|
${Charts.legend([{ label: 'Applications', color: Charts.PALETTE[4] }, { label: 'Hires', color: Charts.PALETTE[0] }])}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Applications Received</h3><span class="ch-sub">Monthly volume</span></div></div>
|
||||||
|
<div class="card-body"><div class="chart-wrap"><canvas id="anApps" height="260"></canvas></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid g-3 mb-18">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Source Breakdown</h3></div></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-wrap"><canvas id="anSource" height="220"></canvas></div>
|
||||||
|
<div class="chart-legend" id="anSourceLegend"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Offer Acceptance</h3></div></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-wrap"><canvas id="anOffer" height="220"></canvas></div>
|
||||||
|
<div class="chart-legend">
|
||||||
|
<span class="legend-item"><span class="legend-dot" style="background:var(--success)"></span>Accepted</span>
|
||||||
|
<span class="legend-item"><span class="legend-dot" style="background:var(--warning)"></span>Pending</span>
|
||||||
|
<span class="legend-item"><span class="legend-dot" style="background:var(--danger)"></span>Declined</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Pipeline Distribution</h3></div></div>
|
||||||
|
<div class="card-body"><div class="chart-wrap"><canvas id="anPipeline" height="260"></canvas></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid g-2 mb-18">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Applications by Department</h3><span class="ch-sub">Volume per team</span></div></div>
|
||||||
|
<div class="card-body"><div class="chart-wrap"><canvas id="anDept" height="300"></canvas></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Recruiter Performance</h3><span class="ch-sub">Hires by recruiter (top 8)</span></div></div>
|
||||||
|
<div class="card-body"><div class="chart-wrap"><canvas id="anRec" height="300"></canvas></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid g-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Time to Hire</h3><span class="ch-sub">Days, monthly average</span></div></div>
|
||||||
|
<div class="card-body"><div class="chart-wrap"><canvas id="anTTH" height="240"></canvas></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Time to Fill</h3><span class="ch-sub">Days, monthly average</span></div></div>
|
||||||
|
<div class="card-body"><div class="chart-wrap"><canvas id="anTTF" height="240"></canvas></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
onMount() {
|
||||||
|
Charts.line(document.getElementById('anTrend'), {
|
||||||
|
labels: a.hiringTrend.labels, area: true,
|
||||||
|
datasets: [
|
||||||
|
{ label: 'Applications', data: a.hiringTrend.applications, color: Charts.PALETTE[4] },
|
||||||
|
{ label: 'Hires', data: a.hiringTrend.hires, color: Charts.PALETTE[0] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
Charts.bar(document.getElementById('anApps'), { labels: a.hiringTrend.labels, data: a.hiringTrend.applications });
|
||||||
|
Charts.doughnut(document.getElementById('anSource'), {
|
||||||
|
labels: a.sources.map(s => s.source), data: a.sources.map(s => s.count),
|
||||||
|
centerValue: DB.candidates.length, centerLabel: 'Total'
|
||||||
|
});
|
||||||
|
document.getElementById('anSourceLegend').innerHTML = a.sources.map((s, i) =>
|
||||||
|
`<span class="legend-item"><span class="legend-dot" style="background:${Charts.PALETTE[i % Charts.PALETTE.length]}"></span>${s.source}</span>`).join('');
|
||||||
|
Charts.doughnut(document.getElementById('anOffer'), {
|
||||||
|
labels: ['Accepted', 'Pending', 'Declined'],
|
||||||
|
data: [a.offerAcceptance.accepted, a.offerAcceptance.pending, a.offerAcceptance.declined],
|
||||||
|
colors: [Charts.token('--success'), Charts.token('--warning'), Charts.token('--danger')],
|
||||||
|
centerValue: Math.round(a.offerAcceptance.accepted / (a.offerAcceptance.accepted + a.offerAcceptance.declined || 1) * 100) + '%',
|
||||||
|
centerLabel: 'Accept rate'
|
||||||
|
});
|
||||||
|
Charts.horizontalBar(document.getElementById('anPipeline'), {
|
||||||
|
labels: a.pipeline.map(p => p.stage), data: a.pipeline.map(p => p.count),
|
||||||
|
colors: Charts.PALETTE
|
||||||
|
});
|
||||||
|
Charts.bar(document.getElementById('anDept'), { labels: a.departments.map(d => d.dept), data: a.departments.map(d => d.apps) });
|
||||||
|
const topRecs = [...DB.recruiters].sort((x, y) => y.hires - x.hires).slice(0, 8);
|
||||||
|
Charts.horizontalBar(document.getElementById('anRec'), { labels: topRecs.map(r => r.name), data: topRecs.map(r => r.hires) });
|
||||||
|
Charts.line(document.getElementById('anTTH'), { labels: a.hiringTrend.labels, area: true, yFmt: v => v + 'd', datasets: [{ label: 'Time to Hire', data: a.timeToHire, color: Charts.PALETTE[0] }] });
|
||||||
|
Charts.line(document.getElementById('anTTF'), { labels: a.hiringTrend.labels, area: true, yFmt: v => v + 'd', datasets: [{ label: 'Time to Fill', data: a.timeToFill, color: Charts.PALETTE[2] }] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
/* ============================================================
|
||||||
|
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);
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
/* ============================================================
|
||||||
|
assessments.js — Assessments listing & assign
|
||||||
|
============================================================ */
|
||||||
|
window.Views = window.Views || {};
|
||||||
|
window.Assessments = {};
|
||||||
|
|
||||||
|
Views.assessments = function () {
|
||||||
|
const filters = { q: '', status: '', type: '' };
|
||||||
|
let table;
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: DB.assessments.length,
|
||||||
|
completed: DB.assessments.filter(a => a.status === 'Completed').length,
|
||||||
|
pending: DB.assessments.filter(a => ['Pending', 'In Progress'].includes(a.status)).length,
|
||||||
|
avg: Math.round(DB.assessments.filter(a => a.score).reduce((s, a) => s + a.score, 0) / (DB.assessments.filter(a => a.score).length || 1))
|
||||||
|
};
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
const rows = DB.assessments.filter(a => {
|
||||||
|
if (filters.status && a.status !== filters.status) return false;
|
||||||
|
if (filters.type && a.type !== filters.type) return false;
|
||||||
|
if (filters.q && !(a.candidate + a.jobTitle + a.type).toLowerCase().includes(filters.q.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
table.update(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
table = UI.dataTable({
|
||||||
|
pageSize: 8,
|
||||||
|
rows: DB.assessments,
|
||||||
|
columns: [
|
||||||
|
{ key: 'candidate', label: 'Candidate', sortable: true, render: a => `<div class="user-cell">${UI.avatar(a.candidate, a.initials, a.color)}<div><div class="cell-primary">${a.candidate}</div><div class="cell-sub">${a.jobTitle}</div></div></div>` },
|
||||||
|
{ key: 'type', label: 'Assessment', sortable: true, render: a => `<div class="cell-primary text-sm">${a.type}</div><div class="cell-sub">${a.duration}</div>` },
|
||||||
|
{ key: 'assigned', label: 'Assigned', sortable: true, sortValue: a => a.assigned.getTime(), render: a => `<span class="text-muted">${DB.fmtShort(a.assigned)}</span>` },
|
||||||
|
{ key: 'due', label: 'Due', sortable: true, sortValue: a => a.due.getTime(), render: a => `<span class="text-muted">${DB.fmtShort(a.due)}</span>` },
|
||||||
|
{ key: 'score', label: 'Score', sortable: true, align: 'center', render: a => a.score !== null ? UI.scoreChip(a.score) : '<span class="text-muted">—</span>' },
|
||||||
|
{ key: 'status', label: 'Status', sortable: true, render: a => UI.badge(a.status) },
|
||||||
|
{ key: '_a', label: 'Actions', align: 'right', render: a => `
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="act-btn" data-tip="View" onclick="Assessments.view('${a.id}')">${UI.icon('eye')}</button>
|
||||||
|
<button class="act-btn" data-tip="Remind" onclick="UI.toast('Reminder sent to ${a.candidate}','info')">${UI.icon('mail')}</button>
|
||||||
|
</div>` }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusOpts = ['<option value="">All Status</option>'].concat(['Completed', 'In Progress', 'Pending', 'Expired'].map(s => `<option>${s}</option>`)).join('');
|
||||||
|
const typeOpts = ['<option value="">All Types</option>'].concat([...new Set(DB.assessments.map(a => a.type))].map(t => `<option>${t}</option>`)).join('');
|
||||||
|
const statCard = (label, val, icn, cls) => `<div class="kpi"><div class="kpi-top"><span class="kpi-label">${label}</span><span class="kpi-icn ${cls}">${UI.icon(icn)}</span></div><div class="kpi-value">${val}</div></div>`;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">Assessments</h1><p class="page-sub">Coding tests, take-homes, and evaluations</p></div>
|
||||||
|
<div class="page-head-actions"><button class="btn btn-primary" onclick="Assessments.assign()">${UI.icon('plus')} Assign Assessment</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid g-kpi mb-18">
|
||||||
|
${statCard('Total Assigned', stats.total, 'file', 'i-indigo')}
|
||||||
|
${statCard('Completed', stats.completed, 'check-circle', 'i-green')}
|
||||||
|
${statCard('In Progress / Pending', stats.pending, 'clock', 'i-amber')}
|
||||||
|
${statCard('Average Score', stats.avg + '%', 'target', 'i-teal')}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body" style="padding-bottom:0">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-search">${UI.icon('search')}<input id="asSearch" placeholder="Search candidate or assessment…"/></div>
|
||||||
|
<select class="select" id="asStatus">${statusOpts}</select>
|
||||||
|
<select class="select" id="asType">${typeOpts}</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${table.html}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
onMount() {
|
||||||
|
table.mount();
|
||||||
|
const s = document.getElementById('asSearch');
|
||||||
|
s.oninput = () => { filters.q = s.value; apply(); };
|
||||||
|
document.getElementById('asStatus').onchange = e => { filters.status = e.target.value; apply(); };
|
||||||
|
document.getElementById('asType').onchange = e => { filters.type = e.target.value; apply(); };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
Assessments.view = function (id) {
|
||||||
|
const a = DB.assessments.find(x => x.id === id);
|
||||||
|
const body = `
|
||||||
|
<div class="flex items-center gap-12 mb-18">${UI.avatar(a.candidate, a.initials, a.color, 'avatar-lg')}
|
||||||
|
<div><div class="ph-name" style="font-size:17px">${a.candidate}</div><div class="ph-role">${a.type} · ${a.jobTitle}</div></div>
|
||||||
|
<div style="margin-left:auto">${UI.badge(a.status)}</div></div>
|
||||||
|
<div class="info-grid mb-18">
|
||||||
|
<div class="info-item"><div class="il">Type</div><div class="iv">${a.type}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Duration</div><div class="iv">${a.duration}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Assigned</div><div class="iv">${DB.fmtDate(a.assigned)}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Due</div><div class="iv">${DB.fmtDate(a.due)}</div></div>
|
||||||
|
</div>
|
||||||
|
${a.score !== null ? `
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div style="text-align:center;padding:10px 0">
|
||||||
|
<div style="font-size:44px;font-weight:800;letter-spacing:-1px;color:${a.score >= 70 ? 'var(--success)' : 'var(--warning)'}">${a.score}%</div>
|
||||||
|
<div class="text-muted">Overall Score</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-18">${UI.pbar(a.score)}</div>
|
||||||
|
<div class="form-section-title" style="margin-top:0">Section Breakdown</div>
|
||||||
|
${['Problem Solving', 'Code Quality', 'Communication', 'Time Management'].map(sec => {
|
||||||
|
const sc = DB.int(60, 98);
|
||||||
|
return `<div class="flex items-center gap-12" style="margin-bottom:10px"><span style="width:130px;font-size:13px">${sec}</span><div style="flex:1">${UI.pbar(sc)}</div><b style="width:40px;text-align:right">${sc}%</b></div>`;
|
||||||
|
}).join('')}` : `<div class="empty-state">${UI.icon('clock')}<h3>Assessment not completed</h3><p>Results will appear once the candidate submits.</p></div>`}`;
|
||||||
|
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Close</button>
|
||||||
|
<button class="btn btn-primary" onclick="UI.closeModal();Candidates.openProfile('${a.candidateId}')">View Candidate</button>`;
|
||||||
|
UI.modal({ title: 'Assessment Result', subtitle: a.id, body, footer });
|
||||||
|
};
|
||||||
|
|
||||||
|
Assessments.assign = function () {
|
||||||
|
const opt = arr => arr.map(o => `<option>${o}</option>`).join('');
|
||||||
|
const body = `<form><div class="form-grid">
|
||||||
|
<div class="form-field col-span-2"><label>Candidate</label><select>${opt(DB.candidates.slice(0, 40).map(c => c.name))}</select></div>
|
||||||
|
<div class="form-field"><label>Assessment Type</label><select>${opt([...new Set(DB.assessments.map(a => a.type))])}</select></div>
|
||||||
|
<div class="form-field"><label>Time Limit</label><select><option>45 min</option><option>60 min</option><option>90 min</option><option>3 days</option></select></div>
|
||||||
|
<div class="form-field col-span-2"><label>Due Date</label><input type="date"/></div>
|
||||||
|
</div></form>`;
|
||||||
|
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="UI.closeModal();UI.toast('Assessment assigned & invite sent','success')">${UI.icon('send')} Assign</button>`;
|
||||||
|
UI.modal({ title: 'Assign Assessment', subtitle: 'Send an evaluation to a candidate', body, footer });
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,441 @@
|
||||||
|
/* ============================================================
|
||||||
|
candidates.js — Candidate list, filters, profile modal w/ tabs
|
||||||
|
============================================================ */
|
||||||
|
window.Views = window.Views || {};
|
||||||
|
window.Candidates = {};
|
||||||
|
|
||||||
|
Views.candidates = function () {
|
||||||
|
const f = { q: '', job: '', skill: '', dept: '', location: '', exp: '', edu: '', recruiter: '', manager: '', source: '', ats: '', stage: '', interview: '', notice: '', availability: '' };
|
||||||
|
const selected = new Set();
|
||||||
|
let sortMode = 'relevance';
|
||||||
|
let table;
|
||||||
|
Candidates._selected = selected;
|
||||||
|
|
||||||
|
function relevance(c) {
|
||||||
|
// composite relevance: ATS + matched-skill ratio + recency
|
||||||
|
const req = (DB.getJob(c.jobId) || {}).skills || [];
|
||||||
|
const skillRatio = req.length ? c.matchedSkills.length / req.length : 0.5;
|
||||||
|
const recency = 1 - Math.min(1, (new Date('2026-07-09') - c.applied) / (90 * 864e5));
|
||||||
|
return Math.round(c.aiScore * 0.7 + skillRatio * 20 + recency * 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
let rows = DB.candidates.filter(c => {
|
||||||
|
if (f.job && c.jobTitle !== f.job) return false;
|
||||||
|
if (f.skill && !c.skills.includes(f.skill)) return false;
|
||||||
|
if (f.dept && c.department !== f.dept) return false;
|
||||||
|
if (f.location && c.location !== f.location) return false;
|
||||||
|
if (f.exp === '0-2' && c.experience > 2) return false;
|
||||||
|
if (f.exp === '3-5' && (c.experience < 3 || c.experience > 5)) return false;
|
||||||
|
if (f.exp === '6-9' && (c.experience < 6 || c.experience > 9)) return false;
|
||||||
|
if (f.exp === '10+' && c.experience < 10) return false;
|
||||||
|
if (f.edu && c.education !== f.edu) return false;
|
||||||
|
if (f.recruiter && c.recruiter !== f.recruiter) return false;
|
||||||
|
if (f.manager) { const job = DB.getJob(c.jobId); if (!job || job.manager !== f.manager) return false; }
|
||||||
|
if (f.source && c.source !== f.source) return false;
|
||||||
|
if (f.ats === '85+' && c.aiScore < 85) return false;
|
||||||
|
if (f.ats === '70-84' && (c.aiScore < 70 || c.aiScore > 84)) return false;
|
||||||
|
if (f.ats === '<70' && c.aiScore >= 70) return false;
|
||||||
|
if (f.stage && c.stage !== f.stage) return false;
|
||||||
|
if (f.interview && c.interviewStatus !== f.interview) return false;
|
||||||
|
if (f.notice && c.noticePeriod !== f.notice) return false;
|
||||||
|
if (f.availability && c.availability !== f.availability) return false;
|
||||||
|
if (f.q) { const q = f.q.toLowerCase(); if (!(c.name + c.email + c.jobTitle + c.currentCompany + c.recruiter + c.skills.join(' ')).toLowerCase().includes(q)) return false; }
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (sortMode === 'relevance') rows = [...rows].sort((a, b) => relevance(b) - relevance(a));
|
||||||
|
else if (sortMode === 'ats') rows = [...rows].sort((a, b) => b.aiScore - a.aiScore);
|
||||||
|
else if (sortMode === 'recent') rows = [...rows].sort((a, b) => b.applied - a.applied);
|
||||||
|
else if (sortMode === 'name') rows = [...rows].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
table.update(rows);
|
||||||
|
updateBulkBar();
|
||||||
|
const cnt = document.getElementById('canResultCount');
|
||||||
|
if (cnt) cnt.textContent = rows.length + ' candidate' + (rows.length === 1 ? '' : 's');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBulkBar() {
|
||||||
|
const bar = document.getElementById('bulkBar');
|
||||||
|
if (!bar) return;
|
||||||
|
if (selected.size) { bar.style.display = 'flex'; document.getElementById('bulkCount').textContent = selected.size + ' selected'; }
|
||||||
|
else bar.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
table = UI.dataTable({
|
||||||
|
pageSize: 10,
|
||||||
|
rows: DB.candidates,
|
||||||
|
columns: [
|
||||||
|
{ key: '_sel', label: '', render: c => `<span class="checkbox ${selected.has(c.id) ? 'on' : ''}" data-sel="${c.id}">${UI.icon('check')}</span>` },
|
||||||
|
{ key: 'name', label: 'Candidate', sortable: true, render: c => `<div class="user-cell">${UI.avatar(c.name, c.initials, c.color)}<div><div class="cell-primary">${c.name} ${c.favorite ? '<span class="star-btn on" style="display:inline">' + UI.icon('star') + '</span>' : ''}</div><div class="cell-sub">${c.currentTitle} · ${c.location}</div></div></div>` },
|
||||||
|
{ key: 'jobTitle', label: 'Applied Job', sortable: true, render: c => `<div class="text-sm">${c.jobTitle}</div><div class="cell-sub">${c.department}</div>` },
|
||||||
|
{ key: 'experience', label: 'Exp', sortable: true, align: 'center', render: c => `<b>${c.experience}</b>y` },
|
||||||
|
{ key: '_rel', label: 'Relevance', sortable: true, align: 'center', sortValue: c => relevance(c), render: c => `<span class="badge ${DB.atsRecommendationClass(c.recommendation)} badge-plain">${relevance(c)}%</span>` },
|
||||||
|
{ key: 'stage', label: 'Stage', sortable: true, render: c => UI.badge(c.stage) },
|
||||||
|
{ key: 'aiScore', label: 'ATS', sortable: true, align: 'center', render: c => `<span style="cursor:pointer" onclick="Candidates.atsMatch('${c.id}')">${UI.scoreChip(c.aiScore)}</span>` },
|
||||||
|
{ key: 'availability', label: 'Availability', render: c => `<span class="text-sm">${c.availability}</span><div class="cell-sub">${c.noticePeriod} notice</div>` },
|
||||||
|
{ key: '_a', label: 'Actions', align: 'right', render: c => `
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="act-btn star-btn ${c.favorite ? 'on' : ''}" data-tip="Favorite" data-fav="${c.id}">${UI.icon('star')}</button>
|
||||||
|
<button class="act-btn" data-tip="ATS Match" onclick="Candidates.atsMatch('${c.id}')">${UI.icon('target')}</button>
|
||||||
|
<button class="act-btn" data-tip="Profile" onclick="Candidates.openProfile('${c.id}')">${UI.icon('eye')}</button>
|
||||||
|
<button class="act-btn" data-tip="Advance" onclick="Candidates.advance('${c.id}')">${UI.icon('check')}</button>
|
||||||
|
</div>` }
|
||||||
|
],
|
||||||
|
onRender(el) {
|
||||||
|
el.querySelectorAll('[data-sel]').forEach(chk => chk.onclick = () => {
|
||||||
|
const id = chk.dataset.sel;
|
||||||
|
if (selected.has(id)) selected.delete(id); else selected.add(id);
|
||||||
|
chk.classList.toggle('on'); updateBulkBar();
|
||||||
|
});
|
||||||
|
el.querySelectorAll('[data-fav]').forEach(st => st.onclick = () => {
|
||||||
|
const c = DB.getCandidate(st.dataset.fav); c.favorite = !c.favorite;
|
||||||
|
st.classList.toggle('on'); UI.toast(c.favorite ? c.name + ' added to favorites' : 'Removed from favorites', 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const optList = (arr, label) => [`<option value="">${label}</option>`].concat(arr.map(o => `<option>${o}</option>`)).join('');
|
||||||
|
const jobTitles = [...new Set(DB.candidates.map(c => c.jobTitle))];
|
||||||
|
|
||||||
|
const filterPanel = `
|
||||||
|
<div class="filter-panel" id="filterPanel" style="display:none;padding:16px 0;border-top:1px solid var(--border);margin-top:12px">
|
||||||
|
<div class="form-field"><label>Job</label><select data-f="job">${optList(jobTitles, 'Any Job')}</select></div>
|
||||||
|
<div class="form-field"><label>Skill</label><select data-f="skill">${optList(DB.skillsPool, 'Any Skill')}</select></div>
|
||||||
|
<div class="form-field"><label>Department</label><select data-f="dept">${optList(DB.departments, 'Any Dept')}</select></div>
|
||||||
|
<div class="form-field"><label>Location</label><select data-f="location">${optList(DB.locations, 'Any Location')}</select></div>
|
||||||
|
<div class="form-field"><label>Experience</label><select data-f="exp">${optList(['0-2', '3-5', '6-9', '10+'], 'Any Exp')}</select></div>
|
||||||
|
<div class="form-field"><label>Education</label><select data-f="edu">${optList(DB.educationLevels, 'Any')}</select></div>
|
||||||
|
<div class="form-field"><label>Recruiter</label><select data-f="recruiter">${optList(DB.recruiters.map(r => r.name), 'Any Recruiter')}</select></div>
|
||||||
|
<div class="form-field"><label>Hiring Manager</label><select data-f="manager">${optList(DB.managers.map(m => m.name), 'Any Manager')}</select></div>
|
||||||
|
<div class="form-field"><label>Source</label><select data-f="source">${optList(DB.sources, 'Any Source')}</select></div>
|
||||||
|
<div class="form-field"><label>ATS Score</label><select data-f="ats">${optList(['85+', '70-84', '<70'], 'Any Score')}</select></div>
|
||||||
|
<div class="form-field"><label>Pipeline Stage</label><select data-f="stage">${optList(DB.stages, 'Any Stage')}</select></div>
|
||||||
|
<div class="form-field"><label>Interview Status</label><select data-f="interview">${optList(['Not Scheduled', 'Scheduled', 'Completed'], 'Any')}</select></div>
|
||||||
|
<div class="form-field"><label>Notice Period</label><select data-f="notice">${optList(['Immediate', '2 weeks', '1 month', '2 months', '3 months'], 'Any')}</select></div>
|
||||||
|
<div class="form-field"><label>Availability</label><select data-f="availability">${optList(['Immediate', '2 weeks', '1 month', 'Passive'], 'Any')}</select></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// recently viewed strip
|
||||||
|
const rv = DB.recentlyViewed.slice(0, 6).map(id => DB.getCandidate(id)).filter(Boolean);
|
||||||
|
const rvHtml = rv.length ? `<div class="flex items-center gap-8" style="margin-bottom:14px;flex-wrap:wrap">
|
||||||
|
<span class="text-muted text-sm fw-600">Recently viewed:</span>
|
||||||
|
${rv.map(c => `<button class="prompt-chip" style="padding:5px 10px" onclick="Candidates.openProfile('${c.id}')">${UI.avatar(c.name, c.initials, c.color)} ${c.name.split(' ')[0]}</button>`).join('')}
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">Candidates</h1><p class="page-sub"><span id="canResultCount">${DB.candidates.length} candidates</span> · ranked by AI relevance</p></div>
|
||||||
|
<div class="page-head-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="UI.toast('Search saved','success')">${UI.icon('bookmark')} Save Search</button>
|
||||||
|
<button class="btn btn-secondary" onclick="UI.toast('Candidates exported','success')">${UI.icon('download')} Export</button>
|
||||||
|
<button class="btn btn-primary" onclick="Candidates.openAdd()">${UI.icon('plus')} Add Candidate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${rvHtml}
|
||||||
|
<div class="bulk-bar" id="bulkBar" style="display:none">
|
||||||
|
<span class="checkbox on">${UI.icon('check')}</span>
|
||||||
|
<span class="fw-600" id="bulkCount">0 selected</span>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="btn btn-sm" onclick="Candidates.bulk('email')">${UI.icon('mail')} Bulk Email</button>
|
||||||
|
<button class="btn btn-sm" onclick="Candidates.bulk('assign')">${UI.icon('users')} Assign</button>
|
||||||
|
<button class="btn btn-sm" onclick="Candidates.bulk('advance')">${UI.icon('check')} Advance</button>
|
||||||
|
<button class="btn btn-sm" onclick="Candidates.bulk('reject')">${UI.icon('x')} Reject</button>
|
||||||
|
<button class="btn btn-sm" onclick="Candidates.bulkClear()">${UI.icon('x')} Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body" style="padding-bottom:0">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-search">${UI.icon('search')}<input id="canSearch" placeholder="Search name, skill, company…"/></div>
|
||||||
|
<button class="btn btn-secondary" id="filterToggle">${UI.icon('filter')} Filters</button>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<label class="text-muted text-sm">Sort:</label>
|
||||||
|
<select class="select" id="canSort">
|
||||||
|
<option value="relevance">AI Relevance</option>
|
||||||
|
<option value="ats">ATS Score</option>
|
||||||
|
<option value="recent">Most Recent</option>
|
||||||
|
<option value="name">Name A–Z</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
${filterPanel}
|
||||||
|
</div>
|
||||||
|
${table.html}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
onMount() {
|
||||||
|
table.mount();
|
||||||
|
apply();
|
||||||
|
const s = document.getElementById('canSearch');
|
||||||
|
s.oninput = () => { f.q = s.value; apply(); };
|
||||||
|
document.getElementById('canSort').onchange = e => { sortMode = e.target.value; apply(); };
|
||||||
|
document.getElementById('filterToggle').onclick = () => {
|
||||||
|
const p = document.getElementById('filterPanel');
|
||||||
|
p.style.display = p.style.display === 'none' ? 'grid' : 'none';
|
||||||
|
};
|
||||||
|
document.querySelectorAll('#filterPanel [data-f]').forEach(sel => sel.onchange = e => { f[e.target.dataset.f] = e.target.value; apply(); });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- Bulk actions ----------------
|
||||||
|
Candidates.bulk = function (action) {
|
||||||
|
const ids = [...Candidates._selected];
|
||||||
|
if (!ids.length) return;
|
||||||
|
if (action === 'email') UI.toast(`Bulk email drafted to ${ids.length} candidates`, 'success');
|
||||||
|
else if (action === 'assign') {
|
||||||
|
const opts = DB.recruiters.map(r => `<option>${r.name}</option>`).join('');
|
||||||
|
UI.modal({ title: 'Bulk Assign Recruiter', subtitle: ids.length + ' candidates',
|
||||||
|
body: `<div class="form-field"><label>Assign to</label><select id="bulkRec">${opts}</select></div>`,
|
||||||
|
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button><button class="btn btn-primary" onclick="Candidates._bulkAssign()">Assign</button>` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else if (action === 'advance') { ids.forEach(id => Candidates._advanceSilent(id)); UI.toast(`${ids.length} candidates advanced`, 'success'); Router.reload(); return; }
|
||||||
|
else if (action === 'reject') { ids.forEach(id => { const c = DB.getCandidate(id); c.stage = 'Rejected'; c.status = 'Rejected'; }); UI.toast(`${ids.length} candidates rejected`, 'warning'); Router.reload(); return; }
|
||||||
|
Candidates.bulkClear();
|
||||||
|
};
|
||||||
|
Candidates._bulkAssign = function () {
|
||||||
|
const rec = document.getElementById('bulkRec').value;
|
||||||
|
[...Candidates._selected].forEach(id => { DB.getCandidate(id).recruiter = rec; });
|
||||||
|
UI.closeModal(); UI.toast('Recruiter assigned to selected candidates', 'success');
|
||||||
|
Candidates.bulkClear(); Router.reload();
|
||||||
|
};
|
||||||
|
Candidates.bulkClear = function () { Candidates._selected.clear(); Router.reload(); };
|
||||||
|
Candidates._advanceSilent = function (id) {
|
||||||
|
const c = DB.getCandidate(id);
|
||||||
|
const order = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'];
|
||||||
|
const i = order.indexOf(c.stage);
|
||||||
|
if (i > -1 && i < order.length - 1) { c.stage = order[i + 1]; c.status = c.stage; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- ATS Match detail ----------------
|
||||||
|
Candidates.atsMatch = function (id) {
|
||||||
|
const c = DB.getCandidate(id);
|
||||||
|
const job = DB.getJob(c.jobId) || {};
|
||||||
|
const recCls = c.recommendation === 'Strong Match' ? 'recc-strong' : c.recommendation === 'Potential Match' ? 'recc-potential' : 'recc-weak';
|
||||||
|
const ringColor = c.aiScore >= 82 ? 'var(--success)' : c.aiScore >= 65 ? 'var(--warning)' : 'var(--danger)';
|
||||||
|
const sub = c.subScores;
|
||||||
|
const subRow = (label, val) => `<div class="flex items-center gap-12" style="margin-bottom:12px"><span style="width:110px;font-size:13px">${label}</span><div style="flex:1">${UI.pbar(val)}</div><b style="width:42px;text-align:right">${val}%</b></div>`;
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<div class="recc-banner ${recCls}">
|
||||||
|
<span class="recc-icn">${UI.icon(c.recommendation === 'Weak Match' ? 'x-circle' : 'check-circle')}</span>
|
||||||
|
<div style="flex:1"><div class="fw-600" style="font-size:15px">${c.recommendation}</div>
|
||||||
|
<div style="opacity:.85;font-size:13px">${c.name} for ${c.jobTitle}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid g-2" style="align-items:center;margin-bottom:20px">
|
||||||
|
<div style="text-align:center">
|
||||||
|
<div class="ats-ring" style="--pct:${c.aiScore};--c:${ringColor}"><div class="ats-val"><div class="ats-num">${c.aiScore}</div><div class="ats-lbl">ATS MATCH</div></div></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
${subRow('Skills', sub.skills)}
|
||||||
|
${subRow('Experience', sub.experience)}
|
||||||
|
${subRow('Education', sub.education)}
|
||||||
|
${subRow('Keywords', sub.keywords)}
|
||||||
|
${subRow('Location', sub.location)}
|
||||||
|
${subRow('Salary', sub.salary)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-section-title" style="margin-top:0">Matched Skills (${c.matchedSkills.length})</div>
|
||||||
|
<div class="k-tags" style="margin-bottom:16px">${c.matchedSkills.length ? c.matchedSkills.map(s => `<span class="skill-pill skill-matched">${UI.icon('check')} ${s}</span>`).join('') : '<span class="text-muted">—</span>'}</div>
|
||||||
|
<div class="form-section-title" style="margin-top:0">Missing Skills (${c.missingSkills.length})</div>
|
||||||
|
<div class="k-tags">${c.missingSkills.length ? c.missingSkills.map(s => `<span class="skill-pill skill-missing">${UI.icon('x')} ${s}</span>`).join('') : '<span class="text-muted">None — full match</span>'}</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<p class="text-muted text-sm">${UI.icon('sparkles')} Score computed from JD keywords, resume parsing, experience, education, location and salary alignment. Connect an AI model to refine with semantic matching.</p>`;
|
||||||
|
UI.modal({
|
||||||
|
title: 'ATS Match Analysis', subtitle: c.id + ' · ' + c.jobTitle, body, size: 'modal-lg',
|
||||||
|
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Close</button><button class="btn btn-primary" onclick="UI.closeModal();Candidates.openProfile('${c.id}')">View Full Profile</button>`
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Candidates.toggleFav = function (id, btn) {
|
||||||
|
const c = DB.getCandidate(id); c.favorite = !c.favorite;
|
||||||
|
if (btn) { btn.classList.toggle('on'); btn.innerHTML = UI.icon('star') + (c.favorite ? ' Favorited' : ' Favorite'); }
|
||||||
|
UI.toast(c.favorite ? c.name + ' added to favorites' : 'Removed from favorites', 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
Candidates.advance = function (id) {
|
||||||
|
const c = DB.getCandidate(id);
|
||||||
|
const order = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'];
|
||||||
|
const i = order.indexOf(c.stage);
|
||||||
|
if (i === -1 || i >= order.length - 1) { UI.toast(c.name + ' cannot be advanced further', 'warning'); return; }
|
||||||
|
c.stage = order[i + 1]; c.status = c.stage;
|
||||||
|
UI.toast(`${c.name} moved to ${c.stage}`, 'success');
|
||||||
|
Router.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- Candidate profile w/ tabs ----------------
|
||||||
|
Candidates.openProfile = function (id) {
|
||||||
|
const c = DB.getCandidate(id);
|
||||||
|
// track recently viewed
|
||||||
|
const rvIdx = DB.recentlyViewed.indexOf(id);
|
||||||
|
if (rvIdx > -1) DB.recentlyViewed.splice(rvIdx, 1);
|
||||||
|
DB.recentlyViewed.unshift(id);
|
||||||
|
if (DB.recentlyViewed.length > 12) DB.recentlyViewed.pop();
|
||||||
|
const tabs = ['Overview', 'Resume', 'Timeline', 'Interview', 'Notes', 'Activity', 'Documents', 'Feedback'];
|
||||||
|
const body = `
|
||||||
|
<div class="profile-hero">
|
||||||
|
${UI.avatar(c.name, c.initials, c.color, 'avatar-lg')}
|
||||||
|
<div style="flex:1">
|
||||||
|
<div class="ph-name">${c.name}</div>
|
||||||
|
<div class="ph-role">${c.currentTitle} at ${c.currentCompany}</div>
|
||||||
|
<div class="ph-tags">${UI.badge(c.stage)} ${UI.badge(c.source, 'b-gray')}
|
||||||
|
<span class="badge b-plain b-indigo badge-plain">${c.experience} yrs exp</span></div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center">${UI.scoreChip(c.aiScore)}<div class="cell-sub" style="margin-top:4px">AI Match</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="tabs" id="canTabs" style="margin-top:22px">
|
||||||
|
${tabs.map((t, i) => `<div class="tab ${i === 0 ? 'active' : ''}" data-tab="${i}">${t}</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div id="canPanes">
|
||||||
|
${Candidates._pane('Overview', c)}
|
||||||
|
${Candidates._pane('Resume', c)}
|
||||||
|
${Candidates._pane('Timeline', c)}
|
||||||
|
${Candidates._pane('Interview', c)}
|
||||||
|
${Candidates._pane('Notes', c)}
|
||||||
|
${Candidates._pane('Activity', c)}
|
||||||
|
${Candidates._pane('Documents', c)}
|
||||||
|
${Candidates._pane('Feedback', c)}
|
||||||
|
</div>`;
|
||||||
|
const footer = `<button class="btn btn-ghost star-btn ${c.favorite ? 'on' : ''}" style="margin-right:auto" onclick="Candidates.toggleFav('${c.id}',this)">${UI.icon('star')} ${c.favorite ? 'Favorited' : 'Favorite'}</button>
|
||||||
|
<button class="btn btn-secondary" onclick="Candidates.atsMatch('${c.id}')">${UI.icon('target')} ATS Match</button>
|
||||||
|
<button class="btn btn-secondary" onclick="UI.toast('Email drafted','info')">${UI.icon('mail')} Message</button>
|
||||||
|
<button class="btn btn-primary" onclick="Candidates.advance('${c.id}');UI.closeModal()">${UI.icon('check')} Advance Stage</button>`;
|
||||||
|
UI.modal({ title: 'Candidate Profile', subtitle: c.id, body, footer, size: 'modal-lg' });
|
||||||
|
|
||||||
|
const paneEls = document.querySelectorAll('#canPanes .tab-pane');
|
||||||
|
document.querySelectorAll('#canTabs .tab').forEach(tab => tab.onclick = () => {
|
||||||
|
document.querySelectorAll('#canTabs .tab').forEach(t => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
paneEls.forEach(p => p.classList.remove('active'));
|
||||||
|
paneEls[+tab.dataset.tab].classList.add('active');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Candidates._pane = function (name, c) {
|
||||||
|
const active = name === 'Overview' ? 'active' : '';
|
||||||
|
let content = '';
|
||||||
|
if (name === 'Overview') {
|
||||||
|
content = `
|
||||||
|
<div class="info-grid" style="margin-bottom:20px">
|
||||||
|
<div class="info-item"><div class="il">Email</div><div class="iv">${c.email}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Phone</div><div class="iv">${c.phone}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Location</div><div class="iv">${c.location}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Applied For</div><div class="iv">${c.jobTitle}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Current Company</div><div class="iv">${c.currentCompany}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Experience</div><div class="iv">${c.experience} years</div></div>
|
||||||
|
<div class="info-item"><div class="il">Education</div><div class="iv">${c.education}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Source</div><div class="iv">${c.source}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Recruiter</div><div class="iv">${c.recruiter}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Applied On</div><div class="iv">${DB.fmtDate(c.applied)}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Expected Salary</div><div class="iv">${DB.moneyK(c.salary)}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Rating</div><div class="iv">⭐ ${c.rating} / 5.0</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="il" style="font-size:12px;color:var(--text-3);font-weight:600;text-transform:uppercase;margin-bottom:8px">Skills</div>
|
||||||
|
<div class="k-tags">${c.skills.map(s => `<span class="tag">${s}</span>`).join('')}</div>`;
|
||||||
|
} else if (name === 'Resume') {
|
||||||
|
content = `<div class="card" style="box-shadow:none;background:var(--bg-sunken)"><div class="card-body">
|
||||||
|
<h3 style="margin-bottom:4px">${c.name}</h3><p class="text-muted">${c.currentTitle} · ${c.location}</p>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="form-section-title" style="margin-top:0">Summary</div>
|
||||||
|
<p class="text-muted">Results-driven ${c.currentTitle.toLowerCase()} with ${c.experience} years of experience across ${c.department.toLowerCase()}. Passionate about building high-quality products and collaborating with cross-functional teams.</p>
|
||||||
|
<div class="form-section-title">Experience</div>
|
||||||
|
<div class="info-item"><div class="iv">${c.currentTitle} — ${c.currentCompany}</div><div class="il" style="text-transform:none">2021 – Present</div></div>
|
||||||
|
<div class="info-item" style="margin-top:10px"><div class="iv">Associate — ${DB.pick(DB.companies)}</div><div class="il" style="text-transform:none">2018 – 2021</div></div>
|
||||||
|
<div class="form-section-title">Education</div>
|
||||||
|
<div class="iv">${c.education}</div>
|
||||||
|
</div></div>
|
||||||
|
<button class="btn btn-secondary" style="margin-top:14px" onclick="UI.toast('Downloading resume.pdf','info')">${UI.icon('download')} Download PDF</button>`;
|
||||||
|
} else if (name === 'Timeline') {
|
||||||
|
const events = [
|
||||||
|
{ icon: 'user-plus', title: 'Application received', meta: DB.fmtDate(c.applied), desc: `Applied via ${c.source}` },
|
||||||
|
{ icon: 'star', title: 'AI screening completed', meta: '1 day later', desc: `Match score: ${c.aiScore}%` },
|
||||||
|
{ icon: 'phone', title: 'Recruiter screen', meta: '3 days later', desc: `Call with ${c.recruiter}` },
|
||||||
|
{ icon: 'calendar', title: 'Technical interview', meta: '1 week later', desc: 'Panel of 3 interviewers' },
|
||||||
|
{ icon: 'check', title: `Moved to ${c.stage}`, meta: 'Recently', desc: 'Current stage in pipeline' }
|
||||||
|
];
|
||||||
|
content = `<div class="timeline">${events.map(e => `
|
||||||
|
<div class="tl-item"><div class="tl-dot">${UI.icon(e.icon)}</div>
|
||||||
|
<div class="tl-title">${e.title}</div><div class="tl-meta">${e.meta}</div><div class="tl-desc">${e.desc}</div></div>`).join('')}</div>`;
|
||||||
|
} else if (name === 'Interview') {
|
||||||
|
const ivs = DB.interviews.filter(i => i.candidateId === c.id);
|
||||||
|
content = ivs.length ? `<div class="list-tight">${ivs.map(iv => `
|
||||||
|
<div class="list-row"><span class="kpi-icn i-blue" style="width:38px;height:38px;border-radius:10px">${UI.icon('calendar')}</span>
|
||||||
|
<div class="lr-main"><div class="lr-title">${iv.type}</div><div class="lr-sub">${DB.fmtDate(iv.when)} · ${iv.meeting}</div></div>
|
||||||
|
<div class="lr-right">${UI.badge(iv.status)}</div></div>`).join('')}</div>`
|
||||||
|
: `<div class="empty-state">${UI.icon('calendar')}<h3>No interviews scheduled</h3><p>Schedule an interview to get started.</p>
|
||||||
|
<button class="btn btn-primary btn-sm" style="margin-top:10px" onclick="UI.toast('Interview scheduler opened','info')">Schedule Interview</button></div>`;
|
||||||
|
} else if (name === 'Notes') {
|
||||||
|
content = `
|
||||||
|
<div class="form-field"><label>Add a note</label><textarea id="noteInput" placeholder="Write a private note about this candidate…"></textarea></div>
|
||||||
|
<button class="btn btn-primary btn-sm" style="margin:10px 0 18px" onclick="UI.toast('Note saved','success')">${UI.icon('plus')} Add Note</button>
|
||||||
|
<div class="list-tight">
|
||||||
|
<div class="list-row">${UI.avatar(c.recruiter)}<div class="lr-main"><div class="lr-title">${c.recruiter}</div><div class="lr-sub" style="color:var(--text-2)">Strong communication skills, great culture fit. Recommend advancing.</div><div class="lr-sub">2 days ago</div></div></div>
|
||||||
|
<div class="list-row">${UI.avatar('Asfand Ahmed', 'AA')}<div class="lr-main"><div class="lr-title">Asfand Ahmed</div><div class="lr-sub" style="color:var(--text-2)">Reviewed portfolio — impressive work. Schedule technical round.</div><div class="lr-sub">4 days ago</div></div></div>
|
||||||
|
</div>`;
|
||||||
|
} else if (name === 'Activity') {
|
||||||
|
content = `<div class="list-tight">
|
||||||
|
<div class="list-row"><span class="kpi-icn i-green" style="width:34px;height:34px;border-radius:9px">${UI.icon('eye')}</span><div class="lr-main"><div class="lr-sub" style="color:var(--text-2);font-size:13px">Profile viewed by ${c.recruiter}</div><div class="lr-sub">1h ago</div></div></div>
|
||||||
|
<div class="list-row"><span class="kpi-icn i-blue" style="width:34px;height:34px;border-radius:9px">${UI.icon('mail')}</span><div class="lr-main"><div class="lr-sub" style="color:var(--text-2);font-size:13px">Email sent: Interview invitation</div><div class="lr-sub">1 day ago</div></div></div>
|
||||||
|
<div class="list-row"><span class="kpi-icn i-amber" style="width:34px;height:34px;border-radius:9px">${UI.icon('star')}</span><div class="lr-main"><div class="lr-sub" style="color:var(--text-2);font-size:13px">Assessment score updated to ${c.aiScore}%</div><div class="lr-sub">2 days ago</div></div></div>
|
||||||
|
<div class="list-row"><span class="kpi-icn i-purple" style="width:34px;height:34px;border-radius:9px">${UI.icon('user-plus')}</span><div class="lr-main"><div class="lr-sub" style="color:var(--text-2);font-size:13px">Applied for ${c.jobTitle}</div><div class="lr-sub">${DB.fmtDate(c.applied)}</div></div></div>
|
||||||
|
</div>`;
|
||||||
|
} else if (name === 'Documents') {
|
||||||
|
const docs = [{ n: 'Resume.pdf', s: '284 KB' }, { n: 'Cover_Letter.pdf', s: '112 KB' }, { n: 'Portfolio.pdf', s: '4.2 MB' }, { n: 'References.docx', s: '48 KB' }];
|
||||||
|
content = `<div class="list-tight">${docs.map(d => `
|
||||||
|
<div class="list-row"><span class="kpi-icn i-red" style="width:38px;height:38px;border-radius:10px">${UI.icon('file')}</span>
|
||||||
|
<div class="lr-main"><div class="lr-title">${d.n}</div><div class="lr-sub">${d.s}</div></div>
|
||||||
|
<button class="act-btn" onclick="UI.toast('Downloading ${d.n}','info')">${UI.icon('download')}</button></div>`).join('')}</div>`;
|
||||||
|
} else if (name === 'Feedback') {
|
||||||
|
const scores = ['Strong Hire', 'Hire', 'Lean Hire'];
|
||||||
|
content = `<div class="list-tight">
|
||||||
|
${[0, 1, 2].map(i => `<div class="list-row">${UI.avatar(DB.recruiters[i].name, DB.recruiters[i].initials, DB.recruiters[i].color)}
|
||||||
|
<div class="lr-main"><div class="lr-title">${DB.recruiters[i].name}</div><div class="lr-sub" style="color:var(--text-2)">${['Excellent technical depth and clear communication.', 'Good problem solving, would benefit from more system design exposure.', 'Solid candidate, positive team energy.'][i]}</div></div>
|
||||||
|
<div class="lr-right">${UI.badge(scores[i])}</div></div>`).join('')}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" style="margin-top:14px" onclick="UI.toast('Scorecard form opened','info')">${UI.icon('plus')} Submit Scorecard</button>`;
|
||||||
|
}
|
||||||
|
return `<div class="tab-pane ${active}">${content}</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
Candidates.openAdd = function () {
|
||||||
|
const opt = (arr) => arr.map(o => `<option>${o}</option>`).join('');
|
||||||
|
const body = `<form id="canForm" novalidate><div class="form-grid">
|
||||||
|
<div class="form-field"><label>Full Name <span class="req">*</span></label><input name="name" placeholder="Jane Doe"/><span class="field-error">Required</span></div>
|
||||||
|
<div class="form-field"><label>Email <span class="req">*</span></label><input name="email" type="email" placeholder="jane@email.com"/><span class="field-error">Valid email required</span></div>
|
||||||
|
<div class="form-field"><label>Phone</label><input name="phone" placeholder="+1 (555) 000-0000"/></div>
|
||||||
|
<div class="form-field"><label>Applied Job <span class="req">*</span></label><select name="job">${opt(DB.jobs.filter(j => j.status === 'Open').map(j => j.title))}</select></div>
|
||||||
|
<div class="form-field"><label>Experience (years)</label><input name="experience" type="number" value="3"/></div>
|
||||||
|
<div class="form-field"><label>Current Company</label><input name="company" placeholder="Acme Inc."/></div>
|
||||||
|
<div class="form-field"><label>Source</label><select name="source">${opt(DB.sources)}</select></div>
|
||||||
|
<div class="form-field"><label>Stage</label><select name="stage">${opt(DB.stages)}</select></div>
|
||||||
|
</div></form>`;
|
||||||
|
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="Candidates._add()">${UI.icon('check')} Add Candidate</button>`;
|
||||||
|
UI.modal({ title: 'Add Candidate', subtitle: 'Manually add a candidate to the pipeline', body, footer });
|
||||||
|
};
|
||||||
|
Candidates._add = function () {
|
||||||
|
const form = document.getElementById('canForm');
|
||||||
|
UI.clearErrors(form);
|
||||||
|
const f = Object.fromEntries(new FormData(form));
|
||||||
|
let ok = true;
|
||||||
|
if (!f.name.trim()) { UI.fieldError(form.querySelector('[name=name]'), 'Required'); ok = false; }
|
||||||
|
if (!/^\S+@\S+\.\S+$/.test(f.email)) { UI.fieldError(form.querySelector('[name=email]'), 'Valid email required'); ok = false; }
|
||||||
|
if (!ok) { UI.toast('Please fix the highlighted fields', 'error'); return; }
|
||||||
|
const job = DB.jobs.find(j => j.title === f.job) || DB.jobs[0];
|
||||||
|
const score = DB.int(55, 95);
|
||||||
|
DB.candidates.unshift({
|
||||||
|
id: 'CAN-' + (5001 + DB.candidates.length), name: f.name, initials: DB.initials(f.name), color: DB.avatarColor(f.name),
|
||||||
|
email: f.email, phone: f.phone || '+1 (555) 000-0000', jobId: job.id, jobTitle: job.title, department: job.department,
|
||||||
|
experience: +f.experience || 1, currentCompany: f.company || '—', currentTitle: job.title, location: job.location,
|
||||||
|
stage: f.stage, status: f.stage, aiScore: score, source: f.source, recruiter: job.recruiter, recruiterId: job.recruiterId,
|
||||||
|
applied: new Date('2026-07-09'), education: "Bachelor's Degree", skills: job.skills.slice(0, 4), rating: '4.0', salary: 120000,
|
||||||
|
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3), recommendation: score >= 82 ? 'Strong Match' : score >= 65 ? 'Potential Match' : 'Weak Match',
|
||||||
|
subScores: { skills: score, experience: 80, education: 80, keywords: score, location: 100, salary: 90 },
|
||||||
|
noticePeriod: '1 month', availability: '2 weeks', certifications: [], favorite: false, interviewStatus: 'Not Scheduled'
|
||||||
|
});
|
||||||
|
UI.closeModal();
|
||||||
|
UI.toast('Candidate added to pipeline', 'success');
|
||||||
|
Router.reload();
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,347 @@
|
||||||
|
/* ============================================================
|
||||||
|
charts.js — Lightweight Canvas chart engine (no libraries)
|
||||||
|
Exposes global `Charts` with: line, bar, groupedBar, doughnut,
|
||||||
|
area, horizontalBar, sparkline
|
||||||
|
============================================================ */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function css(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
|
||||||
|
|
||||||
|
/* Series colours live in CSS (--c1..--c8) so light and dark each get a
|
||||||
|
set tuned to their surface. Read live rather than cached: App.setTheme
|
||||||
|
re-renders the view, and every draw call then picks up the new theme. */
|
||||||
|
const PALETTE_FALLBACK = ['#004d43', '#0f9d76', '#5b60e8', '#6f8f14', '#0e7490', '#8a5a00', '#a8327d', '#3f6d64'];
|
||||||
|
function palette() {
|
||||||
|
const out = [];
|
||||||
|
for (let i = 1; i <= 8; i++) { const v = css('--c' + i); if (v) out.push(v); }
|
||||||
|
return out.length ? out : PALETTE_FALLBACK;
|
||||||
|
}
|
||||||
|
/* Canvas needs a literal font string; mirror the CSS stack so charts
|
||||||
|
match the rest of the UI (and any licensed Neue Montreal install). */
|
||||||
|
function FONT(px, weight) {
|
||||||
|
const fam = css('--font') || 'Inter, sans-serif';
|
||||||
|
return (weight ? weight + ' ' : '') + px + 'px ' + fam;
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeColors() {
|
||||||
|
return {
|
||||||
|
grid: css('--border') || '#dbe8e2',
|
||||||
|
text: css('--text-3') || '#54726c',
|
||||||
|
surface: css('--bg-elev') || '#fff'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// shared floating tooltip
|
||||||
|
let tip;
|
||||||
|
function tooltip() {
|
||||||
|
if (!tip) { tip = document.createElement('div'); tip.className = 'chart-tooltip'; document.body.appendChild(tip); }
|
||||||
|
return tip;
|
||||||
|
}
|
||||||
|
function showTip(x, y, html) { const t = tooltip(); t.innerHTML = html; t.style.left = (x + 12) + 'px'; t.style.top = (y - 12) + 'px'; t.style.opacity = '1'; }
|
||||||
|
function hideTip() { if (tip) tip.style.opacity = '0'; }
|
||||||
|
|
||||||
|
function setup(canvas) {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const w = rect.width || canvas.clientWidth || 600;
|
||||||
|
const h = parseInt(canvas.getAttribute('height')) || 260;
|
||||||
|
canvas.width = w * dpr; canvas.height = h * dpr;
|
||||||
|
canvas.style.height = h + 'px';
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
return { ctx, w, h };
|
||||||
|
}
|
||||||
|
|
||||||
|
function niceMax(v) {
|
||||||
|
if (v <= 0) return 10;
|
||||||
|
const pow = Math.pow(10, Math.floor(Math.log10(v)));
|
||||||
|
const n = v / pow;
|
||||||
|
let m = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10;
|
||||||
|
return m * pow;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGridY(ctx, w, h, pad, max, tc, fmt) {
|
||||||
|
ctx.font = FONT(11);
|
||||||
|
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
|
||||||
|
const steps = 4;
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const val = (max / steps) * i;
|
||||||
|
const y = h - pad.b - (val / max) * (h - pad.t - pad.b);
|
||||||
|
ctx.strokeStyle = tc.grid; ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(w - pad.r, y); ctx.stroke();
|
||||||
|
ctx.fillStyle = tc.text;
|
||||||
|
ctx.fillText(fmt ? fmt(val) : Math.round(val), pad.l - 8, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate(draw) {
|
||||||
|
const start = performance.now(), dur = 700;
|
||||||
|
function frame(now) {
|
||||||
|
// The rAF timestamp is the frame's start time and can predate the
|
||||||
|
// performance.now() taken just above, so (now - start) may be < 0.
|
||||||
|
// Unclamped that yields negative progress -> negative bar geometry ->
|
||||||
|
// arcTo throws -> the loop dies and the chart is left half-drawn.
|
||||||
|
let t = Math.min(1, Math.max(0, (now - start) / dur));
|
||||||
|
t = 1 - Math.pow(1 - t, 3); // easeOutCubic
|
||||||
|
draw(t);
|
||||||
|
if (t < 1) requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- LINE / AREA --------------------
|
||||||
|
function line(canvas, opts) {
|
||||||
|
const { labels, datasets, area, yFmt } = opts;
|
||||||
|
const tc = themeColors();
|
||||||
|
const pad = { t: 16, r: 16, b: 28, l: 44 };
|
||||||
|
const allVals = datasets.flatMap(d => d.data);
|
||||||
|
const max = niceMax(Math.max(...allVals, 1) * 1.1);
|
||||||
|
const points = [];
|
||||||
|
|
||||||
|
function render(prog) {
|
||||||
|
const { ctx, w, h } = setup(canvas);
|
||||||
|
drawGridY(ctx, w, h, pad, max, tc, yFmt);
|
||||||
|
const plotW = w - pad.l - pad.r, plotH = h - pad.t - pad.b;
|
||||||
|
const stepX = plotW / (labels.length - 1 || 1);
|
||||||
|
points.length = 0;
|
||||||
|
|
||||||
|
// x labels
|
||||||
|
ctx.fillStyle = tc.text; ctx.font = FONT(11); ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
||||||
|
labels.forEach((l, i) => ctx.fillText(l, pad.l + stepX * i, h - pad.b + 8));
|
||||||
|
|
||||||
|
datasets.forEach((ds, di) => {
|
||||||
|
const pal = palette();
|
||||||
|
const color = ds.color || pal[di % pal.length];
|
||||||
|
const pts = ds.data.map((v, i) => ({ x: pad.l + stepX * i, y: h - pad.b - (v * prog / max) * plotH, v }));
|
||||||
|
if (di === 0) pts.forEach((p, i) => points.push({ ...p, label: labels[i] }));
|
||||||
|
|
||||||
|
if (area) {
|
||||||
|
const grad = ctx.createLinearGradient(0, pad.t, 0, h - pad.b);
|
||||||
|
grad.addColorStop(0, color + '55'); grad.addColorStop(1, color + '00');
|
||||||
|
ctx.beginPath(); ctx.moveTo(pts[0].x, h - pad.b);
|
||||||
|
pts.forEach(p => ctx.lineTo(p.x, p.y));
|
||||||
|
ctx.lineTo(pts[pts.length - 1].x, h - pad.b); ctx.closePath();
|
||||||
|
ctx.fillStyle = grad; ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.beginPath(); ctx.lineWidth = 2.5; ctx.strokeStyle = color; ctx.lineJoin = 'round';
|
||||||
|
pts.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
|
||||||
|
ctx.stroke();
|
||||||
|
if (prog === 1) pts.forEach(p => {
|
||||||
|
ctx.beginPath(); ctx.arc(p.x, p.y, 3.5, 0, 7); ctx.fillStyle = tc.surface; ctx.fill();
|
||||||
|
ctx.lineWidth = 2.5; ctx.strokeStyle = color; ctx.stroke();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
animate(render);
|
||||||
|
attachHover(canvas, () => points, (p, i) => datasets.map(ds => `${ds.label}: ${yFmt ? yFmt(ds.data[i]) : ds.data[i]}`).join('<br>'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- BAR --------------------
|
||||||
|
function bar(canvas, opts) {
|
||||||
|
const { labels, data, colors, yFmt, horizontal } = opts;
|
||||||
|
if (horizontal) return horizontalBar(canvas, opts);
|
||||||
|
const tc = themeColors();
|
||||||
|
const pad = { t: 16, r: 16, b: 30, l: 44 };
|
||||||
|
const max = niceMax(Math.max(...data, 1) * 1.1);
|
||||||
|
const bars = [];
|
||||||
|
function render(prog) {
|
||||||
|
const { ctx, w, h } = setup(canvas);
|
||||||
|
drawGridY(ctx, w, h, pad, max, tc, yFmt);
|
||||||
|
const plotW = w - pad.l - pad.r, plotH = h - pad.t - pad.b;
|
||||||
|
const slot = plotW / data.length, bw = Math.min(46, slot * 0.6);
|
||||||
|
bars.length = 0;
|
||||||
|
ctx.font = FONT(11); ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
||||||
|
data.forEach((v, i) => {
|
||||||
|
const x = pad.l + slot * i + slot / 2;
|
||||||
|
const bh = (v * prog / max) * plotH;
|
||||||
|
const y = h - pad.b - bh;
|
||||||
|
// One series = one colour. Cycling the palette here made colour look
|
||||||
|
// meaningful when the category is already named on the axis; callers
|
||||||
|
// that genuinely encode category in colour pass `colors` explicitly.
|
||||||
|
const pal = palette();
|
||||||
|
const color = (colors && colors[i]) || pal[0];
|
||||||
|
roundRect(ctx, x - bw / 2, y, bw, bh, 5); ctx.fillStyle = color; ctx.fill();
|
||||||
|
ctx.fillStyle = tc.text;
|
||||||
|
ctx.fillText(labels[i].length > 9 ? labels[i].slice(0, 8) + '…' : labels[i], x, h - pad.b + 8);
|
||||||
|
bars.push({ x: x - bw / 2, y, w: bw, h: bh, cx: x, cy: y, label: labels[i], v });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
animate(render);
|
||||||
|
attachHoverRect(canvas, () => bars, (b) => `${b.label}: ${yFmt ? yFmt(b.v) : b.v}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- GROUPED BAR --------------------
|
||||||
|
function groupedBar(canvas, opts) {
|
||||||
|
const { labels, datasets, yFmt } = opts;
|
||||||
|
const tc = themeColors();
|
||||||
|
const pad = { t: 16, r: 16, b: 30, l: 44 };
|
||||||
|
const max = niceMax(Math.max(...datasets.flatMap(d => d.data), 1) * 1.1);
|
||||||
|
const bars = [];
|
||||||
|
function render(prog) {
|
||||||
|
const { ctx, w, h } = setup(canvas);
|
||||||
|
drawGridY(ctx, w, h, pad, max, tc, yFmt);
|
||||||
|
const plotW = w - pad.l - pad.r, plotH = h - pad.t - pad.b;
|
||||||
|
const slot = plotW / labels.length;
|
||||||
|
const bw = Math.min(18, (slot * 0.7) / datasets.length);
|
||||||
|
const groupW = bw * datasets.length + (datasets.length - 1) * 3;
|
||||||
|
bars.length = 0;
|
||||||
|
ctx.font = FONT(11); ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
||||||
|
labels.forEach((lbl, i) => {
|
||||||
|
const gx = pad.l + slot * i + slot / 2 - groupW / 2;
|
||||||
|
datasets.forEach((ds, di) => {
|
||||||
|
const v = ds.data[i];
|
||||||
|
const bh = (v * prog / max) * plotH;
|
||||||
|
const x = gx + di * (bw + 3);
|
||||||
|
const y = h - pad.b - bh;
|
||||||
|
roundRect(ctx, x, y, bw, bh, 4); ctx.fillStyle = ds.color || palette()[di]; ctx.fill();
|
||||||
|
bars.push({ x, y, w: bw, h: bh, label: lbl + ' · ' + ds.label, v });
|
||||||
|
});
|
||||||
|
ctx.fillStyle = tc.text;
|
||||||
|
ctx.fillText(lbl.length > 9 ? lbl.slice(0, 8) + '…' : lbl, pad.l + slot * i + slot / 2, h - pad.b + 8);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
animate(render);
|
||||||
|
attachHoverRect(canvas, () => bars, (b) => `${b.label}: ${yFmt ? yFmt(b.v) : b.v}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- HORIZONTAL BAR --------------------
|
||||||
|
function horizontalBar(canvas, opts) {
|
||||||
|
const { labels, data, colors, yFmt } = opts;
|
||||||
|
const tc = themeColors();
|
||||||
|
const pad = { t: 8, r: 40, b: 8, l: 108 };
|
||||||
|
const max = niceMax(Math.max(...data, 1));
|
||||||
|
const bars = [];
|
||||||
|
function render(prog) {
|
||||||
|
const { ctx, w, h } = setup(canvas);
|
||||||
|
const plotW = w - pad.l - pad.r, plotH = h - pad.t - pad.b;
|
||||||
|
const slot = plotH / data.length, bh = Math.min(22, slot * 0.6);
|
||||||
|
bars.length = 0;
|
||||||
|
ctx.font = FONT(12);
|
||||||
|
data.forEach((v, i) => {
|
||||||
|
const y = pad.t + slot * i + slot / 2;
|
||||||
|
const bwd = (v * prog / max) * plotW;
|
||||||
|
const pal3 = palette();
|
||||||
|
const color = (colors && colors[i]) || pal3[0];
|
||||||
|
ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillStyle = tc.text;
|
||||||
|
ctx.fillText(labels[i].length > 13 ? labels[i].slice(0, 12) + '…' : labels[i], pad.l - 10, y);
|
||||||
|
roundRect(ctx, pad.l, y - bh / 2, bwd, bh, 4); ctx.fillStyle = color; ctx.fill();
|
||||||
|
ctx.textAlign = 'left'; ctx.fillStyle = css('--text-2') || '#4a625c'; ctx.font = FONT(12,600);
|
||||||
|
if (prog === 1) ctx.fillText(yFmt ? yFmt(v) : v, pad.l + bwd + 8, y);
|
||||||
|
ctx.font = FONT(12);
|
||||||
|
bars.push({ x: pad.l, y: y - bh / 2, w: Math.max(bwd, 60), h: bh, label: labels[i], v });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
animate(render);
|
||||||
|
attachHoverRect(canvas, () => bars, (b) => `${b.label}: ${yFmt ? yFmt(b.v) : b.v}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- DOUGHNUT / PIE --------------------
|
||||||
|
function doughnut(canvas, opts) {
|
||||||
|
const { data, labels, colors, centerLabel, centerValue, pie } = opts;
|
||||||
|
const tc = themeColors();
|
||||||
|
const total = data.reduce((a, b) => a + b, 0) || 1;
|
||||||
|
const segs = [];
|
||||||
|
function render(prog) {
|
||||||
|
const { ctx, w, h } = setup(canvas);
|
||||||
|
const cx = w / 2, cy = h / 2, r = Math.min(w, h) / 2 - 10;
|
||||||
|
const inner = pie ? 0 : r * 0.62;
|
||||||
|
let a0 = -Math.PI / 2;
|
||||||
|
segs.length = 0;
|
||||||
|
data.forEach((v, i) => {
|
||||||
|
const a1 = a0 + (v / total) * Math.PI * 2 * prog;
|
||||||
|
ctx.beginPath(); ctx.moveTo(cx, cy);
|
||||||
|
ctx.arc(cx, cy, r, a0, a1); ctx.closePath();
|
||||||
|
const pal2 = palette();
|
||||||
|
ctx.fillStyle = (colors && colors[i]) || pal2[i % pal2.length]; ctx.fill();
|
||||||
|
segs.push({ a0, a1, i });
|
||||||
|
a0 = a1;
|
||||||
|
});
|
||||||
|
if (!pie) {
|
||||||
|
ctx.beginPath(); ctx.arc(cx, cy, inner, 0, 7); ctx.fillStyle = tc.surface; ctx.fill();
|
||||||
|
if (centerValue !== undefined) {
|
||||||
|
ctx.fillStyle = css('--text') || '#10231f'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||||
|
ctx.font = FONT(26,700); ctx.fillText(centerValue, cx, cy - 6);
|
||||||
|
ctx.font = FONT(12); ctx.fillStyle = tc.text; ctx.fillText(centerLabel || '', cx, cy + 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas._segs = segs; canvas._geo = { cx, cy, r, inner };
|
||||||
|
}
|
||||||
|
animate(render);
|
||||||
|
canvas.onmousemove = (e) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
||||||
|
const g = canvas._geo; if (!g) return;
|
||||||
|
const dx = mx - g.cx, dy = my - g.cy, dist = Math.hypot(dx, dy);
|
||||||
|
if (dist > g.r || dist < g.inner) { hideTip(); canvas.style.cursor = 'default'; return; }
|
||||||
|
let ang = Math.atan2(dy, dx); if (ang < -Math.PI / 2) ang += Math.PI * 2;
|
||||||
|
const s = (canvas._segs || []).find(sg => ang >= sg.a0 && ang <= sg.a1);
|
||||||
|
if (s) { canvas.style.cursor = 'pointer'; const pct = Math.round(data[s.i] / total * 100); showTip(e.clientX, e.clientY, `${labels[s.i]}: <b>${data[s.i]}</b> (${pct}%)`); }
|
||||||
|
else hideTip();
|
||||||
|
};
|
||||||
|
canvas.onmouseleave = hideTip;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- SPARKLINE --------------------
|
||||||
|
function sparkline(canvas, data, color) {
|
||||||
|
const { ctx, w, h } = setup(canvas);
|
||||||
|
const max = Math.max(...data), min = Math.min(...data), rng = max - min || 1;
|
||||||
|
const stepX = w / (data.length - 1);
|
||||||
|
color = color || palette()[0];
|
||||||
|
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
||||||
|
grad.addColorStop(0, color + '40'); grad.addColorStop(1, color + '00');
|
||||||
|
const pts = data.map((v, i) => ({ x: i * stepX, y: h - 4 - ((v - min) / rng) * (h - 8) }));
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, h);
|
||||||
|
pts.forEach(p => ctx.lineTo(p.x, p.y)); ctx.lineTo(w, h); ctx.closePath(); ctx.fillStyle = grad; ctx.fill();
|
||||||
|
ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.lineJoin = 'round';
|
||||||
|
pts.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- helpers --------------------
|
||||||
|
function roundRect(ctx, x, y, w, h, r) {
|
||||||
|
if (h < 0) { y += h; h = -h; }
|
||||||
|
if (w < 0) { x += w; w = -w; }
|
||||||
|
// arcTo rejects a negative radius by throwing, which would abort the
|
||||||
|
// whole render pass — clamp so a degenerate rect is just skipped.
|
||||||
|
r = Math.max(0, Math.min(r, w / 2, h / 2));
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||||
|
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||||
|
ctx.arcTo(x, y + h, x, y, r);
|
||||||
|
ctx.arcTo(x, y, x + w, y, r);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
function attachHover(canvas, getPoints, fmt) {
|
||||||
|
canvas.onmousemove = (e) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = e.clientX - rect.left;
|
||||||
|
const pts = getPoints(); if (!pts.length) return;
|
||||||
|
let nearest = 0, min = Infinity;
|
||||||
|
pts.forEach((p, i) => { const d = Math.abs(p.x - mx); if (d < min) { min = d; nearest = i; } });
|
||||||
|
if (min < 40) { canvas.style.cursor = 'pointer'; showTip(e.clientX, e.clientY, `<b>${pts[nearest].label}</b><br>${fmt(pts[nearest], nearest)}`); }
|
||||||
|
else hideTip();
|
||||||
|
};
|
||||||
|
canvas.onmouseleave = hideTip;
|
||||||
|
}
|
||||||
|
function attachHoverRect(canvas, getBars, fmt) {
|
||||||
|
canvas.onmousemove = (e) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
||||||
|
const b = getBars().find(b => mx >= b.x - 3 && mx <= b.x + b.w + 3 && my >= b.y - 3 && my <= b.y + b.h + 3);
|
||||||
|
if (b) { canvas.style.cursor = 'pointer'; showTip(e.clientX, e.clientY, fmt(b)); }
|
||||||
|
else hideTip();
|
||||||
|
};
|
||||||
|
canvas.onmouseleave = hideTip;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Charts = { line, bar, groupedBar, doughnut, horizontalBar, sparkline, legend, token: css };
|
||||||
|
// Live getter: each read reflects the active theme's --c1..--c8.
|
||||||
|
Object.defineProperty(window.Charts, 'PALETTE', { get: palette, enumerable: true });
|
||||||
|
|
||||||
|
function legend(items) {
|
||||||
|
return `<div class="chart-legend">${items.map(it =>
|
||||||
|
`<span class="legend-item"><span class="legend-dot" style="background:${it.color}"></span>${it.label}</span>`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
/* ============================================================
|
||||||
|
dashboard.js — Main dashboard view
|
||||||
|
============================================================ */
|
||||||
|
window.Views = window.Views || {};
|
||||||
|
|
||||||
|
Views.dashboard = function () {
|
||||||
|
const k = DB.kpis;
|
||||||
|
const kpiCards = [
|
||||||
|
{ label: 'Open Jobs', value: k.openJobs, icon: 'briefcase', cls: 'i-indigo', trend: '+8%', dir: 'up', foot: 'vs last month' },
|
||||||
|
{ label: 'Total Candidates', value: k.totalCandidates, icon: 'users', cls: 'i-blue', trend: '+12%', dir: 'up', foot: 'active in pipeline' },
|
||||||
|
{ label: 'Interviews Today', value: k.interviewsToday, icon: 'calendar', cls: 'i-purple', trend: '3 upcoming', dir: 'flat', foot: 'next at 2:00 PM' },
|
||||||
|
{ label: 'Offers Accepted', value: k.offersAccepted, icon: 'check-circle', cls: 'i-green', trend: '+5%', dir: 'up', foot: `of ${k.offersSent} sent` }
|
||||||
|
];
|
||||||
|
const kpiCards2 = [
|
||||||
|
{ label: 'Time to Hire', value: k.timeToHire + ' days', icon: 'clock', cls: 'i-teal', trend: '-3 days', dir: 'up', foot: 'faster than target' },
|
||||||
|
{ label: 'Time to Fill', value: k.timeToFill + ' days', icon: 'target', cls: 'i-amber', trend: '-2 days', dir: 'up', foot: '41 day average' },
|
||||||
|
{ label: 'Cost per Hire', value: DB.money(k.costPerHire), icon: 'dollar', cls: 'i-red', trend: '+4%', dir: 'down', foot: 'above budget' },
|
||||||
|
{ label: 'Closed Jobs', value: k.closedJobs + k.hires, icon: 'award', cls: 'i-purple', trend: '+15%', dir: 'up', foot: 'this quarter' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function trendHtml(dir, txt) {
|
||||||
|
if (dir === 'flat') return `<span class="trend trend-flat">${txt}</span>`;
|
||||||
|
const cls = dir === 'up' ? 'trend-up' : 'trend-down';
|
||||||
|
const ic = dir === 'up' ? 'trending-up' : 'trending-down';
|
||||||
|
return `<span class="trend ${cls}">${UI.icon(ic)}${txt}</span>`;
|
||||||
|
}
|
||||||
|
const kpiHtml = arr => arr.map(c => `
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-label">${c.label}</span>
|
||||||
|
<span class="kpi-icn ${c.cls}">${UI.icon(c.icon)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-value">${c.value}</div>
|
||||||
|
<div class="kpi-foot">${trendHtml(c.dir, c.trend)}<span class="kpi-foot-text">${c.foot}</span></div>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
// upcoming interviews
|
||||||
|
const upcoming = DB.interviews.filter(iv => iv.status === 'Scheduled').slice(0, 5);
|
||||||
|
const upcomingHtml = upcoming.length ? upcoming.map(iv => `
|
||||||
|
<div class="list-row" onclick="Router.go('interviews')" style="cursor:pointer">
|
||||||
|
${UI.avatar(iv.candidate, iv.candInitials, iv.color)}
|
||||||
|
<div class="lr-main">
|
||||||
|
<div class="lr-title">${iv.candidate}</div>
|
||||||
|
<div class="lr-sub">${iv.type} · ${iv.jobTitle}</div>
|
||||||
|
</div>
|
||||||
|
<div class="lr-right">
|
||||||
|
<div class="fw-600 text-sm">${DB.fmtShort(iv.when)}</div>
|
||||||
|
<div class="lr-sub">${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}</div>
|
||||||
|
</div>
|
||||||
|
</div>`).join('') : '<div class="empty-state" style="padding:30px">No upcoming interviews</div>';
|
||||||
|
|
||||||
|
// recent applications
|
||||||
|
const recentApps = [...DB.candidates].sort((a, b) => b.applied - a.applied).slice(0, 5);
|
||||||
|
const recentHtml = recentApps.map(c => `
|
||||||
|
<div class="list-row" onclick="Candidates.openProfile('${c.id}')" style="cursor:pointer">
|
||||||
|
${UI.avatar(c.name, c.initials, c.color)}
|
||||||
|
<div class="lr-main">
|
||||||
|
<div class="lr-title">${c.name}</div>
|
||||||
|
<div class="lr-sub">${c.jobTitle}</div>
|
||||||
|
</div>
|
||||||
|
<div class="lr-right">${UI.scoreChip(c.aiScore)}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
// activity feed
|
||||||
|
const activityHtml = DB.activity.slice(0, 8).map(a => `
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="kpi-icn ${a.color}" style="width:36px;height:36px;border-radius:9px">${UI.icon(a.icon)}</span>
|
||||||
|
<div class="lr-main">
|
||||||
|
<div class="lr-sub" style="color:var(--text-2);font-size:13px">${a.html}</div>
|
||||||
|
<div class="lr-sub">${DB.relTime(a.time)}</div>
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
// recruiter performance
|
||||||
|
const topRecs = [...DB.recruiters].sort((a, b) => b.hires - a.hires).slice(0, 5);
|
||||||
|
const recPerf = topRecs.map(r => `
|
||||||
|
<div class="list-row">
|
||||||
|
${UI.avatar(r.name, r.initials, r.color)}
|
||||||
|
<div class="lr-main">
|
||||||
|
<div class="lr-title">${r.name}</div>
|
||||||
|
<div class="lr-sub">${r.openReqs} open reqs · ${r.avgTimeToHire}d avg</div>
|
||||||
|
</div>
|
||||||
|
<div class="lr-right"><div class="fw-600">${r.hires}</div><div class="lr-sub">hires</div></div>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Good morning, Asfand 👋</h1>
|
||||||
|
<p class="page-sub">Here's what's happening with your hiring today — Thursday, July 9, 2026</p>
|
||||||
|
</div>
|
||||||
|
<div class="page-head-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="Router.go('reports')">${UI.icon('download')} Export</button>
|
||||||
|
<button class="btn btn-primary" onclick="Jobs.openCreate()">${UI.icon('plus')} Create Job</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid g-kpi">${kpiHtml(kpiCards)}</div>
|
||||||
|
<div class="grid g-kpi mt-18">${kpiHtml(kpiCards2)}</div>
|
||||||
|
|
||||||
|
<div class="grid g-2-1 mt-18">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div><h3>Hiring Trend</h3><span class="ch-sub">Hires vs applications over the last 7 months</span></div>
|
||||||
|
<div class="pill-tabs"><span class="pill-tab active">7M</span><span class="pill-tab">1Y</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-wrap"><canvas id="chartTrend" height="280"></canvas></div>
|
||||||
|
${Charts.legend([{ label: 'Applications', color: Charts.PALETTE[4] }, { label: 'Hires', color: Charts.PALETTE[0] }])}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Candidate Pipeline</h3><span class="ch-sub">Active by stage</span></div></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-wrap"><canvas id="chartPipeline" height="280"></canvas></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid g-2-1 mt-18">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Upcoming Interviews</h3><span class="ch-sub">Next scheduled sessions</span></div>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="Router.go('interviews')">View all</button></div>
|
||||||
|
<div class="card-body"><div class="list-tight">${upcomingHtml}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Source Analytics</h3><span class="ch-sub">Where candidates come from</span></div></div>
|
||||||
|
<div class="card-body"><div class="chart-wrap"><canvas id="chartSource" height="240"></canvas></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid g-3 mt-18">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Recent Applications</h3></div>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="Router.go('candidates')">View all</button></div>
|
||||||
|
<div class="card-body"><div class="list-tight">${recentHtml}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Recruiter Performance</h3></div></div>
|
||||||
|
<div class="card-body"><div class="list-tight">${recPerf}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Recent Activity</h3></div></div>
|
||||||
|
<div class="card-body" style="max-height:360px;overflow-y:auto"><div class="list-tight">${activityHtml}</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
onMount() {
|
||||||
|
const a = DB.analytics;
|
||||||
|
Charts.line(document.getElementById('chartTrend'), {
|
||||||
|
labels: a.hiringTrend.labels, area: true,
|
||||||
|
datasets: [
|
||||||
|
{ label: 'Applications', data: a.hiringTrend.applications, color: Charts.PALETTE[4] },
|
||||||
|
{ label: 'Hires', data: a.hiringTrend.hires, color: Charts.PALETTE[0] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
Charts.horizontalBar(document.getElementById('chartPipeline'), {
|
||||||
|
labels: a.pipeline.map(p => p.stage), data: a.pipeline.map(p => p.count),
|
||||||
|
colors: Charts.PALETTE
|
||||||
|
});
|
||||||
|
Charts.bar(document.getElementById('chartSource'), {
|
||||||
|
labels: a.sources.map(s => s.source), data: a.sources.map(s => s.count)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,512 @@
|
||||||
|
/* ============================================================
|
||||||
|
data.js — Realistic dummy dataset + generators
|
||||||
|
Exposes global `DB`
|
||||||
|
============================================================ */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ---------- seeded pseudo-random for stable data ----------
|
||||||
|
let seed = 88123;
|
||||||
|
function rand() { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }
|
||||||
|
function pick(arr) { return arr[Math.floor(rand() * arr.length)]; }
|
||||||
|
function pickN(arr, n) {
|
||||||
|
const copy = arr.slice(), out = [];
|
||||||
|
for (let i = 0; i < n && copy.length; i++) out.push(copy.splice(Math.floor(rand() * copy.length), 1)[0]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function int(min, max) { return Math.floor(rand() * (max - min + 1)) + min; }
|
||||||
|
|
||||||
|
// ---------- reference lists ----------
|
||||||
|
const departments = ['Engineering', 'Product', 'Design', 'Marketing', 'Sales', 'Finance', 'People Ops', 'Customer Success', 'Data & Analytics', 'Operations'];
|
||||||
|
const businessUnits = ['Core Platform', 'Growth', 'Enterprise', 'Consumer', 'Corporate'];
|
||||||
|
const locations = ['San Francisco, CA', 'New York, NY', 'Austin, TX', 'London, UK', 'Remote — US', 'Remote — EU', 'Berlin, DE', 'Toronto, CA', 'Singapore', 'Seattle, WA'];
|
||||||
|
const empTypes = ['Full-time', 'Part-time', 'Contract', 'Internship'];
|
||||||
|
const grades = ['L2', 'L3', 'L4', 'L5', 'L6', 'L7'];
|
||||||
|
const jobStatuses = ['Open', 'On Hold', 'Closed', 'Draft'];
|
||||||
|
const educationLevels = ["Bachelor's Degree", "Master's Degree", "PhD", "Associate Degree", "High School"];
|
||||||
|
const stages = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired', 'Rejected'];
|
||||||
|
const sources = ['LinkedIn', 'Company Site', 'Referral', 'Indeed', 'Job Fair', 'Agency', 'GitHub', 'AngelList'];
|
||||||
|
const companies = ['Stripe', 'Airbnb', 'Datadog', 'Notion', 'Figma', 'Shopify', 'Snowflake', 'Twilio', 'Coinbase', 'Atlassian', 'Asana', 'Ramp', 'Brex', 'Vercel', 'Retool', 'Amplitude', 'Segment', 'MongoDB', 'HashiCorp', 'Cloudflare'];
|
||||||
|
|
||||||
|
const firstNames = ['Olivia', 'Liam', 'Emma', 'Noah', 'Ava', 'Ethan', 'Sophia', 'Mason', 'Isabella', 'Lucas', 'Mia', 'Aiden', 'Amelia', 'James', 'Harper', 'Benjamin', 'Evelyn', 'Elijah', 'Abigail', 'Daniel', 'Priya', 'Wei', 'Diego', 'Fatima', 'Yuki', 'Omar', 'Ingrid', 'Kwame', 'Sofia', 'Arjun', 'Chloe', 'Marcus', 'Nadia', 'Tariq', 'Lena', 'Hassan', 'Grace', 'Felix', 'Zara', 'Kai'];
|
||||||
|
const lastNames = ['Chen', 'Patel', 'Kim', 'Rodriguez', 'Nguyen', 'Johnson', 'Williams', 'Garcia', 'Müller', 'Rossi', 'Silva', 'Okafor', 'Anderson', 'Ahmed', 'Cohen', 'Novak', 'Yamamoto', 'Brown', 'Dubois', 'Schmidt', 'Ivanov', 'Lopez', 'Martins', 'Andersson', 'Reyes', 'Khan', 'Tremblay', 'Fischer', 'Costa', 'Walsh'];
|
||||||
|
|
||||||
|
const jobTitlesByDept = {
|
||||||
|
'Engineering': ['Senior Backend Engineer', 'Staff Frontend Engineer', 'Platform Engineer', 'Engineering Manager', 'Site Reliability Engineer', 'Mobile Engineer (iOS)', 'Full-Stack Engineer'],
|
||||||
|
'Product': ['Senior Product Manager', 'Group Product Manager', 'Technical Product Manager', 'Product Operations Lead'],
|
||||||
|
'Design': ['Senior Product Designer', 'UX Researcher', 'Brand Designer', 'Design Systems Lead'],
|
||||||
|
'Marketing': ['Growth Marketing Manager', 'Content Strategist', 'Demand Generation Lead', 'SEO Specialist'],
|
||||||
|
'Sales': ['Enterprise Account Executive', 'Sales Development Rep', 'Solutions Engineer', 'Sales Manager'],
|
||||||
|
'Finance': ['Financial Analyst', 'Controller', 'FP&A Manager', 'Accounting Lead'],
|
||||||
|
'People Ops': ['HR Business Partner', 'Recruiting Coordinator', 'People Operations Manager', 'Compensation Analyst'],
|
||||||
|
'Customer Success': ['Customer Success Manager', 'Support Engineer', 'Onboarding Specialist', 'CS Team Lead'],
|
||||||
|
'Data & Analytics': ['Senior Data Scientist', 'Analytics Engineer', 'Data Platform Engineer', 'ML Engineer'],
|
||||||
|
'Operations': ['Operations Manager', 'Business Operations Analyst', 'Procurement Lead', 'Program Manager']
|
||||||
|
};
|
||||||
|
const skillsPool = ['JavaScript', 'TypeScript', 'React', 'Node.js', 'Python', 'Go', 'AWS', 'Kubernetes', 'GraphQL', 'PostgreSQL', 'System Design', 'Figma', 'Product Strategy', 'SQL', 'Data Modeling', 'Leadership', 'A/B Testing', 'Salesforce', 'Communication', 'Roadmapping'];
|
||||||
|
const benefitsPool = ['Equity', '401(k) match', 'Unlimited PTO', 'Health & Dental', 'Remote stipend', 'Learning budget', 'Parental leave', 'Wellness program'];
|
||||||
|
|
||||||
|
function fullName() { return pick(firstNames) + ' ' + pick(lastNames); }
|
||||||
|
function initials(name) { return name.split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase(); }
|
||||||
|
function email(name) { return name.toLowerCase().replace(/[^a-z ]/g, '').replace(/ /g, '.') + '@' + pick(['gmail.com', 'outlook.com', 'proton.me', 'icloud.com']); }
|
||||||
|
function phone() { return '+1 (' + int(200, 989) + ') ' + int(200, 999) + '-' + int(1000, 9999); }
|
||||||
|
function daysAgo(n) { const d = new Date('2026-07-09T09:00:00'); d.setDate(d.getDate() - n); return d; }
|
||||||
|
function fmtDate(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); }
|
||||||
|
function fmtShort(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }
|
||||||
|
|
||||||
|
const avatarColors = ['var(--av-1)', 'var(--av-2)', 'var(--av-3)', 'var(--av-4)', 'var(--av-5)', 'var(--av-6)', 'var(--av-7)', 'var(--av-8)'];
|
||||||
|
function avatarColor(name) { let s = 0; for (const c of name) s += c.charCodeAt(0); return avatarColors[s % avatarColors.length]; }
|
||||||
|
|
||||||
|
// ---------- Recruiters ----------
|
||||||
|
const recruiters = [];
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
const name = fullName();
|
||||||
|
recruiters.push({
|
||||||
|
id: 'REC-' + (101 + i), name, initials: initials(name), email: email(name),
|
||||||
|
color: avatarColor(name),
|
||||||
|
hires: int(4, 34), openReqs: int(2, 12), avgTimeToHire: int(18, 46),
|
||||||
|
offerAccept: int(72, 98), rating: (rand() * 1.4 + 3.5).toFixed(1)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Hiring Managers ----------
|
||||||
|
const managers = [];
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const name = fullName();
|
||||||
|
const dept = departments[i % departments.length];
|
||||||
|
managers.push({
|
||||||
|
id: 'HM-' + (201 + i), name, initials: initials(name), email: email(name).replace(/@.*/, '@utopiabrands.com'),
|
||||||
|
color: avatarColor(name), department: dept, title: 'Director, ' + dept,
|
||||||
|
openReqs: int(1, 6), teamSize: int(6, 40)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Jobs ----------
|
||||||
|
const jobs = [];
|
||||||
|
for (let i = 0; i < 26; i++) {
|
||||||
|
const dept = pick(departments);
|
||||||
|
const title = pick(jobTitlesByDept[dept]);
|
||||||
|
const mgr = pick(managers);
|
||||||
|
const rec = pick(recruiters);
|
||||||
|
const status = i < 16 ? 'Open' : pick(jobStatuses);
|
||||||
|
const created = daysAgo(int(3, 120));
|
||||||
|
const apps = status === 'Draft' ? 0 : int(12, 220);
|
||||||
|
jobs.push({
|
||||||
|
id: 'JOB-' + (1001 + i), title, department: dept, businessUnit: pick(businessUnits),
|
||||||
|
grade: pick(grades), manager: mgr.name, managerId: mgr.id, recruiter: rec.name, recruiterId: rec.id,
|
||||||
|
location: pick(locations), type: pick(empTypes), vacancies: int(1, 5), applications: apps,
|
||||||
|
status, created, deadline: daysAgo(-int(5, 60)),
|
||||||
|
salaryMin: int(80, 160) * 1000, salaryMax: 0, experience: int(2, 10) + '+ years',
|
||||||
|
education: pick(educationLevels), skills: pickN(skillsPool, int(4, 7)), benefits: pickN(benefitsPool, int(3, 5)),
|
||||||
|
description: 'We are looking for a ' + title + ' to join our ' + dept + ' team. You will drive high-impact initiatives, collaborate cross-functionally, and help shape the future of our product.',
|
||||||
|
responsibilities: ['Own and deliver key projects end-to-end', 'Collaborate with cross-functional partners', 'Mentor peers and raise the quality bar', 'Contribute to strategy and roadmap planning'],
|
||||||
|
progress: status === 'Closed' ? 100 : int(10, 92)
|
||||||
|
});
|
||||||
|
jobs[i].salaryMax = jobs[i].salaryMin + int(20, 60) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Candidates ----------
|
||||||
|
const openJobs = jobs.filter(j => j.status === 'Open');
|
||||||
|
const candidates = [];
|
||||||
|
const stageWeights = ['Applied', 'Applied', 'Applied', 'Screening', 'Screening', 'Assessment', 'Interview', 'Interview', 'Offer', 'Hired', 'Rejected', 'Rejected'];
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const name = fullName();
|
||||||
|
const job = pick(openJobs.length ? openJobs : jobs);
|
||||||
|
const stage = pick(stageWeights);
|
||||||
|
const applied = daysAgo(int(1, 75));
|
||||||
|
candidates.push({
|
||||||
|
id: 'CAN-' + (5001 + i), name, initials: initials(name), color: avatarColor(name),
|
||||||
|
email: email(name), phone: phone(),
|
||||||
|
jobId: job.id, jobTitle: job.title, department: job.department,
|
||||||
|
experience: int(1, 14), currentCompany: pick(companies), currentTitle: pick(jobTitlesByDept[job.department] || ['Specialist']),
|
||||||
|
location: pick(locations), stage, status: stage,
|
||||||
|
aiScore: int(52, 98), source: pick(sources), recruiter: job.recruiter, recruiterId: job.recruiterId,
|
||||||
|
applied, education: pick(educationLevels), skills: pickN(skillsPool, int(3, 6)),
|
||||||
|
rating: (rand() * 2 + 3).toFixed(1),
|
||||||
|
salary: int(90, 190) * 1000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Interviews ----------
|
||||||
|
const interviewTypes = ['Phone Screen', 'Technical', 'System Design', 'Onsite Loop', 'Hiring Manager', 'Culture Fit', 'Final Round'];
|
||||||
|
const meetingTypes = ['Video Call', 'On-site', 'Phone'];
|
||||||
|
const interviewStatuses = ['Scheduled', 'Completed', 'Cancelled', 'No Show'];
|
||||||
|
const interviews = [];
|
||||||
|
const interviewPool = candidates.filter(c => ['Screening', 'Assessment', 'Interview', 'Offer'].includes(c.stage));
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const cand = pick(interviewPool.length ? interviewPool : candidates);
|
||||||
|
const offset = int(-20, 14);
|
||||||
|
const when = daysAgo(-offset);
|
||||||
|
when.setHours(int(9, 17), pick([0, 30]), 0, 0);
|
||||||
|
const status = offset > 0 ? 'Scheduled' : pick(['Completed', 'Completed', 'Completed', 'Cancelled', 'No Show']);
|
||||||
|
interviews.push({
|
||||||
|
id: 'INT-' + (7001 + i), candidate: cand.name, candidateId: cand.id, candInitials: cand.initials, color: cand.color,
|
||||||
|
jobTitle: cand.jobTitle, type: pick(interviewTypes), meeting: pick(meetingTypes),
|
||||||
|
when, status, duration: pick([30, 45, 60, 90]),
|
||||||
|
interviewers: pickN([...recruiters, ...managers].map(r => r.name), int(1, 3)),
|
||||||
|
feedback: status === 'Completed' ? pick(['Strong Hire', 'Hire', 'Lean Hire', 'No Hire']) : null,
|
||||||
|
score: status === 'Completed' ? int(2, 5) : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
interviews.sort((a, b) => a.when - b.when);
|
||||||
|
|
||||||
|
// ---------- Assessments ----------
|
||||||
|
const assessTypes = ['Coding Challenge', 'Take-home Project', 'Cognitive Test', 'Personality Assessment', 'SQL Test', 'Case Study'];
|
||||||
|
const assessments = [];
|
||||||
|
for (let i = 0; i < 24; i++) {
|
||||||
|
const cand = pick(candidates);
|
||||||
|
const status = pick(['Completed', 'Completed', 'In Progress', 'Pending', 'Expired']);
|
||||||
|
assessments.push({
|
||||||
|
id: 'ASM-' + (8001 + i), candidate: cand.name, candidateId: cand.id, initials: cand.initials, color: cand.color,
|
||||||
|
jobTitle: cand.jobTitle, type: pick(assessTypes), status,
|
||||||
|
score: status === 'Completed' ? int(45, 99) : null,
|
||||||
|
assigned: daysAgo(int(1, 30)), due: daysAgo(-int(1, 10)),
|
||||||
|
duration: pick(['45 min', '60 min', '90 min', '2 hours', '3 days'])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Offers ----------
|
||||||
|
const offerStatuses = ['Sent', 'Accepted', 'Negotiating', 'Declined', 'Draft', 'Expired'];
|
||||||
|
const offers = [];
|
||||||
|
const offerPool = candidates.filter(c => ['Offer', 'Hired'].includes(c.stage));
|
||||||
|
for (let i = 0; i < 22; i++) {
|
||||||
|
const cand = offerPool[i % offerPool.length] || pick(candidates);
|
||||||
|
const status = cand.stage === 'Hired' ? 'Accepted' : pick(offerStatuses);
|
||||||
|
const base = int(95, 210) * 1000;
|
||||||
|
offers.push({
|
||||||
|
id: 'OFR-' + (9001 + i), candidate: cand.name, candidateId: cand.id, initials: cand.initials, color: cand.color,
|
||||||
|
jobTitle: cand.jobTitle, department: cand.department, status,
|
||||||
|
base, equity: int(0, 40) + 'k RSU', bonus: int(5, 25) + '%',
|
||||||
|
sent: daysAgo(int(1, 25)), expires: daysAgo(-int(3, 14)), recruiter: cand.recruiter
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Activity feed ----------
|
||||||
|
const activityTemplates = [
|
||||||
|
{ icon: 'user-plus', color: 'i-green', text: (n, j) => `<b>${n}</b> applied for <b>${j}</b>` },
|
||||||
|
{ icon: 'calendar', color: 'i-blue', text: (n, j) => `Interview scheduled with <b>${n}</b> for <b>${j}</b>` },
|
||||||
|
{ icon: 'check', color: 'i-teal', text: (n, j) => `<b>${n}</b> moved to <b>Offer</b> stage` },
|
||||||
|
{ icon: 'file', color: 'i-purple', text: (n, j) => `Offer sent to <b>${n}</b> for <b>${j}</b>` },
|
||||||
|
{ icon: 'star', color: 'i-amber', text: (n, j) => `<b>${n}</b> completed an assessment` },
|
||||||
|
{ icon: 'x', color: 'i-red', text: (n, j) => `<b>${n}</b> was rejected for <b>${j}</b>` }
|
||||||
|
];
|
||||||
|
const activity = [];
|
||||||
|
for (let i = 0; i < 18; i++) {
|
||||||
|
const t = pick(activityTemplates);
|
||||||
|
const cand = pick(candidates);
|
||||||
|
activity.push({ icon: t.icon, color: t.color, html: t.text(cand.name, cand.jobTitle), time: int(1, 300), candidateId: cand.id });
|
||||||
|
}
|
||||||
|
activity.sort((a, b) => a.time - b.time);
|
||||||
|
function relTime(mins) {
|
||||||
|
if (mins < 60) return mins + 'm ago';
|
||||||
|
if (mins < 1440) return Math.floor(mins / 60) + 'h ago';
|
||||||
|
return Math.floor(mins / 1440) + 'd ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Notifications ----------
|
||||||
|
const notifications = [
|
||||||
|
{ icon: 'user-plus', color: 'i-green', title: 'New application', text: candidates[0].name + ' applied for ' + candidates[0].jobTitle, time: '8m ago', unread: true },
|
||||||
|
{ icon: 'calendar', color: 'i-blue', title: 'Interview reminder', text: 'Technical interview at 2:00 PM today', time: '25m ago', unread: true },
|
||||||
|
{ icon: 'check', color: 'i-teal', title: 'Offer accepted', text: offers[0].candidate + ' accepted the offer 🎉', time: '1h ago', unread: true },
|
||||||
|
{ icon: 'message', color: 'i-purple', title: 'New message', text: 'Hiring manager left feedback on a candidate', time: '2h ago', unread: true },
|
||||||
|
{ icon: 'star', color: 'i-amber', title: 'Assessment completed', text: assessments[0].candidate + ' scored 87% on Coding Challenge', time: '4h ago', unread: false },
|
||||||
|
{ icon: 'file', color: 'i-indigo', title: 'Approval needed', text: 'Job requisition JOB-1004 awaits your approval', time: '6h ago', unread: false },
|
||||||
|
{ icon: 'x', color: 'i-red', title: 'Interview cancelled', text: 'Culture fit interview was cancelled by candidate', time: '1d ago', unread: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Messages ----------
|
||||||
|
const messages = [
|
||||||
|
{ name: managers[0].name, initials: managers[0].initials, color: managers[0].color, text: 'Can we move the onsite to Thursday?', time: '10m', unread: true },
|
||||||
|
{ name: recruiters[2].name, initials: recruiters[2].initials, color: recruiters[2].color, text: 'Shortlist is ready for your review.', time: '40m', unread: true },
|
||||||
|
{ name: candidates[5].name, initials: candidates[5].initials, color: candidates[5].color, text: 'Thank you! Looking forward to it.', time: '2h', unread: false },
|
||||||
|
{ name: managers[3].name, initials: managers[3].initials, color: managers[3].color, text: 'Approved the offer — go ahead.', time: '5h', unread: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Aggregate analytics ----------
|
||||||
|
const analytics = {
|
||||||
|
hiringTrend: { labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'], hires: [12, 18, 15, 22, 19, 26, 24], applications: [180, 240, 210, 320, 290, 380, 410] },
|
||||||
|
pipeline: stages.filter(s => s !== 'Rejected').map(s => ({ stage: s, count: candidates.filter(c => c.stage === s).length })),
|
||||||
|
sources: sources.map(s => ({ source: s, count: candidates.filter(c => c.source === s).length })),
|
||||||
|
departments: departments.map(d => ({ dept: d, open: jobs.filter(j => j.department === d && j.status === 'Open').length, apps: candidates.filter(c => c.department === d).length })),
|
||||||
|
offerAcceptance: { accepted: offers.filter(o => o.status === 'Accepted').length, declined: offers.filter(o => o.status === 'Declined').length, pending: offers.filter(o => ['Sent', 'Negotiating'].includes(o.status)).length },
|
||||||
|
timeToHire: [32, 28, 35, 30, 26, 29, 27],
|
||||||
|
timeToFill: [45, 42, 48, 40, 38, 41, 39]
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- KPIs ----------
|
||||||
|
const today = new Date('2026-07-09');
|
||||||
|
const kpis = {
|
||||||
|
openJobs: jobs.filter(j => j.status === 'Open').length,
|
||||||
|
closedJobs: jobs.filter(j => j.status === 'Closed').length,
|
||||||
|
totalCandidates: candidates.length,
|
||||||
|
interviewsToday: interviews.filter(iv => iv.when.toDateString() === today.toDateString()).length || 5,
|
||||||
|
offersSent: offers.filter(o => o.status !== 'Draft').length,
|
||||||
|
offersAccepted: offers.filter(o => o.status === 'Accepted').length,
|
||||||
|
timeToHire: 27,
|
||||||
|
timeToFill: 39,
|
||||||
|
costPerHire: 4280,
|
||||||
|
hires: candidates.filter(c => c.stage === 'Hired').length
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Roles / users for settings ----------
|
||||||
|
const roles = [
|
||||||
|
{ name: 'Administrator', users: 3, desc: 'Full access to all settings and data', perms: 'All permissions' },
|
||||||
|
{ name: 'Recruiter', users: 15, desc: 'Manage jobs, candidates, and interviews', perms: '18 permissions' },
|
||||||
|
{ name: 'Hiring Manager', users: 12, desc: 'Review candidates and provide feedback', perms: '9 permissions' },
|
||||||
|
{ name: 'Interviewer', users: 24, desc: 'View assigned interviews and submit scorecards', perms: '5 permissions' },
|
||||||
|
{ name: 'Coordinator', users: 6, desc: 'Schedule interviews and manage logistics', perms: '11 permissions' }
|
||||||
|
];
|
||||||
|
const users = recruiters.slice(0, 8).map((r, i) => ({
|
||||||
|
name: r.name, initials: r.initials, color: r.color, email: r.email,
|
||||||
|
role: pick(['Administrator', 'Recruiter', 'Hiring Manager', 'Coordinator']),
|
||||||
|
status: i % 5 === 0 ? 'Invited' : 'Active', lastActive: pick(['2m ago', '1h ago', 'Yesterday', '3d ago'])
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ENTERPRISE DATA (phase 2)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// ---------- Candidate sources w/ metadata ----------
|
||||||
|
const sourceMeta = {
|
||||||
|
'Microsoft Outlook': { icon: 'mail', color: '#0078d4', channel: 'Email' },
|
||||||
|
'Career Portal': { icon: 'briefcase', color: 'var(--c1)', channel: 'Portal' },
|
||||||
|
'Manual CV Upload': { icon: 'upload', color: 'var(--c2)', channel: 'Upload' },
|
||||||
|
'LinkedIn': { icon: 'linkedin', color: '#0a66c2', channel: 'Job Board' },
|
||||||
|
'Indeed': { icon: 'briefcase', color: '#2164f3', channel: 'Job Board' },
|
||||||
|
'Rozee': { icon: 'briefcase', color: '#e5322d', channel: 'Job Board' },
|
||||||
|
'Mustakbil': { icon: 'briefcase', color: '#00a651', channel: 'Job Board' },
|
||||||
|
'Employee Referral': { icon: 'users', color: 'var(--c3)', channel: 'Referral' },
|
||||||
|
'Recruitment Agency': { icon: 'briefcase', color: 'var(--c6)', channel: 'Agency' },
|
||||||
|
'Campus Hiring': { icon: 'award', color: 'var(--c5)', channel: 'Campus' },
|
||||||
|
'Walk-in': { icon: 'user-plus', color: 'var(--c6)', channel: 'Direct' }
|
||||||
|
};
|
||||||
|
const inboxSources = Object.keys(sourceMeta);
|
||||||
|
const processingStatuses = ['Unread', 'Imported', 'Processed', 'Rejected', 'Duplicate'];
|
||||||
|
const resumeStatuses = ['Parsed', 'Pending', 'Parsing', 'Failed'];
|
||||||
|
|
||||||
|
// ---------- Recruitment Inbox items ----------
|
||||||
|
const inbox = [];
|
||||||
|
for (let i = 0; i < 48; i++) {
|
||||||
|
const name = fullName();
|
||||||
|
const job = pick(openJobs.length ? openJobs : jobs);
|
||||||
|
const src = pick(inboxSources);
|
||||||
|
const proc = i < 14 ? 'Unread' : pick(processingStatuses);
|
||||||
|
const rs = proc === 'Unread' ? pick(['Pending', 'Parsing']) : pick(['Parsed', 'Parsed', 'Parsed', 'Failed']);
|
||||||
|
const received = daysAgo(int(0, 10));
|
||||||
|
received.setHours(int(7, 20), int(0, 59), 0, 0);
|
||||||
|
inbox.push({
|
||||||
|
id: 'APP-' + (30001 + i), name, initials: initials(name), color: avatarColor(name),
|
||||||
|
position: job.title, jobId: job.id, source: src, sourceMeta: sourceMeta[src],
|
||||||
|
resumeStatus: rs, atsScore: int(48, 97), recruiter: pick(recruiters).name,
|
||||||
|
received, processing: proc, unread: proc === 'Unread',
|
||||||
|
email: email(name), phone: phone(), experience: int(1, 12),
|
||||||
|
attachment: name.split(' ')[0] + '_Resume.pdf',
|
||||||
|
duplicate: proc === 'Duplicate'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
inbox.sort((a, b) => b.received - a.received);
|
||||||
|
|
||||||
|
// ---------- Outlook Emails ----------
|
||||||
|
const emailSubjects = [
|
||||||
|
'Application for {job}', 'Interested in the {job} role', 'CV — {job} position',
|
||||||
|
'Referral: strong candidate for {job}', 'Following up on my application', 'Resume attached — {job}'
|
||||||
|
];
|
||||||
|
const emails = [];
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const name = fullName();
|
||||||
|
const job = pick(openJobs.length ? openJobs : jobs);
|
||||||
|
const when = daysAgo(int(0, 6)); when.setHours(int(7, 20), int(0, 59), 0, 0);
|
||||||
|
emails.push({
|
||||||
|
id: 'EML-' + (40001 + i), from: name, fromEmail: email(name), initials: initials(name), color: avatarColor(name),
|
||||||
|
subject: pick(emailSubjects).replace('{job}', job.title), jobTitle: job.title, jobId: job.id,
|
||||||
|
preview: `Dear Hiring Team, I am writing to express my strong interest in the ${job.title} position. With ${int(3, 12)} years of experience, I believe I would be a great fit…`,
|
||||||
|
body: `Dear Hiring Team,\n\nI am writing to express my strong interest in the ${job.title} position at Utopia Brands. With over ${int(3, 12)} years of experience in ${job.department.toLowerCase()}, I have developed a strong foundation in delivering high-impact work.\n\nIn my current role at ${pick(companies)}, I have led cross-functional initiatives and consistently exceeded targets. I am confident my background aligns well with your requirements.\n\nPlease find my resume attached for your review. I would welcome the opportunity to discuss how I can contribute to your team.\n\nBest regards,\n${name}\n${phone()}`,
|
||||||
|
attachment: name.split(' ')[0] + '_CV.pdf', attachmentSize: int(120, 480) + ' KB',
|
||||||
|
when, unread: i < 8, imported: i >= 14, atsScore: int(50, 95)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
emails.sort((a, b) => b.when - a.when);
|
||||||
|
|
||||||
|
// ---------- Publishing platforms ----------
|
||||||
|
const publishPlatforms = [
|
||||||
|
{ name: 'Career Portal', icon: 'briefcase', color: 'var(--c1)', connected: true, cost: 'Free' },
|
||||||
|
{ name: 'LinkedIn', icon: 'linkedin', color: '#0a66c2', connected: true, cost: '$$$' },
|
||||||
|
{ name: 'Indeed', icon: 'briefcase', color: '#2164f3', connected: true, cost: '$$' },
|
||||||
|
{ name: 'Rozee', icon: 'briefcase', color: '#e5322d', connected: true, cost: '$' },
|
||||||
|
{ name: 'Mustakbil', icon: 'briefcase', color: '#00a651', connected: true, cost: '$' },
|
||||||
|
{ name: 'Google Jobs', icon: 'search', color: '#ea4335', connected: true, cost: 'Free' },
|
||||||
|
{ name: 'Facebook Jobs', icon: 'users', color: '#1877f2', connected: false, cost: '$$' },
|
||||||
|
{ name: 'Glassdoor', icon: 'briefcase', color: '#0caa41', connected: false, cost: '$$' }
|
||||||
|
];
|
||||||
|
// per-job publishing records
|
||||||
|
const publishings = [];
|
||||||
|
openJobs.slice(0, 14).forEach(job => {
|
||||||
|
const plats = pickN(publishPlatforms.filter(p => p.connected).map(p => p.name), int(2, 5));
|
||||||
|
plats.forEach(pl => {
|
||||||
|
const views = int(120, 4200), clicks = Math.round(views * (rand() * 0.3 + 0.08)), apps = Math.round(clicks * (rand() * 0.25 + 0.05));
|
||||||
|
publishings.push({ jobId: job.id, jobTitle: job.title, platform: pl, status: pick(['Live', 'Live', 'Live', 'Pending', 'Paused']), views, clicks, apps, published: daysAgo(int(2, 40)) });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Extend recruiters with enterprise metrics ----------
|
||||||
|
recruiters.forEach((r, idx) => {
|
||||||
|
r.openPositions = int(3, 12);
|
||||||
|
r.closedPositions = int(4, 28);
|
||||||
|
r.avgTimeToFill = r.avgTimeToHire + int(6, 16);
|
||||||
|
r.workload = int(28, 96);
|
||||||
|
r.interviewsToday = int(0, 5);
|
||||||
|
r.offersPending = int(0, 6);
|
||||||
|
r.jobsAwaitingApproval = int(0, 4);
|
||||||
|
r.jobsOverdue = int(0, 3);
|
||||||
|
r.efficiency = int(62, 97);
|
||||||
|
r.conversionRate = int(8, 26);
|
||||||
|
r.interviewCompletion = int(72, 99);
|
||||||
|
r.avgResponseTime = (rand() * 6 + 1).toFixed(1);
|
||||||
|
r.sla = pick(['On Track', 'On Track', 'On Track', 'At Risk', 'Breached']);
|
||||||
|
r.tat = int(78, 99);
|
||||||
|
r.monthlyTrend = Array.from({ length: 7 }, () => int(2, 9));
|
||||||
|
r.heatmap = Array.from({ length: 7 }, () => Array.from({ length: 5 }, () => int(0, 5))); // 7 days x 5 weeks-ish
|
||||||
|
r.department = departments[idx % departments.length];
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Extend candidates with ATS match detail ----------
|
||||||
|
candidates.forEach(c => {
|
||||||
|
const job = getJobLocal(c.jobId);
|
||||||
|
const required = job ? job.skills : pickN(skillsPool, 5);
|
||||||
|
const matched = required.filter(s => c.skills.includes(s));
|
||||||
|
const missing = required.filter(s => !c.skills.includes(s));
|
||||||
|
c.matchedSkills = matched.length ? matched : c.skills.slice(0, 2);
|
||||||
|
c.missingSkills = missing;
|
||||||
|
c.recommendation = c.aiScore >= 82 ? 'Strong Match' : c.aiScore >= 65 ? 'Potential Match' : 'Weak Match';
|
||||||
|
c.subScores = {
|
||||||
|
skills: Math.min(99, c.aiScore + int(-8, 8)),
|
||||||
|
experience: Math.min(99, c.aiScore + int(-12, 10)),
|
||||||
|
education: Math.min(99, c.aiScore + int(-10, 12)),
|
||||||
|
keywords: Math.min(99, c.aiScore + int(-14, 8)),
|
||||||
|
location: pick([100, 100, 80, 60]),
|
||||||
|
salary: pick([100, 90, 75, 55])
|
||||||
|
};
|
||||||
|
c.noticePeriod = pick(['Immediate', '2 weeks', '1 month', '2 months', '3 months']);
|
||||||
|
c.availability = pick(['Immediate', 'Immediate', '2 weeks', '1 month', 'Passive']);
|
||||||
|
c.certifications = pickN(['AWS Certified', 'PMP', 'Scrum Master', 'Google Analytics', 'CFA', 'None'], int(0, 2));
|
||||||
|
c.favorite = false;
|
||||||
|
c.interviewStatus = pick(['Not Scheduled', 'Scheduled', 'Completed', 'Not Scheduled']);
|
||||||
|
});
|
||||||
|
function getJobLocal(id) { return jobs.find(j => j.id === id); }
|
||||||
|
|
||||||
|
// ---------- Recruitment Tasks ----------
|
||||||
|
const taskTypes = ['Screen candidate', 'Schedule interview', 'Send offer', 'Review CV', 'Follow up', 'Submit scorecard', 'Approve requisition', 'Reference check'];
|
||||||
|
const tasks = [];
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
const cand = pick(candidates);
|
||||||
|
tasks.push({
|
||||||
|
id: 'TSK-' + (50001 + i), title: pick(taskTypes) + ' — ' + cand.name, candidateId: cand.id,
|
||||||
|
priority: pick(['High', 'Medium', 'Medium', 'Low']), due: daysAgo(-int(-3, 7)),
|
||||||
|
assignee: pick(recruiters).name, done: i % 6 === 0, type: pick(['Interview', 'Review', 'Offer', 'Admin'])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Saved searches / filters ----------
|
||||||
|
const savedSearches = [
|
||||||
|
{ name: 'Senior Engineers · SF', count: 24, filters: 'Engineering · L5+ · San Francisco' },
|
||||||
|
{ name: 'Strong Match Designers', count: 11, filters: 'Design · ATS ≥ 85' },
|
||||||
|
{ name: 'Immediate Availability', count: 38, filters: 'Availability: Immediate' },
|
||||||
|
{ name: 'Referrals to Review', count: 9, filters: 'Source: Referral · Unread' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Interview evaluation templates ----------
|
||||||
|
const evalTemplates = [
|
||||||
|
{ name: 'Engineering — Technical', dept: 'Engineering', criteria: ['Technical Skills', 'Problem Solving', 'System Design', 'Communication', 'Culture Fit'] },
|
||||||
|
{ name: 'Product — PM Loop', dept: 'Product', criteria: ['Product Sense', 'Analytical', 'Execution', 'Leadership', 'Communication'] },
|
||||||
|
{ name: 'Design — Portfolio', dept: 'Design', criteria: ['Craft', 'Process', 'Collaboration', 'Communication', 'Culture Fit'] },
|
||||||
|
{ name: 'General — Behavioral', dept: 'All', criteria: ['Communication', 'Technical', 'Problem Solving', 'Leadership', 'Culture Fit'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- RBAC modules & permission types ----------
|
||||||
|
const rbacModules = ['Dashboard', 'Recruitment Inbox', 'Jobs', 'Candidates', 'Pipeline', 'Interviews', 'Assessments', 'Offers', 'Reports', 'Analytics', 'Job Board', 'Settings', 'RBAC & Users'];
|
||||||
|
const permTypes = ['View', 'Create', 'Edit', 'Delete', 'Approve', 'Export', 'Manage', 'Configure'];
|
||||||
|
const rbacRoles = [
|
||||||
|
{ name: 'System Administrator', users: 2, color: 'var(--av-8)', desc: 'Unrestricted access to all modules and configuration', level: 'Administrator' },
|
||||||
|
{ name: 'HR Administrator', users: 4, color: 'var(--av-7)', desc: 'Manage recruitment operations and team settings', level: 'Manage' },
|
||||||
|
{ name: 'Recruiter', users: 15, color: 'var(--av-1)', desc: 'Full candidate and job lifecycle management', level: 'Edit' },
|
||||||
|
{ name: 'Hiring Manager', users: 12, color: 'var(--av-3)', desc: 'Review candidates, interview, approve offers', level: 'Approve' },
|
||||||
|
{ name: 'Department Head', users: 8, color: 'var(--av-2)', desc: 'Oversight of departmental hiring and approvals', level: 'Approve' },
|
||||||
|
{ name: 'CEO', users: 1, color: 'var(--av-5)', desc: 'Executive dashboards and reporting', level: 'View' },
|
||||||
|
{ name: 'Interviewer', users: 24, color: 'var(--av-6)', desc: 'Access assigned interviews and submit scorecards', level: 'View' },
|
||||||
|
{ name: 'Candidate', users: 0, color: 'var(--av-4)', desc: 'Self-service portal access (external)', level: 'View' }
|
||||||
|
];
|
||||||
|
// default permission matrix per role (module -> [bool x permTypes])
|
||||||
|
function buildMatrix(level) {
|
||||||
|
const idx = { 'View': 1, 'Edit': 3, 'Approve': 5, 'Manage': 7, 'Administrator': 8 }[level] || 1;
|
||||||
|
const m = {};
|
||||||
|
rbacModules.forEach(mod => { m[mod] = permTypes.map((p, i) => i < idx); });
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
rbacRoles.forEach(r => r.matrix = buildMatrix(r.level));
|
||||||
|
|
||||||
|
// ---------- Future AI modules ----------
|
||||||
|
const aiModules = [
|
||||||
|
{ name: 'Resume Ranking', desc: 'Automatically rank applicants for any job by fit', icon: 'award', cls: 'i-indigo', status: 'Beta' },
|
||||||
|
{ name: 'Candidate Matching', desc: 'Match candidates to the best-fit open roles', icon: 'target', cls: 'i-teal', status: 'Beta' },
|
||||||
|
{ name: 'Resume Summary', desc: 'One-click AI summary of any resume', icon: 'file', cls: 'i-blue', status: 'Beta' },
|
||||||
|
{ name: 'Candidate Comparison', desc: 'Side-by-side AI comparison of shortlisted candidates', icon: 'users', cls: 'i-purple', status: 'Coming Soon' },
|
||||||
|
{ name: 'JD Generator', desc: 'Generate polished job descriptions from a brief', icon: 'edit', cls: 'i-green', status: 'Beta' },
|
||||||
|
{ name: 'Email Generator', desc: 'Draft candidate emails in your brand voice', icon: 'mail', cls: 'i-amber', status: 'Beta' },
|
||||||
|
{ name: 'Offer Letter Generator', desc: 'Auto-generate compliant offer letters', icon: 'file', cls: 'i-red', status: 'Coming Soon' },
|
||||||
|
{ name: 'Interview Question Generator', desc: 'Role-specific question banks on demand', icon: 'message', cls: 'i-indigo', status: 'Beta' },
|
||||||
|
{ name: 'Candidate Recommendation', desc: 'Proactive "you should talk to…" suggestions', icon: 'star', cls: 'i-teal', status: 'Coming Soon' },
|
||||||
|
{ name: 'Recruitment Analytics', desc: 'Natural-language analytics across your funnel', icon: 'trending-up', cls: 'i-blue', status: 'Beta' },
|
||||||
|
{ name: 'Hiring Forecast', desc: 'Predict time-to-hire and pipeline health', icon: 'trending-up', cls: 'i-purple', status: 'Coming Soon' },
|
||||||
|
{ name: 'Skill Gap Analysis', desc: 'Identify missing skills across your pipeline', icon: 'target', cls: 'i-green', status: 'Beta' },
|
||||||
|
{ name: 'Recruiter Copilot', desc: 'An assistant embedded in every workflow', icon: 'message', cls: 'i-amber', status: 'Beta' },
|
||||||
|
{ name: 'Hiring Insights', desc: 'Weekly AI-generated hiring insights digest', icon: 'info', cls: 'i-red', status: 'Coming Soon' },
|
||||||
|
{ name: 'Natural Language Search', desc: 'Search candidates in plain English', icon: 'search', cls: 'i-indigo', status: 'Beta' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- AI assistant suggested prompts ----------
|
||||||
|
const aiPrompts = [
|
||||||
|
{ icon: 'award', text: 'Rank candidates', prompt: 'Rank the top candidates for the Senior Backend Engineer role.' },
|
||||||
|
{ icon: 'users', text: 'Compare candidates', prompt: 'Compare the top 3 candidates in the Interview stage.' },
|
||||||
|
{ icon: 'edit', text: 'Generate job description', prompt: 'Write a job description for a Staff Frontend Engineer.' },
|
||||||
|
{ icon: 'message', text: 'Interview questions', prompt: 'Generate technical interview questions for a Data Scientist.' },
|
||||||
|
{ icon: 'file', text: 'Summarize resume', prompt: "Summarize the resume of the last applicant." },
|
||||||
|
{ icon: 'mail', text: 'Generate email', prompt: 'Draft an interview invitation email for a candidate.' },
|
||||||
|
{ icon: 'file', text: 'Write offer letter', prompt: 'Write an offer letter for a Product Manager at $160k.' },
|
||||||
|
{ icon: 'star', text: 'Suggest candidate', prompt: 'Suggest the best candidate for the open Design role.' },
|
||||||
|
{ icon: 'target', text: 'Skill gap analysis', prompt: 'What skills are missing from my Engineering pipeline?' },
|
||||||
|
{ icon: 'trending-up', text: 'Hiring recommendation', prompt: 'Give me a hiring recommendation for this week.' },
|
||||||
|
{ icon: 'briefcase', text: 'Pipeline analysis', prompt: 'Analyze the health of my current pipeline.' },
|
||||||
|
{ icon: 'award', text: 'Recruiter productivity', prompt: 'How productive is my recruiting team this month?' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- ATS matching helper ----------
|
||||||
|
function atsRecommendationClass(rec) {
|
||||||
|
return rec === 'Strong Match' ? 'b-green' : rec === 'Potential Match' ? 'b-amber' : 'b-red';
|
||||||
|
}
|
||||||
|
|
||||||
|
// runtime state buckets
|
||||||
|
const recentlyViewed = [];
|
||||||
|
const favorites = { candidates: [], jobs: [] };
|
||||||
|
|
||||||
|
// ---------- Expose ----------
|
||||||
|
window.DB = {
|
||||||
|
departments, businessUnits, locations, empTypes, grades, jobStatuses, educationLevels, stages, sources, skillsPool, benefitsPool,
|
||||||
|
recruiters, managers, jobs, candidates, interviews, assessments, offers,
|
||||||
|
activity, notifications, messages, analytics, kpis, roles, users,
|
||||||
|
interviewTypes, meetingTypes, companies,
|
||||||
|
// enterprise
|
||||||
|
sourceMeta, inboxSources, processingStatuses, resumeStatuses, inbox, emails,
|
||||||
|
publishPlatforms, publishings, tasks, savedSearches, evalTemplates,
|
||||||
|
rbacModules, permTypes, rbacRoles, aiModules, aiPrompts,
|
||||||
|
recentlyViewed, favorites,
|
||||||
|
// helpers
|
||||||
|
fmtDate, fmtShort, relTime, initials, avatarColor, int, pick, atsRecommendationClass,
|
||||||
|
money: n => '$' + n.toLocaleString('en-US'),
|
||||||
|
moneyK: n => '$' + Math.round(n / 1000) + 'k',
|
||||||
|
getJob: id => jobs.find(j => j.id === id),
|
||||||
|
getCandidate: id => candidates.find(c => c.id === id),
|
||||||
|
getManager: id => managers.find(m => m.id === id),
|
||||||
|
getRecruiter: id => recruiters.find(r => r.id === id),
|
||||||
|
getRecruiterByName: n => recruiters.find(r => r.name === n)
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
/* ============================================================
|
||||||
|
import.js — Manual CV Import (drag&drop, parse, match, dedupe)
|
||||||
|
============================================================ */
|
||||||
|
window.Views = window.Views || {};
|
||||||
|
window.CVImport = {};
|
||||||
|
|
||||||
|
Views.import = function () {
|
||||||
|
const queue = []; // {name, size, status, atsScore, matchedJob, duplicate}
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">CV Import</h1><p class="page-sub">Upload resumes — we parse, score, match, and dedupe automatically</p></div>
|
||||||
|
<div class="page-head-actions">
|
||||||
|
<span class="integration-status pending"><span class="pulse"></span>AI Resume Parser · Ready</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid g-2-1">
|
||||||
|
<div>
|
||||||
|
<div class="card mb-18"><div class="card-body">
|
||||||
|
<div class="dropzone" id="dropzone">
|
||||||
|
<div class="dz-icn">${UI.icon('upload')}</div>
|
||||||
|
<h3>Drag & drop resumes here</h3>
|
||||||
|
<p class="text-muted" style="margin-bottom:16px">or click to browse — PDF, DOC, DOCX and ZIP supported · up to 20 files</p>
|
||||||
|
<button class="btn btn-primary" id="browseBtn">${UI.icon('upload')} Browse Files</button>
|
||||||
|
<div class="flex items-center gap-8" style="justify-content:center;margin-top:16px">
|
||||||
|
${['PDF', 'DOC', 'DOCX', 'ZIP'].map(t => `<span class="badge b-gray badge-plain">${t}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-8" style="margin-top:16px;flex-wrap:wrap">
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="CVImport.simulate(3)">${UI.icon('sparkles')} Simulate 3 files</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="CVImport.simulate(1,true)">${UI.icon('layers')} Simulate ZIP (8 CVs)</button>
|
||||||
|
<span class="text-muted text-sm" style="margin-left:auto">Files are processed locally in this demo</span>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="card" id="queueCard" style="display:none">
|
||||||
|
<div class="card-head"><div><h3>Processing Queue</h3><span class="ch-sub" id="queueSub">0 files</span></div>
|
||||||
|
<button class="btn btn-primary btn-sm" id="importAllBtn" onclick="CVImport.importAll()">${UI.icon('check')} Import All</button></div>
|
||||||
|
<div class="card-body" id="queueList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="align-self:start">
|
||||||
|
<div class="card-head"><div><h3>Auto-Processing</h3><span class="ch-sub">What happens on upload</span></div></div>
|
||||||
|
<div class="card-body"><div class="timeline">
|
||||||
|
${[
|
||||||
|
{ i: 'file', t: 'Resume parsing', d: 'Extract name, contact, experience, skills & education' },
|
||||||
|
{ i: 'target', t: 'ATS scoring', d: 'Generate a match score against the requisition' },
|
||||||
|
{ i: 'briefcase', t: 'Job matching', d: 'Suggest the best-matching open roles' },
|
||||||
|
{ i: 'users', t: 'Duplicate detection', d: 'Flag candidates already in the system' },
|
||||||
|
{ i: 'user-plus', t: 'Profile creation', d: 'Create a candidate profile in Applied stage' }
|
||||||
|
].map(s => `<div class="tl-item"><div class="tl-dot">${UI.icon(s.i)}</div><div class="tl-title">${s.t}</div><div class="tl-desc">${s.d}</div></div>`).join('')}
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
CVImport._queue = queue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
onMount() {
|
||||||
|
const dz = document.getElementById('dropzone');
|
||||||
|
const browse = document.getElementById('browseBtn');
|
||||||
|
dz.addEventListener('click', () => CVImport.simulate(DB.int(2, 4)));
|
||||||
|
browse.addEventListener('click', e => { e.stopPropagation(); CVImport.simulate(DB.int(2, 4)); });
|
||||||
|
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('drag'); });
|
||||||
|
dz.addEventListener('dragleave', () => dz.classList.remove('drag'));
|
||||||
|
dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('drag'); CVImport.simulate(e.dataTransfer.files.length || DB.int(2, 4)); });
|
||||||
|
CVImport._renderQueue();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
CVImport.simulate = function (count, isZip) {
|
||||||
|
const n = isZip ? 8 : count;
|
||||||
|
const jobs = DB.jobs.filter(j => j.status === 'Open');
|
||||||
|
for (let k = 0; k < n; k++) {
|
||||||
|
const name = DB.pick(['Olivia', 'Liam', 'Emma', 'Noah', 'Ava', 'Ethan', 'Sophia', 'Mason', 'Priya', 'Diego', 'Yuki', 'Omar']) + ' ' + DB.pick(['Chen', 'Patel', 'Kim', 'Garcia', 'Silva', 'Ahmed', 'Novak', 'Reyes', 'Khan', 'Costa']);
|
||||||
|
const item = {
|
||||||
|
id: 'UP-' + Math.random().toString(36).slice(2, 8), name, file: name.split(' ')[0] + '_Resume.' + DB.pick(['pdf', 'docx', 'doc']),
|
||||||
|
size: DB.int(120, 620) + ' KB', progress: 0, status: 'Uploading', atsScore: null,
|
||||||
|
job: DB.pick(jobs.length ? jobs : DB.jobs), duplicate: Math.random() < 0.18, imported: false
|
||||||
|
};
|
||||||
|
CVImport._queue.push(item);
|
||||||
|
CVImport._process(item);
|
||||||
|
}
|
||||||
|
document.getElementById('queueCard').style.display = '';
|
||||||
|
UI.toast(isZip ? 'ZIP extracted — 8 resumes queued' : n + ' file(s) uploaded', 'info');
|
||||||
|
CVImport._renderQueue();
|
||||||
|
};
|
||||||
|
|
||||||
|
CVImport._process = function (item) {
|
||||||
|
const tick = setInterval(() => {
|
||||||
|
item.progress += DB.int(12, 30);
|
||||||
|
if (item.progress >= 100) {
|
||||||
|
item.progress = 100; clearInterval(tick);
|
||||||
|
item.status = 'Parsing';
|
||||||
|
CVImport._renderQueue();
|
||||||
|
setTimeout(() => {
|
||||||
|
item.status = 'Ready'; item.atsScore = DB.int(52, 96);
|
||||||
|
CVImport._renderQueue();
|
||||||
|
}, 700 + DB.int(0, 500));
|
||||||
|
}
|
||||||
|
CVImport._renderQueue();
|
||||||
|
}, 220);
|
||||||
|
};
|
||||||
|
|
||||||
|
CVImport._renderQueue = function () {
|
||||||
|
const el = document.getElementById('queueList');
|
||||||
|
if (!el) return;
|
||||||
|
const q = CVImport._queue;
|
||||||
|
document.getElementById('queueSub').textContent = q.length + ' file' + (q.length === 1 ? '' : 's') + ' · ' + q.filter(i => i.imported).length + ' imported';
|
||||||
|
el.innerHTML = q.map(i => `
|
||||||
|
<div class="upload-row">
|
||||||
|
<span class="attach-icn" style="width:38px;height:38px">${UI.icon('file')}</span>
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div class="flex items-center gap-8"><span class="fw-600 text-sm">${i.name}</span>
|
||||||
|
${i.duplicate ? '<span class="badge b-red badge-plain" style="padding:1px 7px;font-size:10px">DUPLICATE</span>' : ''}</div>
|
||||||
|
<div class="cell-sub">${i.file} · ${i.size}</div>
|
||||||
|
${i.status === 'Uploading' || i.status === 'Parsing' ? `<div class="upload-progress" style="margin-top:6px"><div class="upload-progress-fill" style="width:${i.progress}%"></div></div>` :
|
||||||
|
`<div class="cell-sub" style="margin-top:4px">Best match: <b>${i.job.title}</b></div>`}
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right;flex-shrink:0">
|
||||||
|
${i.status === 'Ready' ? UI.scoreChip(i.atsScore) : `<span class="badge ${i.status === 'Parsing' ? 'b-amber' : 'b-blue'}">${i.status}${i.status === 'Uploading' ? ' ' + i.progress + '%' : ''}</span>`}
|
||||||
|
</div>
|
||||||
|
<div style="flex-shrink:0">
|
||||||
|
${i.imported ? `<span class="badge b-green">Imported</span>` :
|
||||||
|
i.status === 'Ready' ? `<button class="btn btn-primary btn-sm" onclick="CVImport.importOne('${i.id}')">Import</button>` :
|
||||||
|
`<button class="act-btn" disabled>${UI.icon('clock')}</button>`}
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
CVImport.importOne = function (id) {
|
||||||
|
const i = CVImport._queue.find(x => x.id === id);
|
||||||
|
if (!i || i.imported) return;
|
||||||
|
if (i.duplicate) {
|
||||||
|
UI.modal({
|
||||||
|
title: 'Duplicate Detected', subtitle: i.name,
|
||||||
|
body: `<div class="flex gap-16 items-center"><span class="kpi-icn i-amber" style="width:48px;height:48px;border-radius:12px;flex-shrink:0">${UI.icon('users')}</span>
|
||||||
|
<div><p class="fw-600" style="font-size:15px">A similar candidate already exists</p>
|
||||||
|
<p class="text-muted" style="margin-top:4px">${i.name} matches an existing profile (95% similarity on name + email). Importing will create a duplicate.</p></div></div>`,
|
||||||
|
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button>
|
||||||
|
<button class="btn btn-secondary" onclick="UI.closeModal();UI.toast('Merged into existing profile','success')">Merge</button>
|
||||||
|
<button class="btn btn-primary" onclick="UI.closeModal();CVImport._doImport('${id}')">Import Anyway</button>`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CVImport._doImport(id);
|
||||||
|
};
|
||||||
|
CVImport._doImport = function (id) {
|
||||||
|
const i = CVImport._queue.find(x => x.id === id);
|
||||||
|
const job = i.job;
|
||||||
|
DB.candidates.unshift({
|
||||||
|
id: 'CAN-' + (5001 + DB.candidates.length), name: i.name, initials: DB.initials(i.name), color: DB.avatarColor(i.name),
|
||||||
|
email: i.name.toLowerCase().replace(/ /g, '.') + '@email.com', phone: '+1 (555) 000-0000', jobId: job.id, jobTitle: job.title, department: job.department,
|
||||||
|
experience: DB.int(2, 12), currentCompany: DB.pick(DB.companies), currentTitle: job.title, location: DB.pick(DB.locations),
|
||||||
|
stage: 'Applied', status: 'Applied', aiScore: i.atsScore, source: 'Manual CV Upload', recruiter: DB.pick(DB.recruiters).name, recruiterId: '',
|
||||||
|
applied: new Date('2026-07-09'), education: "Bachelor's Degree", skills: job.skills.slice(0, 4), rating: '4.0', salary: DB.int(90, 180) * 1000,
|
||||||
|
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3), recommendation: i.atsScore >= 82 ? 'Strong Match' : 'Potential Match',
|
||||||
|
subScores: { skills: i.atsScore, experience: 80, education: 80, keywords: i.atsScore, location: 100, salary: 90 },
|
||||||
|
noticePeriod: '1 month', availability: '2 weeks', certifications: [], favorite: false, interviewStatus: 'Not Scheduled'
|
||||||
|
});
|
||||||
|
i.imported = true;
|
||||||
|
CVImport._renderQueue(); App.updateBadges();
|
||||||
|
UI.toast(`${i.name} imported → ${job.title}`, 'success');
|
||||||
|
};
|
||||||
|
CVImport.importAll = function () {
|
||||||
|
const ready = CVImport._queue.filter(i => i.status === 'Ready' && !i.imported && !i.duplicate);
|
||||||
|
if (!ready.length) { UI.toast('No files ready to import', 'warning'); return; }
|
||||||
|
ready.forEach(i => CVImport._doImport(i.id));
|
||||||
|
UI.toast(`${ready.length} candidates imported`, 'success');
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,332 @@
|
||||||
|
/* ============================================================
|
||||||
|
inbox.js — Central Recruitment Inbox + Outlook Email tab
|
||||||
|
============================================================ */
|
||||||
|
window.Views = window.Views || {};
|
||||||
|
window.Inbox = {};
|
||||||
|
|
||||||
|
Views.inbox = function () {
|
||||||
|
const state = { tab: 'All Applications', selected: null, emailSelected: null, q: '' };
|
||||||
|
const tabs = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email'];
|
||||||
|
|
||||||
|
function filtered() {
|
||||||
|
let list = DB.inbox;
|
||||||
|
if (state.tab === 'Unread') list = list.filter(i => i.processing === 'Unread');
|
||||||
|
else if (state.tab === 'Imported') list = list.filter(i => i.processing === 'Imported');
|
||||||
|
else if (state.tab === 'Processed') list = list.filter(i => i.processing === 'Processed');
|
||||||
|
else if (state.tab === 'Rejected') list = list.filter(i => i.processing === 'Rejected');
|
||||||
|
else if (state.tab === 'Duplicates') list = list.filter(i => i.duplicate);
|
||||||
|
if (state.q) list = list.filter(i => (i.name + i.position + i.source).toLowerCase().includes(state.q.toLowerCase()));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function counts() {
|
||||||
|
return {
|
||||||
|
'All Applications': DB.inbox.length,
|
||||||
|
'Unread': DB.inbox.filter(i => i.processing === 'Unread').length,
|
||||||
|
'Imported': DB.inbox.filter(i => i.processing === 'Imported').length,
|
||||||
|
'Processed': DB.inbox.filter(i => i.processing === 'Processed').length,
|
||||||
|
'Rejected': DB.inbox.filter(i => i.processing === 'Rejected').length,
|
||||||
|
'Duplicates': DB.inbox.filter(i => i.duplicate).length,
|
||||||
|
'Email': DB.emails.filter(e => e.unread).length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceChip(item) {
|
||||||
|
const m = item.sourceMeta;
|
||||||
|
// The dot carries the partner's brand colour; the label uses theme text.
|
||||||
|
// Rendering 11px labels in the partner colour failed AA in both themes.
|
||||||
|
// `--chip` carries the source colour; CSS mixes the tint. String-concat
|
||||||
|
// alpha ("#0a66c214") breaks for the tokenised sources (var(--c1)14).
|
||||||
|
return `<span class="source-chip" style="--chip:${m.color}"><span class="source-dot"></span>${item.source}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
const el = document.getElementById('inboxList');
|
||||||
|
if (!el) return;
|
||||||
|
const list = filtered();
|
||||||
|
if (!list.length) { el.innerHTML = `<div class="empty-state">${UI.icon('inbox')}<h3>Nothing here</h3><p>No applications in this view.</p></div>`; return; }
|
||||||
|
el.innerHTML = list.map(i => `
|
||||||
|
<div class="inbox-item ${i.unread ? 'unread' : ''} ${state.selected === i.id ? 'active' : ''}" data-id="${i.id}">
|
||||||
|
${UI.avatar(i.name, i.initials, i.color)}
|
||||||
|
<div class="ii-main">
|
||||||
|
<div class="ii-name">${i.name} ${i.duplicate ? '<span class="badge b-red badge-plain" style="padding:1px 6px;font-size:10px">DUP</span>' : ''}</div>
|
||||||
|
<div class="ii-pos">${i.position}</div>
|
||||||
|
<div class="ii-meta">${sourceChip(i)} ${UI.badge(i.processing)}</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right;flex-shrink:0">
|
||||||
|
<div class="ii-time">${DB.relTime(Math.round((new Date('2026-07-09T20:00') - i.received) / 60000))}</div>
|
||||||
|
<div style="margin-top:6px">${UI.scoreChip(i.atsScore)}</div>
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
el.querySelectorAll('.inbox-item').forEach(row => row.onclick = () => {
|
||||||
|
state.selected = row.dataset.id;
|
||||||
|
const it = DB.inbox.find(x => x.id === state.selected); if (it) it.unread = false;
|
||||||
|
renderList(); renderDetail(); App.updateBadges();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetail() {
|
||||||
|
const el = document.getElementById('inboxDetail');
|
||||||
|
if (!el) return;
|
||||||
|
const i = DB.inbox.find(x => x.id === state.selected);
|
||||||
|
if (!i) { el.innerHTML = `<div class="empty-state" style="padding:100px 20px">${UI.icon('inbox')}<h3>Select an application</h3><p>Choose an item from the list to view details and take action.</p></div>`; return; }
|
||||||
|
const rec = DB.atsRecommendationClass;
|
||||||
|
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match';
|
||||||
|
el.innerHTML = `
|
||||||
|
<div style="padding:24px">
|
||||||
|
<div class="flex items-center gap-16" style="margin-bottom:20px">
|
||||||
|
${UI.avatar(i.name, i.initials, i.color, 'avatar-lg')}
|
||||||
|
<div style="flex:1"><div class="ph-name" style="font-size:19px">${i.name}</div>
|
||||||
|
<div class="ph-role">${i.position}</div>
|
||||||
|
<div class="ph-tags" style="margin-top:8px">${sourceChip(i)} ${UI.badge(i.processing)} ${UI.badge(i.resumeStatus, i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber')}</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center">
|
||||||
|
<div class="ats-ring" style="width:84px;height:84px;--pct:${i.atsScore};--c:${i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)'}">
|
||||||
|
<div class="ats-val"><div class="ats-num" style="font-size:22px">${i.atsScore}</div></div></div>
|
||||||
|
<div class="cell-sub" style="margin-top:4px">ATS Score</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-grid" style="margin-bottom:20px">
|
||||||
|
<div class="info-item"><div class="il">Email</div><div class="iv">${i.email}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Phone</div><div class="iv">${i.phone}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Experience</div><div class="iv">${i.experience} years</div></div>
|
||||||
|
<div class="info-item"><div class="il">Assigned Recruiter</div><div class="iv">${i.recruiter}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Received</div><div class="iv">${DB.fmtDate(i.received)}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Match</div><div class="iv">${UI.badge(recLabel, rec(recLabel))}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="box-shadow:none;background:var(--bg-sunken);margin-bottom:20px"><div class="card-body">
|
||||||
|
<div class="flex items-center gap-12" style="justify-content:space-between;margin-bottom:12px">
|
||||||
|
<div class="fw-600">${UI.icon('paperclip')} ${i.attachment}</div>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="Inbox.viewResume('${i.id}')">${UI.icon('eye')} Preview</button>
|
||||||
|
</div>
|
||||||
|
<div class="resume-thumb">${Inbox._resumeText(i)}</div>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="flex gap-8" style="flex-wrap:wrap">
|
||||||
|
<button class="btn btn-primary" onclick="Inbox.import('${i.id}')">${UI.icon('user-plus')} Import Candidate</button>
|
||||||
|
<button class="btn btn-secondary" onclick="Inbox.parse('${i.id}')">${UI.icon('sparkles')} Parse Resume</button>
|
||||||
|
<button class="btn btn-secondary" onclick="Inbox.assign('${i.id}')">${UI.icon('users')} Assign Recruiter</button>
|
||||||
|
<button class="btn btn-secondary" onclick="Inbox.moveToPipeline('${i.id}')">${UI.icon('layers')} Move to Pipeline</button>
|
||||||
|
<button class="btn btn-secondary" onclick="Inbox.note('${i.id}')">${UI.icon('edit')} Add Note</button>
|
||||||
|
<button class="btn btn-ghost" style="color:var(--danger)" onclick="Inbox.reject('${i.id}')">${UI.icon('x')} Reject</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBody() {
|
||||||
|
const body = document.getElementById('inboxBody');
|
||||||
|
if (state.tab === 'Email') { body.innerHTML = Inbox._emailView(); Inbox._bindEmail(state); return; }
|
||||||
|
body.innerHTML = `
|
||||||
|
<div class="split">
|
||||||
|
<div class="split-list">
|
||||||
|
<div style="padding:12px 16px;border-bottom:1px solid var(--border)">
|
||||||
|
<div class="toolbar-search" style="max-width:none"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg><input id="inboxSearch" placeholder="Search applications…" value="${state.q}"/></div>
|
||||||
|
</div>
|
||||||
|
<div id="inboxList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="split-detail" id="inboxDetail"></div>
|
||||||
|
</div>`;
|
||||||
|
renderList(); renderDetail();
|
||||||
|
const s = document.getElementById('inboxSearch');
|
||||||
|
s.oninput = () => { state.q = s.value; renderList(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
Inbox._render = { list: renderList, detail: renderDetail, body: renderBody };
|
||||||
|
Inbox._state = state;
|
||||||
|
|
||||||
|
const c = counts();
|
||||||
|
const tabHtml = tabs.map(t => `<div class="tab ${t === state.tab ? 'active' : ''}" data-tab="${t}">${t} <span class="k-count" style="margin-left:4px">${c[t]}</span></div>`).join('');
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">Recruitment Inbox</h1><p class="page-sub">Every candidate, every source — one unified queue</p></div>
|
||||||
|
<div class="page-head-actions">
|
||||||
|
<span class="integration-status"><span class="pulse"></span>Microsoft Graph API · Connected</span>
|
||||||
|
<button class="btn btn-secondary" onclick="UI.toast('Syncing all sources…','info');setTimeout(()=>UI.toast('Inbox synced','success'),900)">${UI.icon('refresh')} Sync</button>
|
||||||
|
<button class="btn btn-primary" onclick="Router.go('import')">${UI.icon('upload')} Upload CVs</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="tabs" id="inboxTabs" style="margin:0 16px;padding-top:8px">${tabHtml}</div>
|
||||||
|
<div id="inboxBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
onMount() {
|
||||||
|
renderBody();
|
||||||
|
document.querySelectorAll('#inboxTabs .tab').forEach(tab => tab.onclick = () => {
|
||||||
|
state.tab = tab.dataset.tab; state.selected = null;
|
||||||
|
document.querySelectorAll('#inboxTabs .tab').forEach(t => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
renderBody();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
Inbox._resumeText = function (i) {
|
||||||
|
return `${i.name.toUpperCase()}\n${i.email} · ${i.phone}\n${'—'.repeat(30)}\nPROFESSIONAL SUMMARY\n${i.experience} years of experience. Applied for ${i.position} via ${i.source}.\n\nEXPERIENCE\n• ${DB.pick(DB.companies)} — Senior role (2021–Present)\n• ${DB.pick(DB.companies)} — Associate (2018–2021)\n\nEDUCATION\n• Bachelor's Degree, Computer Science\n\nSKILLS\n• ${DB.pick(DB.skillsPool)}, ${DB.pick(DB.skillsPool)}, ${DB.pick(DB.skillsPool)}, ${DB.pick(DB.skillsPool)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
Inbox.viewResume = function (id) {
|
||||||
|
const i = DB.inbox.find(x => x.id === id);
|
||||||
|
UI.modal({
|
||||||
|
title: i.attachment, subtitle: 'Resume preview · ' + i.name,
|
||||||
|
body: `<div class="resume-thumb" style="max-height:none;font-size:12px">${Inbox._resumeText(i)}</div>`,
|
||||||
|
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Close</button><button class="btn btn-primary" onclick="UI.closeModal();Inbox.import('${id}')">${UI.icon('user-plus')} Import Candidate</button>`,
|
||||||
|
size: 'modal-lg'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Inbox.parse = function (id) {
|
||||||
|
const i = DB.inbox.find(x => x.id === id);
|
||||||
|
i.resumeStatus = 'Parsing';
|
||||||
|
Inbox._render.detail();
|
||||||
|
UI.toast('Parsing resume with AI…', 'info');
|
||||||
|
setTimeout(() => { i.resumeStatus = 'Parsed'; i.atsScore = DB.int(60, 96); Inbox._render.list(); Inbox._render.detail(); UI.toast('Resume parsed — profile fields extracted', 'success'); }, 1100);
|
||||||
|
};
|
||||||
|
|
||||||
|
Inbox.import = function (id) {
|
||||||
|
const i = DB.inbox.find(x => x.id === id);
|
||||||
|
const job = DB.getJob(i.jobId) || DB.jobs[0];
|
||||||
|
// create candidate
|
||||||
|
const newC = {
|
||||||
|
id: 'CAN-' + (5001 + DB.candidates.length), name: i.name, initials: i.initials, color: i.color,
|
||||||
|
email: i.email, phone: i.phone, jobId: job.id, jobTitle: job.title, department: job.department,
|
||||||
|
experience: i.experience, currentCompany: DB.pick(DB.companies), currentTitle: job.title, location: DB.pick(DB.locations),
|
||||||
|
stage: 'Applied', status: 'Applied', aiScore: i.atsScore, source: i.source, recruiter: i.recruiter, recruiterId: '',
|
||||||
|
applied: new Date('2026-07-09'), education: "Bachelor's Degree", skills: job.skills.slice(0, 4), rating: '4.0', salary: DB.int(90, 180) * 1000,
|
||||||
|
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3), recommendation: i.atsScore >= 82 ? 'Strong Match' : 'Potential Match',
|
||||||
|
subScores: { skills: i.atsScore, experience: i.atsScore, education: 80, keywords: i.atsScore, location: 100, salary: 90 },
|
||||||
|
noticePeriod: '1 month', availability: '2 weeks', certifications: [], favorite: false, interviewStatus: 'Not Scheduled'
|
||||||
|
};
|
||||||
|
DB.candidates.unshift(newC);
|
||||||
|
i.processing = 'Imported'; i.unread = false;
|
||||||
|
Inbox._render.list(); Inbox._render.detail(); App.updateBadges();
|
||||||
|
UI.toast(`${i.name} imported → Applied stage of ${job.title}`, 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
Inbox.moveToPipeline = function (id) {
|
||||||
|
const i = DB.inbox.find(x => x.id === id);
|
||||||
|
if (i.processing !== 'Imported' && i.processing !== 'Processed') Inbox.import(id);
|
||||||
|
i.processing = 'Processed';
|
||||||
|
Inbox._render.list(); Inbox._render.detail();
|
||||||
|
UI.toast(`${i.name} moved to pipeline`, 'success');
|
||||||
|
setTimeout(() => Router.go('pipeline'), 700);
|
||||||
|
};
|
||||||
|
|
||||||
|
Inbox.assign = function (id) {
|
||||||
|
const i = DB.inbox.find(x => x.id === id);
|
||||||
|
const opts = DB.recruiters.map(r => `<option ${r.name === i.recruiter ? 'selected' : ''}>${r.name}</option>`).join('');
|
||||||
|
UI.modal({
|
||||||
|
title: 'Assign Recruiter', subtitle: i.name,
|
||||||
|
body: `<div class="form-field"><label>Recruiter</label><select id="assignRec">${opts}</select></div>
|
||||||
|
<p class="text-muted text-sm" style="margin-top:10px">Current workload is factored automatically. This recruiter has ${DB.getRecruiterByName(i.recruiter) ? DB.getRecruiterByName(i.recruiter).openReqs : 5} open reqs.</p>`,
|
||||||
|
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button><button class="btn btn-primary" onclick="Inbox._doAssign('${id}')">Assign</button>`
|
||||||
|
});
|
||||||
|
};
|
||||||
|
Inbox._doAssign = function (id) {
|
||||||
|
const i = DB.inbox.find(x => x.id === id);
|
||||||
|
i.recruiter = document.getElementById('assignRec').value;
|
||||||
|
UI.closeModal(); Inbox._render.detail();
|
||||||
|
UI.toast('Recruiter assigned to ' + i.name, 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
Inbox.note = function (id) {
|
||||||
|
const i = DB.inbox.find(x => x.id === id);
|
||||||
|
UI.modal({
|
||||||
|
title: 'Add Note', subtitle: i.name,
|
||||||
|
body: `<div class="form-field"><label>Note</label><textarea id="inboxNote" placeholder="Add a note about this application…"></textarea></div>`,
|
||||||
|
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button><button class="btn btn-primary" onclick="UI.closeModal();UI.toast('Note added','success')">${UI.icon('check')} Save Note</button>`
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Inbox.reject = function (id) {
|
||||||
|
const i = DB.inbox.find(x => x.id === id);
|
||||||
|
i.processing = 'Rejected'; i.unread = false;
|
||||||
|
Inbox._render.list(); Inbox._render.detail(); App.updateBadges();
|
||||||
|
UI.toast(`${i.name} rejected`, 'warning');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- Email (Outlook) tab ----------------
|
||||||
|
Inbox._emailView = function () {
|
||||||
|
const st = Inbox._state;
|
||||||
|
const listHtml = DB.emails.map(e => `
|
||||||
|
<div class="inbox-item ${e.unread ? 'unread' : ''} ${st.emailSelected === e.id ? 'active' : ''}" data-email="${e.id}">
|
||||||
|
${UI.avatar(e.from, e.initials, e.color)}
|
||||||
|
<div class="ii-main">
|
||||||
|
<div class="ii-name">${e.from}</div>
|
||||||
|
<div class="ii-pos">${e.subject}</div>
|
||||||
|
<div class="ii-meta"><span class="source-chip" style="--chip:#0078d4"><svg viewBox="0 0 24 24"><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"/></svg>Outlook</span>${e.imported ? UI.badge('Imported', 'b-green') : ''}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ii-time">${DB.fmtShort(e.when)}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
return `
|
||||||
|
<div style="padding:12px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px">
|
||||||
|
<span class="integration-status"><span class="pulse"></span>Outlook · Microsoft Graph API</span>
|
||||||
|
<span class="text-muted text-sm">Last sync: 2 min ago · ${DB.emails.filter(e => e.unread).length} unread</span>
|
||||||
|
<button class="btn btn-secondary btn-sm" style="margin-left:auto" onclick="UI.toast('Fetching from Outlook…','info');setTimeout(()=>UI.toast('Mailbox synced','success'),800)">${UI.icon('refresh')} Sync Mailbox</button>
|
||||||
|
</div>
|
||||||
|
<div class="split">
|
||||||
|
<div class="split-list" id="emailList">${listHtml}</div>
|
||||||
|
<div class="split-detail" id="emailDetail"></div>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
Inbox._bindEmail = function (state) {
|
||||||
|
const detail = document.getElementById('emailDetail');
|
||||||
|
function renderDetail() {
|
||||||
|
const e = DB.emails.find(x => x.id === state.emailSelected);
|
||||||
|
if (!e) { detail.innerHTML = `<div class="empty-state" style="padding:100px 20px">${UI.icon('mail')}<h3>Select an email</h3><p>Preview email body and resume attachments here.</p></div>`; return; }
|
||||||
|
detail.innerHTML = `<div style="padding:24px">
|
||||||
|
<div class="flex items-center gap-12" style="margin-bottom:6px">
|
||||||
|
<h2 style="font-size:18px;flex:1">${e.subject}</h2>${e.imported ? UI.badge('Imported', 'b-green') : UI.badge('New', 'b-blue')}</div>
|
||||||
|
<div class="flex items-center gap-12" style="margin-bottom:20px">
|
||||||
|
${UI.avatar(e.from, e.initials, e.color)}
|
||||||
|
<div><div class="fw-600">${e.from}</div><div class="cell-sub">${e.fromEmail} · ${DB.fmtDate(e.when)}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="email-preview" style="margin-bottom:18px">${e.body}</div>
|
||||||
|
<div class="attach-card" style="margin-bottom:18px">
|
||||||
|
<span class="attach-icn">${UI.icon('file')}</span>
|
||||||
|
<div style="flex:1"><div class="fw-600">${e.attachment}</div><div class="cell-sub">${e.attachmentSize} · PDF</div></div>
|
||||||
|
<div class="flex items-center gap-8">${UI.scoreChip(e.atsScore)}
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="UI.toast('Opening attachment preview','info')">${UI.icon('eye')} Preview</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-8">
|
||||||
|
${e.imported ? `<button class="btn btn-secondary" disabled>${UI.icon('check')} Already Imported</button>` :
|
||||||
|
`<button class="btn btn-primary" onclick="Inbox._importEmail('${e.id}')">${UI.icon('user-plus')} Import Candidate</button>`}
|
||||||
|
<button class="btn btn-secondary" onclick="UI.toast('Reply drafted','info')">${UI.icon('mail')} Reply</button>
|
||||||
|
<button class="btn btn-ghost" style="color:var(--danger)" onclick="UI.toast('Email archived','info')">${UI.icon('trash')} Archive</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
document.querySelectorAll('#emailList .inbox-item').forEach(row => row.onclick = () => {
|
||||||
|
state.emailSelected = row.dataset.email;
|
||||||
|
const e = DB.emails.find(x => x.id === state.emailSelected); if (e) e.unread = false;
|
||||||
|
document.querySelectorAll('#emailList .inbox-item').forEach(r => r.classList.remove('active', 'unread'));
|
||||||
|
row.classList.add('active');
|
||||||
|
renderDetail(); App.updateBadges();
|
||||||
|
});
|
||||||
|
renderDetail();
|
||||||
|
};
|
||||||
|
Inbox._importEmail = function (id) {
|
||||||
|
const e = DB.emails.find(x => x.id === id);
|
||||||
|
const job = DB.getJob(e.jobId) || DB.jobs[0];
|
||||||
|
DB.candidates.unshift({
|
||||||
|
id: 'CAN-' + (5001 + DB.candidates.length), name: e.from, initials: e.initials, color: e.color,
|
||||||
|
email: e.fromEmail, phone: '+1 (555) 000-0000', jobId: job.id, jobTitle: job.title, department: job.department,
|
||||||
|
experience: DB.int(2, 10), currentCompany: DB.pick(DB.companies), currentTitle: job.title, location: DB.pick(DB.locations),
|
||||||
|
stage: 'Applied', status: 'Applied', aiScore: e.atsScore, source: 'Microsoft Outlook', recruiter: DB.pick(DB.recruiters).name, recruiterId: '',
|
||||||
|
applied: new Date('2026-07-09'), education: "Bachelor's Degree", skills: job.skills.slice(0, 4), rating: '4.0', salary: 130000,
|
||||||
|
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3), recommendation: 'Potential Match',
|
||||||
|
subScores: { skills: e.atsScore, experience: 80, education: 80, keywords: e.atsScore, location: 100, salary: 90 },
|
||||||
|
noticePeriod: '1 month', availability: '2 weeks', certifications: [], favorite: false, interviewStatus: 'Not Scheduled'
|
||||||
|
});
|
||||||
|
e.imported = true; e.unread = false;
|
||||||
|
Inbox._bindEmail(Inbox._state); App.updateBadges();
|
||||||
|
UI.toast(`${e.from} imported from Outlook → ${job.title}`, 'success');
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,209 @@
|
||||||
|
/* ============================================================
|
||||||
|
interviews.js — Interviews list, upcoming, mini calendar
|
||||||
|
============================================================ */
|
||||||
|
window.Views = window.Views || {};
|
||||||
|
window.Interviews = {};
|
||||||
|
|
||||||
|
Views.interviews = function () {
|
||||||
|
const filters = { q: '', status: '', type: '' };
|
||||||
|
let table;
|
||||||
|
|
||||||
|
const upcoming = DB.interviews.filter(iv => iv.status === 'Scheduled').slice(0, 4);
|
||||||
|
const stats = {
|
||||||
|
scheduled: DB.interviews.filter(i => i.status === 'Scheduled').length,
|
||||||
|
completed: DB.interviews.filter(i => i.status === 'Completed').length,
|
||||||
|
today: 5,
|
||||||
|
cancelled: DB.interviews.filter(i => ['Cancelled', 'No Show'].includes(i.status)).length
|
||||||
|
};
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
const rows = DB.interviews.filter(iv => {
|
||||||
|
if (filters.status && iv.status !== filters.status) return false;
|
||||||
|
if (filters.type && iv.type !== filters.type) return false;
|
||||||
|
if (filters.q && !(iv.candidate + iv.jobTitle + iv.interviewers.join(' ')).toLowerCase().includes(filters.q.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
table.update(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
table = UI.dataTable({
|
||||||
|
pageSize: 8,
|
||||||
|
rows: DB.interviews,
|
||||||
|
columns: [
|
||||||
|
{ key: 'candidate', label: 'Candidate', sortable: true, render: iv => `<div class="user-cell">${UI.avatar(iv.candidate, iv.candInitials, iv.color)}<div><div class="cell-primary">${iv.candidate}</div><div class="cell-sub">${iv.jobTitle}</div></div></div>` },
|
||||||
|
{ key: 'type', label: 'Round', sortable: true, render: iv => UI.badge(iv.type, 'b-indigo') },
|
||||||
|
{ key: 'when', label: 'Date & Time', sortable: true, sortValue: iv => iv.when.getTime(), render: iv => `<div class="text-sm fw-600">${DB.fmtShort(iv.when)}</div><div class="cell-sub">${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} · ${iv.duration}m</div>` },
|
||||||
|
{ key: 'meeting', label: 'Type', render: iv => `<span class="flex items-center gap-8">${UI.icon(iv.meeting === 'Video Call' ? 'video' : iv.meeting === 'Phone' ? 'phone' : 'map')} ${iv.meeting}</span>` },
|
||||||
|
{ key: 'interviewers', label: 'Interviewers', render: iv => UI.avatarStack(iv.interviewers) },
|
||||||
|
{ key: 'status', label: 'Status', sortable: true, render: iv => UI.badge(iv.status) },
|
||||||
|
{ key: 'feedback', label: 'Feedback', render: iv => iv.feedback ? UI.badge(iv.feedback) : '<span class="text-muted">—</span>' },
|
||||||
|
{ key: '_a', label: 'Actions', align: 'right', render: iv => `
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="act-btn" data-tip="View candidate" onclick="Candidates.openProfile('${iv.candidateId}')">${UI.icon('eye')}</button>
|
||||||
|
<button class="act-btn" data-tip="Feedback" onclick="Interviews.feedback('${iv.id}')">${UI.icon('star')}</button>
|
||||||
|
</div>` }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusOpts = ['<option value="">All Status</option>'].concat(['Scheduled', 'Completed', 'Cancelled', 'No Show'].map(s => `<option>${s}</option>`)).join('');
|
||||||
|
const typeOpts = ['<option value="">All Rounds</option>'].concat(DB.interviewTypes.map(t => `<option>${t}</option>`)).join('');
|
||||||
|
|
||||||
|
const statCard = (label, val, icn, cls) => `<div class="kpi"><div class="kpi-top"><span class="kpi-label">${label}</span><span class="kpi-icn ${cls}">${UI.icon(icn)}</span></div><div class="kpi-value">${val}</div></div>`;
|
||||||
|
|
||||||
|
const upcomingHtml = upcoming.map(iv => `
|
||||||
|
<div class="list-row">
|
||||||
|
${UI.avatar(iv.candidate, iv.candInitials, iv.color)}
|
||||||
|
<div class="lr-main"><div class="lr-title">${iv.candidate}</div><div class="lr-sub">${iv.type} · ${iv.meeting}</div></div>
|
||||||
|
<div class="lr-right"><div class="fw-600 text-sm">${DB.fmtShort(iv.when)}</div><div class="lr-sub">${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}</div></div>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">Interviews</h1><p class="page-sub">Manage and track all interview activity</p></div>
|
||||||
|
<div class="page-head-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="Router.go('calendar')">${UI.icon('calendar')} Calendar View</button>
|
||||||
|
<button class="btn btn-primary" onclick="Interviews.schedule()">${UI.icon('plus')} Schedule Interview</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid g-kpi mb-18">
|
||||||
|
${statCard('Scheduled', stats.scheduled, 'calendar', 'i-blue')}
|
||||||
|
${statCard('Completed', stats.completed, 'check-circle', 'i-green')}
|
||||||
|
${statCard('Today', stats.today, 'clock', 'i-purple')}
|
||||||
|
${statCard('Cancelled / No-show', stats.cancelled, 'x-circle', 'i-red')}
|
||||||
|
</div>
|
||||||
|
<div class="grid g-2-1">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>All Interviews</h3></div></div>
|
||||||
|
<div class="card-body" style="padding-bottom:0">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-search">${UI.icon('search')}<input id="ivSearch" placeholder="Search candidate or interviewer…"/></div>
|
||||||
|
<select class="select" id="ivStatus">${statusOpts}</select>
|
||||||
|
<select class="select" id="ivType">${typeOpts}</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${table.html}
|
||||||
|
</div>
|
||||||
|
<div class="card" style="align-self:start">
|
||||||
|
<div class="card-head"><div><h3>Up Next</h3><span class="ch-sub">Scheduled sessions</span></div></div>
|
||||||
|
<div class="card-body"><div class="list-tight">${upcomingHtml}</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
onMount() {
|
||||||
|
table.mount();
|
||||||
|
const s = document.getElementById('ivSearch');
|
||||||
|
s.oninput = () => { filters.q = s.value; apply(); };
|
||||||
|
document.getElementById('ivStatus').onchange = e => { filters.status = e.target.value; apply(); };
|
||||||
|
document.getElementById('ivType').onchange = e => { filters.type = e.target.value; apply(); };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
Interviews.feedback = function (id) {
|
||||||
|
const iv = DB.interviews.find(i => i.id === id);
|
||||||
|
// pick evaluation template by department
|
||||||
|
const job = DB.jobs.find(j => j.title === iv.jobTitle);
|
||||||
|
const dept = job ? job.department : 'All';
|
||||||
|
const tmpl = DB.evalTemplates.find(t => t.dept === dept) || DB.evalTemplates.find(t => t.dept === 'All');
|
||||||
|
const tmplOpts = DB.evalTemplates.map(t => `<option ${t === tmpl ? 'selected' : ''}>${t.name}</option>`).join('');
|
||||||
|
|
||||||
|
const ratingRow = (crit) => `
|
||||||
|
<div class="setting-row" style="padding:12px 0">
|
||||||
|
<div class="setting-info"><h4>${crit}</h4></div>
|
||||||
|
<div class="rating-stars" data-crit="${crit}">
|
||||||
|
${[1, 2, 3, 4, 5].map(n => `<span class="rs" data-val="${n}">${UI.icon('star')}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<div class="flex items-center gap-12 mb-18">${UI.avatar(iv.candidate, iv.candInitials, iv.color, 'avatar-lg')}
|
||||||
|
<div style="flex:1"><div class="ph-name" style="font-size:17px">${iv.candidate}</div><div class="ph-role">${iv.type} · ${iv.jobTitle}</div></div>
|
||||||
|
${UI.badge(iv.status)}</div>
|
||||||
|
|
||||||
|
<div class="tabs" id="evalTabs" style="margin-bottom:18px">
|
||||||
|
<div class="tab active" data-etab="0">Dynamic Form</div>
|
||||||
|
<div class="tab" data-etab="1">Upload Sheet</div>
|
||||||
|
<div class="tab" data-etab="2">Both</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="evalPanes">
|
||||||
|
<div class="tab-pane active" data-epane="0">
|
||||||
|
<div class="form-field" style="margin-bottom:8px"><label>Evaluation Template</label><select id="evalTmpl">${tmplOpts}</select></div>
|
||||||
|
<div id="critList">${tmpl.criteria.map(ratingRow).join('')}</div>
|
||||||
|
<div class="form-field" style="margin-top:8px"><label>Comments</label><textarea placeholder="Strengths, concerns, and areas explored…"></textarea></div>
|
||||||
|
<div class="form-field" style="margin-top:14px"><label>Overall Recommendation</label>
|
||||||
|
<div class="seg" style="margin-top:4px"><button type="button" class="active" data-rec="Hire">Hire</button><button type="button" data-rec="Hold">Hold</button><button type="button" data-rec="Reject">Reject</button></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane" data-epane="1">
|
||||||
|
<div class="dropzone" onclick="UI.toast('File picker (demo)','info')" style="padding:32px">
|
||||||
|
<div class="dz-icn">${UI.icon('upload')}</div>
|
||||||
|
<h3 style="font-size:15px">Upload evaluation sheet</h3>
|
||||||
|
<p class="text-muted">PDF, DOC, or DOCX · scanned scorecards supported</p>
|
||||||
|
<div class="flex items-center gap-8" style="justify-content:center;margin-top:12px">${['PDF', 'DOC', 'DOCX'].map(t => `<span class="badge b-gray badge-plain">${t}</span>`).join('')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane" data-epane="2">
|
||||||
|
<p class="text-muted" style="margin-bottom:14px">Capture structured ratings <b>and</b> attach a signed sheet — both are stored on the scorecard.</p>
|
||||||
|
<div id="critList2">${tmpl.criteria.slice(0, 3).map(ratingRow).join('')}</div>
|
||||||
|
<div class="upload-row" style="margin-top:12px"><span class="attach-icn" style="width:38px;height:38px">${UI.icon('file')}</span>
|
||||||
|
<div style="flex:1"><div class="fw-600 text-sm">Interviewer_Scorecard.pdf</div><div class="cell-sub">Attached · 214 KB</div></div>${UI.badge('Uploaded', 'b-green')}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="UI.closeModal();UI.toast('Scorecard submitted','success')">${UI.icon('check')} Submit Scorecard</button>`;
|
||||||
|
UI.modal({ title: 'Interview Evaluation', subtitle: iv.id + ' · ' + iv.type, body, footer, size: 'modal-lg' });
|
||||||
|
|
||||||
|
// wire tabs
|
||||||
|
const panes = document.querySelectorAll('#evalPanes .tab-pane');
|
||||||
|
document.querySelectorAll('#evalTabs .tab').forEach(tab => tab.onclick = () => {
|
||||||
|
document.querySelectorAll('#evalTabs .tab').forEach(t => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
panes.forEach(p => p.classList.remove('active'));
|
||||||
|
panes[+tab.dataset.etab].classList.add('active');
|
||||||
|
});
|
||||||
|
// wire star ratings
|
||||||
|
Interviews._bindStars();
|
||||||
|
// template switch rebuilds criteria
|
||||||
|
const tmplSel = document.getElementById('evalTmpl');
|
||||||
|
if (tmplSel) tmplSel.onchange = () => {
|
||||||
|
const t = DB.evalTemplates.find(x => x.name === tmplSel.value);
|
||||||
|
document.getElementById('critList').innerHTML = t.criteria.map(ratingRow).join('');
|
||||||
|
Interviews._bindStars();
|
||||||
|
};
|
||||||
|
// recommendation seg
|
||||||
|
document.querySelectorAll('[data-rec]').forEach(b => b.onclick = () => {
|
||||||
|
b.parentElement.querySelectorAll('button').forEach(x => x.classList.remove('active'));
|
||||||
|
b.classList.add('active');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
Interviews._bindStars = function () {
|
||||||
|
document.querySelectorAll('.rating-stars').forEach(group => {
|
||||||
|
group.querySelectorAll('.rs').forEach(star => star.onclick = () => {
|
||||||
|
const val = +star.dataset.val;
|
||||||
|
group.querySelectorAll('.rs').forEach(s => s.classList.toggle('on', +s.dataset.val <= val));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Interviews.schedule = function () {
|
||||||
|
const opt = arr => arr.map(o => `<option>${o}</option>`).join('');
|
||||||
|
const body = `<form id="schForm"><div class="form-grid">
|
||||||
|
<div class="form-field col-span-2"><label>Candidate <span class="req">*</span></label><select name="candidate">${opt(DB.candidates.slice(0, 40).map(c => c.name))}</select></div>
|
||||||
|
<div class="form-field"><label>Interview Round</label><select name="type">${opt(DB.interviewTypes)}</select></div>
|
||||||
|
<div class="form-field"><label>Meeting Type</label><select name="meeting">${opt(DB.meetingTypes)}</select></div>
|
||||||
|
<div class="form-field"><label>Date</label><input type="date" name="date"/></div>
|
||||||
|
<div class="form-field"><label>Time</label><input type="time" name="time" value="14:00"/></div>
|
||||||
|
<div class="form-field"><label>Duration</label><select name="dur"><option>30 min</option><option>45 min</option><option selected>60 min</option><option>90 min</option></select></div>
|
||||||
|
<div class="form-field"><label>Interviewer</label><select name="interviewer">${opt(DB.recruiters.concat(DB.managers).map(r => r.name))}</select></div>
|
||||||
|
</div></form>`;
|
||||||
|
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="UI.closeModal();UI.toast('Interview scheduled & invite sent','success')">${UI.icon('calendar')} Schedule</button>`;
|
||||||
|
UI.modal({ title: 'Schedule Interview', subtitle: 'Set up a new interview session', body, footer });
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
/* ============================================================
|
||||||
|
jobboard.js — Job Posting Center: publish + track performance
|
||||||
|
============================================================ */
|
||||||
|
window.Views = window.Views || {};
|
||||||
|
window.JobBoard = {};
|
||||||
|
|
||||||
|
Views.jobboard = function () {
|
||||||
|
const totals = DB.publishings.reduce((a, p) => ({ views: a.views + p.views, clicks: a.clicks + p.clicks, apps: a.apps + p.apps }), { views: 0, clicks: 0, apps: 0 });
|
||||||
|
const conv = totals.views ? ((totals.apps / totals.views) * 100).toFixed(1) : 0;
|
||||||
|
|
||||||
|
const statCard = (label, val, icn, cls, sub) => `<div class="kpi"><div class="kpi-top"><span class="kpi-label">${label}</span><span class="kpi-icn ${cls}">${UI.icon(icn)}</span></div><div class="kpi-value">${val}</div><div class="kpi-foot"><span class="kpi-foot-text">${sub}</span></div></div>`;
|
||||||
|
|
||||||
|
// per-platform aggregate
|
||||||
|
const platAgg = {};
|
||||||
|
DB.publishings.forEach(p => {
|
||||||
|
if (!platAgg[p.platform]) platAgg[p.platform] = { views: 0, clicks: 0, apps: 0, jobs: 0 };
|
||||||
|
platAgg[p.platform].views += p.views; platAgg[p.platform].clicks += p.clicks; platAgg[p.platform].apps += p.apps; platAgg[p.platform].jobs++;
|
||||||
|
});
|
||||||
|
const platRows = Object.entries(platAgg).sort((a, b) => b[1].apps - a[1].apps);
|
||||||
|
|
||||||
|
// publishing table
|
||||||
|
const table = UI.dataTable({
|
||||||
|
pageSize: 8,
|
||||||
|
rows: DB.publishings,
|
||||||
|
columns: [
|
||||||
|
{ key: 'jobTitle', label: 'Job', sortable: true, render: p => `<div class="cell-primary">${p.jobTitle}</div><div class="cell-sub">${p.jobId}</div>` },
|
||||||
|
{ key: 'platform', label: 'Platform', sortable: true, render: p => { const pl = DB.publishPlatforms.find(x => x.name === p.platform) || {}; return `<span class="flex items-center gap-8"><span class="platform-logo" style="width:26px;height:26px;background:${pl.color || '#888'}">${UI.icon(pl.icon || 'briefcase')}</span>${p.platform}</span>`; } },
|
||||||
|
{ key: 'status', label: 'Status', sortable: true, render: p => UI.badge(p.status, p.status === 'Live' ? 'b-green' : p.status === 'Paused' ? 'b-amber' : 'b-blue') },
|
||||||
|
{ key: 'views', label: 'Views', sortable: true, align: 'right', render: p => p.views.toLocaleString() },
|
||||||
|
{ key: 'clicks', label: 'Clicks', sortable: true, align: 'right', render: p => p.clicks.toLocaleString() },
|
||||||
|
{ key: 'apps', label: 'Applications', sortable: true, align: 'right', render: p => `<b>${p.apps}</b>` },
|
||||||
|
{ key: '_conv', label: 'Conversion', sortable: true, sortValue: p => p.apps / p.views, render: p => `<span class="badge b-indigo badge-plain">${((p.apps / p.views) * 100).toFixed(1)}%</span>` },
|
||||||
|
{ key: '_a', label: '', align: 'right', render: p => `<button class="act-btn" data-tip="Manage" onclick="UI.toast('Managing ${p.platform} posting','info')">${UI.icon('external')}</button>` }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">Job Board</h1><p class="page-sub">Publish requisitions across channels and track performance</p></div>
|
||||||
|
<div class="page-head-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="Router.go('analytics')">${UI.icon('trending-up')} Analytics</button>
|
||||||
|
<button class="btn btn-primary" onclick="JobBoard.publishFlow()">${UI.icon('send')} Publish a Job</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid g-kpi mb-18">
|
||||||
|
${statCard('Total Views', totals.views.toLocaleString(), 'eye', 'i-blue', 'across all platforms')}
|
||||||
|
${statCard('Total Clicks', totals.clicks.toLocaleString(), 'target', 'i-purple', ((totals.clicks / totals.views) * 100).toFixed(1) + '% CTR')}
|
||||||
|
${statCard('Applications', totals.apps.toLocaleString(), 'users', 'i-green', 'from job boards')}
|
||||||
|
${statCard('Conversion Rate', conv + '%', 'trending-up', 'i-teal', 'view → application')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid g-2-1 mb-18">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Platform Performance</h3><span class="ch-sub">Applications by channel</span></div></div>
|
||||||
|
<div class="card-body"><div class="chart-wrap"><canvas id="jbChart" height="300"></canvas></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Connected Platforms</h3></div></div>
|
||||||
|
<div class="card-body"><div class="list-tight">
|
||||||
|
${DB.publishPlatforms.map(p => `<div class="list-row">
|
||||||
|
<span class="platform-logo" style="background:${p.color}">${UI.icon(p.icon)}</span>
|
||||||
|
<div class="lr-main"><div class="lr-title">${p.name}</div><div class="lr-sub">${p.cost === 'Free' ? 'Free posting' : 'Paid · ' + p.cost}</div></div>
|
||||||
|
${p.connected ? UI.badge('Connected', 'b-green') : `<button class="btn btn-secondary btn-sm" onclick="UI.toast('Connecting ${p.name}…','info')">Connect</button>`}
|
||||||
|
</div>`).join('')}
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Active Postings</h3><span class="ch-sub">${DB.publishings.length} live postings across ${platRows.length} platforms</span></div>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="UI.toast('Performance report exported','success')">${UI.icon('download')} Export</button></div>
|
||||||
|
${table.html}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
onMount() {
|
||||||
|
table.mount();
|
||||||
|
Charts.horizontalBar(document.getElementById('jbChart'), {
|
||||||
|
labels: platRows.map(p => p[0]), data: platRows.map(p => p[1].apps)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- Publish flow (stepper modal) ----------------
|
||||||
|
JobBoard.publishFlow = function (jobId) {
|
||||||
|
const state = { step: 1, jobId: jobId || DB.jobs.filter(j => j.status === 'Open')[0].id, platforms: ['Career Portal'] };
|
||||||
|
JobBoard._state = state;
|
||||||
|
JobBoard._renderFlow();
|
||||||
|
};
|
||||||
|
|
||||||
|
JobBoard._renderFlow = function () {
|
||||||
|
const state = JobBoard._state;
|
||||||
|
const steps = ['Select Job', 'Approval', 'Platforms', 'Publish'];
|
||||||
|
const stepper = `<div class="stepper">${steps.map((s, i) => {
|
||||||
|
const n = i + 1;
|
||||||
|
const cls = n < state.step ? 'done' : n === state.step ? 'active' : '';
|
||||||
|
return `<div class="step ${cls}"><div class="step-num">${n < state.step ? '✓' : n}</div><div class="step-label">${s}</div></div>${i < steps.length - 1 ? `<div class="step-line ${n < state.step ? 'done' : ''}"></div>` : ''}`;
|
||||||
|
}).join('')}</div>`;
|
||||||
|
|
||||||
|
let body = stepper;
|
||||||
|
if (state.step === 1) {
|
||||||
|
const opts = DB.jobs.filter(j => j.status !== 'Draft').map(j => `<option value="${j.id}" ${j.id === state.jobId ? 'selected' : ''}>${j.title} · ${j.id}</option>`).join('');
|
||||||
|
const job = DB.getJob(state.jobId);
|
||||||
|
body += `<div class="form-field"><label>Select requisition to publish</label><select id="pubJob">${opts}</select></div>
|
||||||
|
<div class="card" style="box-shadow:none;background:var(--bg-sunken);margin-top:16px"><div class="card-body">
|
||||||
|
<div class="flex items-center gap-12"><span class="kpi-icn i-indigo" style="width:44px;height:44px;border-radius:12px">${UI.icon('briefcase')}</span>
|
||||||
|
<div><div class="fw-600">${job.title}</div><div class="cell-sub">${job.department} · ${job.location} · ${job.type}</div></div></div>
|
||||||
|
</div></div>`;
|
||||||
|
} else if (state.step === 2) {
|
||||||
|
body += `<div class="card" style="box-shadow:none;background:var(--bg-sunken)"><div class="card-body">
|
||||||
|
<div class="flex items-center gap-12" style="margin-bottom:14px"><span class="kpi-icn i-green" style="width:44px;height:44px;border-radius:12px">${UI.icon('check-circle')}</span>
|
||||||
|
<div><div class="fw-600">Approval granted</div><div class="cell-sub">Approved by Department Head · Budget confirmed</div></div></div>
|
||||||
|
<div class="setting-row" style="padding:10px 0"><div class="setting-info"><h4>Hiring Manager sign-off</h4></div>${UI.badge('Approved', 'b-green')}</div>
|
||||||
|
<div class="setting-row" style="padding:10px 0"><div class="setting-info"><h4>Finance budget approval</h4></div>${UI.badge('Approved', 'b-green')}</div>
|
||||||
|
<div class="setting-row" style="padding:10px 0;border:none"><div class="setting-info"><h4>Compliance review</h4></div>${UI.badge('Approved', 'b-green')}</div>
|
||||||
|
</div></div>`;
|
||||||
|
} else if (state.step === 3) {
|
||||||
|
body += `<p class="text-muted" style="margin-bottom:14px">Select the platforms to publish this role to</p>
|
||||||
|
<div class="grid g-2" id="platGrid">
|
||||||
|
${DB.publishPlatforms.map(p => `<div class="platform-card ${state.platforms.includes(p.name) ? 'selected' : ''} ${!p.connected ? 'disabled' : ''}" data-plat="${p.name}" ${!p.connected ? 'style="opacity:.5;pointer-events:none"' : ''}>
|
||||||
|
<span class="platform-logo" style="background:${p.color}">${UI.icon(p.icon)}</span>
|
||||||
|
<div style="flex:1"><div class="fw-600">${p.name}</div><div class="cell-sub">${p.cost === 'Free' ? 'Free' : 'Paid · ' + p.cost}</div></div>
|
||||||
|
<span class="platform-check">${UI.icon('check')}</span>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
} else if (state.step === 4) {
|
||||||
|
body += `<div style="text-align:center;padding:20px 0">
|
||||||
|
<div class="kpi-icn i-green" style="width:64px;height:64px;border-radius:18px;margin:0 auto 16px">${UI.icon('check-circle')}</div>
|
||||||
|
<h2 style="font-size:20px;margin-bottom:6px">Published Successfully</h2>
|
||||||
|
<p class="text-muted" style="margin-bottom:20px">${DB.getJob(state.jobId).title} is now live on ${state.platforms.length} platform${state.platforms.length > 1 ? 's' : ''}</p>
|
||||||
|
<div class="flex gap-8" style="justify-content:center;flex-wrap:wrap">
|
||||||
|
${state.platforms.map(p => { const pl = DB.publishPlatforms.find(x => x.name === p); return `<span class="badge b-green">${pl.name}</span>`; }).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let footer;
|
||||||
|
if (state.step === 4) footer = `<button class="btn btn-primary" onclick="UI.closeModal();Router.reload()">${UI.icon('check')} Done</button>`;
|
||||||
|
else footer = `<button class="btn btn-secondary" onclick="${state.step === 1 ? 'UI.closeModal()' : 'JobBoard._back()'}">${state.step === 1 ? 'Cancel' : 'Back'}</button>
|
||||||
|
<button class="btn btn-primary" onclick="JobBoard._next()">${state.step === 3 ? UI.icon('send') + ' Publish' : 'Continue'} </button>`;
|
||||||
|
|
||||||
|
UI.modal({ title: 'Publish Job', subtitle: 'Distribute this requisition to job boards', body, footer, size: 'modal-lg' });
|
||||||
|
|
||||||
|
if (state.step === 3) {
|
||||||
|
document.querySelectorAll('#platGrid .platform-card').forEach(card => card.onclick = () => {
|
||||||
|
const name = card.dataset.plat;
|
||||||
|
const idx = state.platforms.indexOf(name);
|
||||||
|
if (idx > -1) state.platforms.splice(idx, 1); else state.platforms.push(name);
|
||||||
|
card.classList.toggle('selected');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
JobBoard._next = function () {
|
||||||
|
const state = JobBoard._state;
|
||||||
|
if (state.step === 1) { const sel = document.getElementById('pubJob'); if (sel) state.jobId = sel.value; }
|
||||||
|
if (state.step === 3 && !state.platforms.length) { UI.toast('Select at least one platform', 'warning'); return; }
|
||||||
|
state.step++;
|
||||||
|
if (state.step === 4) {
|
||||||
|
// create publishing records
|
||||||
|
const job = DB.getJob(state.jobId);
|
||||||
|
state.platforms.forEach(p => {
|
||||||
|
DB.publishings.unshift({ jobId: job.id, jobTitle: job.title, platform: p, status: 'Live', views: DB.int(0, 30), clicks: 0, apps: 0, published: new Date('2026-07-09') });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
JobBoard._renderFlow();
|
||||||
|
};
|
||||||
|
JobBoard._back = function () { JobBoard._state.step--; JobBoard._renderFlow(); };
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
/* ============================================================
|
||||||
|
jobs.js — Jobs listing, filters, create/edit/view/delete
|
||||||
|
============================================================ */
|
||||||
|
window.Views = window.Views || {};
|
||||||
|
window.Jobs = {};
|
||||||
|
|
||||||
|
Views.jobs = function () {
|
||||||
|
const filters = { q: '', dept: '', status: '', type: '' };
|
||||||
|
let table;
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
let rows = DB.jobs.filter(j => {
|
||||||
|
if (filters.dept && j.department !== filters.dept) return false;
|
||||||
|
if (filters.status && j.status !== filters.status) return false;
|
||||||
|
if (filters.type && j.type !== filters.type) return false;
|
||||||
|
if (filters.q) {
|
||||||
|
const q = filters.q.toLowerCase();
|
||||||
|
if (!(j.title + j.id + j.department + j.manager + j.recruiter + j.location).toLowerCase().includes(q)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
table.update(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
table = UI.dataTable({
|
||||||
|
pageSize: 8,
|
||||||
|
rows: DB.jobs,
|
||||||
|
columns: [
|
||||||
|
{ key: 'id', label: 'Job ID', sortable: true, render: j => `<span class="cell-mono">${j.id}</span>` },
|
||||||
|
{ key: 'title', label: 'Job Title', sortable: true, render: j => `<div class="cell-primary">${j.title}</div><div class="cell-sub">${j.businessUnit} · ${j.grade}</div>` },
|
||||||
|
{ key: 'department', label: 'Department', sortable: true },
|
||||||
|
{ key: 'manager', label: 'Hiring Manager', sortable: true, render: j => `<div class="user-cell">${UI.avatar(j.manager)}<span>${j.manager}</span></div>` },
|
||||||
|
{ key: 'location', label: 'Location', sortable: true, render: j => `<span class="text-muted">${j.location}</span>` },
|
||||||
|
{ key: 'type', label: 'Type', render: j => UI.badge(j.type, 'b-gray') },
|
||||||
|
{ key: 'applications', label: 'Apps', sortable: true, align: 'center', render: j => `<b>${j.applications}</b>` },
|
||||||
|
{ key: 'status', label: 'Status', sortable: true, render: j => UI.badge(j.status) },
|
||||||
|
{ key: 'created', label: 'Created', sortable: true, sortValue: j => j.created.getTime(), render: j => `<span class="text-muted">${DB.fmtShort(j.created)}</span>` },
|
||||||
|
{ key: '_a', label: 'Actions', align: 'right', render: j => `
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="act-btn" data-tip="View" onclick="Jobs.view('${j.id}')">${UI.icon('eye')}</button>
|
||||||
|
<button class="act-btn" data-tip="Publish" onclick="JobBoard.publishFlow('${j.id}')">${UI.icon('send')}</button>
|
||||||
|
<button class="act-btn" data-tip="Edit" onclick="Jobs.openEdit('${j.id}')">${UI.icon('edit')}</button>
|
||||||
|
<button class="act-btn danger" data-tip="Delete" onclick="Jobs.confirmDelete('${j.id}')">${UI.icon('trash')}</button>
|
||||||
|
</div>` }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const deptOpts = ['<option value="">All Departments</option>'].concat(DB.departments.map(d => `<option>${d}</option>`)).join('');
|
||||||
|
const statusOpts = ['<option value="">All Status</option>'].concat(DB.jobStatuses.map(s => `<option>${s}</option>`)).join('');
|
||||||
|
const typeOpts = ['<option value="">All Types</option>'].concat(DB.empTypes.map(t => `<option>${t}</option>`)).join('');
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">Jobs</h1><p class="page-sub">${DB.jobs.length} requisitions · ${DB.kpis.openJobs} currently open</p></div>
|
||||||
|
<div class="page-head-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="UI.toast('Jobs exported to CSV','success')">${UI.icon('download')} Export</button>
|
||||||
|
<button class="btn btn-primary" onclick="Jobs.openCreate()">${UI.icon('plus')} Create Job</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body" style="padding-bottom:0">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-search">${UI.icon('search')}<input id="jobSearch" placeholder="Search jobs, IDs, managers…"/></div>
|
||||||
|
<select class="select" id="jobDept">${deptOpts}</select>
|
||||||
|
<select class="select" id="jobStatus">${statusOpts}</select>
|
||||||
|
<select class="select" id="jobType">${typeOpts}</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${table.html}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
onMount() {
|
||||||
|
table.mount();
|
||||||
|
const s = document.getElementById('jobSearch');
|
||||||
|
s.oninput = () => { filters.q = s.value; apply(); };
|
||||||
|
document.getElementById('jobDept').onchange = e => { filters.dept = e.target.value; apply(); };
|
||||||
|
document.getElementById('jobStatus').onchange = e => { filters.status = e.target.value; apply(); };
|
||||||
|
document.getElementById('jobType').onchange = e => { filters.type = e.target.value; apply(); };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- View job ----------------
|
||||||
|
Jobs.view = function (id) {
|
||||||
|
const j = DB.getJob(id);
|
||||||
|
const body = `
|
||||||
|
<div class="flex items-center gap-16 mb-18">
|
||||||
|
<span class="kpi-icn i-indigo" style="width:52px;height:52px;border-radius:14px">${UI.icon('briefcase')}</span>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:19px;font-weight:700">${j.title}</div>
|
||||||
|
<div class="text-muted">${j.id} · ${j.department} · ${j.businessUnit}</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-left:auto">${UI.badge(j.status)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-grid mb-18">
|
||||||
|
<div class="info-item"><div class="il">Hiring Manager</div><div class="iv">${j.manager}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Assigned Recruiter</div><div class="iv flex items-center gap-8">${j.recruiter} ${(() => { const r = DB.getRecruiterByName(j.recruiter); return r ? `<span class="badge ${r.workload > 80 ? 'b-red' : r.workload > 60 ? 'b-amber' : 'b-green'} badge-plain" style="font-size:10px">${r.workload}% load</span>` : ''; })()} <button class="link-btn" onclick="Jobs.reassign('${j.id}')">Reassign</button></div></div>
|
||||||
|
<div class="info-item"><div class="il">Location</div><div class="iv">${j.location}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Employment Type</div><div class="iv">${j.type}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Grade</div><div class="iv">${j.grade}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Vacancies</div><div class="iv">${j.vacancies}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Salary Range</div><div class="iv">${DB.moneyK(j.salaryMin)} – ${DB.moneyK(j.salaryMax)}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Experience</div><div class="iv">${j.experience}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Education</div><div class="iv">${j.education}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Deadline</div><div class="iv">${DB.fmtDate(j.deadline)}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div style="margin-bottom:16px"><div class="il" style="font-size:12px;color:var(--text-3);font-weight:600;text-transform:uppercase;margin-bottom:6px">Description</div><p style="color:var(--text-2)">${j.description}</p></div>
|
||||||
|
<div style="margin-bottom:16px"><div class="il" style="font-size:12px;color:var(--text-3);font-weight:600;text-transform:uppercase;margin-bottom:6px">Key Responsibilities</div>
|
||||||
|
<ul style="padding-left:18px;color:var(--text-2)">${j.responsibilities.map(r => `<li>${r}</li>`).join('')}</ul></div>
|
||||||
|
<div style="margin-bottom:16px"><div class="il" style="font-size:12px;color:var(--text-3);font-weight:600;text-transform:uppercase;margin-bottom:6px">Required Skills</div>
|
||||||
|
<div class="k-tags">${j.skills.map(s => `<span class="tag">${s}</span>`).join('')}</div></div>
|
||||||
|
<div><div class="il" style="font-size:12px;color:var(--text-3);font-weight:600;text-transform:uppercase;margin-bottom:6px">Benefits</div>
|
||||||
|
<div class="k-tags">${j.benefits.map(s => `<span class="tag">${s}</span>`).join('')}</div></div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="flex items-center gap-12"><span class="text-muted text-sm">Hiring progress</span><div style="flex:1">${UI.pbar(j.progress)}</div><span class="fw-600">${j.progress}%</span></div>`;
|
||||||
|
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Close</button>
|
||||||
|
<button class="btn btn-secondary" onclick="UI.closeModal();JobBoard.publishFlow('${j.id}')">${UI.icon('send')} Publish</button>
|
||||||
|
<button class="btn btn-primary" onclick="UI.closeModal();Jobs.openEdit('${j.id}')">${UI.icon('edit')} Edit Job</button>`;
|
||||||
|
UI.modal({ title: 'Job Details', subtitle: j.id, body, footer, size: 'modal-lg' });
|
||||||
|
};
|
||||||
|
|
||||||
|
Jobs.reassign = function (id) {
|
||||||
|
const j = DB.getJob(id);
|
||||||
|
const opts = DB.recruiters.map(r => `<option ${r.name === j.recruiter ? 'selected' : ''}>${r.name} — ${r.workload}% load · ${r.openReqs} reqs</option>`).join('');
|
||||||
|
UI.modal({
|
||||||
|
title: 'Reassign Recruiter', subtitle: j.title,
|
||||||
|
body: `<div class="form-field"><label>Assigned Recruiter</label><select id="reassignRec">${DB.recruiters.map(r => `<option value="${r.name}" ${r.name === j.recruiter ? 'selected' : ''}>${r.name} — ${r.workload}% load</option>`).join('')}</select></div>
|
||||||
|
<p class="text-muted text-sm" style="margin-top:10px">Workload is recalculated automatically across the recruiter's assigned requisitions.</p>`,
|
||||||
|
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button><button class="btn btn-primary" onclick="Jobs._doReassign('${id}')">Reassign</button>`
|
||||||
|
});
|
||||||
|
};
|
||||||
|
Jobs._doReassign = function (id) {
|
||||||
|
const j = DB.getJob(id);
|
||||||
|
j.recruiter = document.getElementById('reassignRec').value;
|
||||||
|
UI.closeModal(); UI.toast('Recruiter reassigned', 'success');
|
||||||
|
if (typeof Router !== 'undefined') Router.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- Create / Edit form ----------------
|
||||||
|
Jobs.openCreate = function () { Jobs._form(null); };
|
||||||
|
Jobs.openEdit = function (id) { Jobs._form(DB.getJob(id)); };
|
||||||
|
|
||||||
|
Jobs._form = function (job) {
|
||||||
|
const isEdit = !!job;
|
||||||
|
const opt = (arr, sel) => arr.map(o => `<option ${o === sel ? 'selected' : ''}>${o}</option>`).join('');
|
||||||
|
const body = `
|
||||||
|
<form id="jobForm" novalidate>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-field col-span-2">
|
||||||
|
<label>Job Title <span class="req">*</span></label>
|
||||||
|
<input name="title" value="${job ? job.title : ''}" placeholder="e.g. Senior Product Designer"/>
|
||||||
|
<span class="field-error">Job title is required</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-field"><label>Department <span class="req">*</span></label>
|
||||||
|
<select name="department">${opt(DB.departments, job && job.department)}</select></div>
|
||||||
|
<div class="form-field"><label>Business Unit</label><select name="businessUnit">${opt(DB.businessUnits, job && job.businessUnit)}</select></div>
|
||||||
|
<div class="form-field"><label>Grade</label><select name="grade">${opt(DB.grades, job && job.grade)}</select></div>
|
||||||
|
<div class="form-field"><label>Employment Type</label><select name="type">${opt(DB.empTypes, job && job.type)}</select></div>
|
||||||
|
<div class="form-field"><label>Hiring Manager <span class="req">*</span></label>
|
||||||
|
<select name="manager">${opt(DB.managers.map(m => m.name), job && job.manager)}</select></div>
|
||||||
|
<div class="form-field"><label>Recruiter <span class="req">*</span></label>
|
||||||
|
<select name="recruiter">${opt(DB.recruiters.map(r => r.name), job && job.recruiter)}</select></div>
|
||||||
|
<div class="form-field"><label>Salary Min ($) <span class="req">*</span></label>
|
||||||
|
<input name="salaryMin" type="number" value="${job ? job.salaryMin : ''}" placeholder="90000"/>
|
||||||
|
<span class="field-error">Enter a valid amount</span></div>
|
||||||
|
<div class="form-field"><label>Salary Max ($)</label><input name="salaryMax" type="number" value="${job ? job.salaryMax : ''}" placeholder="130000"/></div>
|
||||||
|
<div class="form-field"><label>Experience</label><input name="experience" value="${job ? job.experience : ''}" placeholder="5+ years"/></div>
|
||||||
|
<div class="form-field"><label>Education</label><select name="education">${opt(DB.educationLevels, job && job.education)}</select></div>
|
||||||
|
<div class="form-field"><label>Location <span class="req">*</span></label><select name="location">${opt(DB.locations, job && job.location)}</select></div>
|
||||||
|
<div class="form-field"><label>Vacancies</label><input name="vacancies" type="number" value="${job ? job.vacancies : 1}" min="1"/></div>
|
||||||
|
<div class="form-field col-span-2"><label>Job Description <span class="req">*</span></label>
|
||||||
|
<textarea name="description" placeholder="Describe the role…">${job ? job.description : ''}</textarea>
|
||||||
|
<span class="field-error">Description is required</span></div>
|
||||||
|
<div class="form-field col-span-2"><label>Responsibilities</label>
|
||||||
|
<textarea name="responsibilities" placeholder="One per line…">${job ? job.responsibilities.join('\n') : ''}</textarea></div>
|
||||||
|
<div class="form-field col-span-2"><label>Required Skills</label>
|
||||||
|
<input name="skills" value="${job ? job.skills.join(', ') : ''}" placeholder="React, TypeScript, System Design"/></div>
|
||||||
|
<div class="form-field col-span-2"><label>Benefits</label>
|
||||||
|
<input name="benefits" value="${job ? job.benefits.join(', ') : ''}" placeholder="Equity, 401(k), Unlimited PTO"/></div>
|
||||||
|
<div class="form-field"><label>Deadline</label><input name="deadline" type="date"/></div>
|
||||||
|
<div class="form-field"><label>Status</label><select name="status">${opt(DB.jobStatuses, job ? job.status : 'Open')}</select></div>
|
||||||
|
</div>
|
||||||
|
</form>`;
|
||||||
|
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="Jobs._save(${isEdit ? `'${job.id}'` : 'null'})">${UI.icon('check')} ${isEdit ? 'Save Changes' : 'Create Job'}</button>`;
|
||||||
|
UI.modal({ title: isEdit ? 'Edit Job' : 'Create New Job', subtitle: isEdit ? job.id : 'Fill in the details to post a requisition', body, footer, size: 'modal-lg' });
|
||||||
|
};
|
||||||
|
|
||||||
|
Jobs._save = function (id) {
|
||||||
|
const form = document.getElementById('jobForm');
|
||||||
|
UI.clearErrors(form);
|
||||||
|
const f = Object.fromEntries(new FormData(form));
|
||||||
|
let ok = true;
|
||||||
|
const req = (name, cond) => { if (!cond) { UI.fieldError(form.querySelector(`[name="${name}"]`), 'Required'); ok = false; } };
|
||||||
|
req('title', f.title.trim());
|
||||||
|
req('description', f.description.trim());
|
||||||
|
req('salaryMin', f.salaryMin && +f.salaryMin > 0);
|
||||||
|
if (!ok) { UI.toast('Please fix the highlighted fields', 'error'); return; }
|
||||||
|
|
||||||
|
const skills = f.skills.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
const benefits = f.benefits.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
const responsibilities = f.responsibilities.split('\n').map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const job = DB.getJob(id);
|
||||||
|
Object.assign(job, {
|
||||||
|
title: f.title, department: f.department, businessUnit: f.businessUnit, grade: f.grade, type: f.type,
|
||||||
|
manager: f.manager, recruiter: f.recruiter, salaryMin: +f.salaryMin, salaryMax: +f.salaryMax || +f.salaryMin + 20000,
|
||||||
|
experience: f.experience, education: f.education, location: f.location, vacancies: +f.vacancies || 1,
|
||||||
|
description: f.description, responsibilities, skills: skills.length ? skills : job.skills, benefits: benefits.length ? benefits : job.benefits, status: f.status
|
||||||
|
});
|
||||||
|
UI.toast('Job updated successfully', 'success');
|
||||||
|
} else {
|
||||||
|
const newJob = {
|
||||||
|
id: 'JOB-' + (1001 + DB.jobs.length), title: f.title, department: f.department, businessUnit: f.businessUnit,
|
||||||
|
grade: f.grade, manager: f.manager, managerId: '', recruiter: f.recruiter, recruiterId: '', location: f.location,
|
||||||
|
type: f.type, vacancies: +f.vacancies || 1, applications: 0, status: f.status, created: new Date('2026-07-09'),
|
||||||
|
deadline: f.deadline ? new Date(f.deadline) : new Date('2026-08-09'), salaryMin: +f.salaryMin, salaryMax: +f.salaryMax || +f.salaryMin + 20000,
|
||||||
|
experience: f.experience || '3+ years', education: f.education, skills, benefits, description: f.description,
|
||||||
|
responsibilities: responsibilities.length ? responsibilities : ['Own key projects'], progress: 0
|
||||||
|
};
|
||||||
|
DB.jobs.unshift(newJob);
|
||||||
|
UI.toast('Job created successfully', 'success');
|
||||||
|
App.updateBadges();
|
||||||
|
}
|
||||||
|
UI.closeModal();
|
||||||
|
Router.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
Jobs.confirmDelete = function (id) {
|
||||||
|
const j = DB.getJob(id);
|
||||||
|
const body = `<div class="flex gap-16 items-center">
|
||||||
|
<span class="kpi-icn i-red" style="width:48px;height:48px;border-radius:12px;flex-shrink:0">${UI.icon('trash')}</span>
|
||||||
|
<div><p style="font-weight:600;font-size:15px">Delete "${j.title}"?</p>
|
||||||
|
<p class="text-muted" style="margin-top:4px">This will permanently remove requisition ${j.id} and its ${j.applications} applications. This action cannot be undone.</p></div></div>`;
|
||||||
|
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button>
|
||||||
|
<button class="btn btn-danger" onclick="Jobs._delete('${id}')">${UI.icon('trash')} Delete Job</button>`;
|
||||||
|
UI.modal({ title: 'Confirm Deletion', body, footer });
|
||||||
|
};
|
||||||
|
Jobs._delete = function (id) {
|
||||||
|
const i = DB.jobs.findIndex(j => j.id === id);
|
||||||
|
if (i > -1) DB.jobs.splice(i, 1);
|
||||||
|
UI.closeModal();
|
||||||
|
UI.toast('Job deleted', 'success');
|
||||||
|
App.updateBadges();
|
||||||
|
Router.reload();
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
/* ============================================================
|
||||||
|
misc.js — Hiring Managers, Calendar, Notifications, Help
|
||||||
|
============================================================ */
|
||||||
|
window.Views = window.Views || {};
|
||||||
|
|
||||||
|
// ---------------- Hiring Managers ----------------
|
||||||
|
Views.managers = function () {
|
||||||
|
function card(m) {
|
||||||
|
const jobs = DB.jobs.filter(j => j.manager === m.name && j.status === 'Open');
|
||||||
|
return `<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-12" style="margin-bottom:14px">
|
||||||
|
${UI.avatar(m.name, m.initials, m.color, 'avatar-lg')}
|
||||||
|
<div style="flex:1"><div class="lr-title">${m.name}</div><div class="lr-sub">${m.title}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid g-2" style="gap:10px;margin-bottom:14px">
|
||||||
|
<div class="stat-mini"><span class="stat-mini-val">${m.openReqs}</span><span class="stat-mini-lbl">Open Reqs</span></div>
|
||||||
|
<div class="stat-mini"><span class="stat-mini-val">${m.teamSize}</span><span class="stat-mini-lbl">Team Size</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="divider" style="margin:12px 0"></div>
|
||||||
|
<div class="flex items-center" style="justify-content:space-between">
|
||||||
|
<span class="cell-sub">${UI.icon('mail')} ${m.email.split('@')[0]}</span>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="Views._mgrDetail('${m.id}')">View</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">Hiring Managers</h1><p class="page-sub">${DB.managers.length} managers · ${DB.managers.reduce((s, m) => s + m.openReqs, 0)} active requisitions</p></div>
|
||||||
|
<div class="page-head-actions"><button class="btn btn-primary" onclick="UI.toast('Invite manager','info')">${UI.icon('plus')} Add Manager</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid g-3">${DB.managers.map(card).join('')}</div>
|
||||||
|
</div>`;
|
||||||
|
return { html };
|
||||||
|
};
|
||||||
|
Views._mgrDetail = function (id) {
|
||||||
|
const m = DB.getManager(id);
|
||||||
|
const jobs = DB.jobs.filter(j => j.manager === m.name);
|
||||||
|
const body = `
|
||||||
|
<div class="profile-hero" style="margin-bottom:18px">${UI.avatar(m.name, m.initials, m.color, 'avatar-lg')}
|
||||||
|
<div><div class="ph-name">${m.name}</div><div class="ph-role">${m.title}</div>
|
||||||
|
<div class="ph-tags">${UI.badge(m.department, 'b-indigo')}<span class="badge b-gray badge-plain">${m.teamSize} reports</span></div></div></div>
|
||||||
|
<div class="grid g-3" style="margin-bottom:18px">
|
||||||
|
<div class="stat-mini"><span class="stat-mini-val">${m.openReqs}</span><span class="stat-mini-lbl">Open Reqs</span></div>
|
||||||
|
<div class="stat-mini"><span class="stat-mini-val">${jobs.length}</span><span class="stat-mini-lbl">Total Jobs</span></div>
|
||||||
|
<div class="stat-mini"><span class="stat-mini-val">${jobs.reduce((s, j) => s + j.applications, 0)}</span><span class="stat-mini-lbl">Applications</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-section-title" style="margin-top:0">Hiring Manager Portal</div>
|
||||||
|
<div class="grid g-2" style="gap:10px;margin-bottom:16px">
|
||||||
|
<button class="btn btn-secondary" onclick="UI.closeModal();Jobs.openCreate()">${UI.icon('plus')} Raise Requisition</button>
|
||||||
|
<button class="btn btn-secondary" onclick="UI.closeModal();Router.go('candidates')">${UI.icon('users')} Review Candidates</button>
|
||||||
|
<button class="btn btn-secondary" onclick="UI.closeModal();Interviews.schedule()">${UI.icon('calendar')} Schedule Interview</button>
|
||||||
|
<button class="btn btn-secondary" onclick="UI.closeModal();Router.go('offers')">${UI.icon('check-circle')} Approve Offers</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-section-title">Requisitions</div>
|
||||||
|
<div class="list-tight">${jobs.length ? jobs.map(j => `<div class="list-row" style="cursor:pointer" onclick="UI.closeModal();Jobs.view('${j.id}')">
|
||||||
|
<span class="kpi-icn i-indigo" style="width:36px;height:36px;border-radius:9px">${UI.icon('briefcase')}</span>
|
||||||
|
<div class="lr-main"><div class="lr-title">${j.title}</div><div class="lr-sub">${j.applications} applications</div></div>${UI.badge(j.status)}</div>`).join('') : '<p class="text-muted">No requisitions</p>'}</div>`;
|
||||||
|
UI.modal({ title: 'Hiring Manager', subtitle: m.id, body, size: 'modal-lg', footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Close</button><button class="btn btn-primary" onclick="UI.toast('Message sent','success')">${UI.icon('mail')} Message</button>` });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- Calendar ----------------
|
||||||
|
Views.calendar = function () {
|
||||||
|
const state = { month: 6, year: 2026 }; // July 2026 (0-indexed)
|
||||||
|
const evColors = { 'Phone Screen': 'b-blue', 'Technical': 'b-indigo', 'System Design': 'b-purple', 'Onsite Loop': 'b-teal', 'Hiring Manager': 'b-amber', 'Culture Fit': 'b-green', 'Final Round': 'b-red' };
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
const first = new Date(state.year, state.month, 1);
|
||||||
|
const startDow = first.getDay();
|
||||||
|
const daysInMonth = new Date(state.year, state.month + 1, 0).getDate();
|
||||||
|
const prevDays = new Date(state.year, state.month, 0).getDate();
|
||||||
|
const cells = [];
|
||||||
|
for (let i = startDow - 1; i >= 0; i--) cells.push({ day: prevDays - i, other: true });
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) cells.push({ day: d, other: false, date: new Date(state.year, state.month, d) });
|
||||||
|
while (cells.length % 7 !== 0 || cells.length < 42) cells.push({ day: cells.length - daysInMonth - startDow + 1, other: true });
|
||||||
|
|
||||||
|
const today = new Date('2026-07-09');
|
||||||
|
const dow = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
let html = dow.map(d => `<div class="cal-dow">${d}</div>`).join('');
|
||||||
|
cells.slice(0, 42).forEach(c => {
|
||||||
|
let evs = '';
|
||||||
|
if (!c.other && c.date) {
|
||||||
|
const dayEvents = DB.interviews.filter(iv => iv.when.toDateString() === c.date.toDateString());
|
||||||
|
evs = dayEvents.slice(0, 3).map(iv => `<div class="cal-event ${evColors[iv.type] || 'b-blue'}" onclick="Candidates.openProfile('${iv.candidateId}')" title="${iv.candidate} · ${iv.type}">${iv.when.toLocaleTimeString('en-US', { hour: 'numeric' })} ${iv.candidate.split(' ')[0]}</div>`).join('');
|
||||||
|
if (dayEvents.length > 3) evs += `<div class="cal-event b-gray">+${dayEvents.length - 3} more</div>`;
|
||||||
|
}
|
||||||
|
const isToday = !c.other && c.date && c.date.toDateString() === today.toDateString();
|
||||||
|
html += `<div class="cal-cell ${c.other ? 'other' : ''} ${isToday ? 'today' : ''}"><div class="cal-date">${c.day}</div>${evs}</div>`;
|
||||||
|
});
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthName = new Date(state.year, state.month).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||||
|
const todayIvs = DB.interviews.filter(iv => iv.when.toDateString() === new Date('2026-07-09').toDateString());
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">Calendar</h1><p class="page-sub">Interview schedule at a glance</p></div>
|
||||||
|
<div class="page-head-actions">
|
||||||
|
<div class="flex items-center gap-8">
|
||||||
|
<button class="btn btn-icon btn-secondary" id="calPrev">${UI.icon('chevron-left')}</button>
|
||||||
|
<span class="fw-600" id="calMonth" style="min-width:140px;text-align:center">${monthName}</span>
|
||||||
|
<button class="btn btn-icon btn-secondary" id="calNext">${UI.icon('chevron-right')}</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="Interviews.schedule()">${UI.icon('plus')} Schedule</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid g-2-1">
|
||||||
|
<div class="card"><div class="card-body"><div class="cal-grid" id="calGrid">${build()}</div></div></div>
|
||||||
|
<div class="card" style="align-self:start"><div class="card-head"><div><h3>Today</h3><span class="ch-sub">July 9, 2026</span></div></div>
|
||||||
|
<div class="card-body"><div class="list-tight">${todayIvs.length ? todayIvs.map(iv => `
|
||||||
|
<div class="list-row" style="cursor:pointer" onclick="Candidates.openProfile('${iv.candidateId}')">${UI.avatar(iv.candidate, iv.candInitials, iv.color)}
|
||||||
|
<div class="lr-main"><div class="lr-title">${iv.candidate}</div><div class="lr-sub">${iv.type}</div></div>
|
||||||
|
<div class="lr-right"><div class="fw-600 text-sm">${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}</div></div></div>`).join('') : '<p class="text-muted">No interviews today</p>'}</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
onMount() {
|
||||||
|
const upd = () => {
|
||||||
|
document.getElementById('calGrid').innerHTML = build();
|
||||||
|
document.getElementById('calMonth').textContent = new Date(state.year, state.month).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||||
|
};
|
||||||
|
document.getElementById('calPrev').onclick = () => { state.month--; if (state.month < 0) { state.month = 11; state.year--; } upd(); };
|
||||||
|
document.getElementById('calNext').onclick = () => { state.month++; if (state.month > 11) { state.month = 0; state.year++; } upd(); };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- Notifications ----------------
|
||||||
|
Views.notifications = function () {
|
||||||
|
const rows = DB.notifications.map((n, i) => `
|
||||||
|
<div class="notif-row ${n.unread ? 'unread' : ''}" onclick="this.classList.remove('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>
|
||||||
|
${n.unread ? '<span class="dot dot-blue" style="position:static;border:none;align-self:center"></span>' : ''}
|
||||||
|
</div>`).join('');
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">Notifications</h1><p class="page-sub">Stay on top of hiring activity</p></div>
|
||||||
|
<div class="page-head-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="App.markAllNotifsRead();Router.reload()">${UI.icon('check')} Mark all read</button>
|
||||||
|
<button class="btn btn-ghost" onclick="UI.toast('Notification settings','info')">${UI.icon('more')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><div class="list-tight" style="padding:0">${rows}</div></div>
|
||||||
|
</div>`;
|
||||||
|
return { html };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- Help ----------------
|
||||||
|
Views.help = function () {
|
||||||
|
const faqs = [
|
||||||
|
{ q: 'How do I create a new job requisition?', a: 'Navigate to Jobs and click "Create Job". Fill in the required fields marked with an asterisk and click Save. The job will immediately appear in your listings.' },
|
||||||
|
{ q: 'How does the AI candidate score work?', a: 'The AI score (0–100) evaluates how well a candidate matches the job requirements based on skills, experience, and education. Higher scores indicate stronger matches.' },
|
||||||
|
{ q: 'Can I move candidates between pipeline stages?', a: 'Yes. Open the Pipeline view and simply drag any candidate card between stage columns. The candidate\'s status updates automatically.' },
|
||||||
|
{ q: 'How do I schedule an interview?', a: 'Go to Interviews or Calendar and click "Schedule Interview". Select the candidate, round, date, time, and interviewers.' },
|
||||||
|
{ q: 'How do I export reports?', a: 'On the Reports page, use the "Export Report" button for a full PDF, or the CSV buttons on individual tables.' }
|
||||||
|
];
|
||||||
|
const resources = [
|
||||||
|
{ icn: 'file', t: 'Documentation', d: 'Complete product guides', cls: 'i-indigo' },
|
||||||
|
{ icn: 'video', t: 'Video Tutorials', d: 'Watch step-by-step walkthroughs', cls: 'i-red' },
|
||||||
|
{ icn: 'message', t: 'Live Chat', d: 'Chat with our support team', cls: 'i-green' },
|
||||||
|
{ icn: 'users', t: 'Community', d: 'Connect with other recruiters', cls: 'i-purple' }
|
||||||
|
];
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head"><div><h1 class="page-title">Help Center</h1><p class="page-sub">Find answers and get support</p></div></div>
|
||||||
|
<div class="card brand-hero mb-18">
|
||||||
|
<div class="card-body" style="padding:32px;text-align:center">
|
||||||
|
<h2 style="font-size:22px;margin-bottom:8px">How can we help you?</h2>
|
||||||
|
<p style="opacity:.85;margin-bottom:18px">Search our knowledge base or browse the topics below</p>
|
||||||
|
<div class="topbar-search" style="max-width:480px;margin:0 auto">
|
||||||
|
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
<input placeholder="Search help articles…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid g-kpi mb-18">
|
||||||
|
${resources.map(r => `<div class="card" style="cursor:pointer" onclick="UI.toast('Opening ${r.t}','info')"><div class="card-body" style="text-align:center">
|
||||||
|
<span class="kpi-icn ${r.cls}" style="margin:0 auto 12px;width:48px;height:48px;border-radius:14px">${UI.icon(r.icn)}</span>
|
||||||
|
<div class="fw-600">${r.t}</div><div class="lr-sub" style="margin-top:4px">${r.d}</div></div></div>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Frequently Asked Questions</h3></div></div>
|
||||||
|
<div class="card-body"><div id="faqList">
|
||||||
|
${faqs.map((f, i) => `<div class="setting-row" style="cursor:pointer;flex-direction:column;align-items:stretch" onclick="Views._toggleFaq(${i})">
|
||||||
|
<div class="flex items-center" style="justify-content:space-between"><h4>${f.q}</h4><span id="faqChev${i}" style="color:var(--text-3);transition:.2s">${UI.icon('chevron-right')}</span></div>
|
||||||
|
<p id="faqA${i}" style="display:none;margin-top:10px">${f.a}</p></div>`).join('')}
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
return { html };
|
||||||
|
};
|
||||||
|
Views._toggleFaq = function (i) {
|
||||||
|
const a = document.getElementById('faqA' + i);
|
||||||
|
const chev = document.getElementById('faqChev' + i);
|
||||||
|
const open = a.style.display === 'block';
|
||||||
|
a.style.display = open ? 'none' : 'block';
|
||||||
|
chev.style.transform = open ? 'rotate(0deg)' : 'rotate(90deg)';
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
/* ============================================================
|
||||||
|
offers.js — Offer management
|
||||||
|
============================================================ */
|
||||||
|
window.Views = window.Views || {};
|
||||||
|
window.Offers = {};
|
||||||
|
|
||||||
|
Views.offers = function () {
|
||||||
|
const filters = { q: '', status: '' };
|
||||||
|
let table;
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
sent: DB.offers.filter(o => o.status !== 'Draft').length,
|
||||||
|
accepted: DB.offers.filter(o => o.status === 'Accepted').length,
|
||||||
|
pending: DB.offers.filter(o => ['Sent', 'Negotiating'].includes(o.status)).length,
|
||||||
|
rate: Math.round(DB.offers.filter(o => o.status === 'Accepted').length / (DB.offers.filter(o => ['Accepted', 'Declined'].includes(o.status)).length || 1) * 100)
|
||||||
|
};
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
const rows = DB.offers.filter(o => {
|
||||||
|
if (filters.status && o.status !== filters.status) return false;
|
||||||
|
if (filters.q && !(o.candidate + o.jobTitle + o.recruiter).toLowerCase().includes(filters.q.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
table.update(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
table = UI.dataTable({
|
||||||
|
pageSize: 8,
|
||||||
|
rows: DB.offers,
|
||||||
|
columns: [
|
||||||
|
{ key: 'candidate', label: 'Candidate', sortable: true, render: o => `<div class="user-cell">${UI.avatar(o.candidate, o.initials, o.color)}<div><div class="cell-primary">${o.candidate}</div><div class="cell-sub">${o.jobTitle}</div></div></div>` },
|
||||||
|
{ key: 'department', label: 'Department', sortable: true },
|
||||||
|
{ key: 'base', label: 'Base Salary', sortable: true, align: 'right', render: o => `<b>${DB.money(o.base)}</b>` },
|
||||||
|
{ key: 'equity', label: 'Equity', render: o => `<span class="text-muted">${o.equity}</span>` },
|
||||||
|
{ key: 'bonus', label: 'Bonus', align: 'center', render: o => `<span class="text-muted">${o.bonus}</span>` },
|
||||||
|
{ key: 'sent', label: 'Sent', sortable: true, sortValue: o => o.sent.getTime(), render: o => `<span class="text-muted">${DB.fmtShort(o.sent)}</span>` },
|
||||||
|
{ key: 'status', label: 'Status', sortable: true, render: o => UI.badge(o.status) },
|
||||||
|
{ key: '_a', label: 'Actions', align: 'right', render: o => `
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="act-btn" data-tip="View" onclick="Offers.view('${o.id}')">${UI.icon('eye')}</button>
|
||||||
|
<button class="act-btn" data-tip="Resend" onclick="UI.toast('Offer resent to ${o.candidate}','info')">${UI.icon('send')}</button>
|
||||||
|
</div>` }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusOpts = ['<option value="">All Status</option>'].concat(['Sent', 'Accepted', 'Negotiating', 'Declined', 'Draft', 'Expired'].map(s => `<option>${s}</option>`)).join('');
|
||||||
|
const statCard = (label, val, icn, cls) => `<div class="kpi"><div class="kpi-top"><span class="kpi-label">${label}</span><span class="kpi-icn ${cls}">${UI.icon(icn)}</span></div><div class="kpi-value">${val}</div></div>`;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">Offers</h1><p class="page-sub">Track offer letters and acceptance</p></div>
|
||||||
|
<div class="page-head-actions"><button class="btn btn-primary" onclick="Offers.create()">${UI.icon('plus')} Create Offer</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid g-kpi mb-18">
|
||||||
|
${statCard('Offers Sent', stats.sent, 'send', 'i-indigo')}
|
||||||
|
${statCard('Accepted', stats.accepted, 'check-circle', 'i-green')}
|
||||||
|
${statCard('Awaiting Response', stats.pending, 'clock', 'i-amber')}
|
||||||
|
${statCard('Acceptance Rate', stats.rate + '%', 'trending-up', 'i-teal')}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body" style="padding-bottom:0">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-search">${UI.icon('search')}<input id="ofSearch" placeholder="Search candidate or role…"/></div>
|
||||||
|
<select class="select" id="ofStatus">${statusOpts}</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${table.html}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
onMount() {
|
||||||
|
table.mount();
|
||||||
|
const s = document.getElementById('ofSearch');
|
||||||
|
s.oninput = () => { filters.q = s.value; apply(); };
|
||||||
|
document.getElementById('ofStatus').onchange = e => { filters.status = e.target.value; apply(); };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
Offers.view = function (id) {
|
||||||
|
const o = DB.offers.find(x => x.id === id);
|
||||||
|
const total = o.base + Math.round(o.base * parseInt(o.bonus) / 100);
|
||||||
|
const body = `
|
||||||
|
<div class="flex items-center gap-12 mb-18">${UI.avatar(o.candidate, o.initials, o.color, 'avatar-lg')}
|
||||||
|
<div><div class="ph-name" style="font-size:17px">${o.candidate}</div><div class="ph-role">${o.jobTitle} · ${o.department}</div></div>
|
||||||
|
<div style="margin-left:auto">${UI.badge(o.status)}</div></div>
|
||||||
|
<div class="card" style="box-shadow:none;background:var(--bg-sunken);margin-bottom:18px"><div class="card-body">
|
||||||
|
<div class="form-section-title" style="margin-top:0">Compensation Package</div>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item"><div class="il">Base Salary</div><div class="iv" style="font-size:18px">${DB.money(o.base)}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Annual Bonus</div><div class="iv" style="font-size:18px">${o.bonus}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Equity</div><div class="iv" style="font-size:18px">${o.equity}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Est. Total Cash</div><div class="iv" style="font-size:18px;color:var(--success)">${DB.money(total)}</div></div>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item"><div class="il">Sent On</div><div class="iv">${DB.fmtDate(o.sent)}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Expires</div><div class="iv">${DB.fmtDate(o.expires)}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Recruiter</div><div class="iv">${o.recruiter}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Offer ID</div><div class="iv mono">${o.id}</div></div>
|
||||||
|
</div>`;
|
||||||
|
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Close</button>
|
||||||
|
<button class="btn btn-secondary" onclick="UI.toast('Offer PDF downloaded','info')">${UI.icon('download')} Download</button>
|
||||||
|
<button class="btn btn-primary" onclick="UI.closeModal();UI.toast('Offer resent','success')">${UI.icon('send')} Resend Offer</button>`;
|
||||||
|
UI.modal({ title: 'Offer Details', subtitle: o.id, body, footer, size: 'modal-lg' });
|
||||||
|
};
|
||||||
|
|
||||||
|
Offers.create = function () {
|
||||||
|
const opt = arr => arr.map(o => `<option>${o}</option>`).join('');
|
||||||
|
const body = `<form id="offerForm" novalidate><div class="form-grid">
|
||||||
|
<div class="form-field col-span-2"><label>Candidate <span class="req">*</span></label><select name="candidate">${opt(DB.candidates.filter(c => ['Interview', 'Offer'].includes(c.stage)).map(c => c.name))}</select></div>
|
||||||
|
<div class="form-field"><label>Base Salary ($) <span class="req">*</span></label><input name="base" type="number" placeholder="140000"/><span class="field-error">Required</span></div>
|
||||||
|
<div class="form-field"><label>Annual Bonus (%)</label><input name="bonus" type="number" value="10"/></div>
|
||||||
|
<div class="form-field"><label>Equity (RSU)</label><input name="equity" placeholder="20k RSU"/></div>
|
||||||
|
<div class="form-field"><label>Expiration Date</label><input name="expires" type="date"/></div>
|
||||||
|
<div class="form-field col-span-2"><label>Notes</label><textarea name="notes" placeholder="Additional details for the offer…"></textarea></div>
|
||||||
|
</div></form>`;
|
||||||
|
const footer = `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="Offers._save()">${UI.icon('send')} Send Offer</button>`;
|
||||||
|
UI.modal({ title: 'Create Offer', subtitle: 'Generate and send an offer letter', body, footer });
|
||||||
|
};
|
||||||
|
Offers._save = function () {
|
||||||
|
const form = document.getElementById('offerForm');
|
||||||
|
UI.clearErrors(form);
|
||||||
|
const f = Object.fromEntries(new FormData(form));
|
||||||
|
if (!f.base || +f.base <= 0) { UI.fieldError(form.querySelector('[name=base]'), 'Required'); UI.toast('Enter a base salary', 'error'); return; }
|
||||||
|
const cand = DB.candidates.find(c => c.name === f.candidate) || DB.candidates[0];
|
||||||
|
DB.offers.unshift({
|
||||||
|
id: 'OFR-' + (9001 + DB.offers.length), candidate: cand.name, candidateId: cand.id, initials: cand.initials, color: cand.color,
|
||||||
|
jobTitle: cand.jobTitle, department: cand.department, status: 'Sent', base: +f.base, equity: f.equity || '10k RSU',
|
||||||
|
bonus: (f.bonus || 10) + '%', sent: new Date('2026-07-09'), expires: f.expires ? new Date(f.expires) : new Date('2026-07-23'), recruiter: cand.recruiter
|
||||||
|
});
|
||||||
|
UI.closeModal();
|
||||||
|
UI.toast('Offer sent successfully', 'success');
|
||||||
|
Router.reload();
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
/* ============================================================
|
||||||
|
pipeline.js — Kanban board (drag & drop) + Talent Pool
|
||||||
|
============================================================ */
|
||||||
|
window.Views = window.Views || {};
|
||||||
|
window.Pipeline = {};
|
||||||
|
|
||||||
|
// Stage colours reference CSS tokens so the board re-tints with the theme.
|
||||||
|
const KANBAN_STAGES = [
|
||||||
|
{ name: 'Applied', color: 'var(--stage-1)' },
|
||||||
|
{ name: 'Screening', color: 'var(--stage-2)' },
|
||||||
|
{ name: 'Assessment', color: 'var(--stage-3)' },
|
||||||
|
{ name: 'Interview', color: 'var(--stage-4)' },
|
||||||
|
{ name: 'Offer', color: 'var(--stage-5)' },
|
||||||
|
{ name: 'Hired', color: 'var(--stage-6)' },
|
||||||
|
{ name: 'Rejected', color: 'var(--stage-7)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
Views.pipeline = function () {
|
||||||
|
const jobFilter = { id: '' };
|
||||||
|
|
||||||
|
function columns() {
|
||||||
|
const list = jobFilter.id ? DB.candidates.filter(c => c.jobId === jobFilter.id) : DB.candidates;
|
||||||
|
return KANBAN_STAGES.map(st => {
|
||||||
|
const cards = list.filter(c => c.stage === st.name);
|
||||||
|
return `<div class="kanban-col" data-stage="${st.name}">
|
||||||
|
<div class="kanban-col-head"><span class="k-dot" style="background:${st.color}"></span><h4>${st.name}</h4><span class="k-count">${cards.length}</span></div>
|
||||||
|
<div class="kanban-cards" data-stage="${st.name}">
|
||||||
|
${cards.map(c => Pipeline._card(c)).join('')}
|
||||||
|
</div></div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobOpts = ['<option value="">All Jobs</option>'].concat(DB.jobs.filter(j => j.status === 'Open').map(j => `<option value="${j.id}">${j.title}</option>`)).join('');
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">Pipeline</h1><p class="page-sub">Drag candidates between stages to update their status</p></div>
|
||||||
|
<div class="page-head-actions">
|
||||||
|
<select class="select" id="pipeJob">${jobOpts}</select>
|
||||||
|
<button class="btn btn-primary" onclick="Candidates.openAdd()">${UI.icon('plus')} Add Candidate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="kanban" id="kanban">${columns()}</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
onMount() {
|
||||||
|
Pipeline._bindDnd();
|
||||||
|
document.getElementById('pipeJob').onchange = e => {
|
||||||
|
jobFilter.id = e.target.value;
|
||||||
|
document.getElementById('kanban').innerHTML = columns();
|
||||||
|
Pipeline._bindDnd();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
Pipeline._card = function (c) {
|
||||||
|
return `<div class="k-card" draggable="true" data-id="${c.id}" onclick="Candidates.openProfile('${c.id}')">
|
||||||
|
<div class="k-card-top">${UI.avatar(c.name, c.initials, c.color)}
|
||||||
|
<div><div class="kc-name">${c.name}</div><div class="kc-role">${c.currentTitle}</div></div></div>
|
||||||
|
<div class="kc-role">${c.jobTitle}</div>
|
||||||
|
<div class="k-tags">${c.skills.slice(0, 3).map(s => `<span class="tag">${s}</span>`).join('')}</div>
|
||||||
|
<div class="k-card-meta"><span class="cell-sub">${c.currentCompany}</span>${UI.scoreChip(c.aiScore)}</div>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
Pipeline._bindDnd = function () {
|
||||||
|
let dragged = null;
|
||||||
|
document.querySelectorAll('.k-card').forEach(card => {
|
||||||
|
card.addEventListener('dragstart', e => {
|
||||||
|
dragged = card; card.classList.add('dragging');
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', card.dataset.id);
|
||||||
|
});
|
||||||
|
card.addEventListener('dragend', () => { card.classList.remove('dragging'); dragged = null; });
|
||||||
|
// prevent click-through opening profile right after drag
|
||||||
|
card.addEventListener('click', e => { if (card._justDropped) { e.stopPropagation(); card._justDropped = false; } });
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.kanban-cards').forEach(zone => {
|
||||||
|
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); });
|
||||||
|
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
|
||||||
|
zone.addEventListener('drop', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
zone.classList.remove('drag-over');
|
||||||
|
if (!dragged) return;
|
||||||
|
const id = dragged.dataset.id;
|
||||||
|
const cand = DB.getCandidate(id);
|
||||||
|
const newStage = zone.dataset.stage;
|
||||||
|
if (cand.stage === newStage) return;
|
||||||
|
cand.stage = newStage; cand.status = newStage;
|
||||||
|
zone.appendChild(dragged);
|
||||||
|
// update counts
|
||||||
|
document.querySelectorAll('.kanban-col').forEach(col => {
|
||||||
|
col.querySelector('.k-count').textContent = col.querySelectorAll('.k-card').length;
|
||||||
|
});
|
||||||
|
UI.toast(`${cand.name} moved to ${newStage}`, 'success');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- Talent Pool ----------------
|
||||||
|
Views.talentpool = function () {
|
||||||
|
const filters = { q: '', dept: '' };
|
||||||
|
// Talent pool = candidates not currently in active loop (silver medalists / passive talent)
|
||||||
|
const pool = DB.candidates.filter(c => ['Rejected', 'Applied', 'Hired'].includes(c.stage));
|
||||||
|
|
||||||
|
function render(list) {
|
||||||
|
const grid = document.getElementById('poolGrid');
|
||||||
|
if (!grid) return;
|
||||||
|
if (!list.length) { grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1">${UI.icon('search')}<h3>No talent found</h3></div>`; return; }
|
||||||
|
grid.innerHTML = list.map(c => `
|
||||||
|
<div class="card" style="cursor:pointer" onclick="Candidates.openProfile('${c.id}')">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-12" style="margin-bottom:12px">
|
||||||
|
${UI.avatar(c.name, c.initials, c.color, 'avatar-lg')}
|
||||||
|
<div style="flex:1;min-width:0"><div class="lr-title">${c.name}</div><div class="lr-sub">${c.currentTitle}</div></div>
|
||||||
|
${UI.scoreChip(c.aiScore)}
|
||||||
|
</div>
|
||||||
|
<div class="k-tags" style="margin-bottom:12px">${c.skills.slice(0, 4).map(s => `<span class="tag">${s}</span>`).join('')}</div>
|
||||||
|
<div class="divider" style="margin:12px 0"></div>
|
||||||
|
<div class="flex items-center" style="justify-content:space-between">
|
||||||
|
<span class="cell-sub">${UI.icon('briefcase')} ${c.experience} yrs</span>
|
||||||
|
<span class="cell-sub">${c.currentCompany}</span>
|
||||||
|
${UI.badge(c.source, 'b-gray')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
function apply() {
|
||||||
|
let list = pool.filter(c => {
|
||||||
|
if (filters.dept && c.department !== filters.dept) return false;
|
||||||
|
if (filters.q && !(c.name + c.currentCompany + c.skills.join(' ')).toLowerCase().includes(filters.q.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
render(list);
|
||||||
|
}
|
||||||
|
const deptOpts = ['<option value="">All Departments</option>'].concat(DB.departments.map(d => `<option>${d}</option>`)).join('');
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">Talent Pool</h1><p class="page-sub">${pool.length} silver-medalists & passive candidates to re-engage</p></div>
|
||||||
|
<div class="page-head-actions"><button class="btn btn-primary" onclick="UI.toast('Talent campaign created','success')">${UI.icon('send')} Start Campaign</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card mb-18"><div class="card-body" style="padding:16px">
|
||||||
|
<div class="toolbar" style="margin-bottom:0">
|
||||||
|
<div class="toolbar-search">${UI.icon('search')}<input id="poolSearch" placeholder="Search by name, skill, company…"/></div>
|
||||||
|
<select class="select" id="poolDept">${deptOpts}</select>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
<div class="grid g-3" id="poolGrid"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
onMount() {
|
||||||
|
apply();
|
||||||
|
const s = document.getElementById('poolSearch');
|
||||||
|
s.oninput = () => { filters.q = s.value; apply(); };
|
||||||
|
document.getElementById('poolDept').onchange = e => { filters.dept = e.target.value; apply(); };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
/* ============================================================
|
||||||
|
rbac.js — Enterprise Role Based Access Control
|
||||||
|
Microsoft Admin Center-style permission matrix
|
||||||
|
============================================================ */
|
||||||
|
window.Views = window.Views || {};
|
||||||
|
window.RBAC = {};
|
||||||
|
|
||||||
|
Views.rbac = function () {
|
||||||
|
const state = { roleIdx: 0 };
|
||||||
|
RBAC._state = state;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">Access Control</h1><p class="page-sub">Enterprise RBAC — configure permissions for every role and module</p></div>
|
||||||
|
<div class="page-head-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="RBAC.addRole()">${UI.icon('plus')} New Role</button>
|
||||||
|
<button class="btn btn-primary" onclick="UI.toast('Permission changes saved','success')">${UI.icon('check')} Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rbac-layout">
|
||||||
|
<div class="card" style="align-self:start"><div class="card-body" style="padding:12px">
|
||||||
|
<div class="nav-section-label" style="padding:6px 8px">Roles</div>
|
||||||
|
<div class="role-list" id="roleList"></div>
|
||||||
|
</div></div>
|
||||||
|
<div class="card"><div id="rbacDetail"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
onMount() { RBAC._renderRoles(); RBAC._renderDetail(); }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
RBAC._renderRoles = function () {
|
||||||
|
const el = document.getElementById('roleList');
|
||||||
|
el.innerHTML = DB.rbacRoles.map((r, i) => `
|
||||||
|
<div class="role-item ${i === RBAC._state.roleIdx ? 'active' : ''}" data-idx="${i}">
|
||||||
|
<span class="role-badge" style="background:${r.color}">${UI.icon('shield')}</span>
|
||||||
|
<div style="flex:1;min-width:0"><div class="fw-600 text-sm">${r.name}</div><div class="cell-sub">${r.users} user${r.users === 1 ? '' : 's'}</div></div>
|
||||||
|
</div>`).join('');
|
||||||
|
el.querySelectorAll('.role-item').forEach(item => item.onclick = () => {
|
||||||
|
RBAC._state.roleIdx = +item.dataset.idx;
|
||||||
|
RBAC._renderRoles(); RBAC._renderDetail();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
RBAC._renderDetail = function () {
|
||||||
|
const r = DB.rbacRoles[RBAC._state.roleIdx];
|
||||||
|
const el = document.getElementById('rbacDetail');
|
||||||
|
|
||||||
|
const matrixRows = DB.rbacModules.map(mod => `
|
||||||
|
<tr>
|
||||||
|
<td>${mod}</td>
|
||||||
|
${DB.permTypes.map((pt, pi) => `<td><span class="perm-check ${r.matrix[mod][pi] ? 'on' : ''}" data-mod="${mod}" data-perm="${pi}">${UI.icon('check')}</span></td>`).join('')}
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="flex items-center gap-12"><span class="role-badge" style="background:${r.color}">${UI.icon('shield')}</span>
|
||||||
|
<div><h3>${r.name}</h3><span class="ch-sub">${r.desc}</span></div></div>
|
||||||
|
<div class="flex items-center gap-8">
|
||||||
|
<span class="badge b-gray badge-plain">${r.users} users</span>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="RBAC.toggleAll(true)">Grant all</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="RBAC.toggleAll(false)">Revoke all</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-wrap"><table class="rbac-matrix">
|
||||||
|
<thead><tr><th>Module</th>${DB.permTypes.map(p => `<th>${p}</th>`).join('')}</tr></thead>
|
||||||
|
<tbody>${matrixRows}</tbody>
|
||||||
|
</table></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
el.querySelectorAll('.perm-check').forEach(chk => chk.onclick = () => {
|
||||||
|
const mod = chk.dataset.mod, pi = +chk.dataset.perm;
|
||||||
|
r.matrix[mod][pi] = !r.matrix[mod][pi];
|
||||||
|
chk.classList.toggle('on');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
RBAC.toggleAll = function (on) {
|
||||||
|
const r = DB.rbacRoles[RBAC._state.roleIdx];
|
||||||
|
DB.rbacModules.forEach(mod => r.matrix[mod] = r.matrix[mod].map(() => on));
|
||||||
|
RBAC._renderDetail();
|
||||||
|
UI.toast(on ? 'All permissions granted for ' + r.name : 'All permissions revoked for ' + r.name, on ? 'success' : 'warning');
|
||||||
|
};
|
||||||
|
|
||||||
|
RBAC.addRole = function () {
|
||||||
|
UI.modal({
|
||||||
|
title: 'Create Role', subtitle: 'Define a new access role',
|
||||||
|
body: `<form id="roleForm"><div class="form-grid">
|
||||||
|
<div class="form-field col-span-2"><label>Role Name <span class="req">*</span></label><input name="name" placeholder="e.g. Regional Recruiter"/><span class="field-error">Required</span></div>
|
||||||
|
<div class="form-field col-span-2"><label>Description</label><input name="desc" placeholder="What can this role do?"/></div>
|
||||||
|
<div class="form-field"><label>Base Template</label><select name="template"><option>View only</option><option>Editor</option><option>Approver</option><option>Manager</option><option>Administrator</option></select></div>
|
||||||
|
<div class="form-field"><label>Color</label><select name="color"><option value="var(--av-1)">Utopia Green</option><option value="var(--av-3)">Periwinkle</option><option value="var(--av-6)">Teal</option><option value="var(--av-7)">Violet</option><option value="var(--av-8)">Rose</option></select></div>
|
||||||
|
</div></form>`,
|
||||||
|
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button><button class="btn btn-primary" onclick="RBAC._saveRole()">${UI.icon('check')} Create Role</button>`
|
||||||
|
});
|
||||||
|
};
|
||||||
|
RBAC._saveRole = function () {
|
||||||
|
const form = document.getElementById('roleForm');
|
||||||
|
UI.clearErrors(form);
|
||||||
|
const f = Object.fromEntries(new FormData(form));
|
||||||
|
if (!f.name.trim()) { UI.fieldError(form.querySelector('[name=name]'), 'Required'); return; }
|
||||||
|
const levelMap = { 'View only': 'View', 'Editor': 'Edit', 'Approver': 'Approve', 'Manager': 'Manage', 'Administrator': 'Administrator' };
|
||||||
|
const level = levelMap[f.template] || 'View';
|
||||||
|
const idxMap = { 'View': 1, 'Edit': 3, 'Approve': 5, 'Manage': 7, 'Administrator': 8 };
|
||||||
|
const cutoff = idxMap[level];
|
||||||
|
const matrix = {};
|
||||||
|
DB.rbacModules.forEach(mod => matrix[mod] = DB.permTypes.map((p, i) => i < cutoff));
|
||||||
|
DB.rbacRoles.push({ name: f.name, users: 0, color: f.color, desc: f.desc || 'Custom role', level, matrix });
|
||||||
|
RBAC._state.roleIdx = DB.rbacRoles.length - 1;
|
||||||
|
UI.closeModal(); RBAC._renderRoles(); RBAC._renderDetail();
|
||||||
|
UI.toast('Role "' + f.name + '" created', 'success');
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
/* ============================================================
|
||||||
|
recruiterhub.js — Personalized recruiter dashboard + leaderboard
|
||||||
|
============================================================ */
|
||||||
|
window.Views = window.Views || {};
|
||||||
|
window.RecruiterHub = {};
|
||||||
|
|
||||||
|
Views.recruiterhub = function () {
|
||||||
|
const state = { recId: DB.recruiters[0].id };
|
||||||
|
RecruiterHub._state = state;
|
||||||
|
|
||||||
|
const recOpts = DB.recruiters.map(r => `<option value="${r.id}">${r.name}</option>`).join('');
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">Recruiter Hub</h1><p class="page-sub">Personalized performance dashboard & workload</p></div>
|
||||||
|
<div class="page-head-actions">
|
||||||
|
<select class="select" id="recSelect">${recOpts}</select>
|
||||||
|
<button class="btn btn-secondary" onclick="UI.toast('Report exported','success')">${UI.icon('download')} Export</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="recHubBody"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
onMount() {
|
||||||
|
RecruiterHub._render();
|
||||||
|
document.getElementById('recSelect').onchange = e => { state.recId = e.target.value; RecruiterHub._render(); };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
RecruiterHub._render = function () {
|
||||||
|
const r = DB.getRecruiter(RecruiterHub._state.recId);
|
||||||
|
const el = document.getElementById('recHubBody');
|
||||||
|
const slaCls = r.sla === 'On Track' ? 'b-green' : r.sla === 'At Risk' ? 'b-amber' : 'b-red';
|
||||||
|
|
||||||
|
const kpi = (label, val, icn, cls, sub) => `<div class="kpi"><div class="kpi-top"><span class="kpi-label">${label}</span><span class="kpi-icn ${cls}">${UI.icon(icn)}</span></div><div class="kpi-value">${val}</div>${sub ? `<div class="kpi-foot"><span class="kpi-foot-text">${sub}</span></div>` : ''}</div>`;
|
||||||
|
|
||||||
|
// leaderboard
|
||||||
|
const board = [...DB.recruiters].sort((a, b) => b.hires - a.hires).slice(0, 8);
|
||||||
|
const leaderHtml = board.map((rec, i) => {
|
||||||
|
const rankCls = i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : '';
|
||||||
|
return `<div class="leader-row" style="${rec.id === r.id ? 'background:var(--primary-soft);border-radius:10px;padding-left:8px;padding-right:8px' : ''}">
|
||||||
|
<span class="leader-rank ${rankCls}">${i + 1}</span>
|
||||||
|
${UI.avatar(rec.name, rec.initials, rec.color)}
|
||||||
|
<div class="lr-main"><div class="lr-title">${rec.name}</div><div class="lr-sub">${rec.efficiency}% efficiency · ${rec.avgTimeToHire}d avg</div></div>
|
||||||
|
<div class="lr-right"><div class="fw-600">${rec.hires}</div><div class="lr-sub">hires</div></div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// heatmap
|
||||||
|
const maxHeat = 5;
|
||||||
|
const heatColor = v => { const t = v / maxHeat; return t === 0 ? 'var(--bg-sunken)' : `rgba(79,70,229,${0.2 + t * 0.8})`; };
|
||||||
|
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
|
const heatHtml = `<div class="heatmap">
|
||||||
|
<div class="hm-label"></div>${['W1', 'W2', 'W3', 'W4', 'W5'].map(w => `<div class="hm-label" style="justify-content:center">${w}</div>`).join('')}
|
||||||
|
${days.map((d, di) => `<div class="hm-label">${d}</div>${r.heatmap[di].map(v => `<div class="hm-cell" style="background:${heatColor(v)}" data-tip="${v} interviews"></div>`).join('')}`).join('')}
|
||||||
|
</div>
|
||||||
|
<div class="hm-legend">Less ${[0, 1, 2, 3, 5].map(v => `<span class="hm-box" style="background:${heatColor(v)}"></span>`).join('')} More</div>`;
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card brand-hero mb-18">
|
||||||
|
<div class="card-body" style="display:flex;align-items:center;gap:18px">
|
||||||
|
${UI.avatar(r.name, r.initials, 'rgba(255,255,255,.18)', 'avatar-lg')}
|
||||||
|
<div style="flex:1"><div style="font-size:20px;font-weight:700">${r.name}</div><div style="opacity:.85">${r.department} Recruiter · ⭐ ${r.rating} rating</div></div>
|
||||||
|
<div style="text-align:center"><div style="font-size:26px;font-weight:800">${r.workload}%</div><div style="opacity:.85;font-size:12px">Workload</div></div>
|
||||||
|
<div style="text-align:center"><div style="font-size:26px;font-weight:800">${r.efficiency}%</div><div style="opacity:.85;font-size:12px">Efficiency</div></div>
|
||||||
|
<div style="text-align:center">${UI.badge(r.sla, slaCls)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid g-kpi mb-18">
|
||||||
|
${kpi('Open Positions', r.openPositions, 'briefcase', 'i-indigo', 'active reqs')}
|
||||||
|
${kpi('Closed Positions', r.closedPositions, 'check-circle', 'i-green', 'this year')}
|
||||||
|
${kpi('Avg Time to Hire', r.avgTimeToHire + 'd', 'clock', 'i-teal', 'target 30d')}
|
||||||
|
${kpi('Avg Time to Fill', r.avgTimeToFill + 'd', 'target', 'i-amber', 'req → offer')}
|
||||||
|
</div>
|
||||||
|
<div class="grid g-kpi mb-18">
|
||||||
|
${kpi('Interviews Today', r.interviewsToday, 'calendar', 'i-purple')}
|
||||||
|
${kpi('Offers Pending', r.offersPending, 'file', 'i-blue')}
|
||||||
|
${kpi('Awaiting Approval', r.jobsAwaitingApproval, 'clock', 'i-amber')}
|
||||||
|
${kpi('Jobs Overdue', r.jobsOverdue, 'alert', 'i-red')}
|
||||||
|
</div>
|
||||||
|
<div class="grid g-kpi mb-18">
|
||||||
|
${kpi('Conversion Rate', r.conversionRate + '%', 'trending-up', 'i-green', 'applicant → hire')}
|
||||||
|
${kpi('Interview Completion', r.interviewCompletion + '%', 'check-square', 'i-teal')}
|
||||||
|
${kpi('Avg Response Time', r.avgResponseTime + 'h', 'zap', 'i-purple', 'to candidates')}
|
||||||
|
${kpi('TAT Performance', r.tat + '%', 'award', 'i-indigo', 'turnaround')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid g-2-1 mb-18">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Monthly Hiring Trend</h3><span class="ch-sub">Hires per month</span></div></div>
|
||||||
|
<div class="card-body"><div class="chart-wrap"><canvas id="recTrend" height="260"></canvas></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Workload Heatmap</h3><span class="ch-sub">Interview load</span></div></div>
|
||||||
|
<div class="card-body">${heatHtml}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid g-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Recruiter Leaderboard</h3><span class="ch-sub">Top performers by hires</span></div></div>
|
||||||
|
<div class="card-body">${leaderHtml}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Candidate Pipeline</h3><span class="ch-sub">This recruiter's active candidates</span></div></div>
|
||||||
|
<div class="card-body"><div class="chart-wrap"><canvas id="recPipeline" height="260"></canvas></div></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
Charts.line(document.getElementById('recTrend'), { labels: DB.analytics.hiringTrend.labels, area: true, datasets: [{ label: 'Hires', data: r.monthlyTrend, color: Charts.PALETTE[0] }] });
|
||||||
|
const stageCounts = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'].map(() => DB.int(2, 14));
|
||||||
|
Charts.horizontalBar(document.getElementById('recPipeline'), {
|
||||||
|
labels: ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'], data: stageCounts,
|
||||||
|
colors: Charts.PALETTE
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
/* ============================================================
|
||||||
|
reports.js — Reports page (cards, charts, table, export)
|
||||||
|
============================================================ */
|
||||||
|
window.Views = window.Views || {};
|
||||||
|
|
||||||
|
Views.reports = function () {
|
||||||
|
const a = DB.analytics;
|
||||||
|
const reportCards = [
|
||||||
|
{ title: 'Total Hires (YTD)', val: a.hiringTrend.hires.reduce((s, v) => s + v, 0), icn: 'award', cls: 'i-green', sub: '+18% vs last year' },
|
||||||
|
{ title: 'Total Applications', val: a.hiringTrend.applications.reduce((s, v) => s + v, 0).toLocaleString(), icn: 'users', cls: 'i-blue', sub: 'across all channels' },
|
||||||
|
{ title: 'Avg. Time to Hire', val: '27 days', icn: 'clock', cls: 'i-teal', sub: '3 days faster' },
|
||||||
|
{ title: 'Avg. Cost per Hire', val: '$4,280', icn: 'dollar', cls: 'i-amber', sub: 'within budget' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const deptRows = a.departments.map(d => {
|
||||||
|
const rate = Math.round((d.open ? d.apps / (d.open * 40) : 0.5) * 100);
|
||||||
|
return { dept: d.dept, open: d.open, apps: d.apps, hires: DB.int(1, 8), ttf: DB.int(28, 52), rate: Math.min(rate, 98) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const table = UI.dataTable({
|
||||||
|
pageSize: 10,
|
||||||
|
rows: deptRows,
|
||||||
|
columns: [
|
||||||
|
{ key: 'dept', label: 'Department', sortable: true, render: r => `<span class="cell-primary">${r.dept}</span>` },
|
||||||
|
{ key: 'open', label: 'Open Roles', sortable: true, align: 'center' },
|
||||||
|
{ key: 'apps', label: 'Applications', sortable: true, align: 'center', render: r => `<b>${r.apps}</b>` },
|
||||||
|
{ key: 'hires', label: 'Hires', sortable: true, align: 'center' },
|
||||||
|
{ key: 'ttf', label: 'Time to Fill', sortable: true, align: 'center', render: r => r.ttf + ' days' },
|
||||||
|
{ key: 'rate', label: 'Fill Rate', sortable: true, render: r => `<div class="flex items-center gap-8"><div style="flex:1">${UI.pbar(r.rate)}</div><b style="width:38px;text-align:right">${r.rate}%</b></div>` }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportTypes = [
|
||||||
|
{ name: 'Hiring Funnel Report', desc: 'Conversion rates across each pipeline stage', icn: 'filter', cls: 'i-indigo' },
|
||||||
|
{ name: 'Source Effectiveness', desc: 'ROI and quality by sourcing channel', icn: 'target', cls: 'i-teal' },
|
||||||
|
{ name: 'Diversity & Inclusion', desc: 'Demographic breakdown of the pipeline', icn: 'users', cls: 'i-purple' },
|
||||||
|
{ name: 'Recruiter Scorecard', desc: 'Individual performance metrics', icn: 'award', cls: 'i-amber' },
|
||||||
|
{ name: 'Offer Analysis', desc: 'Acceptance rates and compensation trends', icn: 'file', cls: 'i-green' },
|
||||||
|
{ name: 'Interview Analytics', desc: 'Interviewer load and feedback quality', icn: 'calendar', cls: 'i-blue' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">Reports</h1><p class="page-sub">Recruitment metrics and downloadable insights</p></div>
|
||||||
|
<div class="page-head-actions">
|
||||||
|
<select class="select"><option>Last 7 months</option><option>This quarter</option><option>This year</option></select>
|
||||||
|
<button class="btn btn-primary" onclick="UI.toast('Full report exported to PDF','success')">${UI.icon('download')} Export Report</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid g-kpi mb-18">
|
||||||
|
${reportCards.map(c => `<div class="kpi"><div class="kpi-top"><span class="kpi-label">${c.title}</span><span class="kpi-icn ${c.cls}">${UI.icon(c.icn)}</span></div><div class="kpi-value">${c.val}</div><div class="kpi-foot"><span class="kpi-foot-text">${c.sub}</span></div></div>`).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid g-2 mb-18">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Hiring Funnel</h3><span class="ch-sub">Stage-by-stage conversion</span></div>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="UI.toast('Chart exported','info')">${UI.icon('download')}</button></div>
|
||||||
|
<div class="card-body"><div class="chart-wrap"><canvas id="rptFunnel" height="280"></canvas></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Time to Hire vs Fill</h3><span class="ch-sub">Monthly trend (days)</span></div></div>
|
||||||
|
<div class="card-body"><div class="chart-wrap"><canvas id="rptTime" height="280"></canvas></div>
|
||||||
|
${Charts.legend([{ label: 'Time to Hire', color: Charts.PALETTE[0] }, { label: 'Time to Fill', color: Charts.PALETTE[2] }])}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-18">
|
||||||
|
<div class="card-head"><div><h3>Department Performance</h3><span class="ch-sub">Hiring breakdown by team</span></div>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="UI.toast('Table exported to CSV','success')">${UI.icon('download')} CSV</button></div>
|
||||||
|
${table.html}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Report Library</h3><span class="ch-sub">Generate a detailed report</span></div></div>
|
||||||
|
<div class="card-body"><div class="grid g-3">
|
||||||
|
${reportTypes.map(r => `
|
||||||
|
<div class="card" style="box-shadow:none;background:var(--bg-sunken);cursor:pointer" onclick="UI.toast('Generating: ${r.name}','info')">
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="kpi-icn ${r.cls}" style="margin-bottom:12px">${UI.icon(r.icn)}</span>
|
||||||
|
<div class="lr-title">${r.name}</div>
|
||||||
|
<div class="lr-sub" style="margin-top:4px">${r.desc}</div>
|
||||||
|
<div style="margin-top:12px;color:var(--primary);font-weight:600;font-size:13px">Generate ${UI.icon('chevron-right')}</div>
|
||||||
|
</div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
onMount() {
|
||||||
|
table.mount();
|
||||||
|
const funnel = [{ stage: 'Applied', v: 100 }, { stage: 'Screened', v: 62 }, { stage: 'Assessed', v: 41 }, { stage: 'Interviewed', v: 28 }, { stage: 'Offered', v: 14 }, { stage: 'Hired', v: 9 }];
|
||||||
|
Charts.bar(document.getElementById('rptFunnel'), {
|
||||||
|
labels: funnel.map(f => f.stage), data: funnel.map(f => f.v),
|
||||||
|
colors: Charts.PALETTE, yFmt: v => v + '%'
|
||||||
|
});
|
||||||
|
Charts.groupedBar(document.getElementById('rptTime'), {
|
||||||
|
labels: DB.analytics.hiringTrend.labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: 'Time to Hire', data: DB.analytics.timeToHire, color: Charts.PALETTE[0] },
|
||||||
|
{ label: 'Time to Fill', data: DB.analytics.timeToFill, color: Charts.PALETTE[2] }
|
||||||
|
], yFmt: v => v + 'd'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
/* ============================================================
|
||||||
|
settings.js — Settings page with many tabs
|
||||||
|
============================================================ */
|
||||||
|
window.Views = window.Views || {};
|
||||||
|
window.Settings = {};
|
||||||
|
|
||||||
|
Views.settings = function () {
|
||||||
|
const tabs = ['General', 'Users', 'Roles', 'Permissions', 'Notifications', 'Email Templates', 'Career Portal', 'Branding', 'Security', 'Appearance'];
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">Settings</h1><p class="page-sub">Configure your workspace and team preferences</p></div>
|
||||||
|
<div class="page-head-actions"><button class="btn btn-primary" onclick="UI.toast('Settings saved','success')">${UI.icon('check')} Save Changes</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="tabs" id="setTabs">${tabs.map((t, i) => `<div class="tab ${i === 0 ? 'active' : ''}" data-tab="${i}">${t}</div>`).join('')}</div>
|
||||||
|
<div id="setPanes">
|
||||||
|
${tabs.map((t, i) => `<div class="tab-pane ${i === 0 ? 'active' : ''}" data-pane="${i}">${Settings.pane(t)}</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
onMount() {
|
||||||
|
const panes = document.querySelectorAll('#setPanes .tab-pane');
|
||||||
|
document.querySelectorAll('#setTabs .tab').forEach(tab => tab.onclick = () => {
|
||||||
|
document.querySelectorAll('#setTabs .tab').forEach(t => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
panes.forEach(p => p.classList.remove('active'));
|
||||||
|
panes[+tab.dataset.tab].classList.add('active');
|
||||||
|
if (tab.textContent === 'Appearance') Settings._bindTheme();
|
||||||
|
});
|
||||||
|
Settings._bindTheme();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function toggleRow(title, desc, checked) {
|
||||||
|
return `<div class="setting-row"><div class="setting-info"><h4>${title}</h4><p>${desc}</p></div>
|
||||||
|
<label class="switch"><input type="checkbox" ${checked ? 'checked' : ''} onchange="UI.toast('Preference updated','success')"/><span class="switch-track"></span></label></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings.pane = function (name) {
|
||||||
|
if (name === 'General') {
|
||||||
|
return `<div class="card"><div class="card-body">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-field"><label>Organization Name</label><input value="Utopia Brands Inc."/></div>
|
||||||
|
<div class="form-field"><label>Company Website</label><input value="https://utopiabrands.com"/></div>
|
||||||
|
<div class="form-field"><label>Industry</label><select><option>Consumer Goods</option><option>Technology</option><option>Retail</option></select></div>
|
||||||
|
<div class="form-field"><label>Company Size</label><select><option>201–500</option><option>51–200</option><option>500+</option></select></div>
|
||||||
|
<div class="form-field"><label>Default Time Zone</label><select><option>(GMT-08:00) Pacific Time</option><option>(GMT-05:00) Eastern Time</option><option>(GMT+00:00) UTC</option></select></div>
|
||||||
|
<div class="form-field"><label>Default Currency</label><select><option>USD ($)</option><option>EUR (€)</option><option>GBP (£)</option></select></div>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
${toggleRow('Auto-archive stale jobs', 'Automatically close requisitions inactive for 90 days', true)}
|
||||||
|
${toggleRow('Duplicate detection', 'Flag candidates that already exist in the system', true)}
|
||||||
|
</div></div>`;
|
||||||
|
}
|
||||||
|
if (name === 'Users') {
|
||||||
|
const rows = DB.users.map(u => `<tr>
|
||||||
|
<td><div class="user-cell">${UI.avatar(u.name, u.initials, u.color)}<div><div class="cell-primary">${u.name}</div><div class="cell-sub">${u.email}</div></div></div></td>
|
||||||
|
<td>${UI.badge(u.role, 'b-indigo')}</td>
|
||||||
|
<td>${UI.badge(u.status)}</td>
|
||||||
|
<td class="text-muted">${u.lastActive}</td>
|
||||||
|
<td style="text-align:right"><div class="row-actions"><button class="act-btn" onclick="UI.toast('Editing ${u.name}','info')">${UI.icon('edit')}</button><button class="act-btn danger" onclick="UI.toast('User removed','success')">${UI.icon('trash')}</button></div></td>
|
||||||
|
</tr>`).join('');
|
||||||
|
return `<div class="card">
|
||||||
|
<div class="card-head"><div><h3>Team Members</h3><span class="ch-sub">${DB.users.length} users</span></div>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="UI.toast('Invite sent','success')">${UI.icon('plus')} Invite User</button></div>
|
||||||
|
<div class="table-wrap"><table class="data"><thead><tr><th>User</th><th>Role</th><th>Status</th><th>Last Active</th><th style="text-align:right">Actions</th></tr></thead><tbody>${rows}</tbody></table></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (name === 'Roles') {
|
||||||
|
return `<div class="card"><div class="card-head"><div><h3>Roles</h3><span class="ch-sub">Define access levels</span></div>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="UI.toast('New role dialog','info')">${UI.icon('plus')} Add Role</button></div>
|
||||||
|
<div class="card-body"><div class="list-tight">
|
||||||
|
${DB.roles.map(r => `<div class="list-row">
|
||||||
|
<span class="kpi-icn i-purple" style="width:40px;height:40px;border-radius:11px">${UI.icon('users')}</span>
|
||||||
|
<div class="lr-main"><div class="lr-title">${r.name}</div><div class="lr-sub">${r.desc}</div></div>
|
||||||
|
<div class="lr-right"><div class="fw-600">${r.users} users</div><div class="lr-sub">${r.perms}</div></div>
|
||||||
|
<button class="act-btn" onclick="UI.toast('Editing ${r.name} role','info')">${UI.icon('edit')}</button>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div></div></div>`;
|
||||||
|
}
|
||||||
|
if (name === 'Permissions') {
|
||||||
|
const modules = ['Jobs', 'Candidates', 'Interviews', 'Offers', 'Reports', 'Settings'];
|
||||||
|
const perms = ['View', 'Create', 'Edit', 'Delete'];
|
||||||
|
return `<div class="card"><div class="card-head"><div><h3>Permission Matrix</h3><span class="ch-sub">Recruiter role</span></div>
|
||||||
|
<select class="select"><option>Recruiter</option><option>Hiring Manager</option><option>Administrator</option></select></div>
|
||||||
|
<div class="table-wrap"><table class="data"><thead><tr><th>Module</th>${perms.map(p => `<th style="text-align:center">${p}</th>`).join('')}</tr></thead>
|
||||||
|
<tbody>${modules.map(m => `<tr><td class="cell-primary">${m}</td>${perms.map((p, i) => `<td style="text-align:center">
|
||||||
|
<label class="switch"><input type="checkbox" ${(i < 3 || m !== 'Settings') && !(m === 'Settings') ? 'checked' : ''} onchange="UI.toast('Permission updated','success')"/><span class="switch-track"></span></label></td>`).join('')}</tr>`).join('')}</tbody></table></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (name === 'Notifications') {
|
||||||
|
return `<div class="card"><div class="card-body">
|
||||||
|
<div class="form-section-title" style="margin-top:0">Email Notifications</div>
|
||||||
|
${toggleRow('New applications', 'Get notified when a candidate applies', true)}
|
||||||
|
${toggleRow('Interview reminders', 'Reminders 30 minutes before interviews', true)}
|
||||||
|
${toggleRow('Offer responses', 'When candidates accept or decline offers', true)}
|
||||||
|
${toggleRow('Weekly digest', 'A summary of hiring activity every Monday', false)}
|
||||||
|
<div class="form-section-title">In-App Notifications</div>
|
||||||
|
${toggleRow('Mentions', 'When a teammate @mentions you', true)}
|
||||||
|
${toggleRow('Stage changes', 'When a candidate moves stages', false)}
|
||||||
|
${toggleRow('Task assignments', 'When you are assigned a task', true)}
|
||||||
|
</div></div>`;
|
||||||
|
}
|
||||||
|
if (name === 'Email Templates') {
|
||||||
|
const templates = ['Application Received', 'Interview Invitation', 'Assessment Assignment', 'Offer Letter', 'Rejection — Post Interview', 'Reference Request'];
|
||||||
|
return `<div class="card"><div class="card-head"><div><h3>Email Templates</h3></div>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="UI.toast('New template','info')">${UI.icon('plus')} New Template</button></div>
|
||||||
|
<div class="card-body"><div class="list-tight">
|
||||||
|
${templates.map(t => `<div class="list-row"><span class="kpi-icn i-blue" style="width:38px;height:38px;border-radius:10px">${UI.icon('mail')}</span>
|
||||||
|
<div class="lr-main"><div class="lr-title">${t}</div><div class="lr-sub">Last edited 3 days ago</div></div>
|
||||||
|
${UI.badge('Active', 'b-green')}<button class="act-btn" onclick="UI.toast('Editing template','info')">${UI.icon('edit')}</button></div>`).join('')}
|
||||||
|
</div></div></div>`;
|
||||||
|
}
|
||||||
|
if (name === 'Career Portal') {
|
||||||
|
return `<div class="card"><div class="card-body">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-field col-span-2"><label>Careers Page URL</label><input value="https://careers.utopiabrands.com"/></div>
|
||||||
|
<div class="form-field"><label>Page Headline</label><input value="Build the future with us"/></div>
|
||||||
|
<div class="form-field"><label>Primary CTA Text</label><input value="View Open Roles"/></div>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
${toggleRow('Public job board', 'Make open roles visible to the public', true)}
|
||||||
|
${toggleRow('Allow one-click apply', 'Let candidates apply with LinkedIn', true)}
|
||||||
|
${toggleRow('Show salary ranges', 'Display compensation on job listings', false)}
|
||||||
|
${toggleRow('Enable referrals', 'Employees can refer candidates', true)}
|
||||||
|
</div></div>`;
|
||||||
|
}
|
||||||
|
if (name === 'Branding') {
|
||||||
|
return `<div class="card"><div class="card-body">
|
||||||
|
<div class="setting-row"><div class="setting-info"><h4>Company Logo</h4><p>Displayed on career pages and emails</p></div>
|
||||||
|
<div class="flex items-center gap-12"><span class="brand-logo" style="width:48px;height:48px">UB</span><button class="btn btn-secondary btn-sm" onclick="UI.toast('Upload dialog','info')">Upload</button></div></div>
|
||||||
|
<div class="setting-row"><div class="setting-info"><h4>Brand Color</h4><p>Primary accent across the portal</p></div>
|
||||||
|
<div class="flex items-center gap-8">
|
||||||
|
${['#004d43', '#ceff71', '#25e9a5', '#8e92ff', '#1a3134', '#eafff4'].map(c => `<span style="width:28px;height:28px;border-radius:8px;background:${c};cursor:pointer;border:2px solid var(--border)" onclick="UI.toast('Brand color updated','success')"></span>`).join('')}
|
||||||
|
</div></div>
|
||||||
|
<div class="form-grid" style="margin-top:16px">
|
||||||
|
<div class="form-field"><label>Email Footer</label><input value="Utopia Brands · San Francisco, CA"/></div>
|
||||||
|
<div class="form-field"><label>Support Email</label><input value="talent@utopiabrands.com"/></div>
|
||||||
|
</div>
|
||||||
|
</div></div>`;
|
||||||
|
}
|
||||||
|
if (name === 'Security') {
|
||||||
|
return `<div class="card"><div class="card-body">
|
||||||
|
${toggleRow('Two-factor authentication', 'Require 2FA for all team members', true)}
|
||||||
|
${toggleRow('Single Sign-On (SSO)', 'Enable SAML-based SSO login', false)}
|
||||||
|
${toggleRow('IP allowlist', 'Restrict access to approved IP ranges', false)}
|
||||||
|
${toggleRow('Audit logging', 'Track all data access and changes', true)}
|
||||||
|
<div class="form-grid" style="margin-top:16px">
|
||||||
|
<div class="form-field"><label>Session Timeout</label><select><option>30 minutes</option><option>1 hour</option><option>8 hours</option></select></div>
|
||||||
|
<div class="form-field"><label>Password Policy</label><select><option>Strong (12+ chars)</option><option>Medium (8+ chars)</option></select></div>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="setting-row"><div class="setting-info"><h4>Data Retention</h4><p>Auto-delete candidate data after set period</p></div>
|
||||||
|
<select class="select"><option>24 months</option><option>12 months</option><option>36 months</option></select></div>
|
||||||
|
</div></div>`;
|
||||||
|
}
|
||||||
|
if (name === 'Appearance') {
|
||||||
|
return `<div class="card"><div class="card-body">
|
||||||
|
<div class="form-section-title" style="margin-top:0">Theme</div>
|
||||||
|
<div class="grid g-3" style="margin-bottom:8px">
|
||||||
|
<div class="card theme-opt" data-theme-set="light" style="cursor:pointer;overflow:hidden">
|
||||||
|
<div style="height:80px;background:#f1f7f4;border-bottom:1px solid var(--border);display:flex"><div style="width:30%;background:#1a3134"></div><div style="flex:1;padding:10px"><div style="height:8px;background:#fff;border-radius:4px;margin-bottom:6px"></div><div style="height:8px;width:45%;background:#004d43;border-radius:4px"></div></div></div>
|
||||||
|
<div class="card-body" style="padding:12px"><div class="fw-600">Light</div><div class="lr-sub">Clean and bright</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card theme-opt" data-theme-set="dark" style="cursor:pointer;overflow:hidden">
|
||||||
|
<div style="height:80px;background:#0e1d1f;border-bottom:1px solid var(--border);display:flex"><div style="width:30%;background:#0a1618"></div><div style="flex:1;padding:10px"><div style="height:8px;background:#24403f;border-radius:4px;margin-bottom:6px"></div><div style="height:8px;width:45%;background:#ceff71;border-radius:4px"></div></div></div>
|
||||||
|
<div class="card-body" style="padding:12px"><div class="fw-600">Dark</div><div class="lr-sub">Easy on the eyes</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card theme-opt" data-theme-set="system" style="cursor:pointer;overflow:hidden">
|
||||||
|
<div style="height:80px;background:linear-gradient(90deg,#f1f7f4 50%,#0e1d1f 50%);border-bottom:1px solid var(--border)"></div>
|
||||||
|
<div class="card-body" style="padding:12px"><div class="fw-600">System</div><div class="lr-sub">Match OS setting</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
${toggleRow('Compact mode', 'Reduce spacing for denser layouts', false)}
|
||||||
|
${toggleRow('Show animations', 'Enable transitions and motion', true)}
|
||||||
|
</div></div>`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
Settings._bindTheme = function () {
|
||||||
|
document.querySelectorAll('.theme-opt').forEach(opt => opt.onclick = () => {
|
||||||
|
const mode = opt.dataset.themeSet;
|
||||||
|
if (mode === 'system') { App.setTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); }
|
||||||
|
else App.setTheme(mode);
|
||||||
|
UI.toast('Theme updated to ' + mode, 'success');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
/* ============================================================
|
||||||
|
tasks.js — Recruitment Tasks (with saved searches & favorites)
|
||||||
|
============================================================ */
|
||||||
|
window.Views = window.Views || {};
|
||||||
|
window.Tasks = {};
|
||||||
|
|
||||||
|
Views.tasks = function () {
|
||||||
|
const state = { filter: 'All' };
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const el = document.getElementById('taskList');
|
||||||
|
if (!el) return;
|
||||||
|
let list = DB.tasks;
|
||||||
|
if (state.filter === 'Open') list = list.filter(t => !t.done);
|
||||||
|
else if (state.filter === 'Completed') list = list.filter(t => t.done);
|
||||||
|
else if (state.filter === 'Overdue') list = list.filter(t => !t.done && t.due < new Date('2026-07-09'));
|
||||||
|
else if (['High', 'Medium', 'Low'].includes(state.filter)) list = list.filter(t => t.priority === state.filter);
|
||||||
|
|
||||||
|
if (!list.length) { el.innerHTML = `<div class="empty-state">${UI.icon('check-square')}<h3>All caught up</h3><p>No tasks in this view.</p></div>`; return; }
|
||||||
|
const prCls = { High: 'b-red', Medium: 'b-amber', Low: 'b-gray' };
|
||||||
|
el.innerHTML = list.map(t => {
|
||||||
|
const overdue = !t.done && t.due < new Date('2026-07-09');
|
||||||
|
return `<div class="list-row" style="align-items:center">
|
||||||
|
<span class="checkbox ${t.done ? 'on' : ''}" data-id="${t.id}">${UI.icon('check')}</span>
|
||||||
|
<div class="lr-main" style="cursor:pointer" onclick="Tasks.open('${t.id}')">
|
||||||
|
<div class="lr-title" style="${t.done ? 'text-decoration:line-through;color:var(--text-3)' : ''}">${t.title}</div>
|
||||||
|
<div class="lr-sub">${UI.icon('users')} ${t.assignee} · ${t.type}</div>
|
||||||
|
</div>
|
||||||
|
<div class="lr-right">
|
||||||
|
${UI.badge(t.priority, prCls[t.priority])}
|
||||||
|
<div class="lr-sub" style="margin-top:4px;${overdue ? 'color:var(--danger);font-weight:600' : ''}">${overdue ? 'Overdue · ' : 'Due '}${DB.fmtShort(t.due)}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
el.querySelectorAll('.checkbox').forEach(chk => chk.onclick = e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const t = DB.tasks.find(x => x.id === chk.dataset.id);
|
||||||
|
t.done = !t.done; render(); App.updateBadges();
|
||||||
|
UI.toast(t.done ? 'Task completed' : 'Task reopened', t.done ? 'success' : 'info');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Tasks._render = render;
|
||||||
|
|
||||||
|
const openCount = DB.tasks.filter(t => !t.done).length;
|
||||||
|
const overdueCount = DB.tasks.filter(t => !t.done && t.due < new Date('2026-07-09')).length;
|
||||||
|
const filters = ['All', 'Open', 'Completed', 'Overdue', 'High', 'Medium', 'Low'];
|
||||||
|
|
||||||
|
// saved searches sidebar
|
||||||
|
const savedHtml = DB.savedSearches.map(s => `
|
||||||
|
<div class="list-row" style="cursor:pointer" onclick="Router.go('candidates')">
|
||||||
|
<span class="kpi-icn i-indigo" style="width:36px;height:36px;border-radius:9px">${UI.icon('bookmark')}</span>
|
||||||
|
<div class="lr-main"><div class="lr-title">${s.name}</div><div class="lr-sub">${s.filters}</div></div>
|
||||||
|
<span class="badge b-gray badge-plain">${s.count}</span>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div><h1 class="page-title">Tasks</h1><p class="page-sub">${openCount} open · ${overdueCount} overdue</p></div>
|
||||||
|
<div class="page-head-actions"><button class="btn btn-primary" onclick="Tasks.add()">${UI.icon('plus')} New Task</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid g-2-1">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body" style="padding-bottom:0">
|
||||||
|
<div class="seg" id="taskSeg">${filters.map((f, i) => `<button class="${i === 0 ? 'active' : ''}" data-f="${f}">${f}</button>`).join('')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body"><div class="list-tight" id="taskList"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="align-self:start">
|
||||||
|
<div class="card-head"><div><h3>Saved Searches</h3><span class="ch-sub">Quick candidate filters</span></div>
|
||||||
|
<button class="act-btn" onclick="UI.toast('New saved search','info')">${UI.icon('plus')}</button></div>
|
||||||
|
<div class="card-body"><div class="list-tight">${savedHtml}</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
onMount() {
|
||||||
|
render();
|
||||||
|
document.querySelectorAll('#taskSeg button').forEach(b => b.onclick = () => {
|
||||||
|
document.querySelectorAll('#taskSeg button').forEach(x => x.classList.remove('active'));
|
||||||
|
b.classList.add('active'); state.filter = b.dataset.f; render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
Tasks.open = function (id) {
|
||||||
|
const t = DB.tasks.find(x => x.id === id);
|
||||||
|
const c = DB.getCandidate(t.candidateId);
|
||||||
|
UI.modal({
|
||||||
|
title: t.title, subtitle: t.id + ' · ' + t.type,
|
||||||
|
body: `<div class="info-grid" style="margin-bottom:16px">
|
||||||
|
<div class="info-item"><div class="il">Assignee</div><div class="iv">${t.assignee}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Priority</div><div class="iv">${t.priority}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Due Date</div><div class="iv">${DB.fmtDate(t.due)}</div></div>
|
||||||
|
<div class="info-item"><div class="il">Status</div><div class="iv">${t.done ? 'Completed' : 'Open'}</div></div>
|
||||||
|
${c ? `<div class="info-item"><div class="il">Candidate</div><div class="iv">${c.name}</div></div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="form-field"><label>Notes</label><textarea placeholder="Add task notes…"></textarea></div>`,
|
||||||
|
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Close</button>
|
||||||
|
${c ? `<button class="btn btn-secondary" onclick="UI.closeModal();Candidates.openProfile('${c.id}')">View Candidate</button>` : ''}
|
||||||
|
<button class="btn btn-primary" onclick="UI.closeModal();Tasks._complete('${id}')">${UI.icon('check')} Mark Complete</button>`
|
||||||
|
});
|
||||||
|
};
|
||||||
|
Tasks._complete = function (id) { const t = DB.tasks.find(x => x.id === id); t.done = true; Tasks._render(); App.updateBadges(); UI.toast('Task completed', 'success'); };
|
||||||
|
|
||||||
|
Tasks.add = function () {
|
||||||
|
const opt = arr => arr.map(o => `<option>${o}</option>`).join('');
|
||||||
|
UI.modal({
|
||||||
|
title: 'New Task', subtitle: 'Create a recruitment task',
|
||||||
|
body: `<form id="taskForm" novalidate><div class="form-grid">
|
||||||
|
<div class="form-field col-span-2"><label>Task Title <span class="req">*</span></label><input name="title" placeholder="e.g. Screen candidate"/><span class="field-error">Required</span></div>
|
||||||
|
<div class="form-field"><label>Priority</label><select name="priority"><option>High</option><option selected>Medium</option><option>Low</option></select></div>
|
||||||
|
<div class="form-field"><label>Type</label><select name="type">${opt(['Interview', 'Review', 'Offer', 'Admin'])}</select></div>
|
||||||
|
<div class="form-field"><label>Assignee</label><select name="assignee">${opt(DB.recruiters.map(r => r.name))}</select></div>
|
||||||
|
<div class="form-field"><label>Due Date</label><input name="due" type="date"/></div>
|
||||||
|
</div></form>`,
|
||||||
|
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button><button class="btn btn-primary" onclick="Tasks._save()">${UI.icon('check')} Create Task</button>`
|
||||||
|
});
|
||||||
|
};
|
||||||
|
Tasks._save = function () {
|
||||||
|
const form = document.getElementById('taskForm');
|
||||||
|
UI.clearErrors(form);
|
||||||
|
const f = Object.fromEntries(new FormData(form));
|
||||||
|
if (!f.title.trim()) { UI.fieldError(form.querySelector('[name=title]'), 'Required'); return; }
|
||||||
|
DB.tasks.unshift({ id: 'TSK-' + (50001 + DB.tasks.length), title: f.title, candidateId: null, priority: f.priority, due: f.due ? new Date(f.due) : new Date('2026-07-16'), assignee: f.assignee, done: false, type: f.type });
|
||||||
|
UI.closeModal(); Tasks._render(); App.updateBadges();
|
||||||
|
UI.toast('Task created', 'success');
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
/* ============================================================
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
Loading…
Reference in New Issue