diff --git a/backend/job/app.py b/backend/job/app.py index cf18f5c..a880fe8 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -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), diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index 06fafe7..7a3a271 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -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, diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 1eb3f03..f972f04 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -23,7 +23,7 @@ - + diff --git a/frontend/src/api/jobPosts.js b/frontend/src/api/jobPosts.js index 6d49764..63c29e1 100644 --- a/frontend/src/api/jobPosts.js +++ b/frontend/src/api/jobPosts.js @@ -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') +} diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx index 2c3b88e..c9cc71e 100644 --- a/frontend/src/screens/Jobs.jsx +++ b/frontend/src/screens/Jobs.jsx @@ -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() { + {can('job_board.create') && ( + + )} @@ -179,6 +225,18 @@ export default function Jobs() { onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }} /> )} + + {creating && ( + setCreating(false)} + onSubmit={(payload) => createJob.mutate(payload)} + /> + )} ) } @@ -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 ( + + + + + } + > +
{ e.preventDefault(); submit() }}> +
+
+ + + {form.errors.title} +
+ +
+ + + + {departmentOptions.map((d) => +
+
+ + +
+
+ + +
+
+ + + {form.errors.vacancies} +
+ +
+ + + {form.errors.experience_min} +
+
+ + + {form.errors.experience_max} +
+
+ + +
+ +
+ +