/* ============================================================ 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, useRef, useState } from 'react' import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import AiFieldAssist from '../ui/AiFieldAssist' import DataTable from '../ui/DataTable' import Modal from '../ui/Modal' import PageHeader from '../ui/PageHeader' import { Tabs } from '../ui/Tabs' import { Badge, EmptyState, FieldError, Icon, SkeletonRows } 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 * as usersApi from '../api/users' import * as requisitionsApi from '../api/requisitions' import * as offersApi from '../api/offers' import { fmtDate, fmtDateTime, fmtShort, toDate } from '../lib/format' import { empTypes } from '../data/seed' // Backend allows up to 500; stay at 100 so we match Managers.jsx (shared qk.jobs.list). const JOB_LIMIT = 100 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 deptValue(j) { return String(j.requisitionDepartment || j.department || '').trim() } function deptLabel(j) { return deptValue(j) || '—' } 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 [searchParams, setSearchParams] = useSearchParams() const qc = useQueryClient() const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs }) const jobs = useMemo(() => jobsQuery.data ?? [], [jobsQuery.data]) const statusesQuery = useQuery({ queryKey: qk.jobs.requisitionStatuses(), queryFn: async () => { const res = await jobsApi.listRequisitionStatuses() const rows = Array.isArray(res?.data) ? res.data : [] return rows.length ? rows : jobsApi.REQUISITION_STATUSES }, }) const statusLabels = useMemo( () => (statusesQuery.data ?? jobsApi.REQUISITION_STATUSES).map((s) => s.label), [statusesQuery.data], ) const [q, setQ] = useState('') const [dept, setDept] = useState('') const [status, setStatus] = useState('') const [type, setType] = useState('') const [viewing, setViewing] = useState(null) const [viewingTab, setViewingTab] = useState('details') const [editing, setEditing] = useState(null) const [creating, setCreating] = useState(false) const canEdit = can('jobs.edit') const canDelete = can('jobs.delete') // Deep-link intents from notifications, global search, the dashboard and the // manager portal. Consume once and replace history: jobs refetch after a // status PATCH used to replay openCreate and pop the create modal over the // detail view. `/jobs?job=` / `?tab=history` is the notification target. useEffect(() => { const st = location.state const jobId = searchParams.get('job') || st?.openJob const tab = String(searchParams.get('tab') || '').toLowerCase() if (!st?.openCreate && !jobId) return if (st?.openCreate) setCreating(true) if (jobId) { const job = jobs.find((j) => j.id === jobId) if (job) { setViewing(job) setViewingTab(tab === 'history' ? 'history' : 'details') } else if (!jobsQuery.isSuccess) return } const next = new URLSearchParams(searchParams) let queryChanged = false if (next.has('job')) { next.delete('job') queryChanged = true } if (next.has('tab')) { next.delete('tab') queryChanged = true } if (queryChanged) setSearchParams(next, { replace: true }) if (st?.openJob || st?.openCreate) navigate('.', { replace: true, state: null }) }, [location.state, searchParams, jobs, jobsQuery.isSuccess, navigate, setSearchParams]) 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: async ({ payload, imageFile }) => { const res = await jobPostsApi.create(payload) // The cover image rides along after the row exists. Its failure must not // read as "create failed" — the job IS created — so it downgrades to a flag. if (imageFile && res?.data?.id) { try { await jobsApi.uploadImage(res.data.id, imageFile) } catch { return { ...res, imageFailed: true } } } return res }, onSuccess: (res) => { qc.invalidateQueries({ queryKey: qk.jobs.all() }) qc.invalidateQueries({ queryKey: qk.requisitions.all() }) qc.invalidateQueries({ queryKey: qk.notifications.all() }) setCreating(false) if (res?.imageFailed) toast('Job created, but the cover image failed to upload.', 'error') else 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() }) qc.invalidateQueries({ queryKey: qk.requisitions.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() }) qc.invalidateQueries({ queryKey: qk.requisitions.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() }) qc.invalidateQueries({ queryKey: qk.notifications.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() }) qc.invalidateQueries({ queryKey: qk.requisitions.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(deptValue).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 && deptValue(j) !== 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, deptValue(j), j.recruiter, j.hiringManager, 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 [exporting, setExporting] = useState(false) async function exportJobs() { if (exporting) return setExporting(true) try { await jobsApi.exportXlsx({ search: q, department: dept, status, employmentType: type }) toast('Jobs exported to Excel', 'success') } catch (err) { toast(friendlyAuthError(err, 'Could not export jobs'), 'error') } finally { setExporting(false) } } const columns = [ { key: 'title', label: 'Job Title', sortable: true, render: (j) => ( <>
{j.title}
{deptLabel(j)}
), }, { key: 'department', label: 'Department', sortable: true, sortValue: deptValue, render: (j) => deptLabel(j) }, { 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: 'hiringManager', label: 'Hiring Manager', sortable: true, render: (j) => j.hiringManager || '—' }, { key: 'recruiter', label: 'Recruiter', sortable: true, render: (j) => j.recruiter || '—' }, { 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 && ( )} {canEdit && (j.status === 'Closed' || j.status === 'Completed' ? ( ) : ( ))}
), }, ] return (
{can('job_board.create') && ( )} } />
{jobsQuery.isPending && (
)} {jobsQuery.isError && (
{friendlyAuthError(jobsQuery.error, 'Request failed')}
)} {!jobsQuery.isPending && !jobsQuery.isError && ( <>
setQ(e.target.value)} placeholder="Search title, department, location…" />
setViewing(j)} /> )}
{viewing && ( { setViewing(null); setViewingTab('details') }} 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 })} statusLabels={statusLabels} 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, imageFile) => createJob.mutate({ payload, imageFile })} /> )}
) } const SECTION_LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 6, } const IMAGE_ACCEPT = 'image/png,image/jpeg,image/jpg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif' const MAX_IMAGE_MB = 5 const ROLE_LABEL = { primary_recruiter: 'Recruiter', hiring_manager: 'Hiring manager' } function fmtWhen(value) { return fmtDateTime(value) || null } /** * Searchable picker: type to filter, click a row to store the id. * Not free-text — the value is always an option id (or '' when allowEmpty). */ function SearchSelect({ options = [], value, onChange, placeholder = 'Search…', disabled = false, loading = false, allowEmpty = false, emptyLabel = 'Unassigned', error = false, onQueryChange, }) { const [q, setQ] = useState('') const [open, setOpen] = useState(false) const root = useRef(null) const selected = options.find((o) => String(o.id) === String(value || '')) useEffect(() => { function onDoc(e) { if (root.current && !root.current.contains(e.target)) setOpen(false) } document.addEventListener('mousedown', onDoc) return () => document.removeEventListener('mousedown', onDoc) }, []) useEffect(() => { if (!onQueryChange || !open) return onQueryChange(q) }, [q, open, onQueryChange]) const term = q.trim().toLowerCase() const filtered = onQueryChange ? options : options.filter((o) => { if (!term) return true const hay = [o.name, o.email, o.role_name].filter(Boolean).join(' ').toLowerCase() return hay.includes(term) }) return (
{ setOpen(true); setQ('') }} onChange={(e) => { setQ(e.target.value); setOpen(true) }} /> {open && !disabled && !loading && (
{allowEmpty && ( )} {filtered.length === 0 && (
No matches
)} {filtered.map((o) => ( ))}
)}
) } function useManagerDirectory() { return useQuery({ queryKey: qk.managers.directory(), queryFn: async () => { const res = await usersApi.listManagers() return Array.isArray(res?.data) ? res.data : [] }, retry: false, }) } function useRecruiterDirectory() { return useQuery({ queryKey: qk.tasks.assignees(), queryFn: async () => { const res = await tasksApi.listAssignees() return Array.isArray(res?.data) ? res.data : [] }, retry: false, }) } function useRequisitionPicker(initialPicked = null, jobPostId = null) { const [reqQ, setReqQ] = useState('') const [debouncedReqQ, setDebouncedReqQ] = useState('') const [pickedReq, setPickedReq] = useState(initialPicked) useEffect(() => { const t = setTimeout(() => setDebouncedReqQ(reqQ.trim()), 250) return () => clearTimeout(t) }, [reqQ]) const requisitionsQuery = useQuery({ queryKey: qk.requisitions.search(debouncedReqQ, jobPostId), queryFn: async () => { const res = await requisitionsApi.search({ q: debouncedReqQ, jobPostId }) const rows = Array.isArray(res?.data) ? res.data : [] return rows.map((r) => ({ id: r.id, name: r.label, title: r.title || '', department: r.department || '', })) }, placeholderData: keepPreviousData, retry: false, }) const requisitionOptions = useMemo(() => { const rows = requisitionsQuery.data ?? [] if (pickedReq && !rows.some((o) => String(o.id) === String(pickedReq.id))) { return [pickedReq, ...rows] } return rows }, [requisitionsQuery.data, pickedReq]) return { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } } function JobForm({ departmentOptions, busy, onClose, onSubmit }) { const managersQuery = useManagerDirectory() const recruitersQuery = useRecruiterDirectory() const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker() const form = useFormState({ hiring_manager_id: '', current_recruiter_id: '', requisition_id: '', title: '', department: '', location: '', employment_type: empTypes[0] || 'Full-time', vacancies: '1', experience_min: '', experience_max: '', requirements: '', optional_skills: '', description: '', }) const imageInput = useRef(null) const [imageFile, setImageFile] = useState(null) const [imagePreview, setImagePreview] = useState(null) const [draggingImage, setDraggingImage] = useState(false) useEffect(() => { if (!imageFile) { setImagePreview(null) return undefined } const url = URL.createObjectURL(imageFile) setImagePreview(url) return () => URL.revokeObjectURL(url) }, [imageFile]) function pickImage(file) { if (!file || busy) return if (!String(file.type || '').startsWith('image/')) { form.setErrors({ ...form.errors, image: 'Please upload an image file' }) return } if (file.size > MAX_IMAGE_MB * 1024 * 1024) { form.setErrors({ ...form.errors, image: `Image must be under ${MAX_IMAGE_MB} MB` }) return } form.setErrors((prev) => { if (!prev.image) return prev const next = { ...prev } delete next.image return next }) setImageFile(file) } function clearImage() { setImageFile(null) if (imageInput.current) imageInput.current.value = '' } 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. // The cover image is uploaded separately right after the row exists. 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, requirements: splitLines(v.requirements), optional_skills: splitLines(v.optional_skills), description: v.description.trim() || null, hiring_manager_id: v.hiring_manager_id || undefined, current_recruiter_id: v.current_recruiter_id || undefined, requisition_id: v.requisition_id || undefined, }, imageFile) } 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, 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() }}>
{ form.setField('requisition_id', id) if (!id) { setPickedReq(null) return } const opt = requisitionOptions.find((o) => String(o.id) === String(id)) if (opt) { setPickedReq(opt) if (!form.values.title.trim() && opt.title) form.setField('title', opt.title) if (!form.values.department.trim() && opt.department) form.setField('department', opt.department) } }} onQueryChange={setReqQ} placeholder="Search by job title or department…" disabled={busy} loading={requisitionsQuery.isPending && !requisitionsQuery.data} allowEmpty emptyLabel="No requisition" /> {requisitionsQuery.isError && (

Could not load requisitions.

)}
{assist('title')}
{form.errors.title}
form.setField('hiring_manager_id', id)} placeholder="Search hiring managers…" disabled={busy} loading={managersQuery.isPending} allowEmpty emptyLabel="Unassigned" /> {managersQuery.isError && (

Could not load hiring managers.

)}
form.setField('current_recruiter_id', id)} placeholder="Search recruiters…" disabled={busy} loading={recruitersQuery.isPending} allowEmpty emptyLabel="Unassigned" /> {recruitersQuery.isError && (

Recruiter list needs tasks.view — you can assign later.

)}
{assist('department')}
{departmentOptions.map((d) =>
{assist('location')}
{form.errors.vacancies}
{form.errors.experience_min}
{form.errors.experience_max}
{assist('requirements', true)}