// Real data from backend API (dev server proxies /api to cloud backend) const API_BASE_URL = '/api'; // Stage mapping: Backend stage name → Frontend stage ID const STAGE_MAP = { 'sourced': 'sourced', 'intro_call': 'intro', 'first_technical': 'tech1', 'second_technical': 'tech2', 'third_technical': 'tech3', 'onsite': 'onsite', 'offer': 'offer', 'hired': 'hired', 'backburner': 'backburner', 'rejected': 'rejected', 'not_interested': 'not_interested', 'declined_offer': 'declined', }; // Frontend stages (with visual styling) const STAGES = [ { id: "sourced", label: "Sourced", short: "Sourced", color: "var(--nl-paper)", fg: "var(--fg-2)", accent: "#A8AEB0" }, { id: "intro", label: "Intro call", short: "Intro", color: "#D9E2DC", fg: "#3E5A4D", accent: "#96B1AD" }, { id: "tech1", label: "1st technical", short: "Tech 1", color: "#D9E1EE", fg: "#3E4E72", accent: "#7595D5" }, { id: "tech2", label: "2nd technical", short: "Tech 2", color: "#C8D3E8", fg: "#324769", accent: "#637DA7" }, { id: "tech3", label: "3rd technical", short: "Tech 3", color: "#B7C3DD", fg: "#2C4060", accent: "#536C97" }, { id: "onsite", label: "Onsite", short: "Onsite", color: "#EFE2C4", fg: "#7A5A24", accent: "#E3A54E" }, { id: "offer", label: "Offer", short: "Offer", color: "#E6D2B4", fg: "#6E4F26", accent: "#A9824C" }, { id: "hired", label: "Hired", short: "Hired", color: "#CADDC9", fg: "#3F5E45", accent: "#6B8E76" }, { id: "backburner", label: "Backburner", short: "Hold", color: "#E2DFD7", fg: "#67676A", accent: "#9A9A9D" }, { id: "rejected", label: "Rejected by us", short: "Rejected", color: "#E8DDD8", fg: "#7A5A52", accent: "#B4553D" }, { id: "not_interested", label: "Not interested in us", short: "Not interested", color: "#E0D8D2", fg: "#6B5E55", accent: "#8B7060" }, { id: "declined", label: "Declined offer", short: "Declined", color: "#DCD5CE", fg: "#6B5E55", accent: "#8B7765" }, ]; const STAGE_BY_ID = Object.fromEntries(STAGES.map(s => [s.id, s])); const STAGE_FLOW = ["sourced", "intro", "tech1", "tech2", "tech3", "onsite", "offer", "hired"]; // Team data — fetched from /api/auth/users, refreshed every 10 min const TEAM_COLORS = [ "#7595D5", "#E3A54E", "#96B1AD", "#A9824C", "#637DA7", "#6B8E76", "#B4553D", "#9C6B4F", "#536C97", "#8B7765", ]; function _userColor(id) { let hash = 0; for (let i = 0; i < id.length; i++) hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0; return TEAM_COLORS[Math.abs(hash) % TEAM_COLORS.length]; } function _initials(name) { return (name || "").split(" ").map(p => p[0] || "").join("").slice(0, 2).toUpperCase(); } function _apiUserToTeam(u) { const roleLabel = { admin: "Admin", nuance_team: "Team", external_recruiter: "External" }; return { id: u.id, email: (u.email || "").toLowerCase(), name: u.full_name, initials: _initials(u.full_name), color: _userColor(u.id), role: roleLabel[u.role] || u.role, raw_role: u.role, avatar_url: u.avatar_url || null, last_login: u.last_login || null, }; } let TEAM = []; let RECRUITERS = []; let TEAM_BY_ID = {}; let TEAM_BY_EMAIL = {}; const EXT_AGENCIES = []; const EXT_RECRUITERS = []; const ROLES = { admin: { id: "admin", label: "Admin", accent: "#1D3140", desc: "Full access · see and change everything" }, interviewer: { id: "interviewer", label: "Interviewer", accent: "#7595D5", desc: "All candidates · limited visibility" }, external: { id: "external", label: "External Agent", accent: "#9C6B4F", desc: "Only candidates they submitted" }, }; let VIEWERS = []; const SOURCES = [ { id: "greenhouse", label: "Greenhouse", icon: "gh" }, { id: "paraform", label: "Paraform", icon: "pf" }, { id: "Jon Luzha", label: "Jon Luzha", icon: "jl" }, { id: "techire", label: "Techire", icon: "te" }, { id: "Nicholas Jackson", label: "Nicholas Jackson", icon: "nj" }, { id: "referral", label: "Referral", icon: "ref" }, { id: "inbound", label: "Inbound", icon: "→" }, { id: "outbound", label: "Outbound", icon: "src" }, { id: "unknown", label: "Unknown", icon: "?" }, ]; // Helper functions function nextStage(stageId) { const idx = STAGE_FLOW.indexOf(stageId); if (idx === -1 || idx === STAGE_FLOW.length - 1) return null; return STAGE_FLOW[idx + 1]; } function suggestedForStage(stageId) { return TEAM; } // Calculate days in stage function daysInStage(enteredStageAt) { if (!enteredStageAt) return 0; // Backend stores UTC times without 'Z' suffix — ensure JS parses as UTC const ts = enteredStageAt.endsWith('Z') ? enteredStageAt : enteredStageAt + 'Z'; const entered = new Date(ts); const now = new Date(); return Math.max(0, Math.floor((now - entered) / (1000 * 60 * 60 * 24))); } // Global data store (will be populated on app init) let JOBS = []; let CANDIDATES = []; let JOBS_BY_ID = {}; // Fetch data from backend async function fetchJobs() { try { const response = await fetch(`${API_BASE_URL}/jobs?limit=5000`); if (!response.ok) throw new Error(`Failed to fetch jobs: ${response.statusText}`); const jobs = await response.json(); JOBS = jobs.map(job => ({ id: job.id, title: job.title, dept: job.department || "N/A", loc: "Seattle", // Default location (not in backend data yet) type: "Full-time", opened: new Date(job.created_at).toLocaleDateString('en-US', { timeZone: 'America/Los_Angeles', month: 'short', day: 'numeric' }), manager: "Fangchang M.", // Default (not in backend data yet) priority: job.priority || "P1", status: job.status, candidateCount: job.candidate_count || 0, })); JOBS_BY_ID = Object.fromEntries(JOBS.map(j => [j.id, j])); return JOBS; } catch (error) { console.error('Error fetching jobs:', error); return []; } } async function fetchCandidates() { try { const response = await fetch(`${API_BASE_URL}/candidates?limit=5000`); if (!response.ok) throw new Error(`Failed to fetch candidates: ${response.statusText}`); const candidates = await response.json(); CANDIDATES = candidates.map((c, idx) => { const job = JOBS_BY_ID[c.job_id] || { id: c.job_id, title: "Unknown Job" }; const stage = STAGE_MAP[c.stage] || 'sourced'; const recruiter = (c.assigned_recruiter_id && TEAM_BY_ID[c.assigned_recruiter_id]) ? TEAM_BY_ID[c.assigned_recruiter_id] : { id: null, name: "No owner", initials: "—", color: "#A8AEB0" }; const source = SOURCES.find(s => s.id === c.source) || SOURCES[0]; // Extract first and last name from full name const nameParts = (c.name || "Unknown").split(' '); const first = nameParts[0] || "Unknown"; const last = nameParts.slice(1).join(' ') || ""; return { id: c.id, first, last, name: c.name, avatar: c.photo_url || null, stage, jobId: job.id, jobTitle: job.title, recruiter: recruiter.id, recruiterName: recruiter.name, recruiterInitials: recruiter.initials, recruiterColor: recruiter.color, recruiterAvatar: recruiter.avatar_url || null, source: source.id, sourceLabel: source.label, company: c.current_company || "N/A", title: c.current_role || "N/A", location: c.location || "N/A", fit: c.ai_fit_score || 0, days: daysInStage(c.entered_stage_at), lastActivity: c.entered_stage_at ? `Entered ${(STAGE_BY_ID[stage] || {}).label || stage} ${new Date(c.entered_stage_at.endsWith('Z') ? c.entered_stage_at : c.entered_stage_at + 'Z').toLocaleDateString("en-US", { timeZone: "America/Los_Angeles", month: "short", day: "numeric" })}` : "Imported from Greenhouse", salary: c.desired_compensation || "N/A", salaryLo: 0, salaryHi: 0, starred: false, flagged: false, referredBy: c.referrer || null, submittedById: null, submittedByName: c.submitted_by || null, submittedByAgency: null, panelMembers: [], // TODO: Populate from interviews phone: c.phone, email: c.email, created_at: c.created_at, companiesText: c.companies_text || "", educationText: c.education_text || "", _searchHay: [c.name, c.email, c.companies_text, c.education_text] .filter(Boolean).join(" ").toLowerCase(), duplicateIds: [], }; }); // Compute duplicate groups by email (case-insensitive, skip placeholders) const emailGroups = {}; for (const c of CANDIDATES) { if (!c.email || c.email.includes("placeholder")) continue; const key = c.email.toLowerCase(); if (!emailGroups[key]) emailGroups[key] = []; emailGroups[key].push(c.id); } // Also group by exact normalized name (lowercase, trimmed) const nameGroups = {}; for (const c of CANDIDATES) { const key = c.name.toLowerCase().trim(); if (!key || key === "unknown") continue; if (!nameGroups[key]) nameGroups[key] = []; nameGroups[key].push(c.id); } // Merge: for each candidate, collect all duplicate IDs (union of email + name matches) for (const c of CANDIDATES) { const dupSet = new Set(); const emailKey = c.email && !c.email.includes("placeholder") ? c.email.toLowerCase() : null; const nameKey = c.name.toLowerCase().trim(); if (emailKey && emailGroups[emailKey]) { for (const id of emailGroups[emailKey]) if (id !== c.id) dupSet.add(id); } if (nameKey && nameKey !== "unknown" && nameGroups[nameKey]) { for (const id of nameGroups[nameKey]) if (id !== c.id) dupSet.add(id); } c.duplicateIds = Array.from(dupSet); } return CANDIDATES; } catch (error) { console.error('Error fetching candidates:', error); return []; } } async function fetchCandidateDetail(candidateId) { try { const response = await fetch(`${API_BASE_URL}/candidates/${candidateId}`); if (!response.ok) return null; return await response.json(); } catch (error) { console.error('Error fetching candidate detail:', error); return null; } } async function fetchCandidateInterviews(candidateId) { try { const response = await fetch(`${API_BASE_URL}/candidates/${candidateId}/interviews`); if (!response.ok) { console.error(`Failed to fetch interviews: ${response.status}`); return []; } const interviews = await response.json(); return interviews; } catch (error) { console.error('Error fetching interviews:', error); return []; } } // Reverse map: frontend stage ID → backend stage name const REVERSE_STAGE_MAP = Object.fromEntries( Object.entries(STAGE_MAP).map(([backend, frontend]) => [frontend, backend]) ); async function updateCandidateStage(candidateId, frontendStageId) { const backendStage = REVERSE_STAGE_MAP[frontendStageId]; if (!backendStage) { console.error('Unknown stage:', frontendStageId); return null; } try { const response = await fetch(`${API_BASE_URL}/candidates/${candidateId}/stage`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem("ats_token")}` }, body: JSON.stringify({ stage: backendStage }), }); if (!response.ok) { const err = await response.json().catch(() => ({})); console.error('Failed to update stage:', err); return null; } const result = await response.json(); const entry = CANDIDATES.find(c => c.id === candidateId); if (entry) { entry.stage = frontendStageId; entry.days = 0; entry.lastActivity = `Entered ${(STAGE_BY_ID[frontendStageId] || {}).label || frontendStageId} just now`; } notifyCandidatesChanged(); return result; } catch (error) { console.error('Error updating candidate stage:', error); return null; } } async function updateCandidateSource(candidateId, source, referrer) { try { const response = await fetch(`${API_BASE_URL}/candidates/${candidateId}/source`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem("ats_token")}` }, body: JSON.stringify({ source, referrer: referrer || null }), }); if (!response.ok) { const err = await response.json().catch(() => ({})); console.error('Failed to update source:', err); return null; } const result = await response.json(); const entry = CANDIDATES.find(c => c.id === candidateId); if (entry) { const srcObj = SOURCES.find(s => s.id === source) || SOURCES[0]; entry.source = srcObj.id; entry.sourceLabel = srcObj.label; entry.referredBy = referrer || null; } notifyCandidatesChanged(); return result; } catch (error) { console.error('Error updating candidate source:', error); return null; } } async function updateCandidateProfile(candidateId, fields) { try { const response = await fetch(`${API_BASE_URL}/candidates/${candidateId}/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem("ats_token")}` }, body: JSON.stringify(fields), }); if (!response.ok) return null; const result = await response.json(); const entry = CANDIDATES.find(c => c.id === candidateId); if (entry) { if (result.name) { entry.name = result.name; entry.first = result.name.split(" ")[0]; } if (result.current_role !== undefined) entry.title = result.current_role || "N/A"; if (result.job_id) { entry.jobId = result.job_id; const j = JOBS.find(j => j.id === result.job_id); if (j) entry.jobTitle = j.title; } if (result.desired_compensation !== undefined) entry.salary = result.desired_compensation || ""; if (result.assigned_recruiter_id !== undefined) { entry.recruiter = result.assigned_recruiter_id; const t = TEAM_BY_ID[result.assigned_recruiter_id]; if (t) { entry.recruiterName = t.name; entry.recruiterInitials = t.initials; entry.recruiterColor = t.color; } } } notifyCandidatesChanged(); return result; } catch (error) { console.error('Error updating candidate profile:', error); return null; } } // Fetch email threads for a candidate async function fetchCandidateEmails(candidateId) { try { const response = await fetch(`${API_BASE_URL}/candidates/${candidateId}/emails`); if (!response.ok) return []; return await response.json(); } catch (error) { console.error('Error fetching emails:', error); return []; } } // Fetch activities for a candidate async function fetchCandidateActivities(candidateId) { try { const response = await fetch(`${API_BASE_URL}/candidates/${candidateId}/activities`); if (!response.ok) { console.error(`Failed to fetch activities: ${response.status}`); return []; } const activities = await response.json(); return activities; } catch (error) { console.error('Error fetching activities:', error); return []; } } // Initialize data on page load async function fetchUsers() { try { const response = await fetch(`${API_BASE_URL}/auth/users`); if (!response.ok) throw new Error(`Failed to fetch users: ${response.statusText}`); const users = await response.json(); const internal = users.filter(u => u.role !== "external_recruiter"); const external = users.filter(u => u.role === "external_recruiter"); TEAM = internal.map(_apiUserToTeam); RECRUITERS = internal.map(u => ({ id: u.id, name: u.full_name, initials: _initials(u.full_name), color: _userColor(u.id), })); TEAM_BY_ID = Object.fromEntries(TEAM.map(t => [t.id, t])); TEAM_BY_EMAIL = Object.fromEntries(TEAM.filter(t => t.email).map(t => [t.email, t])); const roleMap = { admin: "admin", nuance_team: "interviewer", external_recruiter: "external" }; VIEWERS = internal.map(u => ({ id: u.id, name: u.full_name, initials: _initials(u.full_name), color: _userColor(u.id), role: roleMap[u.role] || u.role, title: u.email, })); // Re-export so other screens see the update Object.assign(window, { TEAM, RECRUITERS, TEAM_BY_ID, TEAM_BY_EMAIL, VIEWERS }); return users; } catch (error) { console.error('Error fetching users:', error); return []; } } let _usersRefreshInterval = null; function _startUsersRefresh() { if (_usersRefreshInterval) return; _usersRefreshInterval = setInterval(fetchUsers, 10 * 60 * 1000); } async function initializeData() { console.log('Initializing data from API...'); try { const resp = await fetch(`${API_BASE_URL}/bootstrap`); if (resp.ok) { const data = await resp.json(); _processUsers(data.users); _processJobs(data.jobs); _processCandidates(data.candidates); console.log(`Bootstrap loaded: ${TEAM.length} users, ${JOBS.length} jobs, ${CANDIDATES.length} candidates`); _startUsersRefresh(); return; } } catch (e) { console.warn('Bootstrap failed, falling back to sequential:', e); } await fetchUsers(); _startUsersRefresh(); await fetchJobs(); await fetchCandidates(); console.log(`Fallback loaded: ${TEAM.length} users, ${JOBS.length} jobs, ${CANDIDATES.length} candidates`); } function _processUsers(users) { const internal = users.filter(u => u.role !== "external_recruiter"); TEAM = internal.map(_apiUserToTeam); RECRUITERS = internal.map(u => ({ id: u.id, name: u.full_name, initials: _initials(u.full_name), color: _userColor(u.id), })); TEAM_BY_ID = Object.fromEntries(TEAM.map(t => [t.id, t])); TEAM_BY_EMAIL = Object.fromEntries(TEAM.filter(t => t.email).map(t => [t.email, t])); const roleMap = { admin: "admin", nuance_team: "interviewer", external_recruiter: "external" }; VIEWERS = internal.map(u => ({ id: u.id, name: u.full_name, initials: _initials(u.full_name), color: _userColor(u.id), role: roleMap[u.role] || u.role, title: u.email, })); Object.assign(window, { TEAM, RECRUITERS, TEAM_BY_ID, TEAM_BY_EMAIL, VIEWERS }); } function _processJobs(jobs) { JOBS = jobs.map(job => ({ id: job.id, title: job.title, dept: job.department || "N/A", loc: "Seattle", type: "Full-time", opened: job.created_at ? new Date(job.created_at).toLocaleDateString('en-US', { timeZone: 'America/Los_Angeles', month: 'short', day: 'numeric' }) : "", manager: "Fangchang M.", priority: job.priority || "P1", status: job.status, candidateCount: job.candidate_count || 0, })); JOBS_BY_ID = Object.fromEntries(JOBS.map(j => [j.id, j])); } function _processCandidates(candidates) { CANDIDATES = candidates.map((c, idx) => { const job = JOBS_BY_ID[c.job_id] || { id: c.job_id, title: "Unknown Job" }; const stage = STAGE_MAP[c.stage] || 'sourced'; const recruiter = (c.assigned_recruiter_id && TEAM_BY_ID[c.assigned_recruiter_id]) ? TEAM_BY_ID[c.assigned_recruiter_id] : { id: null, name: "No owner", initials: "—", color: "#A8AEB0" }; const source = SOURCES.find(s => s.id === c.source) || SOURCES[0]; const nameParts = (c.name || "Unknown").split(' '); const first = nameParts[0] || "Unknown"; const last = nameParts.slice(1).join(' ') || ""; return { id: c.id, first, last, name: c.name, avatar: c.photo_url || null, stage, jobId: job.id, jobTitle: job.title, recruiter: recruiter.id, recruiterName: recruiter.name, recruiterInitials: recruiter.initials, recruiterColor: recruiter.color, recruiterAvatar: recruiter.avatar_url || null, source: source.id, sourceLabel: source.label, company: c.current_company || "N/A", title: c.current_role || "N/A", location: c.location || "N/A", fit: c.ai_fit_score || 0, days: daysInStage(c.entered_stage_at), lastActivity: c.entered_stage_at ? `Entered ${(STAGE_BY_ID[stage] || {}).label || stage} ${new Date(c.entered_stage_at.endsWith('Z') ? c.entered_stage_at : c.entered_stage_at + 'Z').toLocaleDateString("en-US", { timeZone: "America/Los_Angeles", month: "short", day: "numeric" })}` : "Imported from Greenhouse", salary: c.desired_compensation || "N/A", salaryLo: 0, salaryHi: 0, starred: false, flagged: false, referredBy: c.referrer || null, submittedById: null, submittedByName: c.submitted_by || null, submittedByAgency: null, panelMembers: [], phone: c.phone, email: c.email, created_at: c.created_at, companiesText: c.companies_text || "", educationText: c.education_text || "", _searchHay: [c.name, c.email, c.companies_text, c.education_text] .filter(Boolean).join(" ").toLowerCase(), duplicateIds: [], }; }); // Compute duplicate groups const emailGroups = {}; for (const c of CANDIDATES) { if (!c.email || c.email.includes("placeholder")) continue; const key = c.email.toLowerCase(); if (!emailGroups[key]) emailGroups[key] = []; emailGroups[key].push(c.id); } const nameGroups = {}; for (const c of CANDIDATES) { const key = c.name.toLowerCase().trim(); if (!key || key === "unknown") continue; if (!nameGroups[key]) nameGroups[key] = []; nameGroups[key].push(c.id); } for (const c of CANDIDATES) { const dupSet = new Set(); const emailKey = c.email && !c.email.includes("placeholder") ? c.email.toLowerCase() : null; const nameKey = c.name.toLowerCase().trim(); if (emailKey && emailGroups[emailKey]) { for (const id of emailGroups[emailKey]) if (id !== c.id) dupSet.add(id); } if (nameKey && nameKey !== "unknown" && nameGroups[nameKey]) { for (const id of nameGroups[nameKey]) if (id !== c.id) dupSet.add(id); } c.duplicateIds = Array.from(dupSet); } } // Helper functions function candidatesByStage(stageId, jobId = null) { return CANDIDATES.filter(c => c.stage === stageId && (!jobId || c.jobId === jobId)); } function candidatesByJob(jobId) { return CANDIDATES.filter(c => c.jobId === jobId); } function jobStageCount(jobId, stageId) { return CANDIDATES.filter(c => c.jobId === jobId && c.stage === stageId).length; } // Access control - filter candidates based on viewer role function visibleCandidates(viewer, list = CANDIDATES) { if (!viewer) return list; switch (viewer.role) { case "admin": return list; case "interviewer": return list; case "external": return list.filter(c => c.submittedById === viewer.id || c.source === viewer.sourceTag); default: return []; } } // Field visibility based on viewer role function canSeeField(viewer, field) { if (!viewer) return true; if (viewer.role === "admin") return true; if (viewer.role === "interviewer") { return field !== "emails" && field !== "slack" && field !== "other_notes"; } return false; } async function fetchIntegrationStatus() { try { const r = await fetch(`${API_BASE_URL}/integrations/status`); if (!r.ok) return null; return await r.json(); } catch { return null; } } async function updateJobStatus(jobId, status) { try { const resp = await fetch(`${API_BASE_URL}/jobs/${jobId}/status`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${localStorage.getItem("ats_token")}` }, body: JSON.stringify({ status }), }); if (!resp.ok) return null; const result = await resp.json(); const job = JOBS.find(j => j.id === jobId); if (job) job.status = status; window.dispatchEvent(new CustomEvent("jobs-updated")); return result; } catch { return null; } } function notifyCandidatesChanged() { window.dispatchEvent(new CustomEvent("candidates-updated")); } // Export to global scope for screen components Object.assign(window, { STAGES, STAGE_BY_ID, JOBS, CANDIDATES, RECRUITERS, SOURCES, STAGE_FLOW, TEAM, TEAM_BY_ID, TEAM_BY_EMAIL, EXT_AGENCIES, EXT_RECRUITERS, ROLES, VIEWERS, candidatesByStage, candidatesByJob, jobStageCount, nextStage, suggestedForStage, visibleCandidates, canSeeField, initializeData, fetchUsers, fetchCandidateDetail, fetchCandidateInterviews, fetchCandidateEmails, fetchCandidateActivities, updateCandidateStage, updateCandidateSource, updateCandidateProfile, updateJobStatus, notifyCandidatesChanged, fetchIntegrationStatus, });