/* ============================================================ 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 && ( <>
setQ(e.target.value)} placeholder="Search title, department, location…" />
)}
{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 ( } >
{ e.preventDefault(); submit() }}>
{assist('title')}
{form.errors.title}
{assist('department')}
{departmentOptions.map((d) =>
{assist('location')}
{form.errors.vacancies}
{form.errors.experience_min}
{form.errors.experience_max}
{assist('salary')}
{assist('requirements', true)}