/* ============================================================
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 && (
<>
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 (
>
}
>
)
}
function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
const managersQuery = useManagerDirectory()
const recruitersQuery = useRecruiterDirectory()
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker(
j.requisitionId
? {
id: j.requisitionId,
name: j.requisitionLabel || 'Linked requisition',
title: j.requisitionTitle || '',
department: j.requisitionDepartment || '',
}
: null,
j.id,
)
const form = useFormState({
requisition_id: j.requisitionId || '',
title: j.title || '',
department: j.department || '',
location: j.location || '',
employment_type: j.type || '',
vacancies: j.vacancies != null ? String(j.vacancies) : '1',
experience_min: j.experienceMin != null ? String(j.experienceMin) : '',
experience_max: j.experienceMax != null ? String(j.experienceMax) : '',
description: j.description || '',
hiring_manager_id: j.hiringManagerId || '',
current_recruiter_id: j.recruiterId || '',
})
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,
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()
const errors = {}
if (!title) errors.title = 'Job title is required'
form.setErrors(errors)
if (Object.keys(errors).length) 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,
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,
hiring_manager_id: form.values.hiring_manager_id || null,
current_recruiter_id: form.values.current_recruiter_id || null,
requisition_id: form.values.requisition_id || null,
})
}
return (
>
}
>
)
}
/**
* Hiring-manager + recruiter pointers on one requisition.
* Writes go through PATCH /jobs/update; job_assignments keeps the interval log.
*/
function JobOwnership({ job, canEdit }) {
const { toast } = useToast()
const qc = useQueryClient()
const managersQuery = useManagerDirectory()
const recruitersQuery = useRecruiterDirectory()
const patch = useMutation({
mutationFn: (body) => jobsApi.update(job.id, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.assignments.job(job.id) })
qc.invalidateQueries({ queryKey: qk.jobs.all() })
qc.invalidateQueries({ queryKey: qk.managers.all() })
toast('Assignment updated', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not update the assignment.'), 'error'),
})
return (
<>
Ownership
{canEdit ? (
{
const next = id || null
if (String(next || '') === String(job.hiringManagerId || '')) return
patch.mutate({ hiring_manager_id: next })
}}
placeholder="Search hiring managers…"
disabled={patch.isPending}
loading={managersQuery.isPending}
allowEmpty
emptyLabel="Unassigned"
/>
) : (
{job.hiringManager || '—'}
)}
{canEdit ? (
{
const next = id || null
if (String(next || '') === String(job.recruiterId || '')) return
patch.mutate({ current_recruiter_id: next })
}}
placeholder="Search recruiters…"
disabled={patch.isPending}
loading={recruitersQuery.isPending}
allowEmpty
emptyLabel="Unassigned"
/>
) : (
{job.recruiter || 'No recruiter assigned yet.'}
)}
{canEdit && recruitersQuery.isError && (
The recruiter list needs the tasks.view permission.
)}
>
)
}
function JobHistory({ historyQuery, statusQuery, offersQuery }) {
const assignments = historyQuery.data ?? []
const statusRows = statusQuery.data ?? []
const offerRows = offersQuery?.data ?? []
if (historyQuery.isError && statusQuery.isError && offersQuery?.isError) {
return (
{friendlyAuthError(historyQuery.error, 'History did not load.')}
)
}
if (
(historyQuery.isPending && !historyQuery.data)
|| (statusQuery.isPending && !statusQuery.data)
|| (offersQuery?.isPending && !offersQuery.data)
) {
return Loading…
}
const events = [
...assignments.map((row) => ({
kind: 'assignment',
id: `a-${row.id}`,
at: row.validFrom,
row,
})),
...statusRows.map((row) => ({
kind: 'status',
id: `s-${row.id}`,
at: toDate(row.created_at),
row,
})),
...offerRows
.filter((row) => row.status === 'sent' || row.sent_at)
.map((row) => ({
kind: 'offer',
id: `o-${row.id}`,
at: toDate(row.sent_at || row.created_at),
row,
})),
].sort((a, b) => (b.at?.getTime() || 0) - (a.at?.getTime() || 0))
if (events.length === 0) {
return No history yet.
}
return (
{events.map((ev) => {
if (ev.kind === 'status') return
if (ev.kind === 'offer') return
return
})}
)
}
function StatusHistoryRow({ row, at }) {
const fromLabel = row.from_label || row.from_status
const toLabel = row.to_label || row.to_status || '—'
const actor = row.changed_by_name || (row.changed_by ? 'Unknown' : 'System')
const title = fromLabel ? 'Status changed' : `Opened as ${toLabel}`
const change = fromLabel ? `${fromLabel} → ${toLabel}` : null
return (
{title}
{change &&
{change}
}
{[actor, at ? fmtWhen(at) : null].filter(Boolean).join(' · ')}
{toLabel}
)
}
function OfferHistoryRow({ row, at }) {
const candidate = row.candidate_name || 'a candidate'
const actor = row.created_by_name || 'Someone'
const sent = row.status === 'sent'
return (
{sent ? `Offer sent to ${candidate}` : `Offer for ${candidate}`}
{[actor, fmtDate(row.sent_at) || (at ? fmtDate(at) : null)].filter(Boolean).join(' · ')}
{sent ? 'Offer' : (row.status || 'Offer')}
)
}
function AssignmentHistoryRow({ row }) {
return (
{row.name || 'Unassigned'}
{[
ROLE_LABEL[row.role] || row.role?.replace(/_/g, ' '),
row.validFrom ? fmtShort(row.validFrom) : null,
row.validTo ? `→ ${fmtShort(row.validTo)}` : 'current',
row.assignedByName ? `by ${row.assignedByName}` : null,
].filter(Boolean).join(' · ')}
{!row.validTo &&
Current}
)
}
/* Cover image, when the post has one — fetched with the bearer token into an
object URL, because a bare
cannot carry auth headers. null (404)
simply renders nothing. */
function JobCover({ jobId }) {
const [url, setUrl] = useState(null)
useEffect(() => {
let alive = true
let objectUrl = null
jobsApi.fetchImageUrl(jobId)
.then((u) => {
if (!alive) { if (u) URL.revokeObjectURL(u); return }
objectUrl = u
setUrl(u)
})
.catch(() => {})
return () => { alive = false; if (objectUrl) URL.revokeObjectURL(objectUrl) }
}, [jobId])
if (!url) return null
return
}
function JobDetail({
job: j, initialTab = 'details', canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
statusLabels = jobsApi.JOB_STATUSES,
}) {
const [tab, setTab] = useState(initialTab === 'history' ? 'history' : 'details')
const historyQuery = useQuery({
queryKey: qk.assignments.job(j.id),
queryFn: async () => {
const res = await assignmentsApi.listJob(j.id, { currentOnly: false })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((r) => assignmentsApi.toAssignmentView(r))
},
enabled: Boolean(j.id),
retry: false,
})
const statusQuery = useQuery({
queryKey: qk.jobs.statusHistory(j.id),
queryFn: async () => {
const res = await jobsApi.listStatusHistory(j.id)
return Array.isArray(res?.data) ? res.data : []
},
enabled: Boolean(j.id),
retry: false,
})
const offersQuery = useQuery({
queryKey: qk.offers.list({ jobPostId: j.id, top: 200 }),
queryFn: async () => {
const res = await offersApi.list({ jobPostId: j.id, top: 200 })
return Array.isArray(res?.data) ? res.data : []
},
enabled: Boolean(j.id),
retry: false,
})
const historyCount = (historyQuery.data?.length ?? 0) + (statusQuery.data?.length ?? 0) + (offersQuery.data?.length ?? 0)
return (
{canDelete && (
)}
{canEdit && (
)}
>
}
>
{j.title}
{[deptValue(j), j.location].filter(Boolean).join(' · ') || '—'}
{canEdit ? (
) : (
{j.status}
)}
{tab === 'details' && (
<>
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 || '—'}
Requisition
{j.requisitionLabel || '—'}
Hiring Manager
{j.hiringManager || '—'}
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})}
)}
>
)}
{tab === 'history' && }
)
}