pull/17/head
ahmed.mujtaba 2026-08-18 12:47:25 +05:00
commit 27b5bc94c4
8 changed files with 444 additions and 56 deletions

View File

@ -28,5 +28,6 @@
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -1,8 +1,8 @@
{
"hash": "e445d3fc",
"configHash": "4ee64dba",
"hash": "789e2a06",
"configHash": "c5b65d5f",
"lockfileHash": "fac4afd8",
"browserHash": "340fe321",
"browserHash": "8203abd8",
"optimized": {
"react": {
"src": "../../react/index.js",

View File

@ -25,7 +25,7 @@ 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 { Avatar, Badge, EmptyState, Icon, ScoreChip, Stars } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { seedQuery } from '../data/seedQueries'
@ -140,6 +140,15 @@ export default function CandidateProfile({
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.
@ -254,7 +263,17 @@ export default function CandidateProfile({
<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`} />
<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.toFixed(1)} / 5.0</span>
</div>
</div>
<Info label="Applications" val={live.job_posts?.length || 0} />
</div>

View File

@ -14,6 +14,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import JobCandidates from './JobCandidates'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
@ -262,6 +263,11 @@ export default function CvImport() {
</div>
</div>
</div>
{/* Everything ever scored against the selected job this batch, earlier
uploads and synced inbox CVs alike. The scoring mutation invalidates
qk.candidates.all(), so the grid refreshes as each batch lands. */}
<JobCandidates jobId={jobId} jobTitle={selectedJob?.title} />
</div>
)
}

View File

@ -346,27 +346,6 @@ export default function Interviews() {
)
}
/** Star rating — replaces the imperative Interviews._bindStars() DOM toggling. */
function Stars({ value, onChange }) {
return (
<div className="rating-stars">
{[1, 2, 3, 4, 5].map((n) => (
<span
key={n}
className={`rs${n <= value ? ' on' : ''}`}
onClick={() => onChange(n)}
role="radio"
aria-checked={n === value}
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onChange(n) } }}
>
<Icon name="star" />
</span>
))}
</div>
)
}
function CriteriaList({ criteria, ratings, setRating }) {
return criteria.map((c) => (
<div className="setting-row" style={{ padding: '12px 0' }} key={c}>

View File

@ -0,0 +1,341 @@
/* ============================================================
JobCandidates the per-job scored-candidates card grid + detail modal
(the engine test UI's card layout, rebuilt on the portal's primitives).
Fed by GET /candidate/scored/fetch?job_id= via toCandidateView. Upload and
inbox rows live in the same table, so CVs scored from CV Import and CVs
synced from the mailbox both render here; `source` tells them apart.
Completed rows arrive score-desc from the server; failed rows follow in
upload order and render as failed cards rather than disappearing.
============================================================ */
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon, ProgressBar } from '../ui/primitives'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Email' }
/** Extraction keeps the resume's own casing; SHOUTED headers read badly on cards. */
function displayName(name) {
if (!name || /[a-z]/.test(name)) return name
return name.toLowerCase().replace(/\p{L}+/gu, (w) => w[0].toUpperCase() + w.slice(1))
}
function bandOf(score) {
if (score == null) return null
return score >= 82 ? 'Strong Match' : score >= 65 ? 'Potential Match' : 'Weak Match'
}
function ringColor(score) {
return score >= 82 ? 'var(--success)' : score >= 65 ? 'var(--warning)' : 'var(--danger)'
}
/** The 120px .ats-ring shrunk to card size — same conic trick, no new CSS. */
function MiniRing({ score, size = 46 }) {
return (
<div
style={{
width: size, height: size, borderRadius: '50%', flexShrink: 0,
display: 'grid', placeItems: 'center',
background: `conic-gradient(${ringColor(score)} ${score}%, var(--bg-sunken) 0)`,
}}
>
<div
style={{
width: size - 8, height: size - 8, borderRadius: '50%',
background: 'var(--bg-elev)', display: 'grid', placeItems: 'center',
fontWeight: 800, fontSize: size >= 56 ? 17 : 13.5, letterSpacing: '-.3px',
color: ringColor(score),
}}
>
{score}
</div>
</div>
)
}
function CandidateCard({ c, onView }) {
const matched = c.matchedSkills.slice(0, 4)
const missing = c.missingSkills.slice(0, 2)
const more = (c.matchedSkills.length - matched.length) + (c.missingSkills.length - missing.length)
return (
<div className="card cand-card" onClick={() => onView(c)}>
<div className="card-body">
<div className="cand-head">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
<div className="cand-id">
<div className="cand-name">{displayName(c.name)}</div>
<div className="cand-role">{c.currentTitle ?? '—'}</div>
</div>
<MiniRing score={c.aiScore} />
</div>
<div className="cand-skills">
{matched.map((s) => <span className="cand-chip" key={s}>{s}</span>)}
{missing.map((s) => (
<span className="cand-chip miss" key={s}><Icon name="x" /> {s}</span>
))}
{more > 0 && <span className="cand-chip more">+{more} more</span>}
</div>
<p className="cand-crit">{c.critique}</p>
<div className="cand-foot">
<span className="cand-meta"><Icon name="briefcase" /> {c.experience != null ? `${c.experience} yrs` : '—'}</span>
<span className="cand-company">{c.currentCompany ?? ''}</span>
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge>
<button className="act-btn" data-tip="View" onClick={(e) => { e.stopPropagation(); onView(c) }}>
<Icon name="eye" />
</button>
</div>
</div>
</div>
)
}
function FailedCard({ c, onView }) {
return (
<div className="card cand-card" onClick={() => onView(c)}>
<div className="card-body">
<div className="cand-head">
<span className="kpi-icn i-red" style={{ width: 44, height: 44, borderRadius: 12, flexShrink: 0 }}><Icon name="file" /></span>
<div className="cand-id">
<div className="cand-name">{c.filename}</div>
<div className="cand-role">Could not be scored</div>
</div>
</div>
<p className="cand-crit" style={{ height: 'auto' }}>
{c.errorMessage ?? 'No usable text could be extracted from this file.'}
</p>
<div className="cand-foot">
<Badge className="b-red">{c.errorCode ?? 'FAILED'}</Badge>
<span className="cand-company" />
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge>
<button className="act-btn" data-tip="View" onClick={(e) => { e.stopPropagation(); onView(c) }}>
<Icon name="eye" />
</button>
</div>
</div>
</div>
)
}
/** Screenshot-style detail: hero header + Overview / Job Match tabs. */
export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
const completed = c.scoringStatus === 'completed'
const TABS = completed ? ['Overview', 'Job Match'] : ['Overview']
const [tab, setTab] = useState('Overview')
const band = bandOf(c.aiScore)
const bandCls = band === 'Strong Match' ? 'b-green' : band === 'Potential Match' ? 'b-amber' : 'b-red'
const roleLine = [c.currentTitle, c.currentCompany].filter(Boolean).join(' at ') || c.filename
return (
<Modal title="Candidate Profile" subtitle={c.filename} size="modal-lg" onClose={onClose}
footer={<button className="btn btn-primary" onClick={onClose}>Close</button>}
>
<div className="profile-hero">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name">{displayName(c.name)}</div>
<div className="ph-role">{roleLine}</div>
<div className="ph-tags">
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source ?? '—'}</Badge>
{c.applied && <Badge className="b-plain b-indigo badge-plain">Received {fmtDate(c.applied)}</Badge>}
{c.experience != null && (
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
)}
</div>
</div>
{completed && (
<div style={{ textAlign: 'center' }}>
<MiniRing score={c.aiScore} size={64} />
<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 }))} />
</div>
<div className="tab-pane active">
{tab === 'Overview' && (
<>
<div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Candidate</div><div className="iv">{displayName(c.name)}</div></div>
<div className="info-item"><div className="il">Current Title</div><div className="iv">{c.currentTitle ?? '—'}</div></div>
<div className="info-item"><div className="il">Current Company</div><div className="iv">{c.currentCompany ?? '—'}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{c.experience != null ? `${c.experience} years` : '—'}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{SOURCE_LABEL[c.source] ?? c.source ?? '—'}</div></div>
<div className="info-item"><div className="il">File</div><div className="iv">{c.filename ?? '—'}</div></div>
<div className="info-item"><div className="il">Scored For</div><div className="iv">{jobTitle ?? '—'}</div></div>
<div className="info-item"><div className="il">Added On</div><div className="iv">{c.applied ? fmtDate(c.applied) : '—'}</div></div>
</div>
{completed ? (
<>
<div className="form-section-title" style={{ marginTop: 0 }}>AI Assessment</div>
<p className="text-muted">{c.critique ?? '—'}</p>
</>
) : (
<EmptyState icon="alert" title={c.errorCode ?? 'FAILED'}>
{c.errorMessage ?? 'This CV could not be scored.'}
</EmptyState>
)}
</>
)}
{tab === 'Job Match' && completed && (
<>
<div className="form-section-title" style={{ marginTop: 0 }}>Job-Match Score</div>
<p className="text-muted text-sm" style={{ marginBottom: 14 }}>
Match this candidate against the job the CV was scored for.
</p>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
<div className="card-body flex items-center gap-12">
<MiniRing score={c.aiScore} size={56} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="fw-600" style={{ marginBottom: 8 }}>{jobTitle ?? 'Selected job'}</div>
<ProgressBar pct={c.aiScore} />
</div>
{band && <Badge className={bandCls}>{band}</Badge>}
</div>
</div>
<div className="form-section-title" style={{ marginTop: 0 }}>
Matched must-have skills ({c.matchedSkills.length})
</div>
<div className="k-tags" style={{ marginBottom: 16 }}>
{c.matchedSkills.length
? c.matchedSkills.map((s) => (
<span className="skill-pill skill-matched" key={s}><Icon name="check" /> {s}</span>
))
: <span className="text-muted"></span>}
</div>
<div className="form-section-title" style={{ marginTop: 0 }}>
Missing must-have skills ({c.missingSkills.length})
</div>
<div className="k-tags" style={{ marginBottom: 16 }}>
{c.missingSkills.length
? c.missingSkills.map((s) => (
<span className="skill-pill skill-missing" key={s}><Icon name="x" /> {s}</span>
))
: <span className="text-muted">None full match</span>}
</div>
<div className="divider" />
<p className="text-muted text-sm">
<Icon name="sparkles" /> Matched skills are verified to appear in the resume text;
missing skills use the job description's wording.
</p>
</>
)}
</div>
</Modal>
)
}
export default function JobCandidates({ jobId, jobTitle }) {
const [q, setQ] = useState('')
const [filter, setFilter] = useState('all')
const [viewing, setViewing] = useState(null)
const query = useQuery({
queryKey: qk.candidates.list({ jobId }),
queryFn: async () => {
const res = await candidatesApi.listCandidates({ jobId })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(candidatesApi.toCandidateView)
},
enabled: Boolean(jobId),
})
const rows = useMemo(() => query.data ?? [], [query.data])
const scored = rows.filter((r) => r.scoringStatus === 'completed').length
const failed = rows.length - scored
const list = useMemo(
() =>
rows.filter((c) => {
if (filter === 'completed' && c.scoringStatus !== 'completed') return false
if (filter === 'failed' && c.scoringStatus !== 'failed') return false
if (q) {
const hay = [
c.name, c.filename ?? '', c.currentTitle ?? '',
c.currentCompany ?? '', c.matchedSkills.join(' '),
].join(' ').toLowerCase()
if (!hay.includes(q.toLowerCase())) return false
}
return true
}),
[rows, q, filter],
)
if (!jobId) return null
return (
<div style={{ marginTop: 24 }}>
<div style={{ marginBottom: 14 }}>
<h2 className="page-title" style={{ fontSize: 20 }}>Candidates</h2>
<p className="page-sub">
{rows.length} candidate{rows.length === 1 ? '' : 's'} · {scored} scored · {failed} failed
{jobTitle ? ` · vs ${jobTitle}` : ''}
</p>
</div>
<div className="card mb-18">
<div className="card-body" style={{ padding: 16 }}>
<div className="toolbar" style={{ marginBottom: 0 }}>
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by name, skill, company…" />
</div>
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">All results</option>
<option value="completed">Scored</option>
<option value="failed">Failed</option>
</select>
</div>
</div>
</div>
<div className="grid g-3">
{list.length === 0 ? (
<div style={{ gridColumn: '1/-1' }}>
{query.isError ? (
<EmptyState title="Could not load candidates">
{friendlyAuthError(query.error, 'Please try again.')}
</EmptyState>
) : query.isPending ? (
<EmptyState title="Loading candidates…">Fetching scored CVs for this job.</EmptyState>
) : rows.length === 0 ? (
<EmptyState title="No candidates yet">
Upload CVs above or score synced inbox CVs against this job.
</EmptyState>
) : (
<EmptyState title="No matches">Try a different search or filter.</EmptyState>
)}
</div>
) : (
list.map((c) =>
c.scoringStatus === 'completed'
? <CandidateCard key={c.id} c={c} onView={setViewing} />
: <FailedCard key={c.id} c={c} onView={setViewing} />,
)
)}
</div>
{viewing && (
<ScoredCandidateDetail candidate={viewing} jobTitle={jobTitle} onClose={() => setViewing(null)} />
)}
</div>
)
}

View File

@ -896,6 +896,26 @@ canvas { width: 100%; max-width: 100%; display: block; }
.skill-matched { background: var(--success-soft); color: var(--success); }
.skill-missing { background: var(--danger-soft); color: var(--danger); }
/* Per-job scored-candidate cards (JobCandidates.jsx). Every zone has a fixed
height so the grid rows align regardless of how much text a CV produced. */
.cand-card { display: flex; flex-direction: column; cursor: pointer; transition: .15s; }
.cand-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-sm); transform: translateY(-1px); }
.cand-card > .card-body { display: flex; flex-direction: column; flex: 1; padding: 18px; }
.cand-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
.cand-id { flex: 1; min-width: 0; }
.cand-name { font-weight: 700; font-size: 14.5px; letter-spacing: -.1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cand-role { font-size: 12.5px; color: var(--text-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cand-skills { display: flex; flex-wrap: wrap; gap: 6px; align-content: flex-start; height: 54px; overflow: hidden; margin-bottom: 12px; }
.cand-chip { display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 10px; border-radius: 7px; font-size: 12px; font-weight: 600; background: var(--bg-sunken); color: var(--text-2); white-space: nowrap; }
.cand-chip svg { width: 11px; height: 11px; }
.cand-chip.miss { background: var(--danger-soft); color: var(--danger); }
.cand-chip.more { background: transparent; color: var(--text-3); padding: 0 4px; }
.cand-crit { font-size: 13px; line-height: 1.55; color: var(--text-2); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; height: 60px; margin-bottom: 14px; }
.cand-foot { margin-top: auto; display: flex; align-items: center; gap: 10px; padding-top: 12px; border-top: 1px solid var(--border); }
.cand-meta { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-3); white-space: nowrap; flex-shrink: 0; }
.cand-meta svg { width: 13px; height: 13px; }
.cand-company { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--text-3); }
/* Platform publish card */
.platform-card { display: flex; align-items: center; gap: 14px; padding: 16px; border: 1px solid var(--border); border-radius: 14px; transition: .15s; cursor: pointer; background: var(--bg-elev); }
.platform-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-sm); }

View File

@ -121,4 +121,26 @@ export function FieldError({ children }) {
return <span className={`field-error${children ? ' show' : ''}`}>{children || ''}</span>
}
/** 15 star input (interview scorecards, profile rating). */
export function Stars({ value, onChange, disabled }) {
const set = (n) => { if (!disabled) onChange(n) }
return (
<div className="rating-stars">
{[1, 2, 3, 4, 5].map((n) => (
<span
key={n}
className={`rs${n <= value ? ' on' : ''}`}
onClick={() => set(n)}
role="radio"
aria-checked={n === value}
tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); set(n) } }}
>
<Icon name="star" />
</span>
))}
</div>
)
}
export { Icon }