HR-ATS-Portal/frontend/src/ui/SuggestedRoles.jsx

159 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* ============================================================
Suggested-role picker — shared by Job Matching and Recruitment Inbox.
JobCard is the AI/manual radiogroup row. PickRoleModal is the "choose a
different role" search popup. Requirement chips light green when the
resume text contains them (client-side substring, same as Matching).
============================================================ */
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Modal from './Modal'
import { Badge, EmptyState, Icon, ScoreChip } from './primitives'
import { friendlyAuthError } from '../lib/errors'
import { qk } from '../lib/queryKeys'
import * as jobPostsApi from '../api/jobPosts'
/** Requirement chip lights green when the resume text contains it (client-side). */
export function reqInResume(req, resumeText) {
if (!req || !resumeText) return false
const needle = (typeof req === 'string' ? req : (req?.name || req?.label || '')).trim().toLowerCase()
if (!needle) return false
return resumeText.toLowerCase().includes(needle)
}
function reqLabel(req) {
if (req == null) return ''
if (typeof req === 'string' || typeof req === 'number') return String(req)
if (typeof req === 'object') return String(req.name || req.label || req.skill || '')
return String(req)
}
export function JobCard({ post, rank, selected, onSelect, resumeText, manual, badge }) {
const unavailable = Boolean(post?.unavailable) || !post?.title
const title = post?.title || 'Unavailable'
const meta = [
post?.employment_type,
post?.location,
post?.experience_min != null || post?.experience_max != null
? `${post?.experience_min ?? '?'}${post?.experience_max ?? '?'} yrs`
: null,
].filter(Boolean).join(' · ')
const tag = badge ?? (manual ? 'Manual' : `AI #${rank}`)
return (
<div
role="radio"
aria-checked={selected}
tabIndex={0}
className="list-row"
onClick={() => !unavailable && onSelect(post.id)}
onKeyDown={(e) => {
if (unavailable) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect(post.id)
}
}}
style={{
cursor: unavailable ? 'not-allowed' : 'pointer',
opacity: unavailable ? 0.55 : 1,
borderColor: selected ? 'var(--primary)' : undefined,
boxShadow: selected ? 'var(--ring)' : undefined,
marginBottom: 8,
alignItems: 'flex-start',
}}
>
<div className="lr-main" style={{ minWidth: 0 }}>
<div className="flex items-center gap-8" style={{ marginBottom: 4, flexWrap: 'wrap' }}>
<span className="tag">{tag}</span>
<div className="lr-title">{title}</div>
{unavailable ? (
<Badge className="b-gray">Unavailable</Badge>
) : (
<Badge>{post.status || 'draft'}</Badge>
)}
{post?.overall_score != null && <ScoreChip score={post.overall_score} />}
{selected && <Icon name="check-circle" />}
</div>
{meta && <div className="cell-sub">{meta}</div>}
{!unavailable && Array.isArray(post.requirements) && post.requirements.length > 0 && (
<div className="k-tags" style={{ marginTop: 8 }}>
{post.requirements.slice(0, 8).map((req, i) => {
const label = reqLabel(req)
if (!label) return null
const hit = reqInResume(req, resumeText)
return (
<span
key={`${label}-${i}`}
className="tag"
style={hit ? {
background: 'var(--success-soft)',
color: 'var(--success-fg)',
} : undefined}
>
{label}
</span>
)
})}
</div>
)}
</div>
</div>
)
}
export function PickRoleModal({ onClose, onPick }) {
const [q, setQ] = useState('')
const { data = [], isPending, isError, error } = useQuery({
queryKey: qk.jobPosts.list({ search: q || undefined, top: 30 }),
queryFn: async () => {
const res = await jobPostsApi.list({ search: q || undefined, top: 30 })
return Array.isArray(res?.data) ? res.data : []
},
})
return (
<Modal
title="Choose a different role"
subtitle="Search open job posts"
size="modal-lg"
onClose={onClose}
footer={<button className="btn btn-secondary" onClick={onClose}>Cancel</button>}
>
<div className="toolbar-search" style={{ maxWidth: 'none', marginBottom: 14 }}>
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search title or location…" autoFocus />
</div>
{isPending && <EmptyState icon="briefcase" title="Loading…">Fetching job posts.</EmptyState>}
{isError && (
<EmptyState icon="alert" title="Couldnt load roles">
{friendlyAuthError(error, 'Request failed')}
</EmptyState>
)}
{!isPending && !isError && data.length === 0 && (
<EmptyState icon="briefcase" title="No roles found">Try a different search.</EmptyState>
)}
<div className="list-tight">
{data.map((p) => (
<div
key={p.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => { onPick(p); onClose() }}
>
<div className="lr-main">
<div className="lr-title">{p.title}</div>
<div className="lr-sub">
{[p.employment_type, p.location].filter(Boolean).join(' · ') || '—'}
</div>
</div>
<Badge>{p.status}</Badge>
</div>
))}
</div>
</Modal>
)
}