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

1445 lines
54 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, useRef, useState } from 'react'
import { useLocation, useNavigate } 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 { 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 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 [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.
// Consume once and replace history state: jobs refetch after a status PATCH
// used to replay openCreate and pop the create modal over the detail view.
useEffect(() => {
const st = location.state
if (!st?.openCreate && !st?.openJob) return
if (st.openCreate) setCreating(true)
if (st.openJob) {
const job = jobs.find((j) => j.id === st.openJob)
if (job) setViewing(job)
else if (!jobsQuery.isSuccess) return
}
navigate('.', { replace: true, state: null })
}, [location.state, jobs, jobsQuery.isSuccess, navigate])
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() })
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() })
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) => (
<>
<div className="cell-primary">{j.title}</div>
<div className="cell-sub">{deptLabel(j)}</div>
</>
),
},
{ key: 'department', label: 'Department', sortable: true, sortValue: deptValue, render: (j) => deptLabel(j) },
{ 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: '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) => <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" aria-label="View job" onClick={(e) => { e.stopPropagation(); setViewing(j) }}><Icon name="eye" /></button>
{canEdit && (
<button className="act-btn" data-tip="Edit" aria-label="Edit job" onClick={(e) => { e.stopPropagation(); setEditing(j) }}><Icon name="edit" /></button>
)}
{canEdit && (j.status === 'Closed' || j.status === 'Completed' ? (
<button
className="act-btn"
data-tip="Reopen"
aria-label="Reopen job"
disabled={setJobStatus.isPending}
onClick={(e) => { e.stopPropagation(); setJobStatus.mutate({ id: j.id, status: 'Open' }) }}
><Icon name="refresh" /></button>
) : (
<button
className="act-btn"
data-tip="Close job"
aria-label="Close job"
disabled={setJobStatus.isPending}
onClick={(e) => {
e.stopPropagation()
if (window.confirm(`Close “${j.title}”? It stays on the board and can be reopened later.`)) {
setJobStatus.mutate({ id: j.id, status: 'Closed' })
}
}}
><Icon name="x-circle" /></button>
))}
<button className="act-btn" data-tip="Publish" aria-label="Publish job" onClick={(e) => { e.stopPropagation(); navigate('/jobboard', { state: { publishJob: j.id } }) }}><Icon name="send" /></button>
</div>
),
},
]
return (
<div className="page">
<PageHeader
title="Jobs"
sub={`${jobs.length} requisitions · ${openCount} currently open`}
actions={<>
<button className="btn btn-secondary" onClick={exportJobs} disabled={exporting}>
<Icon name="download" /> {exporting ? 'Exporting…' : 'Export'}
</button>
{can('job_board.create') && (
<button className="btn btn-primary" onClick={() => setCreating(true)}>
<Icon name="plus" /> Create Job <Icon name="sparkles" />
</button>
)}
</>}
/>
<div className="card">
{jobsQuery.isPending && (
<div className="card-body">
<SkeletonRows rows={6} />
</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>
{statusLabels.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."
onRowClick={(j) => setViewing(j)}
/>
</>
)}
</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 })}
statusLabels={statusLabels}
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, imageFile) => createJob.mutate({ payload, imageFile })}
/>
)}
</div>
)
}
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 (
<div className={`dropdown${open ? ' open' : ''}`} ref={root} style={{ width: '100%' }}>
<input
className={error ? 'err' : ''}
value={open ? q : (selected?.name || '')}
disabled={disabled || loading}
placeholder={loading ? 'Loading…' : placeholder}
autoComplete="off"
onFocus={() => { setOpen(true); setQ('') }}
onChange={(e) => { setQ(e.target.value); setOpen(true) }}
/>
{open && !disabled && !loading && (
<div className="dropdown-menu" style={{ left: 0, right: 'auto', width: '100%', minWidth: 0, maxHeight: 240, overflowY: 'auto' }}>
{allowEmpty && (
<button
type="button"
className="dropdown-link"
onClick={() => { onChange(''); setQ(''); setOpen(false) }}
>
{emptyLabel}
</button>
)}
{filtered.length === 0 && (
<div className="dropdown-link" style={{ color: 'var(--text-3)' }}>No matches</div>
)}
{filtered.map((o) => (
<button
type="button"
key={o.id}
className="dropdown-link"
onClick={() => { onChange(String(o.id)); setQ(''); setOpen(false) }}
>
{o.name}
{o.email ? <span className="cell-sub" style={{ marginLeft: 8 }}>{o.email}</span> : null}
</button>
))}
</div>
)}
</div>
)
}
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) => (
<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">
<label>Requisition</label>
<SearchSelect
options={requisitionOptions}
value={form.values.requisition_id}
onChange={(id) => {
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 && (
<p className="text-muted text-sm">Could not load requisitions.</p>
)}
</div>
<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">
<label>Hiring manager</label>
<SearchSelect
options={managersQuery.data ?? []}
value={form.values.hiring_manager_id}
onChange={(id) => form.setField('hiring_manager_id', id)}
placeholder="Search hiring managers…"
disabled={busy}
loading={managersQuery.isPending}
allowEmpty
emptyLabel="Unassigned"
/>
{managersQuery.isError && (
<p className="text-muted text-sm">Could not load hiring managers.</p>
)}
</div>
<div className="form-field">
<label>Recruiter</label>
<SearchSelect
options={recruitersQuery.data ?? []}
value={form.values.current_recruiter_id}
onChange={(id) => form.setField('current_recruiter_id', id)}
placeholder="Search recruiters…"
disabled={busy}
loading={recruitersQuery.isPending}
allowEmpty
emptyLabel="Unassigned"
/>
{recruitersQuery.isError && (
<p className="text-muted text-sm">Recruiter list needs tasks.view you can assign later.</p>
)}
</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>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 className="form-field col-span-2">
<label>Cover image</label>
<input
ref={imageInput}
type="file"
accept={IMAGE_ACCEPT}
hidden
onChange={(e) => { pickImage(e.target.files?.[0]); e.target.value = '' }}
/>
<div
className={`dropzone${draggingImage ? ' drag' : ''}`}
style={{ padding: '22px 18px', cursor: busy ? 'default' : 'pointer' }}
role="button"
tabIndex={0}
onClick={() => { if (!busy) imageInput.current?.click() }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (!busy) imageInput.current?.click()
}
}}
onDragOver={(e) => { e.preventDefault(); setDraggingImage(true) }}
onDragLeave={() => setDraggingImage(false)}
onDrop={(e) => {
e.preventDefault()
setDraggingImage(false)
pickImage(e.dataTransfer.files?.[0])
}}
>
<div className="dz-icn" style={{ width: 44, height: 44, borderRadius: 13, marginBottom: 10 }}>
<Icon name="upload" />
</div>
<h3 style={{ fontSize: 15 }}>Drop an image here or click to browse</h3>
<p className="text-muted text-sm">
PNG, JPG, WEBP or GIF · up to {MAX_IMAGE_MB} MB
</p>
</div>
{imageFile && (
<div className="upload-row">
{imagePreview ? (
<img
src={imagePreview}
alt=""
style={{
width: 44,
height: 44,
borderRadius: 10,
objectFit: 'cover',
flexShrink: 0,
background: 'var(--bg-sunken)',
}}
/>
) : (
<span className="attach-icn" style={{ width: 34, height: 34 }}><Icon name="file" /></span>
)}
<div className="flex-1">
<div className="fw-600 text-sm">{imageFile.name}</div>
<div className="cell-sub">{Math.max(1, Math.round(imageFile.size / 1024))} KB</div>
</div>
<button
type="button"
className="act-btn"
aria-label="Remove image"
disabled={busy}
onClick={(e) => { e.stopPropagation(); clearImage() }}
>
<Icon name="trash" />
</button>
</div>
)}
<FieldError>{form.errors.image}</FieldError>
</div>
</div>
<p className="text-muted text-sm mt-16">
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 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) => (
<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()
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 (
<Modal
title="Edit Job"
subtitle={deptValue(j) || 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">
<label>Requisition</label>
<SearchSelect
options={requisitionOptions}
value={form.values.requisition_id}
onChange={(id) => {
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 && (
<p className="text-muted text-sm">Could not load requisitions.</p>
)}
</div>
<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">
<label>Hiring manager</label>
<SearchSelect
options={managersQuery.data ?? []}
value={form.values.hiring_manager_id}
onChange={(id) => form.setField('hiring_manager_id', id)}
placeholder="Search hiring managers…"
disabled={busy}
loading={managersQuery.isPending}
allowEmpty
emptyLabel="Unassigned"
/>
</div>
<div className="form-field">
<label>Recruiter</label>
<SearchSelect
options={recruitersQuery.data ?? []}
value={form.values.current_recruiter_id}
onChange={(id) => form.setField('current_recruiter_id', id)}
placeholder="Search recruiters…"
disabled={busy}
loading={recruitersQuery.isPending}
allowEmpty
emptyLabel="Unassigned"
/>
</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">
<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>
)
}
/**
* 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 (
<>
<div className="divider" />
<div className="mb-16">
<div style={SECTION_LABEL}>Ownership</div>
<div className="form-grid" style={{ marginBottom: 12 }}>
<div className="form-field">
<label>Hiring manager</label>
{canEdit ? (
<SearchSelect
options={managersQuery.data ?? []}
value={job.hiringManagerId || ''}
onChange={(id) => {
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"
/>
) : (
<p className="text-muted text-sm">{job.hiringManager || '—'}</p>
)}
</div>
<div className="form-field">
<label>Recruiter</label>
{canEdit ? (
<SearchSelect
options={recruitersQuery.data ?? []}
value={job.recruiterId || ''}
onChange={(id) => {
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"
/>
) : (
<p className="text-muted text-sm">{job.recruiter || 'No recruiter assigned yet.'}</p>
)}
</div>
</div>
{canEdit && recruitersQuery.isError && (
<p className="text-muted text-sm">The recruiter list needs the <code>tasks.view</code> permission.</p>
)}
</div>
</>
)
}
function JobHistory({ historyQuery, statusQuery }) {
const assignments = historyQuery.data ?? []
const statusRows = statusQuery.data ?? []
if (historyQuery.isError && statusQuery.isError) {
return (
<p className="text-muted text-sm">
{friendlyAuthError(historyQuery.error, 'History did not load.')}
</p>
)
}
if ((historyQuery.isPending && !historyQuery.data) || (statusQuery.isPending && !statusQuery.data)) {
return <p className="text-muted text-sm">Loading</p>
}
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,
})),
].sort((a, b) => (b.at?.getTime() || 0) - (a.at?.getTime() || 0))
if (events.length === 0) {
return <p className="text-muted">No history yet.</p>
}
return (
<div className="list-tight">
{events.map((ev) => (
ev.kind === 'status'
? <StatusHistoryRow key={ev.id} row={ev.row} at={ev.at} />
: <AssignmentHistoryRow key={ev.id} row={ev.row} />
))}
</div>
)
}
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 (
<div className="list-row">
<span className="kpi-icn i-indigo" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="refresh" />
</span>
<div className="lr-main">
<div className="lr-title">{title}</div>
{change && <div className="lr-sub">{change}</div>}
<div className="lr-sub">
{[actor, at ? fmtWhen(at) : null].filter(Boolean).join(' · ')}
</div>
</div>
<Badge>{toLabel}</Badge>
</div>
)
}
function AssignmentHistoryRow({ row }) {
return (
<div className="list-row">
<span className="kpi-icn i-blue" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="user" />
</span>
<div className="lr-main">
<div className="lr-title">{row.name || 'Unassigned'}</div>
<div className="lr-sub">
{[
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(' · ')}
</div>
</div>
{!row.validTo && <Badge className="b-green">Current</Badge>}
</div>
)
}
/* Cover image, when the post has one — fetched with the bearer token into an
object URL, because a bare <img src> 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 <img src={url} alt="Job cover" className="job-cover" />
}
function JobDetail({
job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
statusLabels = jobsApi.JOB_STATUSES,
}) {
const [tab, setTab] = useState('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 historyCount = (historyQuery.data?.length ?? 0) + (statusQuery.data?.length ?? 0)
return (
<Modal
title="Job Details"
subtitle={deptValue(j) || 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>
</>
}
>
<JobCover jobId={j.id} />
<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">{[deptValue(j), 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)}
>
{statusLabels.map((s) => <option key={s}>{s}</option>)}
</select>
) : (
<Badge>{j.status}</Badge>
)}
</div>
</div>
<Tabs
value={tab}
onChange={setTab}
tabs={[
{ key: 'details', label: 'Details' },
{ key: 'history', label: 'History', count: historyCount || undefined },
]}
/>
{tab === 'details' && (
<>
<div className="info-grid mb-18">
<div className="info-item"><div className="il">Department</div><div className="iv">{deptLabel(j)}</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">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">Requisition</div><div className="iv">{j.requisitionLabel || ''}</div></div>
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.hiringManager || ''}</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>
<JobOwnership job={j} canEdit={canEdit} />
{j.description && (
<>
<div className="divider" />
<div className="mb-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>
)}
</>
)}
{tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} />}
</Modal>
)
}