diff --git a/frontend/dist/index.html b/frontend/dist/index.html
index fd0cdbc..4208966 100644
--- a/frontend/dist/index.html
+++ b/frontend/dist/index.html
@@ -1,32 +1,33 @@
-
-
-
-
diff --git a/frontend/src/screens/JobCandidates.jsx b/frontend/src/screens/JobCandidates.jsx
new file mode 100644
index 0000000..74c83a7
--- /dev/null
+++ b/frontend/src/screens/JobCandidates.jsx
@@ -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 (
+
+
= 56 ? 17 : 13.5, letterSpacing: '-.3px',
+ color: ringColor(score),
+ }}
+ >
+ {score}
+
+
+ )
+}
+
+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 (
+
onView(c)}>
+
+
+
+
+
{displayName(c.name)}
+
{c.currentTitle ?? '—'}
+
+
+
+
+
+ {matched.map((s) => {s})}
+ {missing.map((s) => (
+ {s}
+ ))}
+ {more > 0 && +{more} more}
+
+
+
{c.critique}
+
+
+ {c.experience != null ? `${c.experience} yrs` : '—'}
+ {c.currentCompany ?? ''}
+ {SOURCE_LABEL[c.source] ?? c.source}
+
+
+
+
+ )
+}
+
+function FailedCard({ c, onView }) {
+ return (
+
onView(c)}>
+
+
+
+
+
{c.filename}
+
Could not be scored
+
+
+
+ {c.errorMessage ?? 'No usable text could be extracted from this file.'}
+
+
+ {c.errorCode ?? 'FAILED'}
+
+ {SOURCE_LABEL[c.source] ?? c.source}
+
+
+
+
+ )
+}
+
+/** 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 (
+
Close}
+ >
+
+
+
+
{displayName(c.name)}
+
{roleLine}
+
+ {SOURCE_LABEL[c.source] ?? c.source ?? '—'}
+ {c.applied && Received {fmtDate(c.applied)}}
+ {c.experience != null && (
+ {c.experience} yrs exp
+ )}
+
+
+ {completed && (
+
+ )}
+
+
+
+ ({ key: t, label: t }))} />
+
+
+
+ {tab === 'Overview' && (
+ <>
+
+
Candidate
{displayName(c.name)}
+
Current Title
{c.currentTitle ?? '—'}
+
Current Company
{c.currentCompany ?? '—'}
+
Experience
{c.experience != null ? `${c.experience} years` : '—'}
+
Source
{SOURCE_LABEL[c.source] ?? c.source ?? '—'}
+
+
Scored For
{jobTitle ?? '—'}
+
Added On
{c.applied ? fmtDate(c.applied) : '—'}
+
+ {completed ? (
+ <>
+
AI Assessment
+
{c.critique ?? '—'}
+ >
+ ) : (
+
+ {c.errorMessage ?? 'This CV could not be scored.'}
+
+ )}
+ >
+ )}
+
+ {tab === 'Job Match' && completed && (
+ <>
+
Job-Match Score
+
+ Match this candidate against the job the CV was scored for.
+
+
+
+
+
+
{jobTitle ?? 'Selected job'}
+
+
+ {band &&
{band}}
+
+
+
+
+ Matched must-have skills ({c.matchedSkills.length})
+
+
+ {c.matchedSkills.length
+ ? c.matchedSkills.map((s) => (
+ {s}
+ ))
+ : —}
+
+
+
+ Missing must-have skills ({c.missingSkills.length})
+
+
+ {c.missingSkills.length
+ ? c.missingSkills.map((s) => (
+ {s}
+ ))
+ : None — full match}
+
+
+
+
+ Matched skills are verified to appear in the resume text;
+ missing skills use the job description's wording.
+
+ >
+ )}
+
+
+ )
+}
+
+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 (
+
+
+
Candidates
+
+ {rows.length} candidate{rows.length === 1 ? '' : 's'} · {scored} scored · {failed} failed
+ {jobTitle ? ` · vs ${jobTitle}` : ''}
+
+
+
+
+
+
+
+
+ setQ(e.target.value)} placeholder="Search by name, skill, company…" />
+
+
+
+
+
+
+
+ {list.length === 0 ? (
+
+ {query.isError ? (
+
+ {friendlyAuthError(query.error, 'Please try again.')}
+
+ ) : query.isPending ? (
+ Fetching scored CVs for this job.
+ ) : rows.length === 0 ? (
+
+ Upload CVs above or score synced inbox CVs against this job.
+
+ ) : (
+ Try a different search or filter.
+ )}
+
+ ) : (
+ list.map((c) =>
+ c.scoringStatus === 'completed'
+ ?
+ :
,
+ )
+ )}
+
+
+ {viewing && (
+
setViewing(null)} />
+ )}
+
+ )
+}
diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css
index 2792816..505fe1b 100644
--- a/frontend/src/styles/styles.css
+++ b/frontend/src/styles/styles.css
@@ -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); }
diff --git a/frontend/src/ui/primitives.jsx b/frontend/src/ui/primitives.jsx
index e2327b8..51b0d9c 100644
--- a/frontend/src/ui/primitives.jsx
+++ b/frontend/src/ui/primitives.jsx
@@ -121,4 +121,26 @@ export function FieldError({ children }) {
return
{children || ''}
}
+/** 1–5 star input (interview scorecards, profile rating). */
+export function Stars({ value, onChange, disabled }) {
+ const set = (n) => { if (!disabled) onChange(n) }
+ return (
+
+ {[1, 2, 3, 4, 5].map((n) => (
+ set(n)}
+ role="radio"
+ aria-checked={n === value}
+ tabIndex={disabled ? -1 : 0}
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); set(n) } }}
+ >
+
+
+ ))}
+
+ )
+}
+
export { Icon }