Merge branch 'Talha' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into Vibe-Coding
commit
27b5bc94c4
|
|
@ -28,5 +28,6 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"hash": "e445d3fc",
|
||||
"configHash": "4ee64dba",
|
||||
"hash": "789e2a06",
|
||||
"configHash": "c5b65d5f",
|
||||
"lockfileHash": "fac4afd8",
|
||||
"browserHash": "340fe321",
|
||||
"browserHash": "8203abd8",
|
||||
"optimized": {
|
||||
"react": {
|
||||
"src": "../../react/index.js",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -121,4 +121,26 @@ export function FieldError({ children }) {
|
|||
return <span className={`field-error${children ? ' show' : ''}`}>{children || ''}</span>
|
||||
}
|
||||
|
||||
/** 1–5 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 }
|
||||
|
|
|
|||
Loading…
Reference in New Issue