/* ============================================================
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) => (
<>
{j.title}
{j.department || '—'}
>
),
},
{ key: 'department', label: 'Department', sortable: true, render: (j) => j.department || '—' },
{ key: 'location', label: 'Location', sortable: true, render: (j) => {j.location || '—'} },
{ key: 'type', label: 'Type', render: (j) => j.type ? {j.type} : '—' },
{ key: 'platform', label: 'Platform', sortable: true, sortValue: (j) => platformLabel(j.platform), render: (j) => j.platform ? {platformLabel(j.platform)} : '—' },
{ key: 'vacancies', label: 'Vacancies', sortable: true, align: 'center', render: (j) => {j.vacancies ?? '—'} },
{ key: 'status', label: 'Status', sortable: true, render: (j) => {j.status} },
{
key: 'created', label: 'Created', sortable: true,
sortValue: (j) => (j.created ? j.created.getTime() : 0),
render: (j) => {j.created ? fmtShort(j.created) : '—'},
},
{
key: '_a', label: 'Actions', align: 'right',
render: (j) => (
{canEdit && (
)}
),
},
]
return (
Jobs
{jobs.length} requisitions · {openCount} currently open
{can('job_board.create') && (
)}
{jobsQuery.isPending && (
Fetching requisitions from the server.
)}
{jobsQuery.isError && (
{friendlyAuthError(jobsQuery.error, 'Request failed')}
)}
{!jobsQuery.isPending && !jobsQuery.isError && (
<>
>
)}
{viewing && (
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 && (
setEditing(null)}
onSubmit={(body) => updateJob.mutate({ id: editing.id, body })}
/>
)}
{creating && (
setCreating(false)}
onSubmit={(payload) => createJob.mutate(payload)}
/>
)}
)
}
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) => (
form.setField(name, text)}
disabled={busy}
multiline={multiline}
suggestHint={suggestHintFor(name, form.values)}
/>
)
return (
>
}
>
)
}
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) => (
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 (
>
}
>
)
}
/**
* 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 (
<>
Recruiter ownership
{currentQuery.isError ? (
{friendlyAuthError(currentQuery.error, 'Assignments did not load.')}
{' '}Needs the jobs.view permission.
) : (
{currentQuery.isPending
? 'Loading…'
: currentName
? <>Owned by {currentName}{current?.role ? ` · ${current.role.replace(/_/g, ' ')}` : ''}>
: 'No recruiter assigned yet.'}
)}
{canEdit && !currentQuery.isError && (
)}
{canEdit && assigneesQuery.isError && (
The recruiter list needs the tasks.view permission.
)}
>
)
}
function JobDetail({
job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
}) {
return (
{canDelete && (
)}
{canEdit && (
)}
>
}
>
{j.title}
{[j.department, j.location].filter(Boolean).join(' · ') || '—'}
{canEdit ? (
) : (
{j.status}
)}
Department
{j.department || '—'}
Location
{j.location || '—'}
Employment Type
{j.type || '—'}
Platform
{platformLabel(j.platform) || '—'}
Vacancies
{j.vacancies ?? '—'}
Experience
{j.experience || '—'}
Created
{j.created ? fmtShort(j.created) : '—'}
Created by
{j.createdByName || '—'}
Assigned Recruiter
{j.recruiter || '—'}
Closed at
{j.closedAt ? fmtShort(j.closedAt) : '—'}
{j.description && (
<>
Description
{j.description}
>
)}
{!!(j.skills && j.skills.length) && (
Required Skills
{j.skills.map((s) => {s})}
)}
)
}