HR-ATS-Portal/frontend/src/screens/Jobs.jsx

855 lines
33 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.

/* ============================================================
Jobs — requisition list on live backend data (GET /jobs/fetch).
Facets, columns and actions that had no backing column are gone rather than
rendered as placeholders — the Candidates / Inbox screens set that precedent.
Create → POST /job/post-job. Update / delete / status → PATCH /jobs/update,
DELETE /jobs/delete, PATCH /jobs/status.
============================================================ */
import { useEffect, useMemo, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import AiFieldAssist from '../ui/AiFieldAssist'
import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal'
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { useFormState } from '../components/AuthLayout'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import { platformLabel } from '../lib/platforms'
import * as jobsApi from '../api/jobs'
import * as jobPostsApi from '../api/jobPosts'
import * as assignmentsApi from '../api/assignments'
import * as tasksApi from '../api/tasks'
import { JOB_STATUSES } from '../api/jobs'
import { empTypes, fmtShort } from '../data/seed'
const JOB_LIMIT = 200
async function fetchJobs() {
const res = await jobsApi.list({ top: JOB_LIMIT })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(jobsApi.toJobView)
}
function splitLines(text) {
return String(text || '')
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
}
/* Suggest is gated until the anchor fields exist — without the role and its
seniority the model can only write generic filler. Mirrors the backend's
SUGGEST_ANCHORS in job_assist/execute_agent.py, which enforces the same
rule with a 422. Returns '' when suggesting is allowed. */
function suggestHintFor(name, values) {
const hasTitle = String(values.title || '').trim() !== ''
const hasExp = String(values.experience_min ?? '').trim() !== ''
|| String(values.experience_max ?? '').trim() !== ''
if (name === 'title') {
const anyContext = ['department', 'location', 'description']
.some((k) => String(values[k] || '').trim() !== '')
return hasTitle || anyContext ? '' : 'Type a draft title or fill another field first'
}
if (name === 'department' || name === 'location') {
return hasTitle ? '' : 'Fill in the job title first'
}
if (!hasTitle && !hasExp) return 'Fill in the job title and experience first'
if (!hasTitle) return 'Fill in the job title first'
if (!hasExp) return 'Fill in the experience range first'
return ''
}
export default function Jobs() {
const { toast } = useToast()
const { can } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const qc = useQueryClient()
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
const jobs = useMemo(() => jobsQuery.data ?? [], [jobsQuery.data])
const [q, setQ] = useState('')
const [dept, setDept] = useState('')
const [status, setStatus] = useState('')
const [type, setType] = useState('')
const [viewing, setViewing] = useState(null)
const [editing, setEditing] = useState(null)
const [creating, setCreating] = useState(false)
const canEdit = can('jobs.edit')
const canDelete = can('jobs.delete')
// Deep-link intents from global search, the dashboard and the manager portal.
useEffect(() => {
const st = location.state
if (!st) return
if (st.openCreate) setCreating(true)
if (st.openJob) setViewing(jobs.find((j) => j.id === st.openJob) ?? null)
}, [location.state, jobs])
useEffect(() => {
if (!viewing) return
const fresh = jobs.find((j) => j.id === viewing.id)
if (fresh) setViewing(fresh)
else if (jobsQuery.isSuccess) setViewing(null)
}, [jobs]) // eslint-disable-line react-hooks/exhaustive-deps
const createJob = useMutation({
mutationFn: (payload) => jobPostsApi.create(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.jobs.all() })
setCreating(false)
toast('Job created', 'success')
},
onError: (err) => {
// 502: row was created but Buffer publish failed — refresh the board and
// say so; a flat "create failed" toast would be wrong.
qc.invalidateQueries({ queryKey: qk.jobs.all() })
if (err?.status === 502) {
setCreating(false)
toast('Job created, but publishing failed — see its status on the board.', 'error')
return
}
toast(friendlyAuthError(err, 'Could not create the job'), 'error')
},
})
const updateJob = useMutation({
mutationFn: ({ id, body }) => jobsApi.update(id, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.jobs.all() })
setEditing(null)
toast('Job updated', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not update the job'), 'error'),
})
const setJobStatus = useMutation({
mutationFn: ({ id, status: next }) => jobsApi.setStatus(id, next),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: qk.jobs.all() })
toast(`Status set to ${vars.status}`, 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'),
})
const deleteJob = useMutation({
mutationFn: (id) => jobsApi.remove(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.jobs.all() })
setViewing(null)
toast('Job deleted', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the job'), 'error'),
})
const departmentOptions = useMemo(
() => [...new Set(jobs.map((j) => j.department).filter(Boolean))].sort(),
[jobs],
)
const typeOptions = useMemo(
() => [...new Set(jobs.map((j) => j.type).filter(Boolean))].sort(),
[jobs],
)
const rows = useMemo(
() =>
jobs.filter((j) => {
if (dept && j.department !== dept) return false
if (status && j.status !== status) return false
if (type && j.type !== type) return false
if (q) {
const term = q.toLowerCase()
const hay = [j.title, j.department, j.recruiter, j.location]
.filter(Boolean)
.join(' ')
.toLowerCase()
if (!hay.includes(term)) return false
}
return true
}),
[jobs, q, dept, status, type],
)
const openCount = jobs.filter((j) => j.status === 'Open').length
const columns = [
{
key: 'title', label: 'Job Title', sortable: true,
render: (j) => (
<>
<div className="cell-primary">{j.title}</div>
<div className="cell-sub">{j.department || '—'}</div>
</>
),
},
{ key: 'department', label: 'Department', sortable: true, render: (j) => j.department || '—' },
{ key: 'location', label: 'Location', sortable: true, render: (j) => <span className="text-muted">{j.location || '—'}</span> },
{ key: 'type', label: 'Type', render: (j) => j.type ? <Badge className="b-gray">{j.type}</Badge> : '' },
{ key: 'platform', label: 'Platform', sortable: true, sortValue: (j) => platformLabel(j.platform), render: (j) => j.platform ? <Badge className="b-gray">{platformLabel(j.platform)}</Badge> : '' },
{ key: 'vacancies', label: 'Vacancies', sortable: true, align: 'center', render: (j) => <b>{j.vacancies ?? '—'}</b> },
{ key: 'status', label: 'Status', sortable: true, render: (j) => <Badge>{j.status}</Badge> },
{
key: 'created', label: 'Created', sortable: true,
sortValue: (j) => (j.created ? j.created.getTime() : 0),
render: (j) => <span className="text-muted">{j.created ? fmtShort(j.created) : '—'}</span>,
},
{
key: '_a', label: 'Actions', align: 'right',
render: (j) => (
<div className="row-actions">
<button className="act-btn" data-tip="View" onClick={() => setViewing(j)}><Icon name="eye" /></button>
{canEdit && (
<button className="act-btn" data-tip="Edit" onClick={() => setEditing(j)}><Icon name="edit" /></button>
)}
<button className="act-btn" data-tip="Publish" onClick={() => navigate('/jobboard', { state: { publishJob: j.id } })}><Icon name="send" /></button>
</div>
),
},
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Jobs</h1>
<p className="page-sub">{jobs.length} requisitions · {openCount} currently open</p>
</div>
<div className="page-head-actions">
<button className="btn btn-secondary" onClick={() => toast('Jobs exported to CSV', 'success')}>
<Icon name="download" /> Export
</button>
{can('job_board.create') && (
<button className="btn btn-primary" onClick={() => setCreating(true)}>
<Icon name="plus" /> Create Job
</button>
)}
</div>
</div>
<div className="card">
{jobsQuery.isPending && (
<div className="card-body">
<EmptyState icon="briefcase" title="Loading…">Fetching requisitions from the server.</EmptyState>
</div>
)}
{jobsQuery.isError && (
<div className="card-body">
<EmptyState icon="briefcase" title="Couldnt load jobs">
{friendlyAuthError(jobsQuery.error, 'Request failed')}
</EmptyState>
</div>
)}
{!jobsQuery.isPending && !jobsQuery.isError && (
<>
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search title, department, location…" />
</div>
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
<option value="">All Departments</option>
{departmentOptions.map((d) => <option key={d}>{d}</option>)}
</select>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All Status</option>
{JOB_STATUSES.map((s) => <option key={s}>{s}</option>)}
</select>
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
<option value="">All Types</option>
{typeOptions.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
</div>
<DataTable
columns={columns}
rows={rows}
pageSize={8}
empty="No requisitions match these filters."
/>
</>
)}
</div>
{viewing && (
<JobDetail
job={viewing}
canEdit={canEdit}
canDelete={canDelete}
statusBusy={setJobStatus.isPending}
deleteBusy={deleteJob.isPending}
onClose={() => setViewing(null)}
onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }}
onEdit={() => { setEditing(viewing); setViewing(null) }}
onStatus={(s) => setJobStatus.mutate({ id: viewing.id, status: s })}
onDelete={() => {
if (window.confirm(`Delete “${viewing.title}”?`)) deleteJob.mutate(viewing.id)
}}
/>
)}
{editing && (
<EditJobForm
job={editing}
departmentOptions={departmentOptions}
busy={updateJob.isPending}
onClose={() => setEditing(null)}
onSubmit={(body) => updateJob.mutate({ id: editing.id, body })}
/>
)}
{creating && (
<JobForm
departmentOptions={departmentOptions}
busy={createJob.isPending}
onClose={() => setCreating(false)}
onSubmit={(payload) => createJob.mutate(payload)}
/>
)}
</div>
)
}
const SECTION_LABEL = {
fontSize: 12, color: 'var(--text-3)', fontWeight: 600,
textTransform: 'uppercase', marginBottom: 6,
}
function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
const form = useFormState({
title: '',
department: '',
location: '',
employment_type: empTypes[0] || 'Full-time',
vacancies: '1',
experience_min: '',
experience_max: '',
salary: '',
requirements: '',
optional_skills: '',
description: '',
})
function submit() {
if (busy) return
const v = form.values
const errors = {}
if (!v.title.trim()) errors.title = 'Job title is required'
const vacancies = Number(v.vacancies)
if (!Number.isFinite(vacancies) || vacancies < 1) errors.vacancies = 'Vacancies must be at least 1'
const expMin = v.experience_min === '' ? null : Number(v.experience_min)
const expMax = v.experience_max === '' ? null : Number(v.experience_max)
if (expMin != null && !Number.isFinite(expMin)) errors.experience_min = 'Enter a valid number'
if (expMax != null && !Number.isFinite(expMax)) errors.experience_max = 'Enter a valid number'
if (
expMin != null && expMax != null
&& Number.isFinite(expMin) && Number.isFinite(expMax)
&& expMin > expMax
) {
errors.experience_max = 'Must be greater than or equal to minimum'
}
form.setErrors(errors)
if (Object.keys(errors).length) return
// No channel_id / platform: the backend saves an internal-only requisition
// and skips Buffer entirely. Publishing happens later from the Job Board.
onSubmit({
title: v.title.trim(),
department: v.department.trim() || null,
location: v.location.trim() || null,
employment_type: v.employment_type || null,
vacancies,
experience_min: expMin,
experience_max: expMax,
salary: v.salary.trim() || 'Anonymous',
requirements: splitLines(v.requirements),
optional_skills: splitLines(v.optional_skills),
description: v.description.trim() || null,
})
}
const field = (name) => ({
value: form.values[name],
onChange: (e) => form.setField(name, e.target.value),
})
// Everything the assist prompt may draw on; the backend drops the target
// field itself and empty values before building the prompt.
const assistContext = () => ({
title: form.values.title,
department: form.values.department,
location: form.values.location,
employment_type: form.values.employment_type,
experience_min: form.values.experience_min,
experience_max: form.values.experience_max,
salary: form.values.salary,
requirements: form.values.requirements,
optional_skills: form.values.optional_skills,
description: form.values.description,
})
const assist = (name, multiline = false) => (
<AiFieldAssist
field={name}
value={form.values[name]}
getContext={assistContext}
onApply={(text) => form.setField(name, text)}
disabled={busy}
multiline={multiline}
suggestHint={suggestHintFor(name, form.values)}
/>
)
return (
<Modal
title="Create New Job"
subtitle="Creates the requisition on the job board"
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
<button className="btn btn-primary" onClick={submit} disabled={busy}>
<Icon name="check" /> {busy ? 'Creating…' : 'Create Job'}
</button>
</>
}
>
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="form-grid">
<div className="form-field col-span-2">
<div className="field-label-row">
<label>Title <span className="req">*</span></label>
{assist('title')}
</div>
<input {...field('title')} className={form.errors.title ? 'err' : ''} placeholder="e.g. Senior Backend Engineer" />
<FieldError>{form.errors.title}</FieldError>
</div>
<div className="form-field">
<div className="field-label-row">
<label>Department</label>
{assist('department')}
</div>
<input
{...field('department')}
list="job-department-options"
placeholder="e.g. Engineering"
/>
<datalist id="job-department-options">
{departmentOptions.map((d) => <option key={d} value={d} />)}
</datalist>
</div>
<div className="form-field">
<div className="field-label-row">
<label>Location</label>
{assist('location')}
</div>
<input {...field('location')} placeholder="e.g. Remote / New York" />
</div>
<div className="form-field">
<label>Employment Type</label>
<select {...field('employment_type')}>
{empTypes.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
<div className="form-field">
<label>Vacancies</label>
<input type="number" min="1" {...field('vacancies')} className={form.errors.vacancies ? 'err' : ''} />
<FieldError>{form.errors.vacancies}</FieldError>
</div>
<div className="form-field">
<label>Experience min</label>
<input type="number" min="0" {...field('experience_min')} className={form.errors.experience_min ? 'err' : ''} placeholder="0" />
<FieldError>{form.errors.experience_min}</FieldError>
</div>
<div className="form-field">
<label>Experience max</label>
<input type="number" min="0" {...field('experience_max')} className={form.errors.experience_max ? 'err' : ''} placeholder="5" />
<FieldError>{form.errors.experience_max}</FieldError>
</div>
<div className="form-field col-span-2">
<div className="field-label-row">
<label>Salary</label>
{assist('salary')}
</div>
<input {...field('salary')} placeholder="Anonymous" />
</div>
<div className="form-field col-span-2">
<div className="field-label-row">
<label>Requirements</label>
{assist('requirements', true)}
</div>
<textarea {...field('requirements')} placeholder="One requirement per line…" rows={3} />
</div>
<div className="form-field col-span-2">
<div className="field-label-row">
<label>Nice to have</label>
{assist('optional_skills', true)}
</div>
<textarea {...field('optional_skills')} placeholder="One skill per line…" rows={2} />
</div>
<div className="form-field col-span-2">
<div className="field-label-row">
<label>Description</label>
{assist('description', true)}
</div>
<textarea {...field('description')} placeholder="Describe the role…" rows={4} />
</div>
</div>
<p className="text-muted" style={{ marginTop: 16, fontSize: 13 }}>
Saves the requisition to the board publish to a channel later from the Job Board.
</p>
</form>
</Modal>
)
}
function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
const form = useFormState({
title: j.title || '',
department: j.department || '',
location: j.location || '',
employment_type: j.type || '',
vacancies: j.vacancies != null ? String(j.vacancies) : '1',
salary: j.salary || '',
experience_min: j.experienceMin != null ? String(j.experienceMin) : '',
experience_max: j.experienceMax != null ? String(j.experienceMax) : '',
description: j.description || '',
})
const assistContext = () => ({
title: form.values.title,
department: form.values.department,
location: form.values.location,
employment_type: form.values.employment_type,
experience_min: form.values.experience_min,
experience_max: form.values.experience_max,
salary: form.values.salary,
description: form.values.description,
})
const assist = (name, multiline = false) => (
<AiFieldAssist
field={name}
value={form.values[name]}
getContext={assistContext}
onApply={(text) => form.setField(name, text)}
disabled={busy}
multiline={multiline}
suggestHint={suggestHintFor(name, form.values)}
/>
)
function submit() {
if (busy) return
const title = form.values.title.trim()
if (!title) {
form.setErrors({ title: 'Job title is required' })
return
}
onSubmit({
title,
department: form.values.department.trim() || null,
location: form.values.location.trim() || null,
employment_type: form.values.employment_type || null,
vacancies: Number(form.values.vacancies) || 1,
salary: form.values.salary.trim() || null,
experience_min: form.values.experience_min === '' ? null : Number(form.values.experience_min),
experience_max: form.values.experience_max === '' ? null : Number(form.values.experience_max),
description: form.values.description.trim() || null,
})
}
return (
<Modal
title="Edit Job"
subtitle={j.department || undefined}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
<button className="btn btn-primary" onClick={submit} disabled={busy}>
<Icon name="check" /> {busy ? 'Saving…' : 'Save Changes'}
</button>
</>
}
>
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="form-grid">
<div className="form-field col-span-2">
<div className="field-label-row">
<label>Title</label>
{assist('title')}
</div>
<input className={form.errors.title ? 'err' : ''} value={form.values.title} onChange={(e) => form.setField('title', e.target.value)} disabled={busy} />
<FieldError>{form.errors.title}</FieldError>
</div>
<div className="form-field">
<div className="field-label-row">
<label>Department</label>
{assist('department')}
</div>
<input list="edit-job-depts" value={form.values.department} onChange={(e) => form.setField('department', e.target.value)} disabled={busy} />
<datalist id="edit-job-depts">{departmentOptions.map((d) => <option key={d} value={d} />)}</datalist>
</div>
<div className="form-field">
<div className="field-label-row">
<label>Location</label>
{assist('location')}
</div>
<input value={form.values.location} onChange={(e) => form.setField('location', e.target.value)} disabled={busy} />
</div>
<div className="form-field">
<label>Employment Type</label>
<select value={form.values.employment_type} onChange={(e) => form.setField('employment_type', e.target.value)} disabled={busy}>
<option value=""></option>
{empTypes.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
<div className="form-field">
<label>Vacancies</label>
<input type="number" min="1" value={form.values.vacancies} onChange={(e) => form.setField('vacancies', e.target.value)} disabled={busy} />
</div>
<div className="form-field">
<div className="field-label-row">
<label>Salary</label>
{assist('salary')}
</div>
<input value={form.values.salary} onChange={(e) => form.setField('salary', e.target.value)} disabled={busy} />
</div>
<div className="form-field">
<label>Experience min</label>
<input type="number" min="0" value={form.values.experience_min} onChange={(e) => form.setField('experience_min', e.target.value)} disabled={busy} />
</div>
<div className="form-field">
<label>Experience max</label>
<input type="number" min="0" value={form.values.experience_max} onChange={(e) => form.setField('experience_max', e.target.value)} disabled={busy} />
</div>
<div className="form-field col-span-2">
<div className="field-label-row">
<label>Description</label>
{assist('description', true)}
</div>
<textarea rows={4} value={form.values.description} onChange={(e) => form.setField('description', e.target.value)} disabled={busy} />
</div>
</div>
</form>
</Modal>
)
}
/**
* Recruiter ownership of one requisition — GET/POST /job/assignments/*.
*
* Rows are valid-time intervals and the fetch returns only the OPEN one, so
* "the assigned recruiter" is simply the first row back. There is no unassign
* route: posting a new assignment closes the previous interval, which is why
* the control is a picker with a Save rather than an assign/remove pair.
*
* The picker is /tasks/assignees/fetch because the server rejects any
* non-recruiter with a 422, and that endpoint returns exactly the active
* recruiter-role users without needing rbac_users.view.
*/
function RecruiterAssignment({ jobPostId, fallbackName, canEdit }) {
const { toast } = useToast()
const qc = useQueryClient()
const [picked, setPicked] = useState('')
const assigneesQuery = useQuery({
queryKey: qk.tasks.assignees(),
queryFn: async () => {
const res = await tasksApi.listAssignees()
return Array.isArray(res?.data) ? res.data : []
},
retry: false,
})
const namesById = useMemo(() => {
const map = new Map()
for (const u of assigneesQuery.data ?? []) map.set(String(u.id), u.name)
return map
}, [assigneesQuery.data])
const currentQuery = useQuery({
queryKey: qk.assignments.job(jobPostId),
queryFn: async () => {
const res = await assignmentsApi.listJob(jobPostId)
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((r) => assignmentsApi.toAssignmentView(r, namesById))
},
enabled: Boolean(jobPostId),
retry: false,
})
const current = currentQuery.data?.[0] ?? null
const assign = useMutation({
mutationFn: (userId) => assignmentsApi.assignJob({ jobPostId, userId }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.assignments.job(jobPostId) })
qc.invalidateQueries({ queryKey: qk.jobs.all() })
setPicked('')
toast('Recruiter assigned', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not assign the recruiter.'), 'error'),
})
/* current.name resolves only once the assignee list has loaded; the
requisition's own recruiter_name is the fallback until then. */
const currentName = current?.name
|| (current ? namesById.get(String(current.userId)) : null)
|| fallbackName
|| null
return (
<>
<div className="divider" />
<div style={{ marginBottom: 16 }}>
<div style={SECTION_LABEL}>Recruiter ownership</div>
{currentQuery.isError ? (
<p className="text-muted text-sm">
{friendlyAuthError(currentQuery.error, 'Assignments did not load.')}
{' '}Needs the <code>jobs.view</code> permission.
</p>
) : (
<p className="text-muted text-sm" style={{ marginBottom: canEdit ? 10 : 0 }}>
{currentQuery.isPending
? 'Loading…'
: currentName
? <>Owned by <b>{currentName}</b>{current?.role ? ` · ${current.role.replace(/_/g, ' ')}` : ''}</>
: 'No recruiter assigned yet.'}
</p>
)}
{canEdit && !currentQuery.isError && (
<div className="flex items-center gap-8">
<select
className="select"
value={picked}
disabled={assigneesQuery.isPending || assign.isPending}
onChange={(e) => setPicked(e.target.value)}
>
<option value="">
{assigneesQuery.isPending ? 'Loading recruiters…' : 'Assign a recruiter…'}
</option>
{(assigneesQuery.data ?? []).map((u) => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
<button
className="btn btn-secondary btn-sm"
disabled={!picked || assign.isPending}
onClick={() => assign.mutate(picked)}
>
{assign.isPending ? 'Assigning…' : 'Assign'}
</button>
</div>
)}
{canEdit && assigneesQuery.isError && (
<p className="text-muted text-sm">
The recruiter list needs the <code>tasks.view</code> permission.
</p>
)}
</div>
</>
)
}
function JobDetail({
job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
}) {
return (
<Modal
title="Job Details"
subtitle={j.department || undefined}
size="modal-lg"
onClose={onClose}
footer={
<>
{canDelete && (
<button className="btn btn-ghost" style={{ color: 'var(--danger)', marginRight: 'auto' }} onClick={onDelete} disabled={deleteBusy}>
<Icon name="trash" /> {deleteBusy ? 'Deleting…' : 'Delete'}
</button>
)}
<button className="btn btn-secondary" onClick={onClose}>Close</button>
{canEdit && (
<button className="btn btn-secondary" onClick={onEdit}><Icon name="edit" /> Edit</button>
)}
<button className="btn btn-secondary" onClick={onPublish}><Icon name="send" /> Publish</button>
</>
}
>
<div className="flex items-center gap-16 mb-18">
<span className="kpi-icn i-indigo" style={{ width: 52, height: 52, borderRadius: 14 }}>
<Icon name="briefcase" />
</span>
<div>
<div style={{ fontSize: 19, fontWeight: 700 }}>{j.title}</div>
<div className="text-muted">{[j.department, j.location].filter(Boolean).join(' · ') || '—'}</div>
</div>
<div style={{ marginLeft: 'auto' }}>
{canEdit ? (
<select
className="select"
value={j.status}
disabled={statusBusy}
onChange={(e) => onStatus(e.target.value)}
>
{JOB_STATUSES.map((s) => <option key={s}>{s}</option>)}
</select>
) : (
<Badge>{j.status}</Badge>
)}
</div>
</div>
<div className="info-grid mb-18">
<div className="info-item"><div className="il">Department</div><div className="iv">{j.department || ''}</div></div>
<div className="info-item"><div className="il">Location</div><div className="iv">{j.location || ''}</div></div>
<div className="info-item"><div className="il">Employment Type</div><div className="iv">{j.type || ''}</div></div>
<div className="info-item"><div className="il">Platform</div><div className="iv">{platformLabel(j.platform) || ''}</div></div>
<div className="info-item"><div className="il">Vacancies</div><div className="iv">{j.vacancies ?? ''}</div></div>
<div className="info-item"><div className="il">Salary</div><div className="iv">{j.salary || ''}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience || ''}</div></div>
<div className="info-item"><div className="il">Created</div><div className="iv">{j.created ? fmtShort(j.created) : ''}</div></div>
<div className="info-item"><div className="il">Created by</div><div className="iv">{j.createdByName || ''}</div></div>
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{j.recruiter || ''}</div></div>
<div className="info-item"><div className="il">Closed at</div><div className="iv">{j.closedAt ? fmtShort(j.closedAt) : ''}</div></div>
</div>
<RecruiterAssignment jobPostId={j.id} fallbackName={j.recruiter} canEdit={canEdit} />
{j.description && (
<>
<div className="divider" />
<div style={{ marginBottom: 16 }}>
<div style={SECTION_LABEL}>Description</div>
<p style={{ color: 'var(--text-2)' }}>{j.description}</p>
</div>
</>
)}
{!!(j.skills && j.skills.length) && (
<div>
<div style={SECTION_LABEL}>Required Skills</div>
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</div>
)}
</Modal>
)
}