159 lines
5.7 KiB
JavaScript
159 lines
5.7 KiB
JavaScript
/* ============================================================
|
||
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="Couldn’t 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>
|
||
)
|
||
}
|