|
|
|
|
@ -3,23 +3,27 @@
|
|
|
|
|
|
|
|
|
|
Facets, columns and actions that had no backing column are gone rather than
|
|
|
|
|
rendered as placeholders — the Candidates / Inbox screens set that precedent.
|
|
|
|
|
Create / edit / delete stay off this screen until real write endpoints exist;
|
|
|
|
|
publishing still routes to /jobboard.
|
|
|
|
|
Create is wired to POST /job/post-job (create + Buffer publish). Edit / delete
|
|
|
|
|
/ reassign stay off until real write endpoints exist; publishing still routes
|
|
|
|
|
to /jobboard.
|
|
|
|
|
============================================================ */
|
|
|
|
|
|
|
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
|
|
|
import { useLocation, useNavigate } from 'react-router-dom'
|
|
|
|
|
import { useQuery } from '@tanstack/react-query'
|
|
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
|
|
|
|
|
|
|
|
import DataTable from '../ui/DataTable'
|
|
|
|
|
import Modal from '../ui/Modal'
|
|
|
|
|
import { Badge, EmptyState, Icon } from '../ui/primitives'
|
|
|
|
|
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 * as jobsApi from '../api/jobs'
|
|
|
|
|
import * as jobPostsApi from '../api/jobPosts'
|
|
|
|
|
import { JOB_STATUSES } from '../api/jobs'
|
|
|
|
|
import { fmtShort } from '../data/seed'
|
|
|
|
|
import { empTypes, fmtShort } from '../data/seed'
|
|
|
|
|
|
|
|
|
|
const JOB_LIMIT = 200
|
|
|
|
|
|
|
|
|
|
@ -29,10 +33,19 @@ async function fetchJobs() {
|
|
|
|
|
return rows.map(jobsApi.toJobView)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function splitLines(text) {
|
|
|
|
|
return String(text || '')
|
|
|
|
|
.split('\n')
|
|
|
|
|
.map((s) => s.trim())
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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])
|
|
|
|
|
@ -43,14 +56,42 @@ export default function Jobs() {
|
|
|
|
|
const [type, setType] = useState('')
|
|
|
|
|
|
|
|
|
|
const [viewing, setViewing] = useState(null)
|
|
|
|
|
const [creating, setCreating] = useState(false)
|
|
|
|
|
|
|
|
|
|
// 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])
|
|
|
|
|
|
|
|
|
|
const channelsQuery = useQuery({
|
|
|
|
|
queryKey: qk.jobPosts.all(),
|
|
|
|
|
queryFn: async () => (await jobPostsApi.listChannels())?.data ?? [],
|
|
|
|
|
enabled: creating,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const createJob = useMutation({
|
|
|
|
|
mutationFn: (payload) => jobPostsApi.create(payload),
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
|
|
|
|
setCreating(false)
|
|
|
|
|
toast('Job created and sent to the channel', '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 departmentOptions = useMemo(
|
|
|
|
|
() => [...new Set(jobs.map((j) => j.department).filter(Boolean))].sort(),
|
|
|
|
|
[jobs],
|
|
|
|
|
@ -124,6 +165,11 @@ export default function Jobs() {
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
@ -179,6 +225,18 @@ export default function Jobs() {
|
|
|
|
|
onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{creating && (
|
|
|
|
|
<JobForm
|
|
|
|
|
departmentOptions={departmentOptions}
|
|
|
|
|
channels={channelsQuery.data ?? []}
|
|
|
|
|
channelsLoading={channelsQuery.isPending}
|
|
|
|
|
channelsError={channelsQuery.isError}
|
|
|
|
|
busy={createJob.isPending}
|
|
|
|
|
onClose={() => setCreating(false)}
|
|
|
|
|
onSubmit={(payload) => createJob.mutate(payload)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
@ -188,6 +246,237 @@ const SECTION_LABEL = {
|
|
|
|
|
textTransform: 'uppercase', marginBottom: 6,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function channelLabel(ch) {
|
|
|
|
|
const name = ch.displayName || ch.name || ch.id
|
|
|
|
|
const service = ch.service ? String(ch.service) : ''
|
|
|
|
|
return service ? `${name} (${service})` : name
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function publishConsequence(channel, mode) {
|
|
|
|
|
const service = channel?.service
|
|
|
|
|
? String(channel.service).charAt(0).toUpperCase() + String(channel.service).slice(1)
|
|
|
|
|
: (channel?.displayName || channel?.name || 'the selected channel')
|
|
|
|
|
if (mode === 'shareNow') return `Publishes to ${service} — posts immediately`
|
|
|
|
|
if (mode === 'customScheduled') return `Publishes to ${service} — scheduled for later`
|
|
|
|
|
return `Publishes to ${service} — added to queue`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function JobForm({
|
|
|
|
|
departmentOptions, channels, channelsLoading, channelsError, 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: '',
|
|
|
|
|
channel_id: '',
|
|
|
|
|
mode: 'addToQueue',
|
|
|
|
|
scheduler_date: '',
|
|
|
|
|
scheduler_time: '09:00',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Default the channel once the list arrives — same derivation pattern as
|
|
|
|
|
// Candidates.jsx's job-post picker (avoid an effect loop on setField).
|
|
|
|
|
const channelId = form.values.channel_id || (channels[0] ? String(channels[0].id) : '')
|
|
|
|
|
const selectedChannel = channels.find((c) => String(c.id) === String(channelId))
|
|
|
|
|
|
|
|
|
|
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'
|
|
|
|
|
}
|
|
|
|
|
if (!channelId) errors.channel_id = 'Select a channel'
|
|
|
|
|
if (v.mode === 'customScheduled' && !v.scheduler_date) {
|
|
|
|
|
errors.scheduler_date = 'Date is required when scheduling for later'
|
|
|
|
|
}
|
|
|
|
|
form.setErrors(errors)
|
|
|
|
|
if (Object.keys(errors).length) return
|
|
|
|
|
|
|
|
|
|
const payload = {
|
|
|
|
|
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,
|
|
|
|
|
channel_id: channelId,
|
|
|
|
|
mode: v.mode,
|
|
|
|
|
}
|
|
|
|
|
if (v.mode === 'customScheduled') {
|
|
|
|
|
payload.scheduler_date = v.scheduler_date
|
|
|
|
|
if (v.scheduler_time) {
|
|
|
|
|
// Backend expects a time; "HH:MM" is enough for FastAPI's time parser.
|
|
|
|
|
payload.scheduler_time = v.scheduler_time.length === 5
|
|
|
|
|
? `${v.scheduler_time}:00`
|
|
|
|
|
: v.scheduler_time
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
onSubmit(payload)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const field = (name) => ({
|
|
|
|
|
value: form.values[name],
|
|
|
|
|
onChange: (e) => form.setField(name, e.target.value),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Modal
|
|
|
|
|
title="Create New Job"
|
|
|
|
|
subtitle="Creates the requisition and publishes it to Buffer"
|
|
|
|
|
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>Title <span className="req">*</span></label>
|
|
|
|
|
<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>Department</label>
|
|
|
|
|
<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">
|
|
|
|
|
<label>Location</label>
|
|
|
|
|
<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">
|
|
|
|
|
<label>Salary</label>
|
|
|
|
|
<input {...field('salary')} placeholder="Anonymous" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="form-field col-span-2">
|
|
|
|
|
<label>Requirements</label>
|
|
|
|
|
<textarea {...field('requirements')} placeholder="One requirement per line…" rows={3} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="form-field col-span-2">
|
|
|
|
|
<label>Nice to have</label>
|
|
|
|
|
<textarea {...field('optional_skills')} placeholder="One skill per line…" rows={2} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="form-field col-span-2">
|
|
|
|
|
<label>Description</label>
|
|
|
|
|
<textarea {...field('description')} placeholder="Describe the role…" rows={4} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="form-field col-span-2">
|
|
|
|
|
<label>Channel <span className="req">*</span></label>
|
|
|
|
|
<select
|
|
|
|
|
value={channelId}
|
|
|
|
|
className={form.errors.channel_id ? 'err' : ''}
|
|
|
|
|
onChange={(e) => form.setField('channel_id', e.target.value)}
|
|
|
|
|
disabled={channelsLoading || channelsError || channels.length === 0}
|
|
|
|
|
>
|
|
|
|
|
{channelsLoading && <option value="">Loading channels…</option>}
|
|
|
|
|
{channelsError && <option value="">Could not load channels</option>}
|
|
|
|
|
{!channelsLoading && !channelsError && channels.length === 0 && (
|
|
|
|
|
<option value="">No channels connected</option>
|
|
|
|
|
)}
|
|
|
|
|
{channels.map((ch) => (
|
|
|
|
|
<option key={ch.id} value={ch.id}>{channelLabel(ch)}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
<FieldError>{form.errors.channel_id}</FieldError>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="form-field">
|
|
|
|
|
<label>When</label>
|
|
|
|
|
<select {...field('mode')}>
|
|
|
|
|
<option value="addToQueue">Add to queue</option>
|
|
|
|
|
<option value="shareNow">Post now</option>
|
|
|
|
|
<option value="customScheduled">Schedule for later</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
{form.values.mode === 'customScheduled' && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="form-field">
|
|
|
|
|
<label>Date <span className="req">*</span></label>
|
|
|
|
|
<input
|
|
|
|
|
type="date"
|
|
|
|
|
{...field('scheduler_date')}
|
|
|
|
|
className={form.errors.scheduler_date ? 'err' : ''}
|
|
|
|
|
/>
|
|
|
|
|
<FieldError>{form.errors.scheduler_date}</FieldError>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="form-field">
|
|
|
|
|
<label>Time</label>
|
|
|
|
|
<input type="time" {...field('scheduler_time')} />
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<p className="text-muted" style={{ marginTop: 16, fontSize: 13 }}>
|
|
|
|
|
{publishConsequence(selectedChannel, form.values.mode)}
|
|
|
|
|
</p>
|
|
|
|
|
</form>
|
|
|
|
|
</Modal>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function JobDetail({ job: j, onClose, onPublish }) {
|
|
|
|
|
return (
|
|
|
|
|
<Modal
|
|
|
|
|
|