add job create

pull/14/head
ahmed.mujtaba 2026-08-12 20:04:45 +05:00
parent 7ded865ceb
commit 6463b7f377
5 changed files with 319 additions and 10 deletions

View File

@ -243,7 +243,7 @@ async def post_job(
try:
service=JobPost(session=session)
data=payload.model_dump()
if data['mode']=="customScheduled":
if data['mode']=="customScheduled" and data.get('scheduler_date'):
data['due_at']=datetime.combine(
data['scheduler_date'],
data['scheduler_time'] or time(0, 0, 0),

View File

@ -31,7 +31,8 @@ class JobPostCreate(BaseModel):
salary: str = "Anonymous"
location: str | None = None
employment_type: str | None = None
platform: str = "linkedin"
department: str | None = None
vacancies: int = 1
description: str | None = None
platform: str | None = None
channel_id: str | None = None
@ -45,8 +46,8 @@ class JobPostCreate(BaseModel):
allowed = {"addToQueue", "shareNow", "customScheduled"}
if self.mode not in allowed:
raise ValueError(f"mode must be one of {sorted(allowed)}")
if self.mode == "customScheduled" and not self.due_at:
raise ValueError("due_at is required when mode is customScheduled")
if self.mode == "customScheduled" and not self.due_at and not self.scheduler_date:
raise ValueError("due_at or scheduler_date is required when mode is customScheduled")
return self
class JobPost:
@ -90,6 +91,9 @@ class JobPost:
"requirements":list(payload.get("requirements") or []),
"optional_skills":list(payload.get("optional_skills") or []),
"salary":payload.get("salary") or "Anonymous",
# department is NOT NULL with a server_default of "" — pass "", never None.
"department":payload.get("department") or "",
"vacancies":payload.get("vacancies") or 1,
"description":payload.get("description"),
"post_text":text,
"channel_id":channel_id,

View File

@ -23,7 +23,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
<script type="module" crossorigin src="/assets/index-C_hfQYIJ.js"></script>
<script type="module" crossorigin src="/assets/index-Dz75jpEA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
</head>
<body>

View File

@ -18,3 +18,19 @@ export function list({ search, top, skip, ids, activeOnly = true } = {}) {
},
})
}
/**
* Create a job post backend/job/app.py `POST /job/post-job` (job_board.create).
*
* CREATES the row AND publishes it through Buffer; there is no draft-only path.
* A 502 means the row was created but the Buffer post failed (post_job marks it
* status="failed" before re-raising), so do not report it as "nothing happened".
*/
export function create(payload) {
return request('/job/post-job', { method: 'POST', body: payload })
}
/** Connected Buffer channels — GET /job/buffer/channels (job_board.view). */
export function listChannels() {
return request('/job/buffer/channels')
}

View File

@ -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