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 @@ - - - - - - - - - - - - - TalentFlow · Applicant Tracking System - - - - - - - - + + + + + + + + + + + + + TalentFlow · Applicant Tracking System + + + + + + + + - - -
- - + + +
+ + + diff --git a/frontend/node_modules/.vite/deps/_metadata.json b/frontend/node_modules/.vite/deps/_metadata.json index f6fcee4..426a454 100644 --- a/frontend/node_modules/.vite/deps/_metadata.json +++ b/frontend/node_modules/.vite/deps/_metadata.json @@ -1,8 +1,8 @@ { - "hash": "e445d3fc", - "configHash": "4ee64dba", + "hash": "789e2a06", + "configHash": "c5b65d5f", "lockfileHash": "fac4afd8", - "browserHash": "340fe321", + "browserHash": "8203abd8", "optimized": { "react": { "src": "../../react/index.js", diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index 0c937a5..d68fc32 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -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({ - +
+
Rating
+
+ setRating.mutate(n)} + /> + {rating.toFixed(1)} / 5.0 +
+
diff --git a/frontend/src/screens/CvImport.jsx b/frontend/src/screens/CvImport.jsx index 63a4b23..590939e 100644 --- a/frontend/src/screens/CvImport.jsx +++ b/frontend/src/screens/CvImport.jsx @@ -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() { + + {/* 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. */} + ) } diff --git a/frontend/src/screens/Interviews.jsx b/frontend/src/screens/Interviews.jsx index 9193d83..5f1d1d1 100644 --- a/frontend/src/screens/Interviews.jsx +++ b/frontend/src/screens/Interviews.jsx @@ -346,27 +346,6 @@ export default function Interviews() { ) } -/** Star rating — replaces the imperative Interviews._bindStars() DOM toggling. */ -function Stars({ value, onChange }) { - return ( -
- {[1, 2, 3, 4, 5].map((n) => ( - onChange(n)} - role="radio" - aria-checked={n === value} - tabIndex={0} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onChange(n) } }} - > - - - ))} -
- ) -} - function CriteriaList({ criteria, ratings, setRating }) { return criteria.map((c) => (
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 && ( +
+ +
AI Match
+
+ )} +
+ +
+ ({ 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 ?? '—'}
+
File
{c.filename ?? '—'}
+
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 }