HR-ATS-Portal/frontend/src/screens/CandidateProfile.jsx

1243 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { 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 { 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 = ['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
}
/** <input type="date"> + <input type="time"> -> 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 (
<div className="info-item">
<div className="il">{label}</div>
<div className="iv">{val === 0 || val ? val : '—'}</div>
</div>
)
}
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, user } = useAuth()
const isManager = isHiringManager(user)
const visibleTabs = isManager ? ['Forms', 'Notes'] : TABS
const [searchParams] = useSearchParams()
const [tab, setTab] = useState(() => tabFromSearch(
variant === 'page' ? searchParams.get('tab') : null,
visibleTabs,
isManager ? 'Forms' : 'Overview',
))
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 ? `${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() })
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 ? (
<EmptyState icon="refresh" title="Loading candidate…">Fetching the full record.</EmptyState>
) : detail.isError ? (
<EmptyState icon="alert" title="Could not load this candidate">
{friendlyAuthError(detail.error, 'Please try again.')}
</EmptyState>
) : !live ? (
<EmptyState icon="user" title="No record found">This candidate is no longer in the pipeline.</EmptyState>
) : null
const actions = isManager ? null : (
<>
<button
className={`btn btn-ghost star-btn${favorite ? ' on' : ''}`}
style={{ marginRight: 'auto' }}
disabled={isLive && (setFavorite.isPending || !live)}
onClick={() => (isLive ? setFavorite.mutate(!favorite) : onToggleFav(c))}
>
<Icon name="star" /> {favorite ? 'Favorited' : 'Favorite'}
</button>
{canScoreAts && (
<button
className="btn btn-secondary"
disabled={scoreAts.isPending}
onClick={() => scoreAts.mutate()}
>
<Icon name="sparkles" /> {scoreAts.isPending ? 'Scoring…' : 'Score with ATS'}
</button>
)}
{onAtsMatch && (
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
<Icon name="target" /> ATS Match
</button>
)}
{isLive ? (
<button
className="btn btn-primary"
disabled={!live || !nextStage || advanceLive.isPending || !can('pipeline.edit')}
data-tip={!can('pipeline.edit') ? 'Needs pipeline.edit' : undefined}
onClick={() => advanceLive.mutate()}
>
<Icon name="check" />{' '}
{advanceLive.isPending
? 'Moving…'
: nextStage ? `Advance to ${nextStage}`
: stageLabel === 'Rejected' || stageLabel === 'On Hold' ? stageLabel : 'Pipeline complete'}
</button>
) : (
<button className="btn btn-primary" onClick={() => { onAdvance(c); onClose() }}>
<Icon name="check" /> Advance Stage
</button>
)}
</>
)
const body = (
<>
<div className="profile-hero">
<Avatar name={live?.name || c.name} initials={c.initials} color={c.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name">
{live?.name || c.name}
<ReappliedBadge row={live || c} />
</div>
<div className="ph-role">{company ? `${title} at ${company}` : title}</div>
<div className="ph-tags">
{stageLabel && <Badge>{stageLabel}</Badge>}{' '}
{(live?.source || c.source) && <Badge className="b-gray">{live?.source || c.source}</Badge>}
{expChip && <span className="badge b-plain b-indigo badge-plain">{expChip}</span>}
</div>
{(s3Api.canOpen(resumeKey) || live?.linkedin_url) && (
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginTop: 10 }}>
<OpenResumeButton filePath={resumeKey} />
{live?.linkedin_url && (
<a
className="btn btn-secondary btn-sm"
href={live.linkedin_url}
target="_blank"
rel="noopener noreferrer"
>
<Icon name="linkedin" /> LinkedIn
</a>
)}
</div>
)}
</div>
{/* 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 ? (
<div className="flex items-center gap-12" style={{ maxWidth: 380 }}>
{(atsScore ?? live?.ai_score ?? c.aiScore) != null && (
<div style={{ textAlign: 'center', flex: '0 0 auto' }}>
<ScoreChip score={atsScore ?? live?.ai_score ?? c.aiScore} />
{/* The band caption replaces the static label only when the caller
actually fetched one — every other screen keeps "AI Match". */}
<div className="cell-sub" style={{ marginTop: 4 }}>{recommendation || 'AI Match'}</div>
</div>
)}
{live?.professional_summary ? <div className="ats-summary">{live.professional_summary}</div> : null}
</div>
) : null}
</div>
<div style={{ marginTop: 22 }}>
<Tabs
value={tab}
onChange={setTab}
className="tabs tabs-wrap"
tabs={visibleTabs.map((t) => ({ key: t, label: t, count: counts ? counts[t] : undefined }))}
/>
</div>
<div className="tab-pane active">
{tab === 'Overview' && (guard || (live ? (
<>
<PreviousApplications row={live} />
<div className="info-grid" style={{ marginBottom: 20 }}>
<Info label="Email" val={live.email} />
<Info label="Phone" val={live.phone} />
<Info label="Applied For" val={live.job_title} />
<Info label="Current Company" val={live.currentCompany} />
<Info label="Experience" val={live.experience} />
<Info label="Education" val={live.education} />
<Info label="Professional Summary" val={live.professional_summary} />
<Info label="Source" val={live.source} />
<Info label="Recruiter" val={live.recruiter} />
<Info label="Applied On" val={fmtWhen(live.applied)} />
<Info label="Screened On" val={fmtWhen(live.matched_at)} />
<div className="info-item">
<div className="il">Rating</div>
<div className="iv flex items-center gap-8">
<Stars
value={Math.round(rating)}
disabled={setRating.isPending}
onChange={(n) => setRating.mutate(n)}
/>
<span className="cell-sub">{rating ? `${rating.toFixed(1)} / 5.0` : 'Not rated'}</span>
</div>
</div>
<Info label="Applications" val={candidateApplicationsOf(live).length} />
</div>
{(live.match_summary || live.match_reasoning) && (
<>
<div style={LABEL}>AI Screening</div>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 18 }}>
<div className="card-body">
{live.match_summary && <p style={{ marginBottom: live.match_reasoning ? 10 : 0 }}>{live.match_summary}</p>}
{live.match_reasoning && <p className="text-muted text-sm">{live.match_reasoning}</p>}
</div>
</div>
</>
)}
{live.assigned_job_post && (
<>
<div style={LABEL}>Assigned Role</div>
<div className="k-tags" style={{ marginBottom: 14 }}>
{/* --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). */}
<span className="tag" style={{ background: 'var(--primary-soft)', color: 'var(--primary)' }}>
{live.assigned_job_post.title}
</span>
</div>
</>
)}
{live.job_posts?.length > 0 && (
<>
<div style={LABEL}>Suggested Roles</div>
<div className="k-tags">
{live.job_posts.map((j) => <span className="tag" key={j.id}>{j.title}</span>)}
</div>
</>
)}
</>
) : (
<>
<div className="info-grid" style={{ marginBottom: 20 }}>
<Info label="Email" val={c.email} />
<Info label="Phone" val={c.phone} />
<Info label="Location" val={c.location} />
<Info label="Applied For" val={c.jobTitle} />
<Info label="Current Company" val={c.currentCompany} />
<Info label="Experience" val={`${c.experience} years`} />
<Info label="Education" val={c.education} />
<Info label="Source" val={c.source} />
<Info label="Recruiter" val={c.recruiter} />
<Info label="Applied On" val={fmtWhen(c.applied)} />
<Info label="Expected Salary" val={moneyK(c.salary)} />
<Info label="Rating" val={`${c.rating} / 5.0`} />
</div>
<div style={LABEL}>Skills</div>
<div className="k-tags">{c.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</>
)))}
{tab === 'Resume' && (guard || (live ? (
<ResumeTab live={live} />
) : (
<>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
<div className="card-body">
<h3 style={{ marginBottom: 4 }}>{c.name}</h3>
<p className="text-muted">{c.currentTitle} · {c.location}</p>
<div className="divider" />
<h3 className="form-section-title" style={{ marginTop: 0 }}>Summary</h3>
<p className="text-muted">
Results-driven {c.currentTitle.toLowerCase()} with {c.experience} years of experience
across {c.department.toLowerCase()}. Passionate about building high-quality products
and collaborating with cross-functional teams.
</p>
<h3 className="form-section-title">Experience</h3>
<div className="info-item">
<div className="iv">{c.currentTitle} {c.currentCompany}</div>
<div className="il" style={{ textTransform: 'none' }}>2021 Present</div>
</div>
<div className="info-item" style={{ marginTop: 10 }}>
<div className="iv">Associate {priorCompany}</div>
<div className="il" style={{ textTransform: 'none' }}>2018 2021</div>
</div>
<h3 className="form-section-title">Education</h3>
<div className="iv">{c.education}</div>
</div>
</div>
<button className="btn btn-secondary" style={{ marginTop: 14 }} onClick={() => toast('Downloading resume.pdf', 'info')}>
<Icon name="download" /> Download PDF
</button>
</>
)))}
{tab === 'Timeline' && (guard || (live ? (
<TimelineTab live={live} />
) : (
<div className="timeline">
{[
{ 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) => (
<div className="tl-item" key={e.title}>
<div className="tl-dot"><Icon name={e.icon} /></div>
<div className="tl-title">{e.title}</div>
<div className="tl-meta">{e.meta}</div>
<div className="tl-desc">{e.desc}</div>
</div>
))}
</div>
)))}
{tab === 'History' && (guard || (live ? (
<HistoryTab userId={c.userId} />
) : (
<EmptyState icon="clock" title="No history">
Audit history is recorded for live candidates only.
</EmptyState>
)))}
{tab === 'Interview' && (guard || (live ? (
<InterviewTab userId={c.userId} inboxId={inboxId} rows={live.interviews ?? []} />
) : (
candidateInterviews.length ? (
<div className="list-tight">
{candidateInterviews.map((iv) => (
<div className="list-row" key={iv.id}>
<span className="kpi-icn i-blue" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="calendar" />
</span>
<div className="lr-main">
<div className="lr-title">{iv.type}</div>
<div className="lr-sub">{fmtDate(iv.when)} · {iv.meeting}</div>
</div>
<div className="lr-right"><Badge>{iv.status}</Badge></div>
</div>
))}
</div>
) : (
<EmptyState icon="calendar" title="No interviews scheduled">
Schedule an interview to get started.
</EmptyState>
)
)))}
{tab === 'Forms' && (guard || (live ? (
<CandidateFormsTab userId={c.userId} live={live} />
) : (
<EmptyState icon="file" title="Live candidates only">
Hiring forms attach to real applications.
</EmptyState>
)))}
{tab === 'Notes' && (guard || (live ? (
<NotesTab userId={c.userId} rows={live.notes ?? []} />
) : (
<>
<div className="form-field">
<label>Add a note</label>
<textarea placeholder="Write a private note about this candidate…" />
</div>
<button className="btn btn-primary btn-sm" style={{ margin: '10px 0 18px' }} onClick={() => toast('Note saved', 'success')}>
<Icon name="plus" /> Add Note
</button>
<div className="list-tight">
<div className="list-row">
<Avatar name={c.recruiter} />
<div className="lr-main">
<div className="lr-title">{c.recruiter}</div>
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>
Strong communication skills, great culture fit. Recommend advancing.
</div>
<div className="lr-sub">2 days ago</div>
</div>
</div>
<div className="list-row">
<Avatar name="Asfand Ahmed" initials="AA" />
<div className="lr-main">
<div className="lr-title">Asfand Ahmed</div>
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>
Reviewed portfolio impressive work. Schedule technical round.
</div>
<div className="lr-sub">4 days ago</div>
</div>
</div>
</div>
</>
)))}
{tab === 'Activity' && (guard || (live ? (
<ActivityTab userId={c.userId} inboxId={inboxId} rows={live.activity ?? []} />
) : (
<div className="list-tight">
{[
{ icon: 'eye', tone: 'i-green', text: `Profile viewed by ${c.recruiter}`, when: '1h ago' },
{ icon: 'mail', tone: 'i-blue', text: 'Email sent: Interview invitation', when: '1 day ago' },
{ icon: 'star', tone: 'i-amber', text: `Assessment score updated to ${c.aiScore}%`, when: '2 days ago' },
{ icon: 'user-plus', tone: 'i-purple', text: `Applied for ${c.jobTitle}`, when: fmtDate(c.applied) },
].map((a) => (
<div className="list-row" key={a.text}>
<span className={`kpi-icn ${a.tone}`} style={{ width: 34, height: 34, borderRadius: 9 }}>
<Icon name={a.icon} />
</span>
<div className="lr-main">
<div className="lr-sub" style={{ color: 'var(--text-2)', fontSize: 13 }}>{a.text}</div>
<div className="lr-sub">{a.when}</div>
</div>
</div>
))}
</div>
)))}
{tab === 'Documents' && (guard || (live ? (
<DocumentsTab
rows={live.documents ?? []}
inboxId={inboxId}
manualUploadCandidateId={live.manual_upload_candidate_id}
/>
) : (
<div className="list-tight">
{[
{ 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' },
].map((d) => (
<div className="list-row" key={d.n}>
<span className="kpi-icn i-red" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="file" />
</span>
<div className="lr-main"><div className="lr-title">{d.n}</div><div className="lr-sub">{d.s}</div></div>
<button className="act-btn" data-tip="Download" aria-label={`Download ${d.n}`} onClick={() => toast(`Downloading ${d.n}`, 'info')}>
<Icon name="download" />
</button>
</div>
))}
</div>
)))}
</div>
</>
)
if (variant === 'page') {
return (
<div className="cand-page">
<div className="cand-page-bar">
<button className="btn btn-secondary btn-sm" onClick={onClose}>
<Icon name="chevron-left" /> Back
</button>
<div className="cand-page-crumb">
Candidates <span>/</span> <strong>{live?.name || c.name || ''}</strong>
</div>
{actions && <div className="cand-page-actions">{actions}</div>}
</div>
<div className="card">
<div className="card-body">{body}</div>
</div>
</div>
)
}
return (
<Modal title="Candidate Profile" subtitle={c.id} size="modal-lg" onClose={onClose} footer={actions}>
{body}
</Modal>
)
}
/* ------------------------------------------------------------------
Live tabs. Each owns its own form state and its own write, so a half-typed
note is not held by the modal shell and does not survive a tab switch.
------------------------------------------------------------------ */
function ResumeTab({ live }) {
const source = live.documents?.[0]?.name
const resumeKey = s3Api.resumeKeyFrom(live)
if (!s3Api.canOpen(resumeKey)) {
return (
<EmptyState icon="file" title="No résumé on file">
{source
? `${source} is attached but is not stored in S3, so it cannot be opened here.`
: 'This candidate applied without an attachment we could open.'}
</EmptyState>
)
}
return (
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
<div className="card-body">
<h3 style={{ marginBottom: 4 }}>{live.name}</h3>
<p className="text-muted">
{source ? `Original CV — ${source}` : 'Original CV from the application'}
</p>
<div className="flex gap-8" style={{ flexWrap: 'wrap', margin: '10px 0 0' }}>
<OpenResumeButton filePath={resumeKey} />
</div>
</div>
</div>
)
}
function TimelineTab({ live }) {
const events = useMemo(() => {
const out = []
if (live.applied) {
out.push({
icon: 'user-plus',
title: 'Application received',
at: live.applied,
desc: live.source ? `Applied via ${live.source}` : null,
})
}
if (live.matched_at) {
out.push({
icon: 'sparkles',
title: 'AI screening completed',
at: live.matched_at,
desc: live.match_error || live.match_summary || live.match_status,
})
}
for (const iv of live.interviews ?? []) {
out.push({
icon: 'calendar',
title: iv.interview_type || 'Interview',
at: iv.interview_date,
desc: iv.interview_status,
})
}
for (const a of live.activity ?? []) {
out.push({
icon: 'zap',
title: a.activity_type || 'Activity',
at: a.activity_date,
desc: a.description || a.activity_status,
})
}
for (const f of live.feedback ?? []) {
out.push({
icon: 'star',
title: f.review ? `Feedback: ${f.review}` : 'Feedback submitted',
at: f.created_at,
desc: f.reviewed_by_name ? `by ${f.reviewed_by_name}` : f.note,
})
}
return out.sort((a, b) => stamp(a.at) - stamp(b.at))
}, [live])
if (!events.length) {
return <EmptyState icon="clock" title="Nothing recorded yet">Activity appears here as the candidate moves.</EmptyState>
}
return (
<div className="timeline">
{events.map((e, i) => (
<div className="tl-item" key={`${e.title}-${i}`}>
<div className="tl-dot"><Icon name={e.icon} /></div>
<div className="tl-title">{e.title}</div>
<div className="tl-meta">{fmtWhen(e.at)}</div>
{e.desc && <div className="tl-desc">{e.desc}</div>}
</div>
))}
</div>
)
}
const HISTORY_ICON = {
'stage.changed': { icon: 'arrow-right', tone: 'i-indigo' },
'note.created': { icon: 'edit', tone: 'i-blue' },
'note.updated': { icon: 'edit', tone: 'i-blue' },
'feedback.created': { icon: 'award', tone: 'i-teal' },
'feedback.updated': { icon: 'award', tone: 'i-teal' },
'interview.created': { icon: 'calendar', tone: 'i-purple' },
'interview.updated': { icon: 'calendar', tone: 'i-purple' },
'calendar.created': { icon: 'send', tone: 'i-purple' },
'calendar.rescheduled': { icon: 'clock', tone: 'i-amber' },
'calendar.cancelled': { icon: 'x-circle', tone: 'i-red' },
'favorite.changed': { icon: 'star', tone: 'i-amber' },
'rating.changed': { icon: 'star', tone: 'i-amber' },
'candidate.created': { icon: 'user-plus', tone: 'i-green' },
'candidate.imported': { icon: 'upload', tone: 'i-green' },
'document.uploaded': { icon: 'paperclip', tone: 'i-red' },
'ats.scored': { icon: 'sparkles', tone: 'i-blue' },
}
const HISTORY_TITLE = {
'stage.changed': 'Stage changed',
'note.created': 'Note added',
'note.updated': 'Note updated',
'feedback.created': 'Feedback submitted',
'feedback.updated': 'Feedback updated',
'interview.created': 'Interview scheduled',
'interview.updated': 'Interview updated',
'calendar.created': 'Calendar invite sent',
'calendar.rescheduled': 'Calendar rescheduled',
'calendar.cancelled': 'Calendar cancelled',
'favorite.changed': 'Favorite updated',
'rating.changed': 'Rating updated',
'candidate.created': 'Candidate created',
'candidate.imported': 'Candidate imported',
'document.uploaded': 'Document uploaded',
'ats.scored': 'ATS scored',
}
function historyDayLabel(value) {
const d = toDate(value)
if (!d) return 'Unknown'
const today = new Date()
const yday = new Date()
yday.setDate(today.getDate() - 1)
const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
if (sameDay(d, today)) return 'Today'
if (sameDay(d, yday)) return 'Yesterday'
return fmtDate(d)
}
function HistoryTab({ userId }) {
const [skip, setSkip] = useState(0)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const q = useQuery({
queryKey: qk.candidates.history(userId, { limit: pageSize, offset: skip }),
queryFn: async () => {
const res = await candidatesApi.listHistory(userId, { limit: pageSize, offset: skip })
return { rows: Array.isArray(res?.data) ? res.data : [], total: typeof res?.total === 'number' ? res.total : 0 }
},
enabled: Boolean(userId),
})
if (q.isPending) {
return <EmptyState icon="refresh" title="Loading history…">Fetching the audit trail.</EmptyState>
}
if (q.isError) {
return (
<EmptyState icon="alert" title="Could not load history">
{friendlyAuthError(q.error, 'Please try again.')}
</EmptyState>
)
}
const rows = q.data?.rows ?? []
const total = q.data?.total ?? 0
const pages = Math.max(1, Math.ceil(total / pageSize))
const currentPage = Math.min(Math.floor(skip / pageSize) + 1, pages)
const from = total ? skip + 1 : 0
const to = total ? skip + rows.length : 0
if (!rows.length && total === 0) {
return <EmptyState icon="clock" title="No history yet">Actions on this candidate will appear here.</EmptyState>
}
const groups = []
for (const r of rows) {
const label = historyDayLabel(r.created_at)
const last = groups[groups.length - 1]
if (!last || last.label !== label) groups.push({ label, rows: [r] })
else last.rows.push(r)
}
return (
<>
{groups.map((g) => (
<div key={g.label} style={{ marginBottom: 18 }}>
<div style={LABEL}>{g.label}</div>
<div className="list-tight">
{g.rows.map((r) => <HistoryRow key={r.id} row={r} />)}
</div>
</div>
))}
{total > 0 && (
<Pagination
from={from}
to={to}
total={total}
page={currentPage}
pages={pages}
setPage={(p) => setSkip((p - 1) * pageSize)}
pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
onPageSizeChange={(n) => { setPageSize(n); setSkip(0) }}
/>
)}
</>
)
}
const INTERVIEW_HISTORY_TYPES = new Set([
'interview.created', 'interview.updated',
'calendar.created', 'calendar.rescheduled', 'calendar.cancelled',
])
function HistoryRow({ row: r }) {
const look = HISTORY_ICON[r.event_type] || { icon: 'clock', tone: 'i-blue' }
const title = HISTORY_TITLE[r.event_type] || r.event_type
const change = [r.from_value, r.to_value].some((v) => v != null && v !== '')
? `${r.from_value ?? '—'}${r.to_value ?? '—'}`
: null
const isInterview = INTERVIEW_HISTORY_TYPES.has(r.event_type)
const actor = r.actor_name || (r.actor_id ? 'Unknown' : 'System')
const when = [fmtWhen(r.created_at), fmtClock(r.created_at)].filter(Boolean).join(' · ')
const organizerEmail = r.organizer_email || null
const attendeeEmails = Array.isArray(r.attendee_emails) ? r.attendee_emails.filter(Boolean) : []
return (
<div className="list-row">
<span className={`kpi-icn ${look.tone}`} style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name={look.icon} />
</span>
<div className="lr-main">
<div className="lr-title">{title}</div>
{change && <div className="lr-sub">{change}</div>}
{r.description && <div className="lr-sub" style={{ color: 'var(--text-2)' }}>{r.description}</div>}
{organizerEmail && <div className="lr-sub" style={{ color: 'var(--text-2)' }}>Organizer: {organizerEmail}</div>}
{attendeeEmails.length > 0 && (
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>
Attendees: {attendeeEmails.join(', ')}
</div>
)}
<div className="lr-sub">{isInterview ? when : `${actor} · ${when}`}</div>
</div>
<div className="lr-right">
{isInterview
? null
: (r.actor_name || r.actor_id
? <Avatar name={actor} />
: <Badge className="b-gray">System</Badge>)}
</div>
</div>
)
}
function InterviewTab({ userId, inboxId, rows }) {
const { toast } = useToast()
const [form, setForm] = useState({ type: INTERVIEW_TYPES[0], date: '', time: '', status: INTERVIEW_STATES[0] })
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
const create = useProfileWrite({
userId,
// interviews.interview_date and .interview_time are BOTH datetime columns,
// so the same instant goes to each rather than inventing a second one.
mutationFn: (instant) => candidatesApi.createInterview({
inboxId, date: instant, time: instant, type: form.type, status: form.status,
}),
success: 'Interview scheduled',
onDone: () => setForm({ type: INTERVIEW_TYPES[0], date: '', time: '', status: INTERVIEW_STATES[0] }),
})
function submit() {
const instant = toInstant(form.date, form.time)
if (!instant) {
toast('Pick a date for the interview', 'warning')
return
}
create.mutate(instant)
}
return (
<>
{rows.length ? (
<div className="list-tight">
{rows.map((iv) => {
const clock = fmtClock(iv.interview_time)
return (
<div className="list-row" key={iv.id}>
<span className="kpi-icn i-blue" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="calendar" />
</span>
<div className="lr-main">
<div className="lr-title">{iv.interview_type || 'Interview'}</div>
<div className="lr-sub">{fmtWhen(iv.interview_date)}{clock ? ` · ${clock}` : ''}</div>
</div>
<div className="lr-right">
{iv.interview_status ? <Badge>{iv.interview_status}</Badge> : null}
</div>
</div>
)
})}
</div>
) : (
<EmptyState icon="calendar" title="No interviews scheduled">
Schedule the first round below.
</EmptyState>
)}
<div className="divider" />
<h3 className="form-section-title" style={{ marginTop: 0 }}>Schedule an interview</h3>
<div className="form-grid">
<div className="form-field">
<label>Type</label>
<select value={form.type} onChange={(e) => set('type', e.target.value)}>
{INTERVIEW_TYPES.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
<div className="form-field">
<label>Status</label>
<select value={form.status} onChange={(e) => set('status', e.target.value)}>
{INTERVIEW_STATES.map((s) => <option key={s}>{s}</option>)}
</select>
</div>
<div className="form-field">
<label>Date</label>
<input type="date" value={form.date} onChange={(e) => set('date', e.target.value)} />
</div>
<div className="form-field">
<label>Time</label>
<input type="time" value={form.time} onChange={(e) => set('time', e.target.value)} />
</div>
</div>
<button
className="btn btn-primary btn-sm"
style={{ marginTop: 10 }}
disabled={!inboxId || create.isPending}
onClick={submit}
>
<Icon name="plus" /> {create.isPending ? 'Scheduling…' : 'Schedule Interview'}
</button>
{!inboxId && (
<p className="text-muted" style={{ marginTop: 8, fontSize: 12.5 }}>
Interview records attach to an email application this candidate was added
manually, so scheduling is unavailable here.
</p>
)}
</>
)
}
function NotesTab({ userId, rows }) {
const [text, setText] = useState('')
const create = useProfileWrite({
userId,
mutationFn: () => candidatesApi.createNote({ userId, note: text.trim() }),
success: 'Note saved',
onDone: () => setText(''),
})
return (
<>
<div className="form-field">
<label>Add a note</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Write a private note about this candidate…"
/>
</div>
<button
className="btn btn-primary btn-sm"
style={{ margin: '10px 0 18px' }}
disabled={!text.trim() || create.isPending}
onClick={() => create.mutate()}
>
<Icon name="plus" /> {create.isPending ? 'Saving…' : 'Add Note'}
</button>
{rows.length ? (
<div className="list-tight">
{rows.map((n) => <NoteRow key={n.id} note={n} userId={userId} />)}
</div>
) : (
<EmptyState icon="edit" title="No notes yet">The first note on this candidate goes above.</EmptyState>
)}
</>
)
}
/**
* One note, editable in place via PATCH /notes/update.
*
* Editing is offered only on the signed-in user's OWN notes. The route does not
* check authorship and does not reassign `created_by`, so anyone with
* candidates.edit could silently rewrite a colleague's words under that
* colleague's name. Gating it here is the honest read of what the endpoint does.
*/
function NoteRow({ note: n, userId }) {
const { user } = useAuth()
const [editing, setEditing] = useState(false)
const [text, setText] = useState(n.note ?? '')
const mine = Boolean(user?.id && n.created_by && String(user.id) === String(n.created_by))
const save = useProfileWrite({
userId,
mutationFn: () => candidatesApi.updateNote(n.id, text.trim()),
success: 'Note updated',
onDone: () => setEditing(false),
})
if (editing) {
return (
<div className="list-row" style={{ alignItems: 'flex-start' }}>
<Avatar name={n.created_by_name || 'Unknown'} />
<div className="lr-main">
<div className="form-field" style={{ marginBottom: 8 }}>
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={3} />
</div>
<div className="flex items-center gap-8">
<button
className="btn btn-primary btn-sm"
disabled={!text.trim() || save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? 'Saving…' : 'Save'}
</button>
<button
className="btn btn-secondary btn-sm"
disabled={save.isPending}
onClick={() => { setText(n.note ?? ''); setEditing(false) }}
>
Cancel
</button>
</div>
</div>
</div>
)
}
return (
<div className="list-row">
<Avatar name={n.created_by_name || 'Unknown'} />
<div className="lr-main">
<div className="lr-title">{n.created_by_name || 'Unknown author'}</div>
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>{n.note}</div>
<div className="lr-sub">
{fmtWhen(n.created_at)}
{n.updated_at && n.updated_at !== n.created_at ? ' · edited' : ''}
</div>
</div>
{mine && (
<div className="lr-right">
<button className="act-btn" data-tip="Edit note" aria-label="Edit note" onClick={() => setEditing(true)}>
<Icon name="edit" />
</button>
</div>
)}
</div>
)
}
function ActivityTab({ userId, inboxId, rows }) {
const { toast } = useToast()
const [form, setForm] = useState({ type: ACTIVITY_TYPES[0], description: '' })
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
const create = useProfileWrite({
userId,
mutationFn: () => candidatesApi.createActivity({
inboxId, type: form.type, status: 'Logged', description: form.description.trim(),
}),
success: 'Activity logged',
onDone: () => setForm({ type: ACTIVITY_TYPES[0], description: '' }),
})
function submit() {
if (!form.description.trim()) {
toast('Describe what happened', 'warning')
return
}
create.mutate()
}
return (
<>
{rows.length ? (
<div className="list-tight">
{rows.map((a) => {
const clock = fmtClock(a.activity_time)
return (
<div className="list-row" key={a.id}>
<span className="kpi-icn i-purple" style={{ width: 34, height: 34, borderRadius: 9 }}>
<Icon name="zap" />
</span>
<div className="lr-main">
<div className="lr-title">{a.activity_type || 'Activity'}</div>
{a.description && (
<div className="lr-sub" style={{ color: 'var(--text-2)', fontSize: 13 }}>{a.description}</div>
)}
<div className="lr-sub">{fmtWhen(a.activity_date)}{clock ? ` · ${clock}` : ''}</div>
</div>
<div className="lr-right">{a.activity_status ? <Badge>{a.activity_status}</Badge> : null}</div>
</div>
)
})}
</div>
) : (
<EmptyState icon="zap" title="No activity recorded">Log the first touchpoint below.</EmptyState>
)}
<div className="divider" />
<h3 className="form-section-title" style={{ marginTop: 0 }}>Log activity</h3>
<div className="form-grid">
<div className="form-field">
<label>Type</label>
<select value={form.type} onChange={(e) => set('type', e.target.value)}>
{ACTIVITY_TYPES.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
<div className="form-field">
<label>What happened</label>
<input
value={form.description}
onChange={(e) => set('description', e.target.value)}
placeholder="Called to confirm availability"
/>
</div>
</div>
<button
className="btn btn-secondary btn-sm"
style={{ marginTop: 10 }}
disabled={!inboxId || create.isPending}
onClick={submit}
>
<Icon name="plus" /> {create.isPending ? 'Logging…' : 'Log Activity'}
</button>
{!inboxId && (
<p className="text-muted" style={{ marginTop: 8, fontSize: 12.5 }}>
The activity log attaches to an email application this candidate was added
manually, so logging is unavailable here.
</p>
)}
</>
)
}
function DocumentsTab({ rows, inboxId, manualUploadCandidateId }) {
const { toast } = useToast()
const download = useMutation({
mutationFn: ({ index, filename }) => candidatesApi.downloadDocument({
inboxId,
manualUploadCandidateId,
index,
filename,
}),
onError: (err) => toast(friendlyAuthError(err, 'Download failed.'), 'error'),
})
if (!rows.length) {
return <EmptyState icon="file" title="No documents">This application arrived without attachments.</EmptyState>
}
const canDownload = Boolean(inboxId || manualUploadCandidateId)
return (
<div className="list-tight">
{rows.map((d, i) => {
const path = d.path || ''
const openable = s3Api.canOpen(path)
return (
<div className="list-row" key={`${d.name}-${i}`}>
<span className="kpi-icn i-red" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="file" />
</span>
<div className="lr-main">
<div className="lr-title">{d.name}</div>
<div className="lr-sub">Stored with the application</div>
</div>
{openable ? (
<OpenResumeButton
filePath={path}
className="act-btn"
label=""
icon="eye"
/>
) : (
<button
className="act-btn"
disabled={!canDownload || download.isPending}
title={!canDownload ? 'No application id for download' : 'Download'}
aria-label={`Download ${d.name}`}
onClick={() => download.mutate({ index: i, filename: d.name })}
>
<Icon name="download" />
</button>
)}
</div>
)
})}
</div>
)
}