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 && ( +