1245 lines
49 KiB
JavaScript
1245 lines
49 KiB
JavaScript
|
||
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 = ['CLOSED', 'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
|
||
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
|
||
|
||
function tabFromSearch(tabParam, visibleTabs, fallback) {
|
||
if (!tabParam) return fallback
|
||
const wanted = String(tabParam).trim()
|
||
return visibleTabs.find((t) => t.toLowerCase() === wanted.toLowerCase()) || fallback
|
||
}
|
||
const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round']
|
||
const INTERVIEW_STATES = ['Scheduled', 'Completed', 'Cancelled', 'No Show']
|
||
const ACTIVITY_TYPES = ['Call', 'Email', 'Meeting', 'Screening', 'Assessment', 'Note']
|
||
|
||
/** Seed timestamps are Date objects; the API sends YYYY-MM-DD[ T]HH:MM strings. */
|
||
function fmtWhen(value, fallback = '—') {
|
||
if (!value) return fallback
|
||
return fmtDate(value) || fallback
|
||
}
|
||
|
||
function fmtClock(value) {
|
||
return fmtTime(value) || null
|
||
}
|
||
|
||
function stamp(value) {
|
||
const d = toDate(value)
|
||
return d ? d.getTime() : 0
|
||
}
|
||
|
||
/** <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' },
|
||
'offer.sent': { icon: 'send', tone: 'i-teal' },
|
||
}
|
||
|
||
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',
|
||
'offer.sent': 'Offer sent',
|
||
}
|
||
|
||
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>
|
||
)
|
||
}
|