/* The 9-tab candidate profile modal, split out of Candidates.jsx — it was the single largest block in js/candidates.js and deserves its own file. TWO DATA MODES, selected by whether the caller passes a `userId`: SEED (Candidates.jsx) — every tab renders from the seed record, exactly as the prototype did. LIVE (TalentPool.jsx) — GET /candidate/fetch?user_id= switches the endpoint into detail mode and returns the real record: résumé text, the agent's match verdict, documents, and the four child collections (interviews, notes, activity, feedback). The write tabs POST to their own endpoints and invalidate this one query, so the whole modal repaints from a single refetch. History is fetched separately (GET /candidate/history/fetch) when that tab opens — it is append-only and not part of the detail payload. Live collections are NEVER padded with the seed's demo rows. An empty tab gets an empty state, because inventing three scorecards for a real applicant is worse than showing none. Scoping differs between the child tables and is not interchangeable: notes hang off the candidate (users.id), while interviews, activity and feedback hang off one application (inbox.id). */ import { useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Modal from '../ui/Modal' import { Tabs } from '../ui/Tabs' import { Avatar, Badge, EmptyState, Icon, ScoreChip, Stars } from '../ui/primitives' import { useToast } from '../ui/Toast' import { useAuth } from '../auth/AuthContext' import { seedQuery } from '../data/seedQueries' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as candidatesApi from '../api/candidates' import * as formsApi from '../api/forms' import * as pipelineApi from '../api/pipeline' import CandidateFormsTab from './CandidateForms' import { companies, fmtDate, moneyK, pick } from '../data/seed' /* Workflow order: learn (Overview, Resume, Documents) → interview (Interview, Forms, Feedback) → track (Notes, Activity) → audit (Timeline, History). */ const TABS = ['Overview', 'Resume', 'Documents', 'Interview', 'Forms', 'Feedback', 'Notes', 'Activity', 'Timeline', 'History'] // Forward progression for the live Advance button. Rejected has no next stage. const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'] const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 } const REVIEWS = ['Strong Hire', 'Hire', 'Lean Hire', 'No Hire'] const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round'] const INTERVIEW_STATES = ['Scheduled', 'Completed', 'Cancelled', 'No Show'] const ACTIVITY_TYPES = ['Call', 'Email', 'Meeting', 'Screening', 'Assessment', 'Note'] /** Seed timestamps are Date objects; the API sends ISO strings. */ function fmtWhen(value, fallback = '—') { if (!value) return fallback const d = value instanceof Date ? value : new Date(value) return Number.isNaN(d.getTime()) ? String(value) : fmtDate(d) } function fmtClock(value) { if (!value) return null const d = new Date(value) return Number.isNaN(d.getTime()) ? null : d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) } function stamp(value) { const d = value instanceof Date ? value : new Date(value ?? NaN) return Number.isNaN(d.getTime()) ? 0 : d.getTime() } /** + -> one ISO instant, or null. */ function toInstant(date, time) { if (!date) return null const d = new Date(`${date}T${time || '00:00'}`) return Number.isNaN(d.getTime()) ? null : d.toISOString() } function Info({ label, val }) { return (
{label}
{val === 0 || val ? val : '—'}
) } function useCandidateDetail(userId) { return useQuery({ queryKey: qk.candidates.detail(userId), queryFn: async () => candidatesApi.toRows(await candidatesApi.getByUserId(userId))[0] ?? null, enabled: Boolean(userId), }) } /** * A write against one of the child endpoints. Every one of them invalidates the * single detail query the modal renders from, so a saved note and a submitted * scorecard both land through the same refetch rather than through hand-patched * cache entries that could drift from the server's view. */ function useProfileWrite({ userId, mutationFn, success, onDone }) { const qc = useQueryClient() const { toast } = useToast() return useMutation({ mutationFn, onSuccess: async (_data, vars) => { await qc.invalidateQueries({ queryKey: qk.candidates.detail(userId) }) await qc.invalidateQueries({ queryKey: ['candidates', 'history', userId] }) toast(typeof success === 'function' ? success(vars) : success, 'success') onDone?.() }, onError: (err) => toast(friendlyAuthError(err, 'Could not save. Please try again.'), 'error'), }) } /** * `atsScore` / `recommendation` are the CURRENT ats_results row, fetched by the * caller (Talent Pool reads GET /pipeline/candidate/score/fetch on card click). * They are optional: a caller that does not fetch it passes nothing and the hero * falls back to the detail payload's denormalised ai_score, then to the row's. * When present they WIN, because ats_results is the source the denorm copies. */ export default function CandidateProfile({ candidate: c, atsScore = null, recommendation = null, onClose, onAdvance, onToggleFav, onAtsMatch, variant = 'modal', }) { const { toast } = useToast() const { can } = useAuth() const [tab, setTab] = useState('Overview') const { data: interviews = [] } = useQuery(seedQuery('interviews')) const { data: recruiters = [] } = useQuery(seedQuery('recruiters')) const isLive = Boolean(c.userId) const detail = useCandidateDetail(c.userId) const live = detail.data ?? null // The prototype called DB.pick() inline while rendering, so the "previous // employer" changed every repaint. Fixed per candidate. const priorCompany = useMemo(() => pick(companies), []) const candidateInterviews = interviews.filter((i) => i.candidateId === c.id) // The application row interviews/activity/feedback attach to. Detail mode // flattens every application the candidate owns; writes land on the first, // which is the one the header is describing. const inboxId = live?.inbox_id ?? null // Same key as the Forms tab's own query, so the tab count and the tab body // share one fetch. Fetching is not stage-gated (only creating is). Manual // candidates key by their manual_upload_candidate row instead of inbox. const manualFormsId = !inboxId ? (live?.manual_upload_candidate_id ?? null) : null const formsParams = inboxId ? { inboxId } : { manualUploadCandidateId: manualFormsId } const formsQuery = useQuery({ queryKey: qk.forms.list(formsParams), queryFn: () => formsApi.list(formsParams), enabled: isLive && Boolean(inboxId || manualFormsId) && can('interviews.view'), }) const favorite = live ? Boolean(live.favorite) : c.favorite const setFavorite = useProfileWrite({ userId: c.userId, mutationFn: (next) => candidatesApi.update(c.userId, { favorite: next }), success: (next) => (next ? `${c.name} added to favorites` : 'Removed from favorites'), }) // Same PATCH as favorite: the server writes rating onto every inbox row the // candidate owns and returns the refreshed detail payload. const rating = Number(live?.rating ?? 0) const setRating = useProfileWrite({ userId: c.userId, mutationFn: (next) => candidatesApi.update(c.userId, { rating: next }), success: (next) => `Rating saved — ${next}/5`, }) // Score the candidate's inbox CV against the assigned job with the ATS engine. // Needs both an assigned job (what to score against) and a message (whose // attachment to score); the refetch lands the new ai_score in this modal. const canScoreAts = isLive && Boolean(live?.assigned_job_post_id && live?.message_id) const scoreAts = useProfileWrite({ userId: c.userId, mutationFn: () => candidatesApi.scoreInbox(live.assigned_job_post_id, [live.message_id]), success: 'CV scored against the assigned job', }) const title = live?.job_title || c.currentTitle const company = live?.currentCompany || c.currentCompany // Live stage comes from the application record, not from whatever card // opened the modal — c.stage goes stale the moment the stage moves. const rawStatus = String(live?.application_status || '').toUpperCase() const stageLabel = isLive ? (pipelineApi.STAGE_FROM_STATUS[rawStatus] ?? 'Shortlist') : c.stage const stageIdx = KANBAN_ORDER.indexOf(stageLabel) const nextStage = stageIdx >= 0 && stageIdx < KANBAN_ORDER.length - 1 ? KANBAN_ORDER[stageIdx + 1] : null // The REAL stage move (PATCH /candidate/stage) — the seed-only onAdvance walk // is kept for seed candidates only. Requires pipeline.edit server-side. const qc = useQueryClient() const advanceLive = useProfileWrite({ userId: c.userId, mutationFn: () => pipelineApi.changeStage({ inboxId: live?.inbox_id ?? undefined, manualUploadId: live?.inbox_id ? undefined : (live?.manual_upload_candidate_id ?? undefined), toStage: pipelineApi.STATUS_FROM_STAGE[nextStage], changeReason: 'advanced from candidate profile', }), success: () => `Moved to ${nextStage}`, onDone: () => { qc.invalidateQueries({ queryKey: qk.pipeline.all() }) qc.invalidateQueries({ queryKey: qk.forms.all() }) }, }) // The hero experience chip: live experience is free text ("6 years"), seed is // a number. Render nothing rather than a bare "yrs exp". const expRaw = live?.experience ?? c.experience const expChip = expRaw == null || expRaw === '' ? null : Number.isFinite(Number(expRaw)) ? `${expRaw} yrs exp` : String(expRaw) const counts = live && { Interview: live.interviews?.length ?? 0, Forms: formsQuery.data?.total ?? 0, Notes: live.notes?.length ?? 0, Activity: live.activity?.length ?? 0, Documents: live.documents?.length ?? 0, Feedback: live.feedback?.length ?? 0, } // In live mode nothing below the hero can be trusted until the detail payload // lands, so one guard replaces every tab body rather than each tab inventing // its own half-loaded state. const guard = !isLive ? null : detail.isPending ? ( Fetching the full record. ) : detail.isError ? ( {friendlyAuthError(detail.error, 'Please try again.')} ) : !live ? ( This candidate is no longer in the pipeline. ) : null const actions = ( <> {canScoreAts && ( )} {onAtsMatch && ( )} {isLive ? ( ) : ( )} ) const body = ( <>
{live?.name || c.name}
{company ? `${title} at ${company}` : title}
{stageLabel && {stageLabel}}{' '} {(live?.source || c.source) && {live?.source || c.source}} {expChip && {expChip}}
{/* No score anywhere -> the whole block goes, rather than a ring drawn around a blank. Seed-backed callers still pass a number and are unaffected; only live candidates the engine never scored drop out. */} {(atsScore ?? live?.ai_score ?? c.aiScore) != null && (
{/* The band caption replaces the static label only when the caller actually fetched one — every other screen keeps "AI Match". */}
{recommendation || 'AI Match'}
)}
({ key: t, label: t, count: counts ? counts[t] : undefined }))} />
{tab === 'Overview' && (guard || (live ? ( <>
Rating
setRating.mutate(n)} /> {rating ? `${rating.toFixed(1)} / 5.0` : 'Not rated'}
{(live.match_summary || live.match_reasoning) && ( <>
AI Screening
{live.match_summary &&

{live.match_summary}

} {live.match_reasoning &&

{live.match_reasoning}

}
)} {live.assigned_job_post && ( <>
Assigned Role
{live.assigned_job_post.title}
)} {live.job_posts?.length > 0 && ( <>
Suggested Roles
{live.job_posts.map((j) => {j.title})}
)} ) : ( <>
Skills
{c.skills.map((s) => {s})}
)))} {tab === 'Resume' && (guard || (live ? ( ) : ( <>

{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 — {priorCompany}
2018 – 2021
Education
{c.education}
)))} {tab === 'Timeline' && (guard || (live ? ( ) : (
{[ { icon: 'user-plus', title: 'Application received', meta: 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' }, ].map((e) => (
{e.title}
{e.meta}
{e.desc}
))}
)))} {tab === 'History' && (guard || (live ? ( ) : ( Audit history is recorded for live candidates only. )))} {tab === 'Interview' && (guard || (live ? ( ) : ( candidateInterviews.length ? (
{candidateInterviews.map((iv) => (
{iv.type}
{fmtDate(iv.when)} · {iv.meeting}
{iv.status}
))}
) : ( Schedule an interview to get started. ) )))} {tab === 'Forms' && (guard || (live ? ( ) : ( Hiring forms attach to real applications. )))} {tab === 'Notes' && (guard || (live ? ( ) : ( <>