commit 889d48ef7cf64d04eae8706e3bc8b54516b3531c Author: sheheryarsoomro12 Date: Wed Jul 29 19:02:13 2026 +0500 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..893658f --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/css/styles.css b/css/styles.css new file mode 100644 index 0000000..124f191 --- /dev/null +++ b/css/styles.css @@ -0,0 +1,1269 @@ +/* ============================================================ + TalentFlow ATS — Utopia Brands Design System + ------------------------------------------------------------ + Palette source: Utopia Brands Brand Guideline + Primary #004d43 deep green · #1a3134 ink teal + #ceff71 lime · #eafff4 mint white + Secondary #25e9a5 mint · #8e92ff periwinkle + Neutral #ffffff · #000000 + Typography + Display / main headings ....... Belleza + UI, body & sub-headings ....... Neue Montreal (Inter fallback) + + Theme model + Light — white/mint surfaces, deep green as the action colour. + Dark — brand ink-teal surfaces, lime as the action colour. + Because the action colour inverts between themes, ALWAYS pair + `background: var(--primary)` with `color: var(--primary-fg)`. + The same applies to --success/--warning/--danger/--info. + ============================================================ */ + +/* ---------- Brand constants (identical in both themes) ---------- */ +:root { + --brand-green: #004d43; + --brand-ink: #1a3134; + --brand-lime: #ceff71; + --brand-mint: #25e9a5; + --brand-peri: #8e92ff; + --brand-tint: #eafff4; +} + +/* ============================ LIGHT ============================ */ +:root { + color-scheme: light; + + --bg: #f1f7f4; + --bg-elev: #ffffff; + --bg-sunken: #e8f2ec; + + --sidebar-bg: #1a3134; + --sidebar-fg: #9fb8b4; + --sidebar-fg-active: #ffffff; + --sidebar-active-bg: rgba(206,255,113,.14); + --sidebar-rail: #ceff71; + + --border: #dbe8e2; + --border-strong: #c2d6ce; + + --text: #10231f; + --text-2: #4a625c; + --text-3: #54726c; + + /* Action colour — deep brand green on light surfaces */ + --primary: #004d43; + --primary-600: #00382f; + --primary-fg: #ffffff; + --primary-soft: #eafff4; + --primary-border: #b4e3d2; + + /* Signature accent — lime. Light on white, so it is a SURFACE + colour here, never a text colour. Use --accent-ink for text. */ + --accent: #ceff71; + --accent-fg: #1a3134; + --accent-ink: #4f6619; + --accent-soft: #f4ffdf; + + --success: #00734f; --success-fg: #ffffff; --success-soft: #d9f7ec; + --warning: #8a5a00; --warning-fg: #ffffff; --warning-soft: #fff2d9; + --danger: #b3243a; --danger-fg: #ffffff; --danger-soft: #ffe6ea; + --info: #0d6580; --info-fg: #ffffff; --info-soft: #e4f4f9; + --purple: #4b4fd6; --purple-fg: #ffffff; --purple-soft: #ecedff; + --teal: #00734f; --teal-soft: #d9f7ec; + + --shadow-sm: 0 1px 2px rgba(10,35,31,.05); + --shadow: 0 1px 3px rgba(10,35,31,.07), 0 1px 2px rgba(10,35,31,.04); + --shadow-md: 0 4px 12px rgba(10,35,31,.08), 0 2px 4px rgba(10,35,31,.05); + --shadow-lg: 0 12px 32px rgba(10,35,31,.12), 0 4px 8px rgba(10,35,31,.06); + + --radius-sm: 7px; + --radius: 11px; + --radius-lg: 16px; + --radius-xl: 22px; + --sidebar-w: 262px; + --sidebar-w-collapsed: 74px; + --topbar-h: 64px; + + --font: 'Neue Montreal', 'PP Neue Montreal', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + --font-display: 'Belleza', 'Neue Montreal', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --mono: 'SF Mono', ui-monospace, 'Cascadia Code', Menlo, monospace; + + /* Categorical chart series — brand hues darkened to hold >=3:1 + against white so lines and small marks stay legible. */ + --c1: #004d43; --c2: #0f9d76; --c3: #5b60e8; --c4: #6f8f14; + --c5: #0e7490; --c6: #8a5a00; --c7: #a8327d; --c8: #3f6d64; + + /* Avatar + pipeline-stage ramps. Both invert with the theme, so the + JS emits `var(--av-n)` / `var(--stage-n)` rather than fixed hex. */ + --avatar-fg: #ffffff; + --av-1: #004d43; --av-2: #1a3134; --av-3: #0f5f4a; --av-4: #2f4858; + --av-5: #4b4fd6; --av-6: #155e63; --av-7: #5b3f8f; --av-8: #8a2f4a; + + --stage-1: #0e7490; --stage-2: #5b60e8; --stage-3: #8a5a00; --stage-4: #004d43; + --stage-5: #0f9d76; --stage-6: #6f8f14; --stage-7: #b3243a; + + --ring: 0 0 0 3px rgba(0,77,67,.20); + /* select chevron: whole url() is tokenised so the stroke can follow the theme */ + --chev-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2354726c' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); +} + +/* ============================= DARK ============================= */ +[data-theme="dark"] { + color-scheme: dark; + + --bg: #0e1d1f; + --bg-elev: #16292c; + --bg-sunken: #1a3134; + + --sidebar-bg: #0a1618; + --sidebar-fg: #8fa9a4; + --sidebar-fg-active: #ffffff; + --sidebar-active-bg: rgba(206,255,113,.12); + --sidebar-rail: #ceff71; + + --border: #24403f; + --border-strong: #33534f; + + --text: #e6f2ec; + --text-2: #a3bdb6; + --text-3: #8fada6; + + /* Action colour inverts to lime — brand's dark-surface highlight */ + --primary: #ceff71; + --primary-600: #dcff96; + --primary-fg: #0d2523; + --primary-soft: rgba(206,255,113,.08); + --primary-border: rgba(206,255,113,.32); + + --accent: #ceff71; + --accent-fg: #12292b; + --accent-ink: #ceff71; + --accent-soft: rgba(206,255,113,.10); + + --success: #25e9a5; --success-fg: #06251b; --success-soft: rgba(37,233,165,.12); + --warning: #f5c451; --warning-fg: #2a1e04; --warning-soft: rgba(245,196,81,.12); + --danger: #ff7a8a; --danger-fg: #2d0a10; --danger-soft: rgba(255,122,138,.12); + --info: #5fd3e8; --info-fg: #05242b; --info-soft: rgba(95,211,232,.12); + --purple: #8e92ff; --purple-fg: #12133a; --purple-soft: rgba(142,146,255,.12); + --teal: #25e9a5; --teal-soft: rgba(37,233,165,.12); + + --shadow-sm: 0 1px 2px rgba(0,0,0,.45); + --shadow: 0 1px 3px rgba(0,0,0,.55); + --shadow-md: 0 4px 14px rgba(0,0,0,.6); + --shadow-lg: 0 14px 40px rgba(0,0,0,.65); + + /* Same series, brightened for dark surfaces — lime and mint lead. */ + --c1: #ceff71; --c2: #25e9a5; --c3: #8e92ff; --c4: #a8e063; + --c5: #5fd3e8; --c6: #f5c451; --c7: #ff9ec4; --c8: #7fb3aa; + + --avatar-fg: #0d2523; + --av-1: #ceff71; --av-2: #25e9a5; --av-3: #8e92ff; --av-4: #a8e063; + --av-5: #5fd3e8; --av-6: #f5c451; --av-7: #ff9ec4; --av-8: #7fb3aa; + + --stage-1: #5fd3e8; --stage-2: #8e92ff; --stage-3: #f5c451; --stage-4: #ceff71; + --stage-5: #25e9a5; --stage-6: #a8e063; --stage-7: #ff7a8a; + + --ring: 0 0 0 3px rgba(206,255,113,.28); + --chev-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238fada6' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html { + height: 100%; + /* iOS inflates text when a phone is rotated to landscape unless this is + pinned; Windows/Android high-contrast modes need the adjust too. */ + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} +body { + min-height: 100%; + font-family: var(--font); + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + /* Grey flash on tap (iOS/Android WebKit + Chromium); we provide our own + :active feedback further down instead. */ + -webkit-tap-highlight-color: transparent; + /* Stop the whole page rubber-banding behind fixed chrome on iOS. */ + overscroll-behavior-y: none; +} +svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +button { + font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; + touch-action: manipulation; /* removes the legacy 300ms tap delay */ +} +input, select, textarea { font-family: inherit; font-size: 14px; color: var(--text); } +a { color: inherit; text-decoration: none; -webkit-tap-highlight-color: transparent; } +img, svg, video, canvas { max-width: 100%; } + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 20px; border: 2px solid transparent; background-clip: padding-box; } +::-webkit-scrollbar-thumb:hover { background: var(--text-3); background-clip: padding-box; } +::selection { background: var(--brand-lime); color: var(--brand-ink); } + +/* Firefox / non-WebKit scrollbars (WebKit ignores these). */ +@supports (scrollbar-color: auto) { + * { scrollbar-color: var(--border-strong) transparent; scrollbar-width: thin; } +} + +/* ============================================================ + INPUT MODALITY + Hover styling belongs to devices that can hover. On touch, :hover + sticks after a tap and leaves rows/buttons stuck in a hover state, + so every hover rule is gated and touch gets :active feedback instead. + ============================================================ */ +@media (hover: none) { + .btn:active, .icon-btn:active, .nav-item:active, .act-btn:active, + .page-btn:active, .dropdown-link:active, .prompt-chip:active, + .search-item:active, .k-card:active, .platform-card:active, + .role-item:active, .inbox-item:active, .tab:active, .pill-tab:active { + opacity: .68; + transition: opacity .05s; + } +} + +/* Prevent iOS Safari from zooming the page when a control gains focus. + Safari zooms whenever the focused field is under 16px, so on any coarse + pointer the controls step up to 16px rather than shrinking the viewport. */ +@media (pointer: coarse) { + input, select, textarea, + .form-field input, .form-field select, .form-field textarea, + .toolbar-search input, .topbar-search input, .chat-input-bar textarea, .select { + font-size: 16px; + } +} + +/* ============================================================ + BRAND TYPOGRAPHY + Guideline: "main heading always in main typeface belleza / + content & sub-heading always in neue montreal". + Belleza is a display face — reserved for page-level and modal + titles, hero numbers and the wordmark. Everything structural + (labels, tables, controls) stays in the UI face for legibility. + ============================================================ */ +.page-title, +.modal-head h2, +.brand-name, +.ph-name, +.ai-hero h2, +.empty-state h3 { + font-family: var(--font-display); + font-weight: 400; + letter-spacing: 0; +} +.page-title { font-size: 30px; line-height: 1.15; } +.modal-head h2 { font-size: 22px; } +.ph-name { font-size: 25px; } +.brand-name { font-size: 18px; font-weight: 400; letter-spacing: .2px; } + +/* Metrics read as data, not prose — keep them in the UI face, + tabular-lining so digits don't jitter as values update. */ +.kpi-value, .stat-mini-val, .ats-ring .ats-num, .cell-mono, .mono, +table.data td, .k-count, .nav-badge { + font-variant-numeric: tabular-nums; +} + +/* ============================================================ + FOCUS & MOTION (accessibility) + ============================================================ */ +:where(a, button, input, select, textarea, [tabindex]):focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + border-radius: var(--radius-sm); +} +:focus:not(:focus-visible) { outline: none; } +.sidebar :where(a, button):focus-visible { outline-color: var(--brand-lime); } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: .01ms !important; + animation-iteration-count: 1 !important; + transition-duration: .01ms !important; + scroll-behavior: auto !important; + } +} + +/* dvh tracks the visible area as mobile browser chrome collapses; the vh + line above it is the fallback for engines without dvh support. */ +#app { display: flex; min-height: 100vh; min-height: 100dvh; } + +/* ================= SIDEBAR ================= */ +.sidebar { + width: var(--sidebar-w); + background: var(--sidebar-bg); + color: var(--sidebar-fg); + display: flex; + flex-direction: column; + position: sticky; + top: 0; + height: 100vh; + height: 100dvh; + flex-shrink: 0; + transition: width .22s cubic-bezier(.4,0,.2,1); + z-index: 60; +} +.sidebar-brand { + display: flex; align-items: center; gap: 12px; + padding: 18px 20px; + height: var(--topbar-h); + border-bottom: 1px solid rgba(255,255,255,.06); + position: relative; +} +.brand-logo { + width: 38px; height: 38px; border-radius: 10px; flex-shrink: 0; + background: var(--brand-green); + display: grid; place-items: center; + color: var(--brand-lime); + box-shadow: 0 4px 12px rgba(0,0,0,.28); +} +/* The global `svg` rule strokes icons; the emblem is a filled shape. */ +.brand-mark { width: 22px; height: auto; fill: currentColor; stroke: none; } +.brand-text { display: flex; flex-direction: column; line-height: 1.1; overflow: hidden; } +.brand-name { color: #fff; } +.brand-sub { color: var(--sidebar-fg); font-size: 11px; letter-spacing: .3px; } +.sidebar-collapse-btn { + margin-left: auto; color: var(--sidebar-fg); width: 26px; height: 26px; + border-radius: 6px; display: grid; place-items: center; transition: .15s; +} +.sidebar-collapse-btn:hover { background: rgba(255,255,255,.08); color: #fff; } +.sidebar-collapse-btn svg { width: 18px; height: 18px; } + +.sidebar-nav { flex: 1; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; padding: 14px 12px; } +.sidebar-nav::-webkit-scrollbar { width: 6px; } +.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); } +.nav-section-label { + font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .9px; + color: var(--text-3); padding: 14px 12px 6px; +} +.nav-item { + display: flex; align-items: center; gap: 12px; + padding: 9px 12px; border-radius: 9px; margin-bottom: 2px; + color: var(--sidebar-fg); font-weight: 500; font-size: 13.5px; + transition: background .14s, color .14s; position: relative; white-space: nowrap; +} +.nav-item svg { width: 19px; height: 19px; flex-shrink: 0; stroke-width: 1.9; } +.nav-item:hover { background: rgba(255,255,255,.05); color: #fff; } +.nav-item.active { background: var(--sidebar-active-bg); color: #fff; } +.nav-item.active::before { + content: ''; position: absolute; left: -12px; top: 50%; transform: translateY(-50%); + width: 3px; height: 20px; background: var(--sidebar-rail); border-radius: 0 3px 3px 0; +} +.nav-badge { + margin-left: auto; background: rgba(255,255,255,.12); color: #fff; + font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 20px; min-width: 22px; text-align: center; +} +.nav-badge-alert { background: var(--danger); color: var(--danger-fg); } +.nav-badge-ai { background: var(--brand-lime); color: var(--brand-ink); font-weight: 700; letter-spacing: .3px; } +/* The sidebar is a dark surface in BOTH themes, so anything inside it must + use sidebar tokens. Left on the global text/surface tokens these dropped + to ~2:1 in light mode (the section labels and the Upgrade button). */ +.sidebar .nav-section-label { color: var(--sidebar-fg); } +.sidebar .btn-ghost { color: var(--sidebar-fg); } +.sidebar .btn-ghost:hover { background: rgba(255,255,255,.10); color: #fff; } +.sidebar .btn-secondary { + background: rgba(255,255,255,.06); color: #fff; border-color: rgba(255,255,255,.14); +} +.sidebar-footer { padding: 14px; border-top: 1px solid rgba(255,255,255,.06); } +.usage-card { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.07); border-radius: 12px; padding: 14px; } +.usage-top { display: flex; justify-content: space-between; font-size: 12px; color: var(--sidebar-fg); margin-bottom: 8px; } +.usage-top span:last-child { color: #fff; font-weight: 600; } +.usage-bar { height: 6px; background: rgba(255,255,255,.1); border-radius: 20px; overflow: hidden; margin-bottom: 12px; } +.usage-fill { height: 100%; background: linear-gradient(90deg, var(--brand-mint), var(--brand-lime)); border-radius: 20px; } + +/* Collapsed sidebar */ +.sidebar.collapsed { width: var(--sidebar-w-collapsed); } +.sidebar.collapsed .brand-text, +.sidebar.collapsed .nav-item span:not(.nav-badge), +.sidebar.collapsed .nav-section-label, +.sidebar.collapsed .sidebar-footer, +.sidebar.collapsed .nav-badge { display: none; } +.sidebar.collapsed .sidebar-collapse-btn { transform: rotate(180deg); position: absolute; right: 8px; } +.sidebar.collapsed .nav-item { justify-content: center; padding: 10px; } +.sidebar.collapsed .sidebar-brand { justify-content: center; padding: 18px 0; } + +/* ================= MAIN WRAP ================= */ +.main-wrap { flex: 1; display: flex; flex-direction: column; min-width: 0; } + +/* ================= TOPBAR ================= */ +.topbar { + height: var(--topbar-h); background: var(--bg-elev); border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 16px; padding: 0 22px; + position: sticky; top: 0; z-index: 50; +} +.menu-toggle { display: none; } +.topbar-search { position: relative; flex: 1; max-width: 480px; display: flex; align-items: center; } +.topbar-search > svg { position: absolute; left: 14px; width: 18px; height: 18px; color: var(--text-3); pointer-events: none; } +.topbar-search input { + width: 100%; padding: 9px 14px 9px 42px; border-radius: 10px; + background: var(--bg-sunken); border: 1px solid transparent; outline: none; transition: .15s; +} +.topbar-search input:focus { background: var(--bg-elev); border-color: var(--primary); box-shadow: var(--ring); } +.search-kbd { + position: absolute; right: 12px; font-family: var(--mono); font-size: 11px; + color: var(--text-3); background: var(--bg-elev); border: 1px solid var(--border); + padding: 2px 6px; border-radius: 5px; pointer-events: none; +} +.search-results { + position: absolute; top: calc(100% + 8px); left: 0; right: 0; + background: var(--bg-elev); border: 1px solid var(--border); border-radius: 12px; + box-shadow: var(--shadow-lg); max-height: 420px; overflow-y: auto; display: none; z-index: 80; padding: 6px; +} +.search-results.open { display: block; } +.search-group-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .6px; color: var(--text-3); padding: 8px 10px 4px; } +.search-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; cursor: pointer; } +.search-item:hover { background: var(--bg-sunken); } +.search-item .si-title { font-weight: 600; font-size: 13px; } +.search-item .si-sub { font-size: 12px; color: var(--text-3); } +.search-empty { padding: 24px; text-align: center; color: var(--text-3); font-size: 13px; } + +.topbar-actions { display: flex; align-items: center; gap: 6px; margin-left: auto; } +.topbar-divider { width: 1px; height: 30px; background: var(--border); margin: 0 6px; } +.icon-btn { position: relative; width: 40px; height: 40px; border-radius: 10px; display: grid; place-items: center; color: var(--text-2); transition: .15s; } +.icon-btn:hover { background: var(--bg-sunken); color: var(--text); } +.icon-btn svg { width: 20px; height: 20px; } +.icon-sun { display: none; } +[data-theme="dark"] .icon-sun { display: block; } +[data-theme="dark"] .icon-moon { display: none; } +.dot { position: absolute; top: 9px; right: 10px; width: 8px; height: 8px; border-radius: 50%; border: 2px solid var(--bg-elev); } +.dot-red { background: var(--danger); } +.dot-blue { background: var(--info); } + +.avatar { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; font-weight: 600; font-size: 13px; color: var(--avatar-fg); flex-shrink: 0; } +.avatar-grad { background: linear-gradient(135deg, var(--brand-green), var(--brand-mint)); color: #fff; } +.avatar-lg { width: 44px; height: 44px; font-size: 15px; } +.profile-btn { display: flex; align-items: center; gap: 10px; padding: 5px 8px 5px 5px; border-radius: 30px; transition: .15s; } +.profile-btn:hover { background: var(--bg-sunken); } +.profile-meta { display: flex; flex-direction: column; line-height: 1.2; text-align: left; } +.profile-name { font-weight: 600; font-size: 13px; } +.profile-role { font-size: 11.5px; color: var(--text-3); } +.chev { width: 16px; height: 16px; color: var(--text-3); } + +/* ================= DROPDOWN ================= */ +.dropdown { position: relative; } +.dropdown-menu { + position: absolute; top: calc(100% + 10px); right: 0; min-width: 230px; + background: var(--bg-elev); border: 1px solid var(--border); border-radius: 14px; + box-shadow: var(--shadow-lg); padding: 8px; opacity: 0; visibility: hidden; + transform: translateY(-6px); transition: .16s; z-index: 90; +} +.dropdown.open .dropdown-menu { opacity: 1; visibility: visible; transform: translateY(0); } +.dropdown-menu-wide { min-width: 340px; padding: 0; } +.dropdown-head { display: flex; justify-content: space-between; align-items: center; padding: 14px 16px; font-weight: 700; font-size: 14px; border-bottom: 1px solid var(--border); } +.dropdown-foot { padding: 10px 16px; border-top: 1px solid var(--border); text-align: center; } +.dropdown-foot a { color: var(--primary); font-weight: 600; font-size: 13px; } +.dropdown-profile { display: flex; gap: 12px; align-items: center; padding: 14px; } +.dp-name { font-weight: 600; } +.dp-email { font-size: 12px; color: var(--text-3); } +.dropdown-divider { height: 1px; background: var(--border); margin: 6px 0; } +.dropdown-link { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 8px; font-size: 13.5px; font-weight: 500; width: 100%; text-align: left; color: var(--text); } +.dropdown-link svg { width: 17px; height: 17px; color: var(--text-3); } +.dropdown-link:hover { background: var(--bg-sunken); } +.dropdown-link.danger { color: var(--danger); } +.dropdown-link.danger svg { color: var(--danger); } +.link-btn { color: var(--primary); font-size: 12px; font-weight: 600; } +.notif-row { display: flex; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; transition: .12s; } +.notif-row:hover { background: var(--bg-sunken); } +.notif-row.unread { background: var(--primary-soft); } +[data-theme="dark"] .notif-row.unread { background: var(--primary-soft); } +.notif-icn { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; flex-shrink: 0; } +.notif-icn svg { width: 16px; height: 16px; } +.notif-body { flex: 1; min-width: 0; } +.notif-title { font-size: 13px; font-weight: 600; } +.notif-text { font-size: 12.5px; color: var(--text-2); } +.notif-time { font-size: 11px; color: var(--text-3); margin-top: 3px; } +.dd-scroll { max-height: 360px; overflow-y: auto; overscroll-behavior: contain; } + +/* ================= CONTENT / PAGE ================= */ +.content { flex: 1; overflow-y: auto; overscroll-behavior-y: contain; -webkit-overflow-scrolling: touch; padding: 26px 30px 60px; } +.page { animation: fadeUp .3s ease; } +@keyframes fadeUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } +.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 24px; flex-wrap: wrap; } +.page-sub { color: var(--text-2); font-size: 14px; margin-top: 3px; } +.page-head-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.breadcrumb { display: flex; gap: 8px; align-items: center; font-size: 12.5px; color: var(--text-3); margin-bottom: 10px; } +.breadcrumb svg { width: 14px; height: 14px; } + +/* ================= BUTTONS ================= */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + padding: 9px 16px; border-radius: 10px; font-weight: 600; font-size: 13.5px; + transition: .15s; white-space: nowrap; border: 1px solid transparent; +} +.btn svg { width: 17px; height: 17px; } +.btn-primary { background: var(--primary); color: var(--primary-fg); box-shadow: var(--shadow-sm); } +.btn-primary:hover { background: var(--primary-600); } +.btn-secondary { background: var(--bg-elev); color: var(--text); border-color: var(--border-strong); box-shadow: var(--shadow-sm); } +.btn-secondary:hover { background: var(--bg-sunken); } +.btn-ghost { background: transparent; color: var(--text-2); } +.btn-ghost:hover { background: var(--bg-sunken); color: var(--text); } +.btn-danger { background: var(--danger); color: var(--danger-fg); } +.btn-danger:hover { filter: brightness(.94); } +.btn-block { width: 100%; margin-top: 12px; } +.btn-sm { padding: 6px 12px; font-size: 12.5px; } +.btn-icon { padding: 8px; width: 34px; height: 34px; } +.btn:disabled { opacity: .5; cursor: not-allowed; } + +/* ================= CARDS ================= */ +.card { background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); } +.card-pad { padding: 20px; } +.card-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px; border-bottom: 1px solid var(--border); gap: 12px; } +.card-head h3 { font-size: 15px; font-weight: 700; letter-spacing: -.2px; } +.card-head .ch-sub { font-size: 12.5px; color: var(--text-3); font-weight: 400; } +.card-body { padding: 20px; } + +.grid { display: grid; gap: 18px; } +.g-kpi { grid-template-columns: repeat(4, 1fr); } +.g-3 { grid-template-columns: repeat(3, 1fr); } +.g-2 { grid-template-columns: repeat(2, 1fr); } +.g-2-1 { grid-template-columns: 2fr 1fr; } +.g-1-2 { grid-template-columns: 1fr 2fr; } +.mt-18 { margin-top: 18px; } +.mb-18 { margin-bottom: 18px; } + +/* KPI card */ +.kpi { + background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius-lg); + padding: 18px 20px; box-shadow: var(--shadow-sm); position: relative; overflow: hidden; transition: .18s; +} +.kpi:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); } +.kpi-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } +.kpi-label { font-size: 12.5px; color: var(--text-2); font-weight: 500; } +.kpi-icn { width: 40px; height: 40px; border-radius: 11px; display: grid; place-items: center; } +.kpi-icn svg { width: 20px; height: 20px; } +.kpi-value { font-size: 28px; font-weight: 700; letter-spacing: -1px; line-height: 1; } +.kpi-foot { display: flex; align-items: center; gap: 6px; margin-top: 10px; font-size: 12.5px; } +.trend { display: inline-flex; align-items: center; gap: 3px; font-weight: 600; padding: 2px 7px; border-radius: 6px; font-size: 12px; } +.trend svg { width: 13px; height: 13px; } +.trend-up { color: var(--success); background: var(--success-soft); } +.trend-down { color: var(--danger); background: var(--danger-soft); } +.trend-flat { color: var(--text-2); background: var(--bg-sunken); } +.kpi-foot-text { color: var(--text-3); } +.i-indigo { background: var(--primary-soft); color: var(--primary); } +.i-green { background: var(--success-soft); color: var(--success); } +.i-amber { background: var(--warning-soft); color: var(--warning); } +.i-red { background: var(--danger-soft); color: var(--danger); } +.i-blue { background: var(--info-soft); color: var(--info); } +.i-purple { background: var(--purple-soft); color: var(--purple); } +.i-teal { background: var(--teal-soft); color: var(--teal); } + +/* ================= BADGES ================= */ +.badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; white-space: nowrap; } +.badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; } +.badge-plain::before { display: none; } +.b-green { color: var(--success); background: var(--success-soft); } +.b-amber { color: var(--warning); background: var(--warning-soft); } +.b-red { color: var(--danger); background: var(--danger-soft); } +.b-blue { color: var(--info); background: var(--info-soft); } +.b-purple { color: var(--purple); background: var(--purple-soft); } +.b-teal { color: var(--teal); background: var(--teal-soft); } +.b-gray { color: var(--text-2); background: var(--bg-sunken); } +.b-indigo { color: var(--primary); background: var(--primary-soft); } + +/* ================= TABLES ================= */ +/* Horizontal scroll is the right answer for wide data tables on phones; + make the gesture smooth and keep it from chaining to the page. */ +.table-wrap { overflow-x: auto; overscroll-behavior-x: contain; -webkit-overflow-scrolling: touch; } +table.data { width: 100%; border-collapse: collapse; font-size: 13.5px; } +table.data thead th { + text-align: left; padding: 12px 16px; font-size: 11.5px; font-weight: 700; + text-transform: uppercase; letter-spacing: .5px; color: var(--text-3); + border-bottom: 1px solid var(--border); white-space: nowrap; background: var(--bg-elev); position: sticky; top: 0; +} +table.data thead th.sortable { cursor: pointer; user-select: none; } +table.data thead th.sortable:hover { color: var(--text); } +.sort-ind { display: inline-block; margin-left: 4px; opacity: .4; font-size: 10px; } +th.sorted-asc .sort-ind, th.sorted-desc .sort-ind { opacity: 1; color: var(--primary); } +table.data tbody td { padding: 13px 16px; border-bottom: 1px solid var(--border); vertical-align: middle; } +table.data tbody tr { transition: background .12s; } +table.data tbody tr:hover { background: var(--bg-sunken); } +table.data tbody tr:last-child td { border-bottom: none; } +.cell-primary { font-weight: 600; color: var(--text); } +.cell-sub { font-size: 12px; color: var(--text-3); } +.cell-mono { font-family: var(--mono); font-size: 12.5px; color: var(--text-2); } +.user-cell { display: flex; align-items: center; gap: 11px; } +.user-cell .avatar { width: 34px; height: 34px; font-size: 12px; } +.row-actions { display: flex; gap: 4px; justify-content: flex-end; } +.act-btn { width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; color: var(--text-3); transition: .12s; } +.act-btn:hover { background: var(--bg-sunken); color: var(--primary); } +.act-btn.danger:hover { color: var(--danger); background: var(--danger-soft); } +.act-btn svg { width: 16px; height: 16px; } + +/* Toolbar */ +.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; } +.toolbar-search { position: relative; flex: 1; min-width: 200px; max-width: 340px; } +.toolbar-search svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-3); } +.toolbar-search input { width: 100%; padding: 8px 12px 8px 36px; border-radius: 9px; background: var(--bg-elev); border: 1px solid var(--border-strong); outline: none; } +.toolbar-search input:focus { border-color: var(--primary); box-shadow: var(--ring); } +.toolbar .spacer { flex: 1; } +.select { + padding: 8px 32px 8px 12px; border-radius: 9px; background: var(--bg-elev) var(--chev-url) no-repeat right 10px center; + background-size: 15px; border: 1px solid var(--border-strong); outline: none; cursor: pointer; appearance: none; font-weight: 500; +} +.select:focus { border-color: var(--primary); box-shadow: var(--ring); } + +/* Pagination */ +.pagination { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-top: 1px solid var(--border); flex-wrap: wrap; gap: 12px; } +.page-info { font-size: 13px; color: var(--text-2); } +.page-controls { display: flex; gap: 4px; align-items: center; } +.page-btn { min-width: 34px; height: 34px; padding: 0 8px; border-radius: 8px; display: grid; place-items: center; font-size: 13px; font-weight: 600; color: var(--text-2); border: 1px solid transparent; } +.page-btn:hover:not(:disabled) { background: var(--bg-sunken); } +.page-btn.active { background: var(--primary); color: var(--primary-fg); } +.page-btn:disabled { opacity: .4; cursor: not-allowed; } +.page-btn svg { width: 16px; height: 16px; } + +/* Progress */ +.pbar { height: 7px; background: var(--bg-sunken); border-radius: 20px; overflow: hidden; } +.pbar-fill { height: 100%; border-radius: 20px; background: var(--primary); transition: width .5s ease; } +.pbar-fill.green { background: var(--success); } +.pbar-fill.amber { background: var(--warning); } +.pbar-fill.red { background: var(--danger); } + +/* Score chip */ +.score { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; font-size: 13px; } +.score-ring { --pct: 0; position: relative; width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; + background: conic-gradient(var(--sc-color) calc(var(--pct)*1%), var(--bg-sunken) 0); } +.score-ring::after { content: ''; position: absolute; inset: 4px; border-radius: 50%; background: var(--bg-elev); } +.score-ring span { position: relative; z-index: 1; font-size: 10px; font-weight: 700; } + +/* ================= CHARTS ================= */ +.chart-wrap { position: relative; width: 100%; } +/* Without an explicit width a canvas keeps its 300px intrinsic size, so + every chart rendered at 300px regardless of its card. width:100% lets + setup() measure the real container and size the backing store to it. */ +canvas { width: 100%; max-width: 100%; display: block; } +.chart-legend { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 14px; justify-content: center; } +.legend-item { display: flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--text-2); } +.legend-dot { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; } +.chart-tooltip { + position: fixed; background: var(--text); color: var(--bg-elev); padding: 7px 11px; border-radius: 8px; + font-size: 12px; font-weight: 600; pointer-events: none; opacity: 0; transition: opacity .12s; z-index: 200; + box-shadow: var(--shadow-lg); white-space: nowrap; +} +[data-theme="dark"] .chart-tooltip { background: var(--brand-lime); color: var(--brand-ink); } + +/* ================= MODAL ================= */ +.modal-root { position: fixed; inset: 0; z-index: 300; display: none; } +.modal-root.open { display: block; } +.modal-backdrop { position: absolute; inset: 0; background: rgba(10,22,24,.55); backdrop-filter: blur(3px); animation: fadeIn .2s; } +[data-theme="dark"] .modal-backdrop { background: rgba(0,0,0,.65); } +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +.modal { + position: relative; margin: 5vh auto; max-width: 640px; width: calc(100% - 40px); + background: var(--bg-elev); border-radius: var(--radius-xl); box-shadow: var(--shadow-lg); + animation: modalIn .25s cubic-bezier(.34,1.3,.64,1); max-height: 90vh; display: flex; flex-direction: column; +} +.modal-lg { max-width: 860px; } +.modal-xl { max-width: 1040px; } +@keyframes modalIn { from { opacity: 0; transform: translateY(24px) scale(.97); } to { opacity: 1; transform: none; } } +.modal-head { display: flex; align-items: flex-start; justify-content: space-between; padding: 22px 24px; border-bottom: 1px solid var(--border); } +.modal-head p { font-size: 13px; color: var(--text-3); margin-top: 3px; } +.modal-close { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; color: var(--text-3); } +.modal-close:hover { background: var(--bg-sunken); color: var(--text); } +.modal-body { padding: 24px; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; } +.modal-foot { display: flex; justify-content: flex-end; gap: 10px; padding: 18px 24px; border-top: 1px solid var(--border); background: var(--bg-sunken); border-radius: 0 0 var(--radius-xl) var(--radius-xl); } + +/* ================= FORMS ================= */ +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.form-field { display: flex; flex-direction: column; gap: 6px; } +.form-field.col-span-2 { grid-column: 1 / -1; } +.form-field label { font-size: 12.5px; font-weight: 600; color: var(--text-2); } +.form-field label .req { color: var(--danger); } +.form-field input, .form-field select, .form-field textarea { + padding: 9px 12px; border-radius: 9px; background: var(--bg-elev); border: 1px solid var(--border-strong); outline: none; transition: .15s; width: 100%; +} +.form-field select { appearance: none; background: var(--bg-elev) var(--chev-url) no-repeat right 12px center; background-size: 15px; cursor: pointer; } +.form-field textarea { resize: vertical; min-height: 84px; } +.form-field input:focus, .form-field select:focus, .form-field textarea:focus { border-color: var(--primary); box-shadow: var(--ring); } +.form-field input.err, .form-field select.err, .form-field textarea.err { border-color: var(--danger); } +.field-error { font-size: 11.5px; color: var(--danger); display: none; } +.field-error.show { display: block; } +.form-section-title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .6px; color: var(--text-3); margin: 22px 0 4px; grid-column: 1/-1; } + +/* Switch */ +.switch { position: relative; display: inline-flex; align-items: center; } +.switch input { position: absolute; opacity: 0; width: 0; height: 0; } +.switch-track { width: 42px; height: 24px; border-radius: 20px; background: var(--border-strong); position: relative; transition: .2s; cursor: pointer; } +.switch-track::after { content: ''; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: .2s; box-shadow: var(--shadow-sm); } +.switch input:checked + .switch-track { background: var(--primary); } +.switch input:checked + .switch-track::after { background: var(--primary-fg); } +.switch input:checked + .switch-track::after { transform: translateX(18px); } +.setting-row { display: flex; align-items: center; justify-content: space-between; padding: 16px 0; border-bottom: 1px solid var(--border); gap: 20px; } +.setting-row:last-child { border-bottom: none; } +.setting-info h4 { font-size: 14px; font-weight: 600; } +.setting-info p { font-size: 13px; color: var(--text-3); margin-top: 2px; } + +/* ================= TABS ================= */ +.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 22px; overflow-x: auto; } +.tab { padding: 11px 16px; font-weight: 600; font-size: 13.5px; color: var(--text-2); border-bottom: 2px solid transparent; white-space: nowrap; transition: .15s; margin-bottom: -1px; } +.tab:hover { color: var(--text); } +.tab.active { color: var(--primary); border-bottom-color: var(--primary); } +.tab-pane { display: none; animation: fadeUp .25s; } +.tab-pane.active { display: block; } +.pill-tabs { display: inline-flex; gap: 4px; background: var(--bg-sunken); padding: 4px; border-radius: 11px; } +.pill-tab { padding: 7px 14px; border-radius: 8px; font-weight: 600; font-size: 13px; color: var(--text-2); transition: .15s; } +.pill-tab.active { background: var(--bg-elev); color: var(--text); box-shadow: var(--shadow-sm); } + +/* ================= KANBAN ================= */ +.kanban { display: flex; gap: 16px; overflow-x: auto; overscroll-behavior-x: contain; -webkit-overflow-scrolling: touch; padding-bottom: 12px; align-items: flex-start; scroll-snap-type: x proximity; } +.kanban-col { flex: 0 0 288px; background: var(--bg-sunken); border-radius: var(--radius-lg); display: flex; flex-direction: column; max-height: calc(100vh - 220px); max-height: calc(100dvh - 220px); } +.kanban-col-head { display: flex; align-items: center; gap: 8px; padding: 14px 16px; position: sticky; top: 0; } +.kanban-col-head .k-dot { width: 9px; height: 9px; border-radius: 50%; } +.kanban-col-head h4 { font-size: 13.5px; font-weight: 700; } +.k-count { margin-left: auto; background: var(--bg-elev); color: var(--text-2); font-size: 12px; font-weight: 700; padding: 1px 9px; border-radius: 20px; } +.kanban-cards { padding: 0 12px 12px; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; min-height: 60px; } +.kanban-cards.drag-over { background: var(--primary-soft); border-radius: 10px; outline: 2px dashed var(--primary); outline-offset: -4px; } +.k-card { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 11px; padding: 13px; cursor: grab; box-shadow: var(--shadow-sm); transition: .15s; } +.k-card:hover { box-shadow: var(--shadow-md); border-color: var(--border-strong); } +.k-card.dragging { opacity: .5; transform: rotate(2deg); cursor: grabbing; } +.k-card-top { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +.k-card-top .avatar { width: 32px; height: 32px; font-size: 11px; } +.kc-name { font-weight: 600; font-size: 13.5px; } +.kc-role { font-size: 12px; color: var(--text-3); } +.k-card-meta { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); } +.k-tags { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 8px; } +.tag { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 6px; background: var(--bg-sunken); color: var(--text-2); } + +/* ================= MISC ================= */ +.list-tight > * + * { border-top: 1px solid var(--border); } +.list-row { display: flex; align-items: center; gap: 12px; padding: 13px 0; } +.list-row:first-child { padding-top: 0; } +.list-row .avatar { width: 38px; height: 38px; font-size: 13px; } +.lr-main { flex: 1; min-width: 0; } +.lr-title { font-weight: 600; font-size: 13.5px; } +.lr-sub { font-size: 12.5px; color: var(--text-3); } +.lr-right { text-align: right; flex-shrink: 0; } +.timeline { position: relative; padding-left: 28px; } +.timeline::before { content: ''; position: absolute; left: 9px; top: 4px; bottom: 4px; width: 2px; background: var(--border); } +.tl-item { position: relative; padding-bottom: 22px; } +.tl-item:last-child { padding-bottom: 0; } +.tl-dot { position: absolute; left: -28px; top: 2px; width: 20px; height: 20px; border-radius: 50%; background: var(--bg-elev); border: 2px solid var(--primary); display: grid; place-items: center; } +.tl-dot svg { width: 11px; height: 11px; color: var(--primary); } +.tl-title { font-weight: 600; font-size: 13.5px; } +.tl-meta { font-size: 12px; color: var(--text-3); margin-top: 2px; } +.tl-desc { font-size: 13px; color: var(--text-2); margin-top: 5px; } + +.empty-state { text-align: center; padding: 60px 20px; color: var(--text-3); } +.empty-state svg { width: 48px; height: 48px; margin-bottom: 14px; opacity: .5; } +.empty-state h3 { font-size: 18px; color: var(--text-2); margin-bottom: 6px; } + +.avatar-stack { display: flex; } +.avatar-stack .avatar { width: 30px; height: 30px; font-size: 11px; border: 2px solid var(--bg-elev); margin-left: -8px; } +.avatar-stack .avatar:first-child { margin-left: 0; } +.more-count { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; background: var(--bg-sunken); color: var(--text-2); font-size: 11px; font-weight: 700; border: 2px solid var(--bg-elev); margin-left: -8px; } + +.stat-mini { display: flex; flex-direction: column; gap: 4px; } +.stat-mini-val { font-size: 22px; font-weight: 700; letter-spacing: -.5px; } +.stat-mini-lbl { font-size: 12.5px; color: var(--text-3); } + +.divider { height: 1px; background: var(--border); margin: 16px 0; } +.flex { display: flex; } +.items-center { align-items: center; } +.gap-8 { gap: 8px; } .gap-12 { gap: 12px; } .gap-16 { gap: 16px; } +.text-muted { color: var(--text-3); } +.fw-600 { font-weight: 600; } +.text-sm { font-size: 12.5px; } +.mono { font-family: var(--mono); } + +/* Calendar */ +.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; } +.cal-dow { background: var(--bg-elev); padding: 10px; text-align: center; font-size: 11.5px; font-weight: 700; text-transform: uppercase; color: var(--text-3); letter-spacing: .5px; } +.cal-cell { background: var(--bg-elev); min-height: 108px; padding: 8px; position: relative; transition: .12s; } +.cal-cell:hover { background: var(--bg-sunken); } +.cal-cell.other { background: var(--bg-sunken); } +.cal-date { font-size: 12.5px; font-weight: 600; color: var(--text-2); } +.cal-cell.today .cal-date { background: var(--primary); color: var(--primary-fg); width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; } +.cal-cell.other .cal-date { color: var(--text-3); } +.cal-event { font-size: 11px; font-weight: 600; padding: 3px 6px; border-radius: 5px; margin-top: 4px; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* Toast */ +.toast-root { position: fixed; bottom: 24px; right: 24px; z-index: 500; display: flex; flex-direction: column; gap: 10px; } +.toast { display: flex; align-items: center; gap: 12px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: 12px; padding: 13px 16px; box-shadow: var(--shadow-lg); min-width: 300px; max-width: 400px; animation: toastIn .3s cubic-bezier(.34,1.3,.64,1); } +@keyframes toastIn { from { opacity: 0; transform: translateX(40px); } to { opacity: 1; transform: none; } } +.toast.out { animation: toastOut .3s forwards; } +@keyframes toastOut { to { opacity: 0; transform: translateX(40px); } } +.toast-icn { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; flex-shrink: 0; } +.toast-icn svg { width: 18px; height: 18px; } +.toast-body { flex: 1; } +.toast-title { font-weight: 600; font-size: 13.5px; } +.toast-msg { font-size: 12.5px; color: var(--text-3); } +.toast-close { color: var(--text-3); width: 24px; height: 24px; display: grid; place-items: center; border-radius: 6px; } +.toast-close:hover { background: var(--bg-sunken); } + +/* Scrim */ +.scrim { position: fixed; inset: 0; background: rgba(10,22,24,.55); z-index: 55; display: none; } +.scrim.open { display: block; } + +/* Tooltip */ +[data-tip] { position: relative; } +[data-tip]::after { content: attr(data-tip); position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); background: var(--text); color: var(--bg-elev); padding: 5px 9px; border-radius: 7px; font-size: 11.5px; font-weight: 600; white-space: nowrap; opacity: 0; pointer-events: none; transition: .15s; z-index: 100; } +[data-tip]:hover::after { opacity: 1; } +/* Hover tooltips have no dismiss gesture on touch and stick after a tap. */ +@media (hover: none) { [data-tip]::after { content: none; } } +[data-theme="dark"] [data-tip]::after { background: var(--brand-lime); color: var(--brand-ink); } + +/* Segmented insight bars */ +.mini-bars { display: flex; align-items: flex-end; gap: 3px; height: 40px; } +.mini-bar { flex: 1; background: var(--primary-soft); border-radius: 3px 3px 0 0; min-height: 4px; transition: .3s; } +.mini-bar.hl { background: var(--primary); } + +/* Profile header */ +.profile-hero { display: flex; gap: 18px; align-items: center; margin-bottom: 4px; } +.profile-hero .avatar { width: 68px; height: 68px; font-size: 24px; } +.ph-role { color: var(--text-2); font-size: 14px; } +.ph-tags { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; } +.info-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 14px 24px; } +.info-item .il { font-size: 12px; color: var(--text-3); font-weight: 600; text-transform: uppercase; letter-spacing: .4px; } +.info-item .iv { font-size: 14px; font-weight: 500; margin-top: 3px; } + +/* ================= ENTERPRISE PHASE 2 ================= */ + +/* Split inbox layout */ +.split { display: grid; grid-template-columns: 380px 1fr; gap: 0; min-height: 560px; } +.split-list { border-right: 1px solid var(--border); overflow-y: auto; overscroll-behavior: contain; max-height: calc(100vh - 260px); max-height: calc(100dvh - 260px); } +.split-detail { overflow-y: auto; overscroll-behavior: contain; max-height: calc(100vh - 260px); max-height: calc(100dvh - 260px); } +.inbox-item { display: flex; gap: 12px; padding: 14px 18px; border-bottom: 1px solid var(--border); cursor: pointer; transition: .12s; position: relative; } +.inbox-item:hover { background: var(--bg-sunken); } +.inbox-item.active { background: var(--primary-soft); } +[data-theme="dark"] .inbox-item.active { background: var(--primary-soft); } +.inbox-item.unread::before { content: ''; position: absolute; left: 6px; top: 50%; transform: translateY(-50%); width: 6px; height: 6px; border-radius: 50%; background: var(--primary); } +.inbox-item.unread .ii-name { font-weight: 700; } +.ii-main { flex: 1; min-width: 0; } +.ii-name { font-weight: 600; font-size: 13.5px; display: flex; align-items: center; gap: 6px; } +.ii-pos { font-size: 12.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ii-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; } +.ii-time { font-size: 11px; color: var(--text-3); white-space: nowrap; } +/* `--chip` is the source's own brand colour, set inline. It tints the + background and fills the dot, while the label stays on theme text so + 11px copy keeps its contrast in both modes. */ +.source-chip { + display: inline-flex; align-items: center; gap: 5px; + font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; + --chip: var(--text-3); + color: var(--text-2); + background: var(--bg-sunken); /* fallback: color-mix needs Safari 16.2+ / Chrome 111+ */ + background: color-mix(in srgb, var(--chip) 14%, transparent); +} +.source-chip svg { width: 12px; height: 12px; color: var(--chip); } +.source-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: var(--chip); } + +.integration-status { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 20px; font-size: 12.5px; font-weight: 600; background: var(--success-soft); color: var(--success); } +.integration-status.pending { background: var(--warning-soft); color: var(--warning); } +.integration-status .pulse { width: 8px; height: 8px; border-radius: 50%; background: currentColor; position: relative; } +.integration-status .pulse::after { content: ''; position: absolute; inset: 0; border-radius: 50%; background: currentColor; animation: pulse 1.8s infinite; } +@keyframes pulse { 0% { transform: scale(1); opacity: .7; } 100% { transform: scale(3); opacity: 0; } } + +.email-preview { background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 12px; padding: 18px; white-space: pre-wrap; font-size: 13.5px; line-height: 1.7; color: var(--text-2); } +.attach-card { display: flex; align-items: center; gap: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-elev); } +.attach-icn { width: 42px; height: 42px; border-radius: 10px; background: var(--danger-soft); color: var(--danger); display: grid; place-items: center; } +.resume-thumb { border: 1px solid var(--border); border-radius: 10px; background: var(--bg-sunken); padding: 20px; font-family: var(--mono); font-size: 11px; color: var(--text-2); line-height: 1.8; max-height: 300px; overflow: hidden; position: relative; } +.resume-thumb::after { content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 60px; background: linear-gradient(transparent, var(--bg-sunken)); } + +/* Upload dropzone */ +.dropzone { border: 2px dashed var(--border-strong); border-radius: var(--radius-lg); padding: 48px 24px; text-align: center; transition: .18s; background: var(--bg-sunken); cursor: pointer; } +.dropzone.drag { border-color: var(--primary); background: var(--primary-soft); transform: scale(1.005); } +.dropzone .dz-icn { width: 64px; height: 64px; border-radius: 18px; background: var(--primary-soft); color: var(--primary); display: grid; place-items: center; margin: 0 auto 16px; } +.dropzone .dz-icn svg { width: 30px; height: 30px; } +.dropzone h3 { font-size: 17px; margin-bottom: 6px; } +.upload-row { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 11px; margin-top: 10px; background: var(--bg-elev); } +.upload-progress { height: 5px; background: var(--bg-sunken); border-radius: 20px; overflow: hidden; flex: 1; } +.upload-progress-fill { height: 100%; background: var(--primary); border-radius: 20px; transition: width .2s; } + +/* ATS match ring big */ +.ats-ring { --pct: 0; --c: var(--primary); position: relative; width: 120px; height: 120px; border-radius: 50%; display: grid; place-items: center; margin: 0 auto; + background: conic-gradient(var(--c) calc(var(--pct)*1%), var(--bg-sunken) 0); } +.ats-ring::after { content: ''; position: absolute; inset: 12px; border-radius: 50%; background: var(--bg-elev); } +.ats-ring .ats-val { position: relative; z-index: 1; text-align: center; } +.ats-ring .ats-num { font-size: 30px; font-weight: 800; letter-spacing: -1px; line-height: 1; } +.ats-ring .ats-lbl { font-size: 11px; color: var(--text-3); font-weight: 600; } +.skill-pill { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 8px; } +.skill-pill svg { width: 12px; height: 12px; } +.skill-matched { background: var(--success-soft); color: var(--success); } +.skill-missing { background: var(--danger-soft); color: var(--danger); } + +/* Platform publish card */ +.platform-card { display: flex; align-items: center; gap: 14px; padding: 16px; border: 1px solid var(--border); border-radius: 14px; transition: .15s; cursor: pointer; background: var(--bg-elev); } +.platform-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-sm); } +.platform-card.selected { border-color: var(--primary); box-shadow: var(--ring); background: var(--primary-soft); } +[data-theme="dark"] .platform-card.selected { background: var(--primary-soft); } +.platform-logo { width: 44px; height: 44px; border-radius: 11px; display: grid; place-items: center; color: #fff; flex-shrink: 0; } +.platform-check { width: 22px; height: 22px; border-radius: 6px; border: 2px solid var(--border-strong); display: grid; place-items: center; margin-left: auto; flex-shrink: 0; transition: .15s; } +.platform-card.selected .platform-check { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); } +.platform-check svg { width: 14px; height: 14px; opacity: 0; } +.platform-card.selected .platform-check svg { opacity: 1; } + +/* Stepper */ +.stepper { display: flex; align-items: center; margin-bottom: 24px; } +.step { display: flex; align-items: center; gap: 10px; } +.step-num { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-weight: 700; font-size: 13px; background: var(--bg-sunken); color: var(--text-3); border: 2px solid var(--border); } +.step.active .step-num { background: var(--primary); color: var(--primary-fg); border-color: var(--primary); } +.step.done .step-num { background: var(--success); color: var(--success-fg); border-color: var(--success); } +.step-label { font-size: 13px; font-weight: 600; color: var(--text-3); } +.step.active .step-label, .step.done .step-label { color: var(--text); } +.step-line { flex: 1; height: 2px; background: var(--border); margin: 0 14px; min-width: 20px; } +.step.done + .step-line, .step-line.done { background: var(--success); } + +/* Heatmap */ +.heatmap { display: grid; grid-template-columns: 40px repeat(5, 1fr); gap: 5px; } +.hm-label { font-size: 11px; color: var(--text-3); display: flex; align-items: center; } +.hm-cell { aspect-ratio: 1.4; border-radius: 5px; background: var(--bg-sunken); transition: .15s; cursor: pointer; } +.hm-cell:hover { outline: 2px solid var(--primary); } +.hm-legend { display: flex; align-items: center; gap: 4px; justify-content: flex-end; margin-top: 10px; font-size: 11px; color: var(--text-3); } +.hm-legend .hm-box { width: 13px; height: 13px; border-radius: 3px; } + +/* Leaderboard */ +.leader-row { display: flex; align-items: center; gap: 14px; padding: 12px 0; border-bottom: 1px solid var(--border); } +.leader-row:last-child { border-bottom: none; } +.leader-rank { width: 28px; height: 28px; border-radius: 8px; display: grid; place-items: center; font-weight: 800; font-size: 13px; background: var(--bg-sunken); color: var(--text-2); flex-shrink: 0; } +.leader-rank.gold { background: var(--accent); color: var(--accent-fg); } +.leader-rank.silver { background: var(--success-soft); color: var(--success); } +.leader-rank.bronze { background: var(--warning-soft); color: var(--warning); } + +/* RBAC matrix (Microsoft admin center style) */ +.rbac-layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; } +.role-list { display: flex; flex-direction: column; gap: 6px; } +.role-item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 11px; cursor: pointer; border: 1px solid transparent; transition: .12s; } +.role-item:hover { background: var(--bg-sunken); } +.role-item.active { background: var(--primary-soft); border-color: var(--primary); } +[data-theme="dark"] .role-item.active { background: var(--primary-soft); } +/* Role chips use the avatar ramp, which inverts with the theme. */ +.role-badge { width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center; color: var(--avatar-fg); flex-shrink: 0; } +.rbac-matrix { width: 100%; border-collapse: collapse; font-size: 13px; } +.rbac-matrix th { padding: 12px 8px; font-size: 11px; text-transform: uppercase; letter-spacing: .4px; color: var(--text-3); border-bottom: 1px solid var(--border); text-align: center; font-weight: 700; } +.rbac-matrix th:first-child { text-align: left; padding-left: 16px; } +.rbac-matrix td { padding: 10px 8px; border-bottom: 1px solid var(--border); text-align: center; } +.rbac-matrix td:first-child { text-align: left; padding-left: 16px; font-weight: 600; } +.perm-check { width: 22px; height: 22px; border-radius: 6px; border: 2px solid var(--border-strong); display: inline-grid; place-items: center; cursor: pointer; transition: .12s; } +.perm-check.on { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); } +.perm-check.on svg { width: 13px; height: 13px; } +.perm-check:not(.on) svg { display: none; } + +/* AI Assistant chat */ +.chat-wrap { display: flex; flex-direction: column; height: calc(100vh - 190px); height: calc(100dvh - 190px); } +.chat-scroll { flex: 1; overflow-y: auto; padding: 8px 4px 20px; } +.chat-msg { display: flex; gap: 12px; margin-bottom: 22px; max-width: 820px; } +.chat-msg .chat-av { width: 32px; height: 32px; border-radius: 9px; display: grid; place-items: center; flex-shrink: 0; color: #fff; } +.chat-av.ai { background: linear-gradient(135deg, var(--brand-green), var(--brand-mint)); } +.chat-av.user { background: var(--bg-sunken); color: var(--text-2); } +.chat-bubble { padding-top: 3px; } +.chat-role { font-weight: 700; font-size: 13px; margin-bottom: 4px; } +.chat-text { font-size: 14px; line-height: 1.65; color: var(--text); } +.chat-text p { margin-bottom: 10px; } +.chat-text ul { padding-left: 20px; margin-bottom: 10px; } +.chat-text li { margin-bottom: 4px; } +.chat-typing span { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--text-3); margin-right: 3px; animation: typing 1.2s infinite; } +.chat-typing span:nth-child(2) { animation-delay: .2s; } +.chat-typing span:nth-child(3) { animation-delay: .4s; } +@keyframes typing { 0%,60%,100% { opacity: .3; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-3px); } } +.chat-input-bar { border: 1px solid var(--border-strong); border-radius: 16px; padding: 10px 12px; display: flex; gap: 10px; align-items: flex-end; background: var(--bg-elev); box-shadow: var(--shadow-sm); } +.chat-input-bar:focus-within { border-color: var(--primary); box-shadow: var(--ring); } +.chat-input-bar textarea { flex: 1; border: none; outline: none; resize: none; background: transparent; font-size: 14px; max-height: 140px; line-height: 1.5; padding: 6px 4px; } +.chat-send { width: 38px; height: 38px; border-radius: 10px; background: var(--primary); color: var(--primary-fg); display: grid; place-items: center; flex-shrink: 0; transition: .15s; } +.chat-send:hover { background: var(--primary-600); } +.chat-send:disabled { opacity: .4; } +.prompt-chip { display: inline-flex; align-items: center; gap: 8px; padding: 9px 14px; border: 1px solid var(--border-strong); border-radius: 11px; font-size: 13px; font-weight: 500; cursor: pointer; transition: .15s; background: var(--bg-elev); text-align: left; } +.prompt-chip:hover { border-color: var(--primary); background: var(--primary-soft); color: var(--primary); } +.prompt-chip svg { width: 15px; height: 15px; color: var(--primary); } +.ai-hero { text-align: center; padding: 30px 0 24px; } +.ai-hero .ai-logo { width: 64px; height: 64px; border-radius: 20px; background: linear-gradient(135deg, var(--brand-green), var(--brand-mint)); display: grid; place-items: center; margin: 0 auto 16px; box-shadow: 0 10px 30px rgba(0,77,67,.35); } +.ai-hero .ai-logo svg { width: 32px; height: 32px; color: var(--brand-lime); } + +/* ============================================================ + BRAND HERO + Full-bleed brand-green panel used for page-level banners. It keeps + the same deep green in BOTH themes — it is a brand surface, not a + theme surface — so everything inside is scoped to light-on-green. + ============================================================ */ +.brand-hero { + background: linear-gradient(120deg, var(--brand-green), #0a6a58); + border: none; + color: #fff; + position: relative; + overflow: hidden; +} +/* Emblem-derived curve, echoing the logo's flowing edge. */ +.brand-hero::after { + content: ''; + position: absolute; right: -40px; top: -60px; + width: 260px; height: 260px; border-radius: 50%; + background: radial-gradient(circle at 30% 30%, rgba(206,255,113,.20), transparent 68%); + pointer-events: none; +} +.brand-hero .card-body { position: relative; z-index: 1; } +.brand-hero h2, .brand-hero h3 { color: #fff; } +.brand-hero p { color: rgba(255,255,255,.86); } +.brand-hero .ai-logo { background: rgba(206,255,113,.16); box-shadow: none; } +.brand-hero .avatar { color: #fff; } +.brand-hero .ai-logo svg { color: var(--brand-lime); } +.brand-hero .topbar-search > svg { color: var(--text-3); } +.brand-hero .topbar-search input { + background: #fff; color: #10231f; border-color: transparent; +} +.brand-hero .topbar-search input::placeholder { color: #54726c; } +.btn-on-brand { background: var(--brand-lime); color: var(--brand-ink); font-weight: 600; } +.btn-on-brand:hover { background: #dcff96; } + +/* AI FAB + dock */ +.ai-fab { position: fixed; bottom: 26px; right: 26px; width: 56px; height: 56px; border-radius: 50%; background: var(--brand-green); color: var(--brand-lime); display: grid; place-items: center; box-shadow: 0 10px 30px rgba(0,77,67,.45); z-index: 200; transition: .2s; } +.ai-fab:hover { transform: scale(1.08) rotate(8deg); } +/* Redundant (and overlapping the send button) on the assistant page itself. */ +[data-view="aiassistant"] .ai-fab { display: none; } +.ai-fab svg { width: 26px; height: 26px; } +.ai-dock { position: fixed; top: 0; right: 0; bottom: 0; width: 440px; max-width: 92vw; background: var(--bg-elev); border-left: 1px solid var(--border); box-shadow: var(--shadow-lg); z-index: 310; transform: translateX(100%); transition: transform .28s cubic-bezier(.4,0,.2,1); } +.ai-dock.open { transform: translateX(0); } +.ai-dock-inner { height: 100%; display: flex; flex-direction: column; } + +/* Bulk action bar */ +.bulk-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; background: var(--primary); color: var(--primary-fg); border-radius: 12px; margin-bottom: 14px; animation: fadeUp .2s; } +.bulk-bar .btn { background: rgba(255,255,255,.15); background: color-mix(in srgb, var(--primary-fg) 14%, transparent); color: var(--primary-fg); border: none; } +.bulk-bar .btn:hover { background: rgba(255,255,255,.25); background: color-mix(in srgb, var(--primary-fg) 24%, transparent); } +.checkbox { width: 18px; height: 18px; border-radius: 5px; border: 2px solid var(--border-strong); display: inline-grid; place-items: center; cursor: pointer; flex-shrink: 0; transition: .12s; background: var(--bg-elev); } +.checkbox.on { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); } +.checkbox svg { width: 12px; height: 12px; opacity: 0; } +.checkbox.on svg { opacity: 1; } + +/* Filter panel */ +.filter-panel { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } +.filter-panel .form-field label { font-size: 11.5px; } +.star-btn { color: var(--text-3); transition: .12s; } +.star-btn.on { color: var(--warning); } +.star-btn.on svg { fill: currentColor; } + +/* Segmented control */ +.seg { display: inline-flex; background: var(--bg-sunken); padding: 3px; border-radius: 10px; } +.seg button { padding: 6px 14px; border-radius: 8px; font-size: 13px; font-weight: 600; color: var(--text-2); } +.seg button.active { background: var(--bg-elev); color: var(--text); box-shadow: var(--shadow-sm); } + +.rating-stars { display: inline-flex; gap: 3px; } +.rating-stars .rs { color: var(--border-strong); cursor: pointer; transition: .1s; } +.rating-stars .rs svg { width: 22px; height: 22px; } +.rating-stars .rs.on { color: var(--warning); } +.rating-stars .rs.on svg { fill: currentColor; } + +.recc-banner { display: flex; align-items: center; gap: 14px; padding: 16px 18px; border-radius: 14px; margin-bottom: 18px; } +.recc-strong { background: var(--success-soft); color: var(--success); } +.recc-potential { background: var(--warning-soft); color: var(--warning); } +.recc-weak { background: var(--danger-soft); color: var(--danger); } +.recc-banner .recc-icn { width: 44px; height: 44px; border-radius: 12px; background: rgba(255,255,255,.5); display: grid; place-items: center; } +[data-theme="dark"] .recc-banner .recc-icn { background: rgba(0,0,0,.2); } + +/* ============================================================ + SAFE AREAS (iOS notch / Dynamic Island / home indicator, + and Android gesture bars). index.html sets viewport-fit=cover, + so the layout reaches the physical edges and these insets pull + the actual content back into the safe region. + ============================================================ */ +.sidebar { padding-left: env(safe-area-inset-left); } +.topbar { + padding-left: max(22px, env(safe-area-inset-left)); + padding-right: max(22px, env(safe-area-inset-right)); +} +.content { + padding-left: max(30px, env(safe-area-inset-left)); + padding-right: max(30px, env(safe-area-inset-right)); + padding-bottom: max(60px, env(safe-area-inset-bottom)); +} +.ai-fab { + right: max(26px, env(safe-area-inset-right)); + bottom: max(26px, env(safe-area-inset-bottom)); +} +.toast-root { + right: max(24px, env(safe-area-inset-right)); + bottom: max(24px, env(safe-area-inset-bottom)); +} +.ai-dock { padding-bottom: env(safe-area-inset-bottom); } +.modal-foot { padding-bottom: max(18px, env(safe-area-inset-bottom)); } + +/* ============================================================ + TOUCH TARGETS + Apple HIG asks for 44pt, Material for 48dp. Rather than inflate + the visuals everywhere, controls keep their drawn size and gain + an invisible centred hit area via ::after on coarse pointers. + ============================================================ */ +@media (pointer: coarse) { + .act-btn, .page-btn, .icon-btn, .modal-close, .toast-close, + .sidebar-collapse-btn, .checkbox, .perm-check, .rating-stars .rs, + .star-btn, .link-btn { + position: relative; + } + /* ::before, not ::after — .act-btn carries data-tip, whose tooltip is + rendered through [data-tip]::after and would be clobbered. */ + .act-btn::before, .page-btn::before, .icon-btn::before, .modal-close::before, + .toast-close::before, .sidebar-collapse-btn::before, .checkbox::before, + .perm-check::before, .rating-stars .rs::before, .star-btn::before { + content: ''; + position: absolute; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + width: 44px; height: 44px; + /* purely a hit area — never paints, never blocks the icon */ + pointer-events: auto; + } + .btn { min-height: 44px; padding-top: 11px; padding-bottom: 11px; } + .btn-sm { min-height: 40px; } + .nav-item { padding-top: 12px; padding-bottom: 12px; } + .dropdown-link { padding-top: 12px; padding-bottom: 12px; } + .tab { padding-top: 14px; padding-bottom: 14px; } + .list-row, .search-item, .notif-row { min-height: 48px; } + .form-field input, .form-field select, .form-field textarea, + .toolbar-search input, .select { min-height: 44px; } + .switch-track { width: 50px; height: 30px; } + .switch-track::after { width: 24px; height: 24px; } + .switch input:checked + .switch-track::after { transform: translateX(20px); } +} + +/* ============================================================ + RESPONSIVE + ≥1600 ultrawide · 1200 laptop · 980 tablet-landscape + 900 tablet-portrait (sidebar goes off-canvas) · 640 phone + 400 small phone · plus a landscape-phone height rule + ============================================================ */ + +/* Ultrawide: stop dashboards stretching to unreadable line lengths. */ +@media (min-width: 1600px) { + .content > .page { max-width: 1560px; margin-inline: auto; } +} + +@media (max-width: 1200px) { + .g-kpi { grid-template-columns: repeat(2, 1fr); } + .g-3 { grid-template-columns: 1fr; } + .g-2-1, .g-1-2, .g-2 { grid-template-columns: 1fr; } + .filter-panel { grid-template-columns: repeat(3, 1fr); } +} +@media (max-width: 980px) { + .split { grid-template-columns: 1fr; } + .split-list { border-right: none; border-bottom: 1px solid var(--border); max-height: 380px; } + .rbac-layout { grid-template-columns: 1fr; } + .filter-panel { grid-template-columns: repeat(2, 1fr); } + .rbac-matrix { min-width: 620px; } /* scrolls inside .table-wrap */ +} +@media (max-width: 900px) { + .sidebar { + /* above the scrim (55) but below the AI dock (310) and modals (300), + so an open drawer never sits on top of a dialog. */ + position: fixed; left: 0; z-index: 100; + transform: translateX(-100%); transition: transform .25s; + box-shadow: none; + } + .sidebar.mobile-open { transform: translateX(0); box-shadow: var(--shadow-lg); } + /* The FAB floated over the scrim and stayed tappable behind the drawer. */ + .nav-open .ai-fab { display: none; } + .menu-toggle { display: grid; } + .search-kbd { display: none; } + .content { padding: 20px max(16px, env(safe-area-inset-left)) 50px max(16px, env(safe-area-inset-right)); } + .profile-meta { display: none; } + .topbar { padding-left: max(16px, env(safe-area-inset-left)); padding-right: max(16px, env(safe-area-inset-right)); } + .chat-wrap { height: calc(100vh - 170px); height: calc(100dvh - 170px); } +} +@media (max-width: 640px) { + .g-kpi { grid-template-columns: 1fr; } + .topbar { padding-left: max(12px, env(safe-area-inset-left)); padding-right: max(12px, env(safe-area-inset-right)); gap: 6px; } + .form-grid, .info-grid, .filter-panel { grid-template-columns: 1fr; } + .page-title { font-size: 25px; } + .page-head { gap: 12px; margin-bottom: 18px; } + .page-head-actions { width: 100%; } + .page-head-actions .btn { flex: 1 1 auto; justify-content: center; } + + /* The action cluster was eating 232px of a 375px bar, leaving search + ~59px — too narrow to read or tap. Drop the secondary affordances + (messages, divider, profile chevron) so search stays usable. */ + .topbar-search { max-width: none; min-width: 0; } + #messagesDropdown, .topbar-divider, .profile-btn .chev { display: none; } + .topbar-actions { gap: 2px; } + .icon-btn { width: 40px; height: 40px; } + .profile-btn { padding: 4px; } + + /* Dropdowns were 340px wide and ran off the left edge of a 320px screen. + Anchor them to the viewport instead of the trigger. */ + .dropdown-menu, .dropdown-menu-wide { + position: fixed; + top: calc(var(--topbar-h) + 6px); + left: 8px; right: 8px; + width: auto; min-width: 0; max-width: none; + max-height: calc(100vh - var(--topbar-h) - 24px); + max-height: calc(100dvh - var(--topbar-h) - 24px); + overflow-y: auto; overscroll-behavior: contain; + } + .dd-scroll { max-height: none; } + + /* Toast overhung the left edge at 300px min-width. */ + .toast-root { left: max(12px, env(safe-area-inset-left)); right: max(12px, env(safe-area-inset-right)); } + .toast { min-width: 0; max-width: none; width: 100%; } + + /* Modals read better as near-full-height sheets on a phone. */ + .modal { margin: 0; width: 100%; max-width: none; border-radius: var(--radius-xl) var(--radius-xl) 0 0; + position: fixed; left: 0; right: 0; bottom: 0; max-height: 94vh; max-height: 94dvh; } + .modal-head { padding: 18px 18px 14px; } + .modal-body { padding: 18px; } + .modal-foot { padding: 14px 18px max(14px, env(safe-area-inset-bottom)); flex-direction: column-reverse; } + .modal-foot .btn { width: 100%; } + + /* Kanban: a 288px column left 32px of the next one visible, so the board + didn't read as scrollable. Show a slice of the neighbour and snap. */ + .kanban { gap: 12px; scroll-padding-left: 16px; } + .kanban-col { flex: 0 0 min(78vw, 300px); scroll-snap-align: start; max-height: none; } + .kanban-cards { max-height: 60vh; } + + .ai-dock { width: 100%; max-width: 100%; } + .split-list { max-height: 320px; } + .card-head { padding: 14px 16px; } + .card-body, .card-pad { padding: 16px; } + .kpi { padding: 16px; } + .tabs { gap: 0; } + .cal-cell { min-height: 76px; } + .chat-msg { gap: 10px; } + .brand-hero .card-body { padding: 22px 18px; } + .stepper { overflow-x: auto; padding-bottom: 6px; } + .step-label { display: none; } /* numbers alone keep the stepper legible */ + .step-line { margin: 0 8px; } + + /* Data tables: let the row scroll sideways at a legible column width + rather than squeezing names onto three lines. The wrapper already + handles the horizontal gesture. */ + table.data { min-width: 720px; } + table.data thead th { padding: 10px 12px; } + table.data tbody td { padding: 11px 12px; } + .user-cell .cell-primary { white-space: nowrap; } + .user-cell .cell-sub { + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 190px; + } + /* Sticky identity column keeps context while scrolling the rest. */ + table.data thead th:nth-child(2), + table.data tbody td:nth-child(2) { + position: sticky; left: 0; z-index: 2; + background: var(--bg-elev); + box-shadow: 1px 0 0 var(--border); + } + table.data tbody tr:hover td:nth-child(2) { background: var(--bg-sunken); } +} + +/* Small phones (iPhone SE / older Android at 320–400px). */ +@media (max-width: 400px) { + .content { padding-left: max(12px, env(safe-area-inset-left)); padding-right: max(12px, env(safe-area-inset-right)); } + .page-title { font-size: 22px; } + .kpi-value { font-size: 25px; } + .sidebar { width: min(88vw, var(--sidebar-w)); } + .toolbar-search { max-width: none; } + .pagination { justify-content: center; } + .page-info { width: 100%; text-align: center; } +} + +/* Landscape phones: only ~375px of height, most of it chrome. */ +@media (max-height: 500px) and (orientation: landscape) { + :root { --topbar-h: 52px; } + .content { padding-top: 14px; padding-bottom: 28px; } + .page-head { margin-bottom: 14px; } + .chat-wrap { height: calc(100vh - 120px); height: calc(100dvh - 120px); } + .kanban-col { max-height: calc(100vh - 150px); max-height: calc(100dvh - 150px); } + .split-list, .split-detail { max-height: calc(100vh - 150px); max-height: calc(100dvh - 150px); } + .modal { max-height: 96vh; max-height: 96dvh; } + .sidebar-footer { display: none; } /* reclaim height in the drawer */ +} + +/* Foldables / very short windows on desktop. */ +@media (max-height: 420px) { + .ai-fab { width: 46px; height: 46px; } + .ai-fab svg { width: 22px; height: 22px; } +} diff --git a/devserver.py b/devserver.py new file mode 100644 index 0000000..14ba60b --- /dev/null +++ b/devserver.py @@ -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() diff --git a/docs/TalentFlow-ATS-Business-Requirements-v1.0.docx b/docs/TalentFlow-ATS-Business-Requirements-v1.0.docx new file mode 100644 index 0000000..eaaaaa7 Binary files /dev/null and b/docs/TalentFlow-ATS-Business-Requirements-v1.0.docx differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..c2ccd8d --- /dev/null +++ b/index.html @@ -0,0 +1,287 @@ + + + + + + + + + + + + TalentFlow · Applicant Tracking System + + + + + + + + + + + + +
+ + + + +
+ +
+ + + + +
+ + + + + + +
+ + +
+
+ + +
+
+
+ + + + + +
+
+
+ + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/aiassistant.js b/js/aiassistant.js new file mode 100644 index 0000000..e2b601c --- /dev/null +++ b/js/aiassistant.js @@ -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 `

Here are the top-ranked candidates by ATS match score:

This is a UI preview. Connect an LLM endpoint to generate live rankings from resume + JD embeddings.

`; + } + if (p.includes('compare')) { + const two = DB.candidates.slice(0, 2); + return `

Comparing ${two[0].name} vs ${two[1].name}:

Suggested: ${two[0].aiScore >= two[1].aiScore ? two[0].name : two[1].name} appears stronger on core criteria.

`; + } + if (p.includes('job description') || p.includes('jd')) { + return `

Senior Product Designer

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.

Responsibilities: lead design for key initiatives, run user research, build and maintain design systems, mentor peers.

Requirements: 5+ years product design, strong portfolio, fluency in Figma, systems thinking.

`; + } + if (p.includes('interview question')) { + return `

Here are role-specific interview questions:

`; + } + if (p.includes('summar')) { + const c = DB.candidates[0]; + return `

Resume summary — ${c.name}

${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}.

`; + } + if (p.includes('email')) { + return `

Subject: Interview Invitation — Next Steps

Hi [Candidate],

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.

Best regards,
Talent Team

`; + } + if (p.includes('offer letter')) { + return `

Offer Letter

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.

We're excited about the possibility of you joining the team.

`; + } + if (p.includes('skill gap')) { + return `

Skill Gap Analysis — Engineering pipeline

Consider sourcing candidates with cloud-native infra experience.

`; + } + if (p.includes('pipeline')) { + const total = DB.candidates.length; + return `

Pipeline health analysis

Recommendation: accelerate assessment turnaround to improve velocity.

`; + } + if (p.includes('productivity') || p.includes('recruiter')) { + return `

Team productivity this month

`; + } + if (p.includes('recommend') || p.includes('suggest')) { + const c = [...DB.candidates].sort((a, b) => b.aiScore - a.aiScore)[0]; + return `

Top recommendation: ${c.name} (${c.aiScore}% match) for ${c.jobTitle}. Strong on ${c.matchedSkills.slice(0, 2).join(' & ')}. I'd prioritise scheduling a screen this week.

`; + } + return `

I can help with ranking candidates, comparing profiles, drafting JDs, interview questions, emails, offer letters, skill-gap and pipeline analysis, and more.

This is a fully-designed interface. Wire an AI endpoint (Claude / OpenAI) into AI.send() to make responses live.

`; +}; + +AI._chatHtml = function (compact) { + const promptsHtml = DB.aiPrompts.slice(0, compact ? 6 : 12).map(p => + ``).join(''); + return ` +
+
+
+ +

AI Recruiter Assistant

+

Ask anything about your candidates, jobs, and pipeline

+
+
${promptsHtml}
+
+
+
+ + +
+

UI preview · responses are simulated. ${UI.icon('lock')} API-ready for backend integration.

+
+
`; +}; + +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', ` +
${UI.icon('users')}
+
You
${text.replace(/
`); + 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', ` +
${UI.icon('sparkles')}
+
AI Assistant
`); + scroll.scrollTop = scroll.scrollHeight; + + setTimeout(() => { + const t = document.getElementById(typingId); + if (t) t.querySelector('.chat-bubble').innerHTML = `
AI Assistant
${AI._reply(text)}
`; + scroll.scrollTop = scroll.scrollHeight; + }, 850 + Math.random() * 500); +}; + +// ---------------- Full page ---------------- +Views.aiassistant = function () { + const html = ` +
+
+

AI Assistant

Your recruiting copilot — powered by AI (interface preview)

+
+ Model endpoint · Not connected + +
+
+
${AI._chatHtml(false)}
+
`; + 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 = ` +

${UI.icon('sparkles')} AI Assistant

+
+ +
+
${AI._chatHtml(true)}
`; + 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 => ` +
+
+
+ ${UI.icon(m.icon)} + ${UI.badge(m.status, m.status === 'Beta' ? 'b-indigo' : 'b-gray')} +
+
${m.name}
+
${m.desc}
+
${m.status === 'Beta' ? 'Try it' : 'Join waitlist'} ${UI.icon('arrow-right')}
+
+
`).join(''); + + const html = ` +
+
+

AI Studio

Next-generation AI modules — designed and API-ready for backend integration

+
${DB.aiModules.filter(m => m.status === 'Beta').length} in Beta +
+
+
+
+ +

Everything is API-ready

+

Each module below ships with a complete, production-grade interface. Connect your model endpoint to activate them — no UI work required.

+ +
+
+
${cards}
+
`; + 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: `
${UI.icon(m.icon)} +
${m.name}
${m.desc}
+
+
API Contract (preview)
+
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 } +}
+
+

${UI.icon('lock')} This feature's UI is complete. Backend wiring is the only remaining step.

`, + footer: ` + `, + size: 'modal-lg' + }); +}; diff --git a/js/analytics.js b/js/analytics.js new file mode 100644 index 0000000..6af8a81 --- /dev/null +++ b/js/analytics.js @@ -0,0 +1,114 @@ +/* ============================================================ + analytics.js — Analytics dashboard (many charts) + ============================================================ */ +window.Views = window.Views || {}; + +Views.analytics = function () { + const a = DB.analytics; + + const html = ` +
+
+

Analytics

Deep-dive metrics across your recruitment funnel

+
+
WeekMonthQuarter
+ +
+
+ +
+
+

Hiring Trend

Hires vs applications
+
+ ${Charts.legend([{ label: 'Applications', color: Charts.PALETTE[4] }, { label: 'Hires', color: Charts.PALETTE[0] }])}
+
+
+

Applications Received

Monthly volume
+
+
+
+ +
+
+

Source Breakdown

+
+
+
+
+
+
+

Offer Acceptance

+
+
+
+ Accepted + Pending + Declined +
+
+
+
+

Pipeline Distribution

+
+
+
+ +
+
+

Applications by Department

Volume per team
+
+
+
+

Recruiter Performance

Hires by recruiter (top 8)
+
+
+
+ +
+
+

Time to Hire

Days, monthly average
+
+
+
+

Time to Fill

Days, monthly average
+
+
+
+
`; + + 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) => + `${s.source}`).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] }] }); + } + }; +}; diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..0ddf485 --- /dev/null +++ b/js/app.js @@ -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 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 => ` +
+ ${UI.icon(n.icon)} +
${n.title}
${n.text}
${n.time}
+
`).join(''); +}; +App.renderMessages = function () { + const list = document.getElementById('messagesList'); + list.className = 'dd-scroll'; + list.innerHTML = DB.messages.map(m => ` +
+ ${UI.avatar(m.name, m.initials, m.color)} +
${m.name}
${m.text}
${m.time} ago
+
`).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 += `
Jobs
` + jobs.map(j => + `
${UI.icon('briefcase')}
${j.title}
${j.id} · ${j.department}
`).join(''); + if (cands.length) html += `
Candidates
` + cands.map(c => + `
${UI.avatar(c.name, c.initials, c.color)}
${c.name}
${c.jobTitle}
`).join(''); + if (mgrs.length) html += `
Hiring Managers
` + mgrs.map(m => + `
${UI.avatar(m.name, m.initials, m.color)}
${m.name}
${m.title}
`).join(''); + if (!html) html = `
No results for "${q}"
`; + + 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 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); diff --git a/js/assessments.js b/js/assessments.js new file mode 100644 index 0000000..0f2eb55 --- /dev/null +++ b/js/assessments.js @@ -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 => `
${UI.avatar(a.candidate, a.initials, a.color)}
${a.candidate}
${a.jobTitle}
` }, + { key: 'type', label: 'Assessment', sortable: true, render: a => `
${a.type}
${a.duration}
` }, + { key: 'assigned', label: 'Assigned', sortable: true, sortValue: a => a.assigned.getTime(), render: a => `${DB.fmtShort(a.assigned)}` }, + { key: 'due', label: 'Due', sortable: true, sortValue: a => a.due.getTime(), render: a => `${DB.fmtShort(a.due)}` }, + { key: 'score', label: 'Score', sortable: true, align: 'center', render: a => a.score !== null ? UI.scoreChip(a.score) : '' }, + { key: 'status', label: 'Status', sortable: true, render: a => UI.badge(a.status) }, + { key: '_a', label: 'Actions', align: 'right', render: a => ` +
+ + +
` } + ] + }); + + const statusOpts = [''].concat(['Completed', 'In Progress', 'Pending', 'Expired'].map(s => ``)).join(''); + const typeOpts = [''].concat([...new Set(DB.assessments.map(a => a.type))].map(t => ``)).join(''); + const statCard = (label, val, icn, cls) => `
${label}${UI.icon(icn)}
${val}
`; + + const html = ` +
+
+

Assessments

Coding tests, take-homes, and evaluations

+
+
+
+ ${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')} +
+
+
+
+ + + +
+
+ ${table.html} +
+
`; + + 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 = ` +
${UI.avatar(a.candidate, a.initials, a.color, 'avatar-lg')} +
${a.candidate}
${a.type} · ${a.jobTitle}
+
${UI.badge(a.status)}
+
+
Type
${a.type}
+
Duration
${a.duration}
+
Assigned
${DB.fmtDate(a.assigned)}
+
Due
${DB.fmtDate(a.due)}
+
+ ${a.score !== null ? ` +
+
+
${a.score}%
+
Overall Score
+
+
${UI.pbar(a.score)}
+
Section Breakdown
+ ${['Problem Solving', 'Code Quality', 'Communication', 'Time Management'].map(sec => { + const sc = DB.int(60, 98); + return `
${sec}
${UI.pbar(sc)}
${sc}%
`; + }).join('')}` : `
${UI.icon('clock')}

Assessment not completed

Results will appear once the candidate submits.

`}`; + const footer = ` + `; + UI.modal({ title: 'Assessment Result', subtitle: a.id, body, footer }); +}; + +Assessments.assign = function () { + const opt = arr => arr.map(o => ``).join(''); + const body = `
+
+
+
+
+
`; + const footer = ` + `; + UI.modal({ title: 'Assign Assessment', subtitle: 'Send an evaluation to a candidate', body, footer }); +}; diff --git a/js/candidates.js b/js/candidates.js new file mode 100644 index 0000000..ad80747 --- /dev/null +++ b/js/candidates.js @@ -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 => `${UI.icon('check')}` }, + { key: 'name', label: 'Candidate', sortable: true, render: c => `
${UI.avatar(c.name, c.initials, c.color)}
${c.name} ${c.favorite ? '' + UI.icon('star') + '' : ''}
${c.currentTitle} · ${c.location}
` }, + { key: 'jobTitle', label: 'Applied Job', sortable: true, render: c => `
${c.jobTitle}
${c.department}
` }, + { key: 'experience', label: 'Exp', sortable: true, align: 'center', render: c => `${c.experience}y` }, + { key: '_rel', label: 'Relevance', sortable: true, align: 'center', sortValue: c => relevance(c), render: c => `${relevance(c)}%` }, + { key: 'stage', label: 'Stage', sortable: true, render: c => UI.badge(c.stage) }, + { key: 'aiScore', label: 'ATS', sortable: true, align: 'center', render: c => `${UI.scoreChip(c.aiScore)}` }, + { key: 'availability', label: 'Availability', render: c => `${c.availability}
${c.noticePeriod} notice
` }, + { key: '_a', label: 'Actions', align: 'right', render: c => ` +
+ + + + +
` } + ], + 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) => [``].concat(arr.map(o => ``)).join(''); + const jobTitles = [...new Set(DB.candidates.map(c => c.jobTitle))]; + + const filterPanel = ` + `; + + // recently viewed strip + const rv = DB.recentlyViewed.slice(0, 6).map(id => DB.getCandidate(id)).filter(Boolean); + const rvHtml = rv.length ? `
+ Recently viewed: + ${rv.map(c => ``).join('')} +
` : ''; + + const html = ` +
+
+

Candidates

${DB.candidates.length} candidates · ranked by AI relevance

+
+ + + +
+
+ ${rvHtml} + +
+
+
+ + +
+ + +
+ ${filterPanel} +
+ ${table.html} +
+
`; + + 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 => ``).join(''); + UI.modal({ title: 'Bulk Assign Recruiter', subtitle: ids.length + ' candidates', + body: `
`, + footer: `` }); + 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) => `
${label}
${UI.pbar(val)}
${val}%
`; + + const body = ` +
+ ${UI.icon(c.recommendation === 'Weak Match' ? 'x-circle' : 'check-circle')} +
${c.recommendation}
+
${c.name} for ${c.jobTitle}
+
+
+
+
${c.aiScore}
ATS MATCH
+
+
+ ${subRow('Skills', sub.skills)} + ${subRow('Experience', sub.experience)} + ${subRow('Education', sub.education)} + ${subRow('Keywords', sub.keywords)} + ${subRow('Location', sub.location)} + ${subRow('Salary', sub.salary)} +
+
+
Matched Skills (${c.matchedSkills.length})
+
${c.matchedSkills.length ? c.matchedSkills.map(s => `${UI.icon('check')} ${s}`).join('') : ''}
+
Missing Skills (${c.missingSkills.length})
+
${c.missingSkills.length ? c.missingSkills.map(s => `${UI.icon('x')} ${s}`).join('') : 'None — full match'}
+
+

${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.

`; + UI.modal({ + title: 'ATS Match Analysis', subtitle: c.id + ' · ' + c.jobTitle, body, size: 'modal-lg', + footer: `` + }); +}; + +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 = ` +
+ ${UI.avatar(c.name, c.initials, c.color, 'avatar-lg')} +
+
${c.name}
+
${c.currentTitle} at ${c.currentCompany}
+
${UI.badge(c.stage)} ${UI.badge(c.source, 'b-gray')} + ${c.experience} yrs exp
+
+
${UI.scoreChip(c.aiScore)}
AI Match
+
+
+ ${tabs.map((t, i) => `
${t}
`).join('')} +
+
+ ${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)} +
`; + const footer = ` + + + `; + 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 = ` +
+
Email
${c.email}
+
Phone
${c.phone}
+
Location
${c.location}
+
Applied For
${c.jobTitle}
+
Current Company
${c.currentCompany}
+
Experience
${c.experience} years
+
Education
${c.education}
+
Source
${c.source}
+
Recruiter
${c.recruiter}
+
Applied On
${DB.fmtDate(c.applied)}
+
Expected Salary
${DB.moneyK(c.salary)}
+
Rating
⭐ ${c.rating} / 5.0
+
+
Skills
+
${c.skills.map(s => `${s}`).join('')}
`; + } else if (name === 'Resume') { + content = `
+

${c.name}

${c.currentTitle} · ${c.location}

+
+
Summary
+

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.

+
Experience
+
${c.currentTitle} — ${c.currentCompany}
2021 – Present
+
Associate — ${DB.pick(DB.companies)}
2018 – 2021
+
Education
+
${c.education}
+
+ `; + } 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 = `
${events.map(e => ` +
${UI.icon(e.icon)}
+
${e.title}
${e.meta}
${e.desc}
`).join('')}
`; + } else if (name === 'Interview') { + const ivs = DB.interviews.filter(i => i.candidateId === c.id); + content = ivs.length ? `
${ivs.map(iv => ` +
${UI.icon('calendar')} +
${iv.type}
${DB.fmtDate(iv.when)} · ${iv.meeting}
+
${UI.badge(iv.status)}
`).join('')}
` + : `
${UI.icon('calendar')}

No interviews scheduled

Schedule an interview to get started.

+
`; + } else if (name === 'Notes') { + content = ` +
+ +
+
${UI.avatar(c.recruiter)}
${c.recruiter}
Strong communication skills, great culture fit. Recommend advancing.
2 days ago
+
${UI.avatar('Asfand Ahmed', 'AA')}
Asfand Ahmed
Reviewed portfolio — impressive work. Schedule technical round.
4 days ago
+
`; + } else if (name === 'Activity') { + content = `
+
${UI.icon('eye')}
Profile viewed by ${c.recruiter}
1h ago
+
${UI.icon('mail')}
Email sent: Interview invitation
1 day ago
+
${UI.icon('star')}
Assessment score updated to ${c.aiScore}%
2 days ago
+
${UI.icon('user-plus')}
Applied for ${c.jobTitle}
${DB.fmtDate(c.applied)}
+
`; + } 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 = `
${docs.map(d => ` +
${UI.icon('file')} +
${d.n}
${d.s}
+
`).join('')}
`; + } else if (name === 'Feedback') { + const scores = ['Strong Hire', 'Hire', 'Lean Hire']; + content = `
+ ${[0, 1, 2].map(i => `
${UI.avatar(DB.recruiters[i].name, DB.recruiters[i].initials, DB.recruiters[i].color)} +
${DB.recruiters[i].name}
${['Excellent technical depth and clear communication.', 'Good problem solving, would benefit from more system design exposure.', 'Solid candidate, positive team energy.'][i]}
+
${UI.badge(scores[i])}
`).join('')} +
+ `; + } + return `
${content}
`; +}; + +Candidates.openAdd = function () { + const opt = (arr) => arr.map(o => ``).join(''); + const body = `
+
Required
+
Valid email required
+
+
+
+
+
+
+
`; + const footer = ` + `; + 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(); +}; diff --git a/js/charts.js b/js/charts.js new file mode 100644 index 0000000..f578ae0 --- /dev/null +++ b/js/charts.js @@ -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('
')); + } + + // -------------------- 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]}: ${data[s.i]} (${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, `${pts[nearest].label}
${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 `
${items.map(it => + `${it.label}`).join('')}
`; + } +})(); diff --git a/js/dashboard.js b/js/dashboard.js new file mode 100644 index 0000000..fc99eb7 --- /dev/null +++ b/js/dashboard.js @@ -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 `${txt}`; + const cls = dir === 'up' ? 'trend-up' : 'trend-down'; + const ic = dir === 'up' ? 'trending-up' : 'trending-down'; + return `${UI.icon(ic)}${txt}`; + } + const kpiHtml = arr => arr.map(c => ` +
+
+ ${c.label} + ${UI.icon(c.icon)} +
+
${c.value}
+
${trendHtml(c.dir, c.trend)}${c.foot}
+
`).join(''); + + // upcoming interviews + const upcoming = DB.interviews.filter(iv => iv.status === 'Scheduled').slice(0, 5); + const upcomingHtml = upcoming.length ? upcoming.map(iv => ` +
+ ${UI.avatar(iv.candidate, iv.candInitials, iv.color)} +
+
${iv.candidate}
+
${iv.type} · ${iv.jobTitle}
+
+
+
${DB.fmtShort(iv.when)}
+
${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
+
+
`).join('') : '
No upcoming interviews
'; + + // recent applications + const recentApps = [...DB.candidates].sort((a, b) => b.applied - a.applied).slice(0, 5); + const recentHtml = recentApps.map(c => ` +
+ ${UI.avatar(c.name, c.initials, c.color)} +
+
${c.name}
+
${c.jobTitle}
+
+
${UI.scoreChip(c.aiScore)}
+
`).join(''); + + // activity feed + const activityHtml = DB.activity.slice(0, 8).map(a => ` +
+ ${UI.icon(a.icon)} +
+
${a.html}
+
${DB.relTime(a.time)}
+
+
`).join(''); + + // recruiter performance + const topRecs = [...DB.recruiters].sort((a, b) => b.hires - a.hires).slice(0, 5); + const recPerf = topRecs.map(r => ` +
+ ${UI.avatar(r.name, r.initials, r.color)} +
+
${r.name}
+
${r.openReqs} open reqs · ${r.avgTimeToHire}d avg
+
+
${r.hires}
hires
+
`).join(''); + + const html = ` +
+
+
+

Good morning, Asfand 👋

+

Here's what's happening with your hiring today — Thursday, July 9, 2026

+
+
+ + +
+
+ +
${kpiHtml(kpiCards)}
+
${kpiHtml(kpiCards2)}
+ +
+
+
+

Hiring Trend

Hires vs applications over the last 7 months
+
7M1Y
+
+
+
+ ${Charts.legend([{ label: 'Applications', color: Charts.PALETTE[4] }, { label: 'Hires', color: Charts.PALETTE[0] }])} +
+
+
+

Candidate Pipeline

Active by stage
+
+
+
+
+
+ +
+
+

Upcoming Interviews

Next scheduled sessions
+
+
${upcomingHtml}
+
+
+

Source Analytics

Where candidates come from
+
+
+
+ +
+
+

Recent Applications

+
+
${recentHtml}
+
+
+

Recruiter Performance

+
${recPerf}
+
+
+

Recent Activity

+
${activityHtml}
+
+
+
`; + + 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) + }); + } + }; +}; diff --git a/js/data.js b/js/data.js new file mode 100644 index 0000000..cbf941b --- /dev/null +++ b/js/data.js @@ -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) => `${n} applied for ${j}` }, + { icon: 'calendar', color: 'i-blue', text: (n, j) => `Interview scheduled with ${n} for ${j}` }, + { icon: 'check', color: 'i-teal', text: (n, j) => `${n} moved to Offer stage` }, + { icon: 'file', color: 'i-purple', text: (n, j) => `Offer sent to ${n} for ${j}` }, + { icon: 'star', color: 'i-amber', text: (n, j) => `${n} completed an assessment` }, + { icon: 'x', color: 'i-red', text: (n, j) => `${n} was rejected for ${j}` } + ]; + 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) + }; +})(); diff --git a/js/import.js b/js/import.js new file mode 100644 index 0000000..a9998c9 --- /dev/null +++ b/js/import.js @@ -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 = ` +
+
+

CV Import

Upload resumes — we parse, score, match, and dedupe automatically

+
+ AI Resume Parser · Ready +
+
+ +
+
+
+
+
${UI.icon('upload')}
+

Drag & drop resumes here

+

or click to browse — PDF, DOC, DOCX and ZIP supported · up to 20 files

+ +
+ ${['PDF', 'DOC', 'DOCX', 'ZIP'].map(t => `${t}`).join('')} +
+
+
+ + + Files are processed locally in this demo +
+
+ + +
+ +
+

Auto-Processing

What happens on upload
+
+ ${[ + { 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 => `
${UI.icon(s.i)}
${s.t}
${s.d}
`).join('')} +
+
+
+
`; + + 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 => ` +
+ ${UI.icon('file')} +
+
${i.name} + ${i.duplicate ? 'DUPLICATE' : ''}
+
${i.file} · ${i.size}
+ ${i.status === 'Uploading' || i.status === 'Parsing' ? `
` : + `
Best match: ${i.job.title}
`} +
+
+ ${i.status === 'Ready' ? UI.scoreChip(i.atsScore) : `${i.status}${i.status === 'Uploading' ? ' ' + i.progress + '%' : ''}`} +
+
+ ${i.imported ? `Imported` : + i.status === 'Ready' ? `` : + ``} +
+
`).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: `
${UI.icon('users')} +

A similar candidate already exists

+

${i.name} matches an existing profile (95% similarity on name + email). Importing will create a duplicate.

`, + footer: ` + + ` + }); + 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'); +}; diff --git a/js/inbox.js b/js/inbox.js new file mode 100644 index 0000000..4247188 --- /dev/null +++ b/js/inbox.js @@ -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 `${item.source}`; + } + + function renderList() { + const el = document.getElementById('inboxList'); + if (!el) return; + const list = filtered(); + if (!list.length) { el.innerHTML = `
${UI.icon('inbox')}

Nothing here

No applications in this view.

`; return; } + el.innerHTML = list.map(i => ` +
+ ${UI.avatar(i.name, i.initials, i.color)} +
+
${i.name} ${i.duplicate ? 'DUP' : ''}
+
${i.position}
+
${sourceChip(i)} ${UI.badge(i.processing)}
+
+
+
${DB.relTime(Math.round((new Date('2026-07-09T20:00') - i.received) / 60000))}
+
${UI.scoreChip(i.atsScore)}
+
+
`).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 = `
${UI.icon('inbox')}

Select an application

Choose an item from the list to view details and take action.

`; return; } + const rec = DB.atsRecommendationClass; + const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'; + el.innerHTML = ` +
+
+ ${UI.avatar(i.name, i.initials, i.color, 'avatar-lg')} +
${i.name}
+
${i.position}
+
${sourceChip(i)} ${UI.badge(i.processing)} ${UI.badge(i.resumeStatus, i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber')}
+
+
+
+
${i.atsScore}
+
ATS Score
+
+
+ +
+
Email
${i.email}
+
Phone
${i.phone}
+
Experience
${i.experience} years
+
Assigned Recruiter
${i.recruiter}
+
Received
${DB.fmtDate(i.received)}
+
Match
${UI.badge(recLabel, rec(recLabel))}
+
+ +
+
+
${UI.icon('paperclip')} ${i.attachment}
+ +
+
${Inbox._resumeText(i)}
+
+ +
+ + + + + + +
+
`; + } + + function renderBody() { + const body = document.getElementById('inboxBody'); + if (state.tab === 'Email') { body.innerHTML = Inbox._emailView(); Inbox._bindEmail(state); return; } + body.innerHTML = ` +
+
+
+ +
+
+
+
+
`; + 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 => `
${t} ${c[t]}
`).join(''); + + const html = ` +
+
+

Recruitment Inbox

Every candidate, every source — one unified queue

+
+ Microsoft Graph API · Connected + + +
+
+
+
${tabHtml}
+
+
+
`; + + 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: `
${Inbox._resumeText(i)}
`, + footer: ``, + 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 => ``).join(''); + UI.modal({ + title: 'Assign Recruiter', subtitle: i.name, + body: `
+

Current workload is factored automatically. This recruiter has ${DB.getRecruiterByName(i.recruiter) ? DB.getRecruiterByName(i.recruiter).openReqs : 5} open reqs.

`, + footer: `` + }); +}; +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: `
`, + footer: `` + }); +}; + +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 => ` +
+ ${UI.avatar(e.from, e.initials, e.color)} +
+
${e.from}
+
${e.subject}
+
Outlook${e.imported ? UI.badge('Imported', 'b-green') : ''}
+
+
${DB.fmtShort(e.when)}
+
`).join(''); + return ` +
+ Outlook · Microsoft Graph API + Last sync: 2 min ago · ${DB.emails.filter(e => e.unread).length} unread + +
+
+
${listHtml}
+
+
`; +}; +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 = `
${UI.icon('mail')}

Select an email

Preview email body and resume attachments here.

`; return; } + detail.innerHTML = `
+
+

${e.subject}

${e.imported ? UI.badge('Imported', 'b-green') : UI.badge('New', 'b-blue')}
+
+ ${UI.avatar(e.from, e.initials, e.color)} +
${e.from}
${e.fromEmail} · ${DB.fmtDate(e.when)}
+
+ +
+ ${UI.icon('file')} +
${e.attachment}
${e.attachmentSize} · PDF
+
${UI.scoreChip(e.atsScore)} +
+
+
+ ${e.imported ? `` : + ``} + + +
+
`; + } + 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'); +}; diff --git a/js/interviews.js b/js/interviews.js new file mode 100644 index 0000000..54ae7b1 --- /dev/null +++ b/js/interviews.js @@ -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 => `
${UI.avatar(iv.candidate, iv.candInitials, iv.color)}
${iv.candidate}
${iv.jobTitle}
` }, + { 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 => `
${DB.fmtShort(iv.when)}
${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} · ${iv.duration}m
` }, + { key: 'meeting', label: 'Type', render: iv => `${UI.icon(iv.meeting === 'Video Call' ? 'video' : iv.meeting === 'Phone' ? 'phone' : 'map')} ${iv.meeting}` }, + { 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) : '' }, + { key: '_a', label: 'Actions', align: 'right', render: iv => ` +
+ + +
` } + ] + }); + + const statusOpts = [''].concat(['Scheduled', 'Completed', 'Cancelled', 'No Show'].map(s => ``)).join(''); + const typeOpts = [''].concat(DB.interviewTypes.map(t => ``)).join(''); + + const statCard = (label, val, icn, cls) => `
${label}${UI.icon(icn)}
${val}
`; + + const upcomingHtml = upcoming.map(iv => ` +
+ ${UI.avatar(iv.candidate, iv.candInitials, iv.color)} +
${iv.candidate}
${iv.type} · ${iv.meeting}
+
${DB.fmtShort(iv.when)}
${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
+
`).join(''); + + const html = ` +
+
+

Interviews

Manage and track all interview activity

+
+ + +
+
+
+ ${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')} +
+
+
+

All Interviews

+
+
+ + + +
+
+ ${table.html} +
+
+

Up Next

Scheduled sessions
+
${upcomingHtml}
+
+
+
`; + + 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 => ``).join(''); + + const ratingRow = (crit) => ` +
+

${crit}

+
+ ${[1, 2, 3, 4, 5].map(n => `${UI.icon('star')}`).join('')} +
+
`; + + const body = ` +
${UI.avatar(iv.candidate, iv.candInitials, iv.color, 'avatar-lg')} +
${iv.candidate}
${iv.type} · ${iv.jobTitle}
+ ${UI.badge(iv.status)}
+ +
+
Dynamic Form
+
Upload Sheet
+
Both
+
+ +
+
+
+
${tmpl.criteria.map(ratingRow).join('')}
+
+
+
+
+ +
+
+
${UI.icon('upload')}
+

Upload evaluation sheet

+

PDF, DOC, or DOCX · scanned scorecards supported

+
${['PDF', 'DOC', 'DOCX'].map(t => `${t}`).join('')}
+
+
+ +
+

Capture structured ratings and attach a signed sheet — both are stored on the scorecard.

+
${tmpl.criteria.slice(0, 3).map(ratingRow).join('')}
+
${UI.icon('file')} +
Interviewer_Scorecard.pdf
Attached · 214 KB
${UI.badge('Uploaded', 'b-green')}
+
+
`; + + const footer = ` + `; + 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 => ``).join(''); + const body = `
+
+
+
+
+
+
+
+
`; + const footer = ` + `; + UI.modal({ title: 'Schedule Interview', subtitle: 'Set up a new interview session', body, footer }); +}; diff --git a/js/jobboard.js b/js/jobboard.js new file mode 100644 index 0000000..efaf466 --- /dev/null +++ b/js/jobboard.js @@ -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) => `
${label}${UI.icon(icn)}
${val}
${sub}
`; + + // 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 => `
${p.jobTitle}
${p.jobId}
` }, + { key: 'platform', label: 'Platform', sortable: true, render: p => { const pl = DB.publishPlatforms.find(x => x.name === p.platform) || {}; return `${p.platform}`; } }, + { 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 => `${p.apps}` }, + { key: '_conv', label: 'Conversion', sortable: true, sortValue: p => p.apps / p.views, render: p => `${((p.apps / p.views) * 100).toFixed(1)}%` }, + { key: '_a', label: '', align: 'right', render: p => `` } + ] + }); + + const html = ` +
+
+

Job Board

Publish requisitions across channels and track performance

+
+ + +
+
+ +
+ ${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')} +
+ +
+
+

Platform Performance

Applications by channel
+
+
+
+

Connected Platforms

+
+ ${DB.publishPlatforms.map(p => `
+ +
${p.name}
${p.cost === 'Free' ? 'Free posting' : 'Paid · ' + p.cost}
+ ${p.connected ? UI.badge('Connected', 'b-green') : ``} +
`).join('')} +
+
+
+ +
+

Active Postings

${DB.publishings.length} live postings across ${platRows.length} platforms
+
+ ${table.html} +
+
`; + + 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 = `
${steps.map((s, i) => { + const n = i + 1; + const cls = n < state.step ? 'done' : n === state.step ? 'active' : ''; + return `
${n < state.step ? '✓' : n}
${s}
${i < steps.length - 1 ? `
` : ''}`; + }).join('')}
`; + + let body = stepper; + if (state.step === 1) { + const opts = DB.jobs.filter(j => j.status !== 'Draft').map(j => ``).join(''); + const job = DB.getJob(state.jobId); + body += `
+
+
${UI.icon('briefcase')} +
${job.title}
${job.department} · ${job.location} · ${job.type}
+
`; + } else if (state.step === 2) { + body += `
+
${UI.icon('check-circle')} +
Approval granted
Approved by Department Head · Budget confirmed
+

Hiring Manager sign-off

${UI.badge('Approved', 'b-green')}
+

Finance budget approval

${UI.badge('Approved', 'b-green')}
+

Compliance review

${UI.badge('Approved', 'b-green')}
+
`; + } else if (state.step === 3) { + body += `

Select the platforms to publish this role to

+
+ ${DB.publishPlatforms.map(p => `
+ +
${p.name}
${p.cost === 'Free' ? 'Free' : 'Paid · ' + p.cost}
+ ${UI.icon('check')} +
`).join('')} +
`; + } else if (state.step === 4) { + body += `
+
${UI.icon('check-circle')}
+

Published Successfully

+

${DB.getJob(state.jobId).title} is now live on ${state.platforms.length} platform${state.platforms.length > 1 ? 's' : ''}

+
+ ${state.platforms.map(p => { const pl = DB.publishPlatforms.find(x => x.name === p); return `${pl.name}`; }).join('')} +
+
`; + } + + let footer; + if (state.step === 4) footer = ``; + else footer = ` + `; + + 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(); }; diff --git a/js/jobs.js b/js/jobs.js new file mode 100644 index 0000000..ff58987 --- /dev/null +++ b/js/jobs.js @@ -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 => `${j.id}` }, + { key: 'title', label: 'Job Title', sortable: true, render: j => `
${j.title}
${j.businessUnit} · ${j.grade}
` }, + { key: 'department', label: 'Department', sortable: true }, + { key: 'manager', label: 'Hiring Manager', sortable: true, render: j => `
${UI.avatar(j.manager)}${j.manager}
` }, + { key: 'location', label: 'Location', sortable: true, render: j => `${j.location}` }, + { key: 'type', label: 'Type', render: j => UI.badge(j.type, 'b-gray') }, + { key: 'applications', label: 'Apps', sortable: true, align: 'center', render: j => `${j.applications}` }, + { 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 => `${DB.fmtShort(j.created)}` }, + { key: '_a', label: 'Actions', align: 'right', render: j => ` +
+ + + + +
` } + ] + }); + + const deptOpts = [''].concat(DB.departments.map(d => ``)).join(''); + const statusOpts = [''].concat(DB.jobStatuses.map(s => ``)).join(''); + const typeOpts = [''].concat(DB.empTypes.map(t => ``)).join(''); + + const html = ` +
+
+

Jobs

${DB.jobs.length} requisitions · ${DB.kpis.openJobs} currently open

+
+ + +
+
+
+
+
+ + + + +
+
+ ${table.html} +
+
`; + + 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 = ` +
+ ${UI.icon('briefcase')} +
+
${j.title}
+
${j.id} · ${j.department} · ${j.businessUnit}
+
+
${UI.badge(j.status)}
+
+
+
Hiring Manager
${j.manager}
+
Assigned Recruiter
${j.recruiter} ${(() => { const r = DB.getRecruiterByName(j.recruiter); return r ? `${r.workload}% load` : ''; })()}
+
Location
${j.location}
+
Employment Type
${j.type}
+
Grade
${j.grade}
+
Vacancies
${j.vacancies}
+
Salary Range
${DB.moneyK(j.salaryMin)} – ${DB.moneyK(j.salaryMax)}
+
Experience
${j.experience}
+
Education
${j.education}
+
Deadline
${DB.fmtDate(j.deadline)}
+
+
+
Description

${j.description}

+
Key Responsibilities
+
    ${j.responsibilities.map(r => `
  • ${r}
  • `).join('')}
+
Required Skills
+
${j.skills.map(s => `${s}`).join('')}
+
Benefits
+
${j.benefits.map(s => `${s}`).join('')}
+
+
Hiring progress
${UI.pbar(j.progress)}
${j.progress}%
`; + const footer = ` + + `; + 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 => ``).join(''); + UI.modal({ + title: 'Reassign Recruiter', subtitle: j.title, + body: `
+

Workload is recalculated automatically across the recruiter's assigned requisitions.

`, + footer: `` + }); +}; +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 => ``).join(''); + const body = ` +
+
+
+ + + Job title is required +
+
+
+
+
+
+
+
+
+
+
+ + Enter a valid amount
+
+
+
+
+
+
+ + Description is required
+
+
+
+
+
+
+
+
+
+
`; + const footer = ` + `; + 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 = `
+ ${UI.icon('trash')} +

Delete "${j.title}"?

+

This will permanently remove requisition ${j.id} and its ${j.applications} applications. This action cannot be undone.

`; + const footer = ` + `; + 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(); +}; diff --git a/js/misc.js b/js/misc.js new file mode 100644 index 0000000..0edc541 --- /dev/null +++ b/js/misc.js @@ -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 `
+
+
+ ${UI.avatar(m.name, m.initials, m.color, 'avatar-lg')} +
${m.name}
${m.title}
+
+
+
${m.openReqs}Open Reqs
+
${m.teamSize}Team Size
+
+
+
+ ${UI.icon('mail')} ${m.email.split('@')[0]} + +
+
+
`; + } + const html = ` +
+
+

Hiring Managers

${DB.managers.length} managers · ${DB.managers.reduce((s, m) => s + m.openReqs, 0)} active requisitions

+
+
+
${DB.managers.map(card).join('')}
+
`; + return { html }; +}; +Views._mgrDetail = function (id) { + const m = DB.getManager(id); + const jobs = DB.jobs.filter(j => j.manager === m.name); + const body = ` +
${UI.avatar(m.name, m.initials, m.color, 'avatar-lg')} +
${m.name}
${m.title}
+
${UI.badge(m.department, 'b-indigo')}${m.teamSize} reports
+
+
${m.openReqs}Open Reqs
+
${jobs.length}Total Jobs
+
${jobs.reduce((s, j) => s + j.applications, 0)}Applications
+
+
Hiring Manager Portal
+
+ + + + +
+
Requisitions
+
${jobs.length ? jobs.map(j => `
+ ${UI.icon('briefcase')} +
${j.title}
${j.applications} applications
${UI.badge(j.status)}
`).join('') : '

No requisitions

'}
`; + UI.modal({ title: 'Hiring Manager', subtitle: m.id, body, size: 'modal-lg', footer: `` }); +}; + +// ---------------- 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 => `
${d}
`).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 => `
${iv.when.toLocaleTimeString('en-US', { hour: 'numeric' })} ${iv.candidate.split(' ')[0]}
`).join(''); + if (dayEvents.length > 3) evs += `
+${dayEvents.length - 3} more
`; + } + const isToday = !c.other && c.date && c.date.toDateString() === today.toDateString(); + html += `
${c.day}
${evs}
`; + }); + 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 = ` +
+
+

Calendar

Interview schedule at a glance

+
+
+ + ${monthName} + +
+ +
+
+
+
${build()}
+

Today

July 9, 2026
+
${todayIvs.length ? todayIvs.map(iv => ` +
${UI.avatar(iv.candidate, iv.candInitials, iv.color)} +
${iv.candidate}
${iv.type}
+
${iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
`).join('') : '

No interviews today

'}
+
+
+
`; + + 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) => ` +
+ ${UI.icon(n.icon)} +
${n.title}
${n.text}
${n.time}
+ ${n.unread ? '' : ''} +
`).join(''); + const html = ` +
+
+

Notifications

Stay on top of hiring activity

+
+ + +
+
+
${rows}
+
`; + 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 = ` +
+

Help Center

Find answers and get support

+
+
+

How can we help you?

+

Search our knowledge base or browse the topics below

+ +
+
+
+ ${resources.map(r => `
+ ${UI.icon(r.icn)} +
${r.t}
${r.d}
`).join('')} +
+
+

Frequently Asked Questions

+
+ ${faqs.map((f, i) => `
+

${f.q}

${UI.icon('chevron-right')}
+
`).join('')} +
+
+
`; + 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)'; +}; diff --git a/js/offers.js b/js/offers.js new file mode 100644 index 0000000..ddb61a8 --- /dev/null +++ b/js/offers.js @@ -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 => `
${UI.avatar(o.candidate, o.initials, o.color)}
${o.candidate}
${o.jobTitle}
` }, + { key: 'department', label: 'Department', sortable: true }, + { key: 'base', label: 'Base Salary', sortable: true, align: 'right', render: o => `${DB.money(o.base)}` }, + { key: 'equity', label: 'Equity', render: o => `${o.equity}` }, + { key: 'bonus', label: 'Bonus', align: 'center', render: o => `${o.bonus}` }, + { key: 'sent', label: 'Sent', sortable: true, sortValue: o => o.sent.getTime(), render: o => `${DB.fmtShort(o.sent)}` }, + { key: 'status', label: 'Status', sortable: true, render: o => UI.badge(o.status) }, + { key: '_a', label: 'Actions', align: 'right', render: o => ` +
+ + +
` } + ] + }); + + const statusOpts = [''].concat(['Sent', 'Accepted', 'Negotiating', 'Declined', 'Draft', 'Expired'].map(s => ``)).join(''); + const statCard = (label, val, icn, cls) => `
${label}${UI.icon(icn)}
${val}
`; + + const html = ` +
+
+

Offers

Track offer letters and acceptance

+
+
+
+ ${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')} +
+
+
+
+ + +
+
+ ${table.html} +
+
`; + + 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 = ` +
${UI.avatar(o.candidate, o.initials, o.color, 'avatar-lg')} +
${o.candidate}
${o.jobTitle} · ${o.department}
+
${UI.badge(o.status)}
+
+
Compensation Package
+
+
Base Salary
${DB.money(o.base)}
+
Annual Bonus
${o.bonus}
+
Equity
${o.equity}
+
Est. Total Cash
${DB.money(total)}
+
+
+
+
Sent On
${DB.fmtDate(o.sent)}
+
Expires
${DB.fmtDate(o.expires)}
+
Recruiter
${o.recruiter}
+
Offer ID
${o.id}
+
`; + const footer = ` + + `; + UI.modal({ title: 'Offer Details', subtitle: o.id, body, footer, size: 'modal-lg' }); +}; + +Offers.create = function () { + const opt = arr => arr.map(o => ``).join(''); + const body = `
+
+
Required
+
+
+
+
+
`; + const footer = ` + `; + 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(); +}; diff --git a/js/pipeline.js b/js/pipeline.js new file mode 100644 index 0000000..2f54be5 --- /dev/null +++ b/js/pipeline.js @@ -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 `
+

${st.name}

${cards.length}
+
+ ${cards.map(c => Pipeline._card(c)).join('')} +
`; + }).join(''); + } + + const jobOpts = [''].concat(DB.jobs.filter(j => j.status === 'Open').map(j => ``)).join(''); + + const html = ` +
+
+

Pipeline

Drag candidates between stages to update their status

+
+ + +
+
+
${columns()}
+
`; + + 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 `
+
${UI.avatar(c.name, c.initials, c.color)} +
${c.name}
${c.currentTitle}
+
${c.jobTitle}
+
${c.skills.slice(0, 3).map(s => `${s}`).join('')}
+
${c.currentCompany}${UI.scoreChip(c.aiScore)}
+
`; +}; + +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 = `
${UI.icon('search')}

No talent found

`; return; } + grid.innerHTML = list.map(c => ` +
+
+
+ ${UI.avatar(c.name, c.initials, c.color, 'avatar-lg')} +
${c.name}
${c.currentTitle}
+ ${UI.scoreChip(c.aiScore)} +
+
${c.skills.slice(0, 4).map(s => `${s}`).join('')}
+
+
+ ${UI.icon('briefcase')} ${c.experience} yrs + ${c.currentCompany} + ${UI.badge(c.source, 'b-gray')} +
+
+
`).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 = [''].concat(DB.departments.map(d => ``)).join(''); + + const html = ` +
+
+

Talent Pool

${pool.length} silver-medalists & passive candidates to re-engage

+
+
+
+
+ + +
+
+
+
`; + + 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(); }; + } + }; +}; diff --git a/js/rbac.js b/js/rbac.js new file mode 100644 index 0000000..31c9981 --- /dev/null +++ b/js/rbac.js @@ -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 = ` +
+
+

Access Control

Enterprise RBAC — configure permissions for every role and module

+
+ + +
+
+
+
+ +
+
+
+
+
`; + + return { + html, + onMount() { RBAC._renderRoles(); RBAC._renderDetail(); } + }; +}; + +RBAC._renderRoles = function () { + const el = document.getElementById('roleList'); + el.innerHTML = DB.rbacRoles.map((r, i) => ` +
+ ${UI.icon('shield')} +
${r.name}
${r.users} user${r.users === 1 ? '' : 's'}
+
`).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 => ` + + ${mod} + ${DB.permTypes.map((pt, pi) => `${UI.icon('check')}`).join('')} + `).join(''); + + el.innerHTML = ` +
+
${UI.icon('shield')} +

${r.name}

${r.desc}
+
+ ${r.users} users + + +
+
+
+
+ ${DB.permTypes.map(p => ``).join('')} + ${matrixRows} +
Module${p}
+
`; + + 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: `
+
Required
+
+
+
+
`, + footer: `` + }); +}; +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'); +}; diff --git a/js/recruiterhub.js b/js/recruiterhub.js new file mode 100644 index 0000000..b9622e7 --- /dev/null +++ b/js/recruiterhub.js @@ -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 => ``).join(''); + + const html = ` +
+
+

Recruiter Hub

Personalized performance dashboard & workload

+
+ + +
+
+
+
`; + + 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) => `
${label}${UI.icon(icn)}
${val}
${sub ? `
${sub}
` : ''}
`; + + // 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 `
+ ${i + 1} + ${UI.avatar(rec.name, rec.initials, rec.color)} +
${rec.name}
${rec.efficiency}% efficiency · ${rec.avgTimeToHire}d avg
+
${rec.hires}
hires
+
`; + }).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 = `
+
${['W1', 'W2', 'W3', 'W4', 'W5'].map(w => `
${w}
`).join('')} + ${days.map((d, di) => `
${d}
${r.heatmap[di].map(v => `
`).join('')}`).join('')} +
+
Less ${[0, 1, 2, 3, 5].map(v => ``).join('')} More
`; + + el.innerHTML = ` +
+
+ ${UI.avatar(r.name, r.initials, 'rgba(255,255,255,.18)', 'avatar-lg')} +
${r.name}
${r.department} Recruiter · ⭐ ${r.rating} rating
+
${r.workload}%
Workload
+
${r.efficiency}%
Efficiency
+
${UI.badge(r.sla, slaCls)}
+
+
+ +
+ ${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')} +
+
+ ${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')} +
+
+ ${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')} +
+ +
+
+

Monthly Hiring Trend

Hires per month
+
+
+
+

Workload Heatmap

Interview load
+
${heatHtml}
+
+
+ +
+
+

Recruiter Leaderboard

Top performers by hires
+
${leaderHtml}
+
+
+

Candidate Pipeline

This recruiter's active candidates
+
+
+
`; + + 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 + }); +}; diff --git a/js/reports.js b/js/reports.js new file mode 100644 index 0000000..6ee9e97 --- /dev/null +++ b/js/reports.js @@ -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 => `${r.dept}` }, + { key: 'open', label: 'Open Roles', sortable: true, align: 'center' }, + { key: 'apps', label: 'Applications', sortable: true, align: 'center', render: r => `${r.apps}` }, + { 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 => `
${UI.pbar(r.rate)}
${r.rate}%
` } + ] + }); + + 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 = ` +
+
+

Reports

Recruitment metrics and downloadable insights

+
+ + +
+
+ +
+ ${reportCards.map(c => `
${c.title}${UI.icon(c.icn)}
${c.val}
${c.sub}
`).join('')} +
+ +
+
+

Hiring Funnel

Stage-by-stage conversion
+
+
+
+
+

Time to Hire vs Fill

Monthly trend (days)
+
+ ${Charts.legend([{ label: 'Time to Hire', color: Charts.PALETTE[0] }, { label: 'Time to Fill', color: Charts.PALETTE[2] }])}
+
+
+ +
+

Department Performance

Hiring breakdown by team
+
+ ${table.html} +
+ +
+

Report Library

Generate a detailed report
+
+ ${reportTypes.map(r => ` +
+
+ ${UI.icon(r.icn)} +
${r.name}
+
${r.desc}
+
Generate ${UI.icon('chevron-right')}
+
+
`).join('')} +
+
+
`; + + 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' + }); + } + }; +}; diff --git a/js/settings.js b/js/settings.js new file mode 100644 index 0000000..2db2378 --- /dev/null +++ b/js/settings.js @@ -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 = ` +
+
+

Settings

Configure your workspace and team preferences

+
+
+
${tabs.map((t, i) => `
${t}
`).join('')}
+
+ ${tabs.map((t, i) => `
${Settings.pane(t)}
`).join('')} +
+
`; + + 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 `

${title}

${desc}

+
`; +} + +Settings.pane = function (name) { + if (name === 'General') { + return `
+
+
+
+
+
+
+
+
+
+ ${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)} +
`; + } + if (name === 'Users') { + const rows = DB.users.map(u => ` +
${UI.avatar(u.name, u.initials, u.color)}
${u.name}
${u.email}
+ ${UI.badge(u.role, 'b-indigo')} + ${UI.badge(u.status)} + ${u.lastActive} +
+ `).join(''); + return `
+

Team Members

${DB.users.length} users
+
+
${rows}
UserRoleStatusLast ActiveActions
+
`; + } + if (name === 'Roles') { + return `

Roles

Define access levels
+
+
+ ${DB.roles.map(r => `
+ ${UI.icon('users')} +
${r.name}
${r.desc}
+
${r.users} users
${r.perms}
+ +
`).join('')} +
`; + } + if (name === 'Permissions') { + const modules = ['Jobs', 'Candidates', 'Interviews', 'Offers', 'Reports', 'Settings']; + const perms = ['View', 'Create', 'Edit', 'Delete']; + return `

Permission Matrix

Recruiter role
+
+
${perms.map(p => ``).join('')} + ${modules.map(m => `${perms.map((p, i) => ``).join('')}`).join('')}
Module${p}
${m} +
+
`; + } + if (name === 'Notifications') { + return `
+
Email Notifications
+ ${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)} +
In-App Notifications
+ ${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)} +
`; + } + if (name === 'Email Templates') { + const templates = ['Application Received', 'Interview Invitation', 'Assessment Assignment', 'Offer Letter', 'Rejection — Post Interview', 'Reference Request']; + return `

Email Templates

+
+
+ ${templates.map(t => `
${UI.icon('mail')} +
${t}
Last edited 3 days ago
+ ${UI.badge('Active', 'b-green')}
`).join('')} +
`; + } + if (name === 'Career Portal') { + return `
+
+
+
+
+
+
+ ${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)} +
`; + } + if (name === 'Branding') { + return `
+

Company Logo

Displayed on career pages and emails

+
+

Brand Color

Primary accent across the portal

+
+ ${['#004d43', '#ceff71', '#25e9a5', '#8e92ff', '#1a3134', '#eafff4'].map(c => ``).join('')} +
+
+
+
+
+
`; + } + if (name === 'Security') { + return `
+ ${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)} +
+
+
+
+
+

Data Retention

Auto-delete candidate data after set period

+
+
`; + } + if (name === 'Appearance') { + return `
+
Theme
+
+
+
+
Light
Clean and bright
+
+
+
+
Dark
Easy on the eyes
+
+
+
+
System
Match OS setting
+
+
+
+ ${toggleRow('Compact mode', 'Reduce spacing for denser layouts', false)} + ${toggleRow('Show animations', 'Enable transitions and motion', true)} +
`; + } + 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'); + }); +}; diff --git a/js/tasks.js b/js/tasks.js new file mode 100644 index 0000000..8a70485 --- /dev/null +++ b/js/tasks.js @@ -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 = `
${UI.icon('check-square')}

All caught up

No tasks in this view.

`; 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 `
+ ${UI.icon('check')} +
+
${t.title}
+
${UI.icon('users')} ${t.assignee} · ${t.type}
+
+
+ ${UI.badge(t.priority, prCls[t.priority])} +
${overdue ? 'Overdue · ' : 'Due '}${DB.fmtShort(t.due)}
+
+
`; + }).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 => ` +
+ ${UI.icon('bookmark')} +
${s.name}
${s.filters}
+ ${s.count} +
`).join(''); + + const html = ` +
+
+

Tasks

${openCount} open · ${overdueCount} overdue

+
+
+
+
+
+
${filters.map((f, i) => ``).join('')}
+
+
+
+
+

Saved Searches

Quick candidate filters
+
+
${savedHtml}
+
+
+
`; + + 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: `
+
Assignee
${t.assignee}
+
Priority
${t.priority}
+
Due Date
${DB.fmtDate(t.due)}
+
Status
${t.done ? 'Completed' : 'Open'}
+ ${c ? `
Candidate
${c.name}
` : ''} +
+
`, + footer: ` + ${c ? `` : ''} + ` + }); +}; +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 => ``).join(''); + UI.modal({ + title: 'New Task', subtitle: 'Create a recruitment task', + body: `
+
Required
+
+
+
+
+
`, + footer: `` + }); +}; +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'); +}; diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..97ad730 --- /dev/null +++ b/js/ui.js @@ -0,0 +1,252 @@ +/* ============================================================ + ui.js — Reusable UI primitives & helpers + Exposes global `UI` + ============================================================ */ +(function () { + 'use strict'; + + const ICONS = { + 'user-plus': '', + 'calendar': '', + 'check': '', + 'check-circle': '', + 'x': '', + 'x-circle': '', + 'file': '', + 'star': '', + 'message': '', + 'info': '', + 'alert': '', + 'eye': '', + 'edit': '', + 'trash': '', + 'more': '', + 'plus': '', + 'download': '', + 'filter': '', + 'clock': '', + 'mail': '', + 'phone': '', + 'map': '', + 'briefcase': '', + 'trending-up': '', + 'trending-down': '', + 'users': '', + 'award': '', + 'dollar': '', + 'target': '', + 'send': '', + 'video': '', + 'search': '', + 'chevron-left': '', + 'chevron-right': '', + 'refresh': '', + 'copy': '', + 'upload': '', + 'linkedin': '', + 'inbox': '', + 'sparkles': '', + 'zap': '', + 'grid': '', + 'bookmark': '', + 'paperclip': '', + 'external': '', + 'shield': '', + 'lock': '', + 'flame': '', + 'bell': '', + 'layers': '', + 'list': '', + 'check-square': '', + 'arrow-right': '' + }; + + function icon(name, cls) { return `${ICONS[name] || ICONS['info']}`; } + + function avatar(name, initials, color, cls) { + const bg = color || DB.avatarColor(name || ''); + const init = initials || DB.initials(name || '?'); + return `${init}`; + } + + // ---------- 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 `${text}`; } + + 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 `${score}`; + } + + function pbar(pct, cls) { + const c = pct >= 80 ? 'green' : pct >= 50 ? '' : pct >= 30 ? 'amber' : 'red'; + return `
`; + } + + function avatarStack(names, max) { + max = max || 3; + const shown = names.slice(0, max); + const extra = names.length - max; + let html = '
'; + shown.forEach(n => html += avatar(n, DB.initials(n))); + if (extra > 0) html += `+${extra}`; + return html + '
'; + } + + // ---------- Modal ---------- + function modal({ title, subtitle, body, footer, size }) { + const root = document.getElementById('modalRoot'); + root.innerHTML = ` + + `; + 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 = ` + ${icon(cfg.i)} +
${title || cfg.t}
${msg}
+ `; + 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 ? `${state.sortKey === c.key ? (state.sortDir === 1 ? '▲' : '▼') : '⇅'}` : ''; + return `${c.label}${ind}`; + }).join(''); + + let tbody; + if (!pageRows.length) { + tbody = ` +
${icon('search')}

No results found

${config.empty || 'Try adjusting your filters or search.'}

`; + } else { + tbody = pageRows.map(row => `${config.columns.map(c => + `${c.render ? c.render(row) : (row[c.key] ?? '')}`).join('')}`).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 = ` +
+ ${thead}${tbody}
+ `; + + 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 = ``; + 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 === '…' ? `` : ``); + btns += ``; + return btns; + } + // public API + return { + html: `
`, + 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 }; +})();