855 lines
33 KiB
JavaScript
855 lines
33 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, useState } from 'react'
|
||
import { useLocation, useNavigate } from 'react-router-dom'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
|
||
import AiFieldAssist from '../ui/AiFieldAssist'
|
||
import DataTable from '../ui/DataTable'
|
||
import Modal from '../ui/Modal'
|
||
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
|
||
import { useToast } from '../ui/Toast'
|
||
import { useAuth } from '../auth/AuthContext'
|
||
import { useFormState } from '../components/AuthLayout'
|
||
import { qk } from '../lib/queryKeys'
|
||
import { friendlyAuthError } from '../lib/errors'
|
||
import { platformLabel } from '../lib/platforms'
|
||
import * as jobsApi from '../api/jobs'
|
||
import * as jobPostsApi from '../api/jobPosts'
|
||
import * as assignmentsApi from '../api/assignments'
|
||
import * as tasksApi from '../api/tasks'
|
||
import { JOB_STATUSES } from '../api/jobs'
|
||
import { empTypes, fmtShort } from '../data/seed'
|
||
|
||
const JOB_LIMIT = 200
|
||
|
||
async function fetchJobs() {
|
||
const res = await jobsApi.list({ top: JOB_LIMIT })
|
||
const rows = Array.isArray(res?.data) ? res.data : []
|
||
return rows.map(jobsApi.toJobView)
|
||
}
|
||
|
||
function splitLines(text) {
|
||
return String(text || '')
|
||
.split('\n')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
}
|
||
|
||
/* Suggest is gated until the anchor fields exist — without the role and its
|
||
seniority the model can only write generic filler. Mirrors the backend's
|
||
SUGGEST_ANCHORS in job_assist/execute_agent.py, which enforces the same
|
||
rule with a 422. Returns '' when suggesting is allowed. */
|
||
function suggestHintFor(name, values) {
|
||
const hasTitle = String(values.title || '').trim() !== ''
|
||
const hasExp = String(values.experience_min ?? '').trim() !== ''
|
||
|| String(values.experience_max ?? '').trim() !== ''
|
||
if (name === 'title') {
|
||
const anyContext = ['department', 'location', 'description']
|
||
.some((k) => String(values[k] || '').trim() !== '')
|
||
return hasTitle || anyContext ? '' : 'Type a draft title or fill another field first'
|
||
}
|
||
if (name === 'department' || name === 'location') {
|
||
return hasTitle ? '' : 'Fill in the job title first'
|
||
}
|
||
if (!hasTitle && !hasExp) return 'Fill in the job title and experience first'
|
||
if (!hasTitle) return 'Fill in the job title first'
|
||
if (!hasExp) return 'Fill in the experience range first'
|
||
return ''
|
||
}
|
||
|
||
export default function Jobs() {
|
||
const { toast } = useToast()
|
||
const { can } = useAuth()
|
||
const navigate = useNavigate()
|
||
const location = useLocation()
|
||
const qc = useQueryClient()
|
||
|
||
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
|
||
const jobs = useMemo(() => jobsQuery.data ?? [], [jobsQuery.data])
|
||
|
||
const [q, setQ] = useState('')
|
||
const [dept, setDept] = useState('')
|
||
const [status, setStatus] = useState('')
|
||
const [type, setType] = useState('')
|
||
|
||
const [viewing, setViewing] = useState(null)
|
||
const [editing, setEditing] = useState(null)
|
||
const [creating, setCreating] = useState(false)
|
||
|
||
const canEdit = can('jobs.edit')
|
||
const canDelete = can('jobs.delete')
|
||
|
||
// Deep-link intents from global search, the dashboard and the manager portal.
|
||
useEffect(() => {
|
||
const st = location.state
|
||
if (!st) return
|
||
if (st.openCreate) setCreating(true)
|
||
if (st.openJob) setViewing(jobs.find((j) => j.id === st.openJob) ?? null)
|
||
}, [location.state, jobs])
|
||
|
||
useEffect(() => {
|
||
if (!viewing) return
|
||
const fresh = jobs.find((j) => j.id === viewing.id)
|
||
if (fresh) setViewing(fresh)
|
||
else if (jobsQuery.isSuccess) setViewing(null)
|
||
}, [jobs]) // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const createJob = useMutation({
|
||
mutationFn: (payload) => jobPostsApi.create(payload),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||
setCreating(false)
|
||
toast('Job created', 'success')
|
||
},
|
||
onError: (err) => {
|
||
// 502: row was created but Buffer publish failed — refresh the board and
|
||
// say so; a flat "create failed" toast would be wrong.
|
||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||
if (err?.status === 502) {
|
||
setCreating(false)
|
||
toast('Job created, but publishing failed — see its status on the board.', 'error')
|
||
return
|
||
}
|
||
toast(friendlyAuthError(err, 'Could not create the job'), 'error')
|
||
},
|
||
})
|
||
|
||
const updateJob = useMutation({
|
||
mutationFn: ({ id, body }) => jobsApi.update(id, body),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||
setEditing(null)
|
||
toast('Job updated', 'success')
|
||
},
|
||
onError: (err) => toast(friendlyAuthError(err, 'Could not update the job'), 'error'),
|
||
})
|
||
|
||
const setJobStatus = useMutation({
|
||
mutationFn: ({ id, status: next }) => jobsApi.setStatus(id, next),
|
||
onSuccess: (_d, vars) => {
|
||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||
toast(`Status set to ${vars.status}`, 'success')
|
||
},
|
||
onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'),
|
||
})
|
||
|
||
const deleteJob = useMutation({
|
||
mutationFn: (id) => jobsApi.remove(id),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||
setViewing(null)
|
||
toast('Job deleted', 'success')
|
||
},
|
||
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the job'), 'error'),
|
||
})
|
||
|
||
const departmentOptions = useMemo(
|
||
() => [...new Set(jobs.map((j) => j.department).filter(Boolean))].sort(),
|
||
[jobs],
|
||
)
|
||
const typeOptions = useMemo(
|
||
() => [...new Set(jobs.map((j) => j.type).filter(Boolean))].sort(),
|
||
[jobs],
|
||
)
|
||
|
||
const rows = useMemo(
|
||
() =>
|
||
jobs.filter((j) => {
|
||
if (dept && j.department !== dept) return false
|
||
if (status && j.status !== status) return false
|
||
if (type && j.type !== type) return false
|
||
if (q) {
|
||
const term = q.toLowerCase()
|
||
const hay = [j.title, j.department, j.recruiter, j.location]
|
||
.filter(Boolean)
|
||
.join(' ')
|
||
.toLowerCase()
|
||
if (!hay.includes(term)) return false
|
||
}
|
||
return true
|
||
}),
|
||
[jobs, q, dept, status, type],
|
||
)
|
||
|
||
const openCount = jobs.filter((j) => j.status === 'Open').length
|
||
|
||
const columns = [
|
||
{
|
||
key: 'title', label: 'Job Title', sortable: true,
|
||
render: (j) => (
|
||
<>
|
||
<div className="cell-primary">{j.title}</div>
|
||
<div className="cell-sub">{j.department || '—'}</div>
|
||
</>
|
||
),
|
||
},
|
||
{ key: 'department', label: 'Department', sortable: true, render: (j) => j.department || '—' },
|
||
{ 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: '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" onClick={() => setViewing(j)}><Icon name="eye" /></button>
|
||
{canEdit && (
|
||
<button className="act-btn" data-tip="Edit" onClick={() => setEditing(j)}><Icon name="edit" /></button>
|
||
)}
|
||
<button className="act-btn" data-tip="Publish" onClick={() => navigate('/jobboard', { state: { publishJob: j.id } })}><Icon name="send" /></button>
|
||
</div>
|
||
),
|
||
},
|
||
]
|
||
|
||
return (
|
||
<div className="page">
|
||
<div className="page-head">
|
||
<div>
|
||
<h1 className="page-title">Jobs</h1>
|
||
<p className="page-sub">{jobs.length} requisitions · {openCount} currently open</p>
|
||
</div>
|
||
<div className="page-head-actions">
|
||
<button className="btn btn-secondary" onClick={() => toast('Jobs exported to CSV', 'success')}>
|
||
<Icon name="download" /> Export
|
||
</button>
|
||
{can('job_board.create') && (
|
||
<button className="btn btn-primary" onClick={() => setCreating(true)}>
|
||
<Icon name="plus" /> Create Job
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card">
|
||
{jobsQuery.isPending && (
|
||
<div className="card-body">
|
||
<EmptyState icon="briefcase" title="Loading…">Fetching requisitions from the server.</EmptyState>
|
||
</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>
|
||
{JOB_STATUSES.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."
|
||
/>
|
||
</>
|
||
)}
|
||
</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 })}
|
||
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) => createJob.mutate(payload)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const SECTION_LABEL = {
|
||
fontSize: 12, color: 'var(--text-3)', fontWeight: 600,
|
||
textTransform: 'uppercase', marginBottom: 6,
|
||
}
|
||
|
||
function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
||
const form = useFormState({
|
||
title: '',
|
||
department: '',
|
||
location: '',
|
||
employment_type: empTypes[0] || 'Full-time',
|
||
vacancies: '1',
|
||
experience_min: '',
|
||
experience_max: '',
|
||
salary: '',
|
||
requirements: '',
|
||
optional_skills: '',
|
||
description: '',
|
||
})
|
||
|
||
function submit() {
|
||
if (busy) return
|
||
const v = form.values
|
||
const errors = {}
|
||
if (!v.title.trim()) errors.title = 'Job title is required'
|
||
const vacancies = Number(v.vacancies)
|
||
if (!Number.isFinite(vacancies) || vacancies < 1) errors.vacancies = 'Vacancies must be at least 1'
|
||
const expMin = v.experience_min === '' ? null : Number(v.experience_min)
|
||
const expMax = v.experience_max === '' ? null : Number(v.experience_max)
|
||
if (expMin != null && !Number.isFinite(expMin)) errors.experience_min = 'Enter a valid number'
|
||
if (expMax != null && !Number.isFinite(expMax)) errors.experience_max = 'Enter a valid number'
|
||
if (
|
||
expMin != null && expMax != null
|
||
&& Number.isFinite(expMin) && Number.isFinite(expMax)
|
||
&& expMin > expMax
|
||
) {
|
||
errors.experience_max = 'Must be greater than or equal to minimum'
|
||
}
|
||
form.setErrors(errors)
|
||
if (Object.keys(errors).length) return
|
||
|
||
// No channel_id / platform: the backend saves an internal-only requisition
|
||
// and skips Buffer entirely. Publishing happens later from the Job Board.
|
||
onSubmit({
|
||
title: v.title.trim(),
|
||
department: v.department.trim() || null,
|
||
location: v.location.trim() || null,
|
||
employment_type: v.employment_type || null,
|
||
vacancies,
|
||
experience_min: expMin,
|
||
experience_max: expMax,
|
||
salary: v.salary.trim() || 'Anonymous',
|
||
requirements: splitLines(v.requirements),
|
||
optional_skills: splitLines(v.optional_skills),
|
||
description: v.description.trim() || null,
|
||
})
|
||
}
|
||
|
||
const field = (name) => ({
|
||
value: form.values[name],
|
||
onChange: (e) => form.setField(name, e.target.value),
|
||
})
|
||
|
||
// Everything the assist prompt may draw on; the backend drops the target
|
||
// field itself and empty values before building the prompt.
|
||
const assistContext = () => ({
|
||
title: form.values.title,
|
||
department: form.values.department,
|
||
location: form.values.location,
|
||
employment_type: form.values.employment_type,
|
||
experience_min: form.values.experience_min,
|
||
experience_max: form.values.experience_max,
|
||
salary: form.values.salary,
|
||
requirements: form.values.requirements,
|
||
optional_skills: form.values.optional_skills,
|
||
description: form.values.description,
|
||
})
|
||
|
||
const assist = (name, multiline = false) => (
|
||
<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">
|
||
<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">
|
||
<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>Salary</label>
|
||
{assist('salary')}
|
||
</div>
|
||
<input {...field('salary')} placeholder="Anonymous" />
|
||
</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>
|
||
|
||
<p className="text-muted" style={{ marginTop: 16, fontSize: 13 }}>
|
||
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 form = useFormState({
|
||
title: j.title || '',
|
||
department: j.department || '',
|
||
location: j.location || '',
|
||
employment_type: j.type || '',
|
||
vacancies: j.vacancies != null ? String(j.vacancies) : '1',
|
||
salary: j.salary || '',
|
||
experience_min: j.experienceMin != null ? String(j.experienceMin) : '',
|
||
experience_max: j.experienceMax != null ? String(j.experienceMax) : '',
|
||
description: j.description || '',
|
||
})
|
||
|
||
const assistContext = () => ({
|
||
title: form.values.title,
|
||
department: form.values.department,
|
||
location: form.values.location,
|
||
employment_type: form.values.employment_type,
|
||
experience_min: form.values.experience_min,
|
||
experience_max: form.values.experience_max,
|
||
salary: form.values.salary,
|
||
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()
|
||
if (!title) {
|
||
form.setErrors({ title: 'Job title is required' })
|
||
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,
|
||
salary: form.values.salary.trim() || null,
|
||
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,
|
||
})
|
||
}
|
||
|
||
return (
|
||
<Modal
|
||
title="Edit Job"
|
||
subtitle={j.department || 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">
|
||
<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">
|
||
<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">
|
||
<div className="field-label-row">
|
||
<label>Salary</label>
|
||
{assist('salary')}
|
||
</div>
|
||
<input value={form.values.salary} onChange={(e) => form.setField('salary', 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>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Recruiter ownership of one requisition — GET/POST /job/assignments/*.
|
||
*
|
||
* Rows are valid-time intervals and the fetch returns only the OPEN one, so
|
||
* "the assigned recruiter" is simply the first row back. There is no unassign
|
||
* route: posting a new assignment closes the previous interval, which is why
|
||
* the control is a picker with a Save rather than an assign/remove pair.
|
||
*
|
||
* The picker is /tasks/assignees/fetch because the server rejects any
|
||
* non-recruiter with a 422, and that endpoint returns exactly the active
|
||
* recruiter-role users without needing rbac_users.view.
|
||
*/
|
||
function RecruiterAssignment({ jobPostId, fallbackName, canEdit }) {
|
||
const { toast } = useToast()
|
||
const qc = useQueryClient()
|
||
const [picked, setPicked] = useState('')
|
||
|
||
const assigneesQuery = useQuery({
|
||
queryKey: qk.tasks.assignees(),
|
||
queryFn: async () => {
|
||
const res = await tasksApi.listAssignees()
|
||
return Array.isArray(res?.data) ? res.data : []
|
||
},
|
||
retry: false,
|
||
})
|
||
|
||
const namesById = useMemo(() => {
|
||
const map = new Map()
|
||
for (const u of assigneesQuery.data ?? []) map.set(String(u.id), u.name)
|
||
return map
|
||
}, [assigneesQuery.data])
|
||
|
||
const currentQuery = useQuery({
|
||
queryKey: qk.assignments.job(jobPostId),
|
||
queryFn: async () => {
|
||
const res = await assignmentsApi.listJob(jobPostId)
|
||
const rows = Array.isArray(res?.data) ? res.data : []
|
||
return rows.map((r) => assignmentsApi.toAssignmentView(r, namesById))
|
||
},
|
||
enabled: Boolean(jobPostId),
|
||
retry: false,
|
||
})
|
||
|
||
const current = currentQuery.data?.[0] ?? null
|
||
|
||
const assign = useMutation({
|
||
mutationFn: (userId) => assignmentsApi.assignJob({ jobPostId, userId }),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: qk.assignments.job(jobPostId) })
|
||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||
setPicked('')
|
||
toast('Recruiter assigned', 'success')
|
||
},
|
||
onError: (err) => toast(friendlyAuthError(err, 'Could not assign the recruiter.'), 'error'),
|
||
})
|
||
|
||
/* current.name resolves only once the assignee list has loaded; the
|
||
requisition's own recruiter_name is the fallback until then. */
|
||
const currentName = current?.name
|
||
|| (current ? namesById.get(String(current.userId)) : null)
|
||
|| fallbackName
|
||
|| null
|
||
|
||
return (
|
||
<>
|
||
<div className="divider" />
|
||
<div style={{ marginBottom: 16 }}>
|
||
<div style={SECTION_LABEL}>Recruiter ownership</div>
|
||
{currentQuery.isError ? (
|
||
<p className="text-muted text-sm">
|
||
{friendlyAuthError(currentQuery.error, 'Assignments did not load.')}
|
||
{' '}Needs the <code>jobs.view</code> permission.
|
||
</p>
|
||
) : (
|
||
<p className="text-muted text-sm" style={{ marginBottom: canEdit ? 10 : 0 }}>
|
||
{currentQuery.isPending
|
||
? 'Loading…'
|
||
: currentName
|
||
? <>Owned by <b>{currentName}</b>{current?.role ? ` · ${current.role.replace(/_/g, ' ')}` : ''}</>
|
||
: 'No recruiter assigned yet.'}
|
||
</p>
|
||
)}
|
||
|
||
{canEdit && !currentQuery.isError && (
|
||
<div className="flex items-center gap-8">
|
||
<select
|
||
className="select"
|
||
value={picked}
|
||
disabled={assigneesQuery.isPending || assign.isPending}
|
||
onChange={(e) => setPicked(e.target.value)}
|
||
>
|
||
<option value="">
|
||
{assigneesQuery.isPending ? 'Loading recruiters…' : 'Assign a recruiter…'}
|
||
</option>
|
||
{(assigneesQuery.data ?? []).map((u) => (
|
||
<option key={u.id} value={u.id}>{u.name}</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
className="btn btn-secondary btn-sm"
|
||
disabled={!picked || assign.isPending}
|
||
onClick={() => assign.mutate(picked)}
|
||
>
|
||
{assign.isPending ? 'Assigning…' : 'Assign'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{canEdit && assigneesQuery.isError && (
|
||
<p className="text-muted text-sm">
|
||
The recruiter list needs the <code>tasks.view</code> permission.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function JobDetail({
|
||
job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
|
||
}) {
|
||
return (
|
||
<Modal
|
||
title="Job Details"
|
||
subtitle={j.department || 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>
|
||
</>
|
||
}
|
||
>
|
||
<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">{[j.department, 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)}
|
||
>
|
||
{JOB_STATUSES.map((s) => <option key={s}>{s}</option>)}
|
||
</select>
|
||
) : (
|
||
<Badge>{j.status}</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="info-grid mb-18">
|
||
<div className="info-item"><div className="il">Department</div><div className="iv">{j.department || '—'}</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">Salary</div><div className="iv">{j.salary || '—'}</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">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>
|
||
|
||
<RecruiterAssignment jobPostId={j.id} fallbackName={j.recruiter} canEdit={canEdit} />
|
||
|
||
{j.description && (
|
||
<>
|
||
<div className="divider" />
|
||
<div style={{ marginBottom: 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>
|
||
)}
|
||
</Modal>
|
||
)
|
||
}
|