1510 lines
56 KiB
JavaScript
1510 lines
56 KiB
JavaScript
/* ============================================================
|
||
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) => (
|
||
<>
|
||
<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="Couldn’t 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={50}
|
||
empty="No requisitions match these filters."
|
||
onRowClick={(j) => setViewing(j)}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{viewing && (
|
||
<JobDetail
|
||
key={viewing.id}
|
||
job={viewing}
|
||
initialTab={viewingTab}
|
||
canEdit={canEdit}
|
||
canDelete={canDelete}
|
||
statusBusy={setJobStatus.isPending}
|
||
deleteBusy={deleteJob.isPending}
|
||
onClose={() => { 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 && (
|
||
<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, offersQuery }) {
|
||
const assignments = historyQuery.data ?? []
|
||
const statusRows = statusQuery.data ?? []
|
||
const offerRows = offersQuery?.data ?? []
|
||
|
||
if (historyQuery.isError && statusQuery.isError && offersQuery?.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)
|
||
|| (offersQuery?.isPending && !offersQuery.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,
|
||
})),
|
||
...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 <p className="text-muted">No history yet.</p>
|
||
}
|
||
|
||
return (
|
||
<div className="list-tight">
|
||
{events.map((ev) => {
|
||
if (ev.kind === 'status') return <StatusHistoryRow key={ev.id} row={ev.row} at={ev.at} />
|
||
if (ev.kind === 'offer') return <OfferHistoryRow key={ev.id} row={ev.row} at={ev.at} />
|
||
return <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 OfferHistoryRow({ row, at }) {
|
||
const candidate = row.candidate_name || 'a candidate'
|
||
const actor = row.created_by_name || 'Someone'
|
||
const sent = row.status === 'sent'
|
||
return (
|
||
<div className="list-row">
|
||
<span className="kpi-icn i-teal" style={{ width: 38, height: 38, borderRadius: 10 }}>
|
||
<Icon name="send" />
|
||
</span>
|
||
<div className="lr-main">
|
||
<div className="lr-title">{sent ? `Offer sent to ${candidate}` : `Offer for ${candidate}`}</div>
|
||
<div className="lr-sub">
|
||
{[actor, fmtDate(row.sent_at) || (at ? fmtDate(at) : null)].filter(Boolean).join(' · ')}
|
||
</div>
|
||
</div>
|
||
<Badge className={sent ? 'b-teal' : 'b-red'}>{sent ? 'Offer' : (row.status || 'Offer')}</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, 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 (
|
||
<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} offersQuery={offersQuery} />}
|
||
</Modal>
|
||
)
|
||
}
|