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

919 lines
36 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.

/* The 8-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.
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 } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery } from '../data/seedQueries'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import { companies, fmtDate, moneyK, pick } from '../data/seed'
const TABS = ['Overview', 'Resume', 'Timeline', 'Interview', 'Notes', 'Activity', 'Documents', 'Feedback']
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()
}
/** <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) })
toast(typeof success === 'function' ? success(vars) : success, 'success')
onDone?.()
},
onError: (err) => toast(friendlyAuthError(err, 'Could not save. Please try again.'), 'error'),
})
}
export default function CandidateProfile({ candidate: c, onClose, onAdvance, onToggleFav, onAtsMatch }) {
const { toast } = useToast()
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
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'),
})
// 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
const counts = live && {
Interview: live.interviews?.length ?? 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 ? (
<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
return (
<Modal
title="Candidate Profile"
subtitle={c.id}
size="modal-lg"
onClose={onClose}
footer={
<>
<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>
)}
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
<Icon name="target" /> ATS Match
</button>
<button className="btn btn-secondary" onClick={() => toast('Email drafted', 'info')}>
<Icon name="mail" /> Message
</button>
<button className="btn btn-primary" onClick={() => { onAdvance(c); onClose() }}>
<Icon name="check" /> Advance Stage
</button>
</>
}
>
<div className="profile-hero">
<Avatar name={c.name} initials={c.initials} color={c.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name">{live?.name || c.name}</div>
<div className="ph-role">{company ? `${title} at ${company}` : title}</div>
<div className="ph-tags">
<Badge>{c.stage}</Badge> <Badge className="b-gray">{live?.source || c.source}</Badge>
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
</div>
</div>
<div style={{ textAlign: 'center' }}>
<ScoreChip score={live?.ai_score ?? c.aiScore} />
<div className="cell-sub" style={{ marginTop: 4 }}>AI Match</div>
</div>
</div>
<div style={{ marginTop: 22 }}>
<Tabs
value={tab}
onChange={setTab}
tabs={TABS.map((t) => ({ key: t, label: t, count: counts ? counts[t] : undefined }))}
/>
</div>
<div className="tab-pane active">
{tab === 'Overview' && (guard || (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="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)} />
<Info label="Rating" val={`${(live.rating ?? 0).toFixed(1)} / 5.0`} />
<Info label="Applications" val={live.job_posts?.length || 0} />
</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 }}>
<span className="tag" style={{ background: 'var(--primary-soft)', color: 'var(--primary-fg)' }}>
{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" />
<div className="form-section-title" style={{ marginTop: 0 }}>Summary</div>
<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>
<div className="form-section-title">Experience</div>
<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>
<div className="form-section-title">Education</div>
<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 === '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 === '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 ?? []} />
) : (
<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" onClick={() => toast(`Downloading ${d.n}`, 'info')}>
<Icon name="download" />
</button>
</div>
))}
</div>
)))}
{tab === 'Feedback' && (guard || (live ? (
<FeedbackTab userId={c.userId} inboxId={inboxId} rows={live.feedback ?? []} />
) : (
<>
<div className="list-tight">
{['Strong Hire', 'Hire', 'Lean Hire'].map((score, i) => {
const r = recruiters[i]
if (!r) return null
const notes = [
'Excellent technical depth and clear communication.',
'Good problem solving, would benefit from more system design exposure.',
'Solid candidate, positive team energy.',
]
return (
<div className="list-row" key={score}>
<Avatar name={r.name} initials={r.initials} color={r.color} />
<div className="lr-main">
<div className="lr-title">{r.name}</div>
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>{notes[i]}</div>
</div>
<div className="lr-right"><Badge>{score}</Badge></div>
</div>
)
})}
</div>
<button className="btn btn-primary btn-sm" style={{ marginTop: 14 }} onClick={() => toast('Scorecard form opened', 'info')}>
<Icon name="plus" /> Submit Scorecard
</button>
</>
)))}
</div>
</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
if (!live.resume_text) {
return (
<EmptyState icon="file" title="No résumé text">
{source
? `${source} is attached but has not been parsed yet — run the match to extract it.`
: 'This candidate applied without an attachment we could read.'}
</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 ? `Extracted from ${source}` : 'Extracted from the application email'}
</p>
<div className="divider" />
<div className="text-sm" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>{live.resume_text}</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>
)
}
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" />
<div className="form-section-title" style={{ marginTop: 0 }}>Schedule an interview</div>
<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>
</>
)
}
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) => (
<div className="list-row" key={n.id}>
<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)}</div>
</div>
</div>
))}
</div>
) : (
<EmptyState icon="edit" title="No notes yet">The first note on this candidate goes above.</EmptyState>
)}
</>
)
}
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" />
<div className="form-section-title" style={{ marginTop: 0 }}>Log activity</div>
<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>
</>
)
}
function DocumentsTab({ rows }) {
if (!rows.length) {
return <EmptyState icon="file" title="No documents">This application arrived without attachments.</EmptyState>
}
return (
<>
<div className="list-tight">
{rows.map((d, i) => (
<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">{d.path || 'Stored with the application'}</div>
</div>
</div>
))}
</div>
{/* No download button: attachments live on the worker's filesystem and no
route serves them yet, so a button here could only lie. */}
<p className="text-muted text-sm" style={{ marginTop: 12 }}>
<Icon name="info" /> Attachments are stored server-side; download is not exposed yet.
</p>
</>
)
}
function FeedbackTab({ userId, inboxId, rows }) {
const { toast } = useToast()
const [form, setForm] = useState({ review: REVIEWS[0], score: '', note: '' })
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
const create = useProfileWrite({
userId,
mutationFn: () => candidatesApi.createFeedback({
inboxId,
review: form.review,
score: form.score === '' ? 0 : Number(form.score),
note: form.note.trim(),
}),
success: 'Scorecard submitted',
onDone: () => setForm({ review: REVIEWS[0], score: '', note: '' }),
})
function submit() {
const score = form.score === '' ? 0 : Number(form.score)
if (!Number.isFinite(score) || score < 0 || score > 100) {
toast('Score must be between 0 and 100', 'warning')
return
}
create.mutate()
}
return (
<>
{rows.length ? (
<div className="list-tight">
{rows.map((f) => (
<div className="list-row" key={f.id}>
<Avatar name={f.reviewed_by_name || 'Unknown'} />
<div className="lr-main">
<div className="lr-title">{f.reviewed_by_name || 'Unknown reviewer'}</div>
{f.note && <div className="lr-sub" style={{ color: 'var(--text-2)' }}>{f.note}</div>}
<div className="lr-sub">{fmtWhen(f.created_at)}{f.score ? ` · ${f.score}/100` : ''}</div>
</div>
<div className="lr-right">{f.review ? <Badge>{f.review}</Badge> : null}</div>
</div>
))}
</div>
) : (
<EmptyState icon="award" title="No scorecards yet">Be the first to review this candidate.</EmptyState>
)}
<div className="divider" />
<div className="form-section-title" style={{ marginTop: 0 }}>Submit a scorecard</div>
<div className="form-grid">
<div className="form-field">
<label>Recommendation</label>
<select value={form.review} onChange={(e) => set('review', e.target.value)}>
{REVIEWS.map((r) => <option key={r}>{r}</option>)}
</select>
</div>
<div className="form-field">
<label>Score (0100)</label>
<input
type="number"
min="0"
max="100"
value={form.score}
onChange={(e) => set('score', e.target.value)}
placeholder="80"
/>
</div>
</div>
<div className="form-field">
<label>Notes</label>
<textarea
value={form.note}
onChange={(e) => set('note', e.target.value)}
placeholder="What stood out, and what would you probe next round?"
/>
</div>
<button
className="btn btn-primary btn-sm"
style={{ marginTop: 10 }}
disabled={!inboxId || create.isPending}
onClick={submit}
>
<Icon name="plus" /> {create.isPending ? 'Submitting…' : 'Submit Scorecard'}
</button>
</>
)
}