import { useId, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Modal from '../ui/Modal' import OpenResumeButton from '../ui/OpenResumeButton' import { Tabs } from '../ui/Tabs' import { DEFAULT_PAGE_SIZE, Pagination, pageWindow } from '../ui/DataTable' import { Avatar, Badge, EmptyState, Icon, ScoreChip, Stars } from '../ui/primitives' import { useToast } from '../ui/Toast' import { useAuth } from '../auth/AuthContext' import { isHiringManager } from '../auth/permissions' 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 * as s3Api from '../api/s3' import CandidateFormsTab from './CandidateForms' import CandidateWorkspaceOverview, { CandidateWorkspaceHero } from './CandidateWorkspace' import { CandidateBrowseNav } from './CandidateBrowse' import { PreviousApplications, ReappliedBadge, candidateApplicationsOf } from '../components/ReapplicantHistory' import { fmtDate, fmtTime, toDate } from '../lib/format' import { companies, moneyK, pick } from '../data/seed' /* Workflow order: learn (Overview, Resume, Documents) → interview (Interview, Forms) → track (Notes, Activity) → audit (Timeline, History). */ const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Notes', 'Activity', 'Timeline', 'History'] // Forward progression for the live Advance button. Rejected has no next stage. const KANBAN_ORDER = ['CLOSED', 'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired'] const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 } function tabFromSearch(tabParam, visibleTabs, fallback) { if (!tabParam) return fallback const wanted = String(tabParam).trim() return visibleTabs.find((t) => t.toLowerCase() === wanted.toLowerCase()) || fallback } 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 YYYY-MM-DD[ T]HH:MM strings. */ function fmtWhen(value, fallback = '—') { if (!value) return fallback return fmtDate(value) || fallback } function fmtClock(value) { return fmtTime(value) || null } function stamp(value) { const d = toDate(value) return d ? d.getTime() : 0 } /** + -> 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, error, 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, error || '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', browse = null, onBrowse, }) { const { toast } = useToast() const { can, user } = useAuth() const isManager = isHiringManager(user) const visibleTabs = isManager ? ['Forms', 'Notes'] : TABS const [searchParams, setSearchParams] = useSearchParams() const [tab, setTab] = useState(() => tabFromSearch( variant === 'page' ? searchParams.get('tab') : null, visibleTabs, isManager ? 'Forms' : 'Overview', )) function changeTab(next) { setTab(next) if (variant !== 'page') return setSearchParams((prev) => { const params = new URLSearchParams(prev) params.set('tab', next) return params }, { replace: true }) } const tabId = useId() const [dialog, setDialog] = useState(null) const [stageReason, setStageReason] = useState('') const openAction = (type, stage) => { setStageReason(''); setDialog({ type, stage }) } const { data: interviews = [] } = useQuery(seedQuery('interviews')) 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 const resumeKey = s3Api.resumeKeyFrom(live) // 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 ? `${live?.name || c.name || 'Candidate'} 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`, }) const qc = useQueryClient() // Re-score from stored professional_summary (candidates table), not a PDF re-read. const canRerunAts = isLive && Boolean(live) && (can('candidates.create') || can('candidates.edit')) const rerunAts = useProfileWrite({ userId: c.userId, mutationFn: () => candidatesApi.rerunAts({ userId: c.userId, inboxMessageId: live?.message_id || undefined, manualUploadCandidateId: live?.manual_upload_candidate_id || undefined, }), success: 'ATS match updated from the stored summary', error: 'Could not run ATS match. Please try again.', onDone: () => { qc.invalidateQueries({ queryKey: qk.candidates.all() }) qc.invalidateQueries({ queryKey: qk.mailbox.all() }) qc.invalidateQueries({ queryKey: qk.analytics.all() }) }, }) 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 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() }) qc.invalidateQueries({ queryKey: qk.analytics.all() }) }, }) const moveStage = useProfileWrite({ userId: c.userId, mutationFn: () => pipelineApi.changeStage({ inboxId: live?.inbox_id ?? undefined, manualUploadId: live?.inbox_id ? undefined : live?.manual_upload_candidate_id, toStage: pipelineApi.STATUS_FROM_STAGE[dialog.stage], changeReason: stageReason.trim() || 'Updated from candidate profile', }), success: () => `Moved to ${dialog.stage}`, onDone: () => { setDialog(null) qc.invalidateQueries({ queryKey: qk.pipeline.all() }) qc.invalidateQueries({ queryKey: qk.forms.all() }) qc.invalidateQueries({ queryKey: qk.analytics.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?.data ?? []).filter((r) => r.form_type !== 'requisition').length, Notes: live.notes?.length ?? 0, Activity: live.activity?.length ?? 0, Documents: live.documents?.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 = isManager ? null : ( <>
{isLive && ( )}
{isLive ? ( ) : ( )} ) const body = ( <> {variant === 'page' ? :
{live?.name || c.name}
{company ? `${title} at ${company}` : title}
{stageLabel && {stageLabel}}{' '} {(live?.source || c.source) && {live?.source || c.source}} {expChip && {expChip}}
{(s3Api.canOpen(resumeKey) || live?.linkedin_url) && (
{live?.linkedin_url && ( LinkedIn )}
)}
{/* 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 || live?.professional_summary ? (
{(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'}
)} {live?.professional_summary ?
{live.professional_summary}
: null}
) : null}
}
({ key: t, label: t === 'Interview' ? 'Interviews' : t, count: counts && ['Interview', 'Forms', 'Notes'].includes(t) ? counts[t] : undefined }))} />
{tab === 'Overview' && (guard || (variant === 'page' ? setRating.mutate(n)} atsScore={atsScore} recommendation={recommendation} atsAction={canScoreAts && can('candidates.create') ? : null} /> : 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
{/* --primary-fg is the on-solid-primary text color (white in light theme) — on --primary-soft it was white-on-mint, unreadable. Soft chips pair with --primary (see .b-indigo). */} {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 ? ( ) : ( <>