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
}
/** + -> 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, 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 ? (
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 ? setFavorite.mutate(!favorite) : onToggleFav(c))}
>
{favorite ? 'Favorited' : 'Favorite'}
{canScoreAts && (
scoreAts.mutate()}
>
{scoreAts.isPending ? 'Scoring…' : 'Score with ATS'}
)}
{onAtsMatch && (
onAtsMatch(c)}>
ATS Match
)}
{isLive ? (
advanceLive.mutate()}
>
{' '}
{advanceLive.isPending
? 'Moving…'
: nextStage ? `Advance to ${nextStage}`
: stageLabel === 'Rejected' || stageLabel === 'On Hold' ? stageLabel : 'Pipeline complete'}
) : (
{ onAdvance(c); onClose() }}>
Advance Stage
)}
>
)
const body = (
<>
{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) && (
)}
{/* 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, 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
{/* --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}
toast('Downloading resume.pdf', 'info')}>
Download PDF
>
)))}
{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 ? (
) : (
<>
Add a note
toast('Note saved', 'success')}>
Add Note
{c.recruiter}
Strong communication skills, great culture fit. Recommend advancing.
2 days ago
Asfand Ahmed
Reviewed portfolio — impressive work. Schedule technical round.
4 days ago
>
)))}
{tab === 'Activity' && (guard || (live ? (
) : (
{[
{ 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) => (
))}
)))}
{tab === 'Documents' && (guard || (live ? (
) : (
{[
{ 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) => (
toast(`Downloading ${d.n}`, 'info')}>
))}
)))}
>
)
if (variant === 'page') {
return (
Back
Candidates / {live?.name || c.name || '…'}
{actions &&
{actions}
}
)
}
return (
{body}
)
}
/* ------------------------------------------------------------------
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 (
{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.'}
)
}
return (
{live.name}
{source ? `Original CV — ${source}` : 'Original CV from the application'}
)
}
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 Activity appears here as the candidate moves.
}
return (
{events.map((e, i) => (
{e.title}
{fmtWhen(e.at)}
{e.desc &&
{e.desc}
}
))}
)
}
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 Fetching the audit trail.
}
if (q.isError) {
return (
{friendlyAuthError(q.error, 'Please try again.')}
)
}
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 Actions on this candidate will appear here.
}
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) => (
{g.label}
{g.rows.map((r) => )}
))}
{total > 0 && (
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 (
{title}
{change &&
{change}
}
{r.description &&
{r.description}
}
{organizerEmail &&
Organizer: {organizerEmail}
}
{attendeeEmails.length > 0 && (
Attendees: {attendeeEmails.join(', ')}
)}
{isInterview ? when : `${actor} · ${when}`}
{isInterview
? null
: (r.actor_name || r.actor_id
?
:
System )}
)
}
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 ? (
{rows.map((iv) => {
const clock = fmtClock(iv.interview_time)
return (
{iv.interview_type || 'Interview'}
{fmtWhen(iv.interview_date)}{clock ? ` · ${clock}` : ''}
{iv.interview_status ? {iv.interview_status} : null}
)
})}
) : (
Schedule the first round below.
)}
Schedule an interview
{create.isPending ? 'Scheduling…' : 'Schedule Interview'}
{!inboxId && (
Interview records attach to an email application — this candidate was added
manually, so scheduling is unavailable here.
)}
>
)
}
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 (
<>
Add a note
create.mutate()}
>
{create.isPending ? 'Saving…' : 'Add Note'}
{rows.length ? (
{rows.map((n) => )}
) : (
The first note on this candidate goes above.
)}
>
)
}
/**
* 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 (
save.mutate()}
>
{save.isPending ? 'Saving…' : 'Save'}
{ setText(n.note ?? ''); setEditing(false) }}
>
Cancel
)
}
return (
{n.created_by_name || 'Unknown author'}
{n.note}
{fmtWhen(n.created_at)}
{n.updated_at && n.updated_at !== n.created_at ? ' · edited' : ''}
{mine && (
setEditing(true)}>
)}
)
}
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 ? (
{rows.map((a) => {
const clock = fmtClock(a.activity_time)
return (
{a.activity_type || 'Activity'}
{a.description && (
{a.description}
)}
{fmtWhen(a.activity_date)}{clock ? ` · ${clock}` : ''}
{a.activity_status ? {a.activity_status} : null}
)
})}
) : (
Log the first touchpoint below.
)}
Log activity
{create.isPending ? 'Logging…' : 'Log Activity'}
{!inboxId && (
The activity log attaches to an email application — this candidate was added
manually, so logging is unavailable here.
)}
>
)
}
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 This application arrived without attachments.
}
const canDownload = Boolean(inboxId || manualUploadCandidateId)
return (
{rows.map((d, i) => {
const path = d.path || ''
const openable = s3Api.canOpen(path)
return (
{d.name}
Stored with the application
{openable ? (
) : (
download.mutate({ index: i, filename: d.name })}
>
)}
)
})}
)
}