diff --git a/backend/job/app.py b/backend/job/app.py index b708d6f..cf18f5c 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -13,8 +13,6 @@ from job.cost.views import HiringCost from sqlalchemy.ext.asyncio import AsyncSession from users.permissions import PermissionTag, require_permission from job.job_post.views import JobPost,JobPostCreate -from job.job_post.models import JobPosts -from job.job_post.serializers import serialize_job_post import logging from users.views import User from job.job_post.plugins import PlatformAlias @@ -365,6 +363,34 @@ async def fetch_job_posts( raise HTTPException(status_code=500,detail=str(e)) +@router.get("/jobs/fetch") +async def fetch_jobs( + search: str | None = Query(None), + department: str | None = Query(None), + requisition_status: str | None = Query(None), + employment_type: str | None = Query(None), + top: int | None = Query(None), + skip: int = Query(0, ge=0), + # Defaults False, unlike /job/fetch: a requisition list must show CLOSED + # requisitions, and those carry is_active = false. Soft-deleted rows are still + # excluded by the include_deleted branch in fetch_job_posts. + active_only: bool = Query(False), + current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=JobPost(session=session) + data,total=await service.fetch_jobs( + search=search,department=department,requisition_status=requisition_status, + employment_type=employment_type,top=top,skip=skip,active_only=active_only, + ) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.get("/candidate/fetch_by_id") async def fetch_candidate_by_id( candidate_id: str = Query(...), diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index b688739..0d954ab 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -111,6 +111,10 @@ class JobPosts(SQLModel, table=True): skip: int = 0, ids: list[str] | None = None, active_only: bool = True, + include_deleted: bool = False, + department: str | None = None, + requisition_status: str | None = None, + employment_type: str | None = None, ): if ids: rows = await cls.get_by_ids(session, ids, active_only=active_only) @@ -119,11 +123,19 @@ class JobPosts(SQLModel, table=True): statement = select(cls) if active_only: statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712 + elif not include_deleted: + statement = statement.where(cls.is_deleted == False) # noqa: E712 if search: like = f"%{search.strip()}%" statement = statement.where( - or_(cls.title.ilike(like), cls.location.ilike(like)) + or_(cls.title.ilike(like), cls.location.ilike(like), cls.department.ilike(like)) ) + if department: + statement = statement.where(cls.department == department) + if requisition_status: + statement = statement.where(cls.requisition_status == requisition_status) + if employment_type: + statement = statement.where(cls.employment_type == employment_type) count_statement = select(func.count()).select_from(statement.subquery()) total = (await session.execute(count_statement)).scalar_one() statement = statement.order_by(cls.created_at.desc()) @@ -134,6 +146,23 @@ class JobPosts(SQLModel, table=True): result = await session.execute(statement) return list(result.scalars().all()), total + @classmethod + async def recruiter_names(cls, session: AsyncSession, recruiter_ids) -> dict[str, str]: + """Resolve {recruiter_id: name} for a page of rows in a single query.""" + # Local import and COLUMN select, both load-bearing: users.models imports + # this module at its top, so a module-level import here is a startup cycle; + # and a Users *entity* would drag in its five selectin relations for what is + # a two-column lookup. + from users.models import Users + + uids = {u for u in (recruiter_ids or []) if u} + if not uids: + return {} + result = await session.execute( + select(Users.id, Users.name).where(Users.id.in_(uids)) + ) + return {str(uid): name for uid, name in result.all()} + @classmethod async def insert_job_post(cls, session: AsyncSession, fields: dict): row = cls(**fields) diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index 53d737a..65220c2 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -25,3 +25,39 @@ def serialize_job_post(row) -> dict: "created_at": row.created_at.isoformat() if row.created_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, } + + +def serialize_job_row(row, *, recruiter_name=None) -> dict: + """Requisition view of a job post, for the Jobs screen. + + Deliberately separate from serialize_job_post: that payload is shared by the + inbox, candidate and matching paths, and widening it would change five + response shapes at once. + """ + return { + "id": str(row.id), + "title": row.title, + "department": row.department or None, + "location": row.location, + "employment_type": row.employment_type, + "vacancies": row.vacancies, + "platform": row.platform or None, + # Two different lifecycles, never conflate: requisition_status is hiring + # (open/closed/on_hold), status is Buffer publishing (draft/scheduled/...). + "requisition_status": row.requisition_status, + "status": row.status, + "experience_min": row.experience_min, + "experience_max": row.experience_max, + "salary": row.salary, + "requirements": list(row.requirements or []), + "optional_skills": list(row.optional_skills or []), + "description": row.description, + "is_active": row.is_active, + "closed_at": row.closed_at.isoformat() if row.closed_at else None, + "current_recruiter_id": str(row.current_recruiter_id) if row.current_recruiter_id else None, + "recruiter_name": recruiter_name, + "created_by": str(row.created_by) if row.created_by else None, + "created_by_name": row.user.name if getattr(row, "user", None) else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index a01bcb0..06fafe7 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -17,7 +17,7 @@ from job.job_post.plugins import ( render_job_post, resolve_channel, ) -from job.job_post.serializers import serialize_job_post +from job.job_post.serializers import serialize_job_post, serialize_job_row load_dotenv() @@ -143,3 +143,18 @@ class JobPost: active_only=active_only, ) return [serialize_job_post(r) for r in rows],total + + async def fetch_jobs(self,search=None,department=None,requisition_status=None, + employment_type=None,top=None,skip=0,active_only=True): + rows,total=await JobPosts.fetch_job_posts( + self.session,search=search,top=top,skip=skip,active_only=active_only, + department=department,requisition_status=requisition_status, + employment_type=employment_type, + ) + names=await JobPosts.recruiter_names( + self.session,[r.current_recruiter_id for r in rows], + ) + return [ + serialize_job_row(r,recruiter_name=names.get(str(r.current_recruiter_id))) + for r in rows + ],total diff --git a/frontend/dist/index.html b/frontend/dist/index.html index ca0d247..1eb3f03 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -23,7 +23,7 @@ - + diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js new file mode 100644 index 0000000..e089abe --- /dev/null +++ b/frontend/src/api/jobs.js @@ -0,0 +1,61 @@ +import { request } from '../lib/apiClient' + +/** + * Job requisitions — backend/job/app.py `GET /jobs/fetch`. + * + * Distinct from api/jobPosts.js on purpose: that serves the Matching and CV-import + * PICKERS off /job/fetch (job_board.view). This is the requisition list behind + * jobs.view, and carries department / vacancies / requisition_status, which the + * picker payload does not. + */ +export function list({ search, department, requisitionStatus, employmentType, + top, skip, activeOnly } = {}) { + return request('/jobs/fetch', { + params: { + search, + department, + requisition_status: requisitionStatus, + employment_type: employmentType, + top, + skip, + active_only: activeOnly, + }, + }) +} + +/* requisition_status is the HIRING lifecycle. The row's separate `status` field is + the Buffer publishing lifecycle — never map the two onto one badge. */ +const REQ_STATUS_LABEL = { open: 'Open', closed: 'Closed', on_hold: 'On Hold' } +export const JOB_STATUSES = Object.values(REQ_STATUS_LABEL) + +function experienceLabel(min, max) { + if (min == null && max == null) return null + if (min != null && max != null) return `${min}–${max} years` + return `${min ?? max}+ years` +} + +/** API row -> what the Jobs table and detail modal render. */ +export function toJobView(row) { + return { + id: row.id, + title: row.title, + department: row.department, + location: row.location, + type: row.employment_type, + vacancies: row.vacancies, + platform: row.platform || null, + status: REQ_STATUS_LABEL[row.requisition_status] ?? row.requisition_status, + publishStatus: row.status, + recruiter: row.recruiter_name, + recruiterId: row.current_recruiter_id, + createdByName: row.created_by_name, + // A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working. + created: row.created_at ? new Date(row.created_at) : null, + closedAt: row.closed_at ? new Date(row.closed_at) : null, + experience: experienceLabel(row.experience_min, row.experience_max), + salary: row.salary, + skills: row.requirements ?? [], + optionalSkills: row.optional_skills ?? [], + description: row.description, + } +} diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 044a2bc..8df4a7c 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -29,7 +29,7 @@ export const qk = { }, jobs: { all: () => ['jobs'], - list: () => ['jobs', 'list'], + list: (p = {}) => ['jobs', 'list', p], }, candidates: { all: () => ['candidates'], diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 6144436..f09848a 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -93,7 +93,7 @@ export default function Candidates() { const navigate = useNavigate() const candidatesQuery = useQuery({ queryKey: qk.candidates.list(), queryFn: fetchCandidates }) - const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs }) + const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs }) const candidates = useMemo(() => candidatesQuery.data ?? [], [candidatesQuery.data]) const jobsById = useMemo( () => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])), diff --git a/frontend/src/screens/CvImport.jsx b/frontend/src/screens/CvImport.jsx index e188e4f..63a4b23 100644 --- a/frontend/src/screens/CvImport.jsx +++ b/frontend/src/screens/CvImport.jsx @@ -42,7 +42,7 @@ let rowSeq = 0 export default function CvImport() { const { toast } = useToast() const qc = useQueryClient() - const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs }) + const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs }) const jobs = jobsQuery.data ?? [] const [jobId, setJobId] = useState('') diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx index aa1d5a9..2c3b88e 100644 --- a/frontend/src/screens/Jobs.jsx +++ b/frontend/src/screens/Jobs.jsx @@ -1,7 +1,10 @@ /* ============================================================ - Jobs — the reference CRUD pattern for the app: filtered DataTable plus - view / reassign / create-edit / delete modals. The other CRUD screens follow - this shape. + 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 / edit / delete stay off this screen until real write endpoints exist; + publishing still routes to /jobboard. ============================================================ */ import { useEffect, useMemo, useState } from 'react' @@ -10,24 +13,29 @@ import { useQuery } from '@tanstack/react-query' import DataTable from '../ui/DataTable' import Modal from '../ui/Modal' -import { Avatar, Badge, FieldError, Icon, ProgressBar } from '../ui/primitives' +import { Badge, EmptyState, Icon } from '../ui/primitives' import { useToast } from '../ui/Toast' -import { useFormState } from '../components/AuthLayout' -import { seedQuery, useSeedMutation } from '../data/seedQueries' -import { - businessUnits, departments, educationLevels, empTypes, fmtDate, fmtShort, - getRecruiterByName, grades, jobStatuses, locations, moneyK, TODAY, -} from '../data/seed' +import { qk } from '../lib/queryKeys' +import { friendlyAuthError } from '../lib/errors' +import * as jobsApi from '../api/jobs' +import { JOB_STATUSES } from '../api/jobs' +import { 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) +} export default function Jobs() { const { toast } = useToast() const navigate = useNavigate() const location = useLocation() - const { data: jobs = [] } = useQuery(seedQuery('jobs')) - const { data: managers = [] } = useQuery(seedQuery('managers')) - const { data: recruiters = [] } = useQuery(seedQuery('recruiters')) - const updateJobs = useSeedMutation('jobs') + const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs }) + const jobs = useMemo(() => jobsQuery.data ?? [], [jobsQuery.data]) const [q, setQ] = useState('') const [dept, setDept] = useState('') @@ -35,18 +43,23 @@ export default function Jobs() { const [type, setType] = useState('') const [viewing, setViewing] = useState(null) - const [editing, setEditing] = useState(undefined) // undefined = closed, null = create - const [reassigning, setReassigning] = useState(null) - const [deleting, setDeleting] = useState(null) // Deep-link intents from global search, the dashboard and the manager portal. useEffect(() => { const st = location.state if (!st) return - if (st.openCreate) setEditing(null) if (st.openJob) setViewing(jobs.find((j) => j.id === st.openJob) ?? null) }, [location.state, jobs]) + 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) => { @@ -55,7 +68,10 @@ export default function Jobs() { if (type && j.type !== type) return false if (q) { const term = q.toLowerCase() - const hay = (j.title + j.id + j.department + j.manager + j.recruiter + j.location).toLowerCase() + const hay = [j.title, j.department, j.recruiter, j.location] + .filter(Boolean) + .join(' ') + .toLowerCase() if (!hay.includes(term)) return false } return true @@ -66,36 +82,32 @@ export default function Jobs() { const openCount = jobs.filter((j) => j.status === 'Open').length const columns = [ - { key: 'id', label: 'Job ID', sortable: true, render: (j) => {j.id} }, { key: 'title', label: 'Job Title', sortable: true, render: (j) => ( <>
{j.title}
-
{j.businessUnit} · {j.grade}
+
{j.department || '—'}
), }, - { key: 'department', label: 'Department', sortable: true }, - { - key: 'manager', label: 'Hiring Manager', sortable: true, - render: (j) => ( -
{j.manager}
- ), - }, - { key: 'location', label: 'Location', sortable: true, render: (j) => {j.location} }, - { key: 'type', label: 'Type', render: (j) => {j.type} }, - { key: 'applications', label: 'Apps', sortable: true, align: 'center', render: (j) => {j.applications} }, + { key: 'department', label: 'Department', sortable: true, render: (j) => j.department || '—' }, + { key: 'location', label: 'Location', sortable: true, render: (j) => {j.location || '—'} }, + { key: 'type', label: 'Type', render: (j) => j.type ? {j.type} : '—' }, + { key: 'platform', label: 'Platform', sortable: true, render: (j) => j.platform ? {j.platform} : '—' }, + { key: 'vacancies', label: 'Vacancies', sortable: true, align: 'center', render: (j) => {j.vacancies ?? '—'} }, { key: 'status', label: 'Status', sortable: true, render: (j) => {j.status} }, - { key: 'created', label: 'Created', sortable: true, sortValue: (j) => j.created.getTime(), render: (j) => {fmtShort(j.created)} }, + { + key: 'created', label: 'Created', sortable: true, + sortValue: (j) => (j.created ? j.created.getTime() : 0), + render: (j) => {j.created ? fmtShort(j.created) : '—'}, + }, { key: '_a', label: 'Actions', align: 'right', render: (j) => (
- -
), }, @@ -112,109 +124,61 @@ export default function Jobs() { -
-
-
-
- - setQ(e.target.value)} placeholder="Search jobs, IDs, managers…" /> -
- - - + {jobsQuery.isPending && ( +
+ Fetching requisitions from the server.
-
- + )} + {jobsQuery.isError && ( +
+ + {friendlyAuthError(jobsQuery.error, 'Request failed')} + +
+ )} + {!jobsQuery.isPending && !jobsQuery.isError && ( + <> +
+
+
+ + setQ(e.target.value)} placeholder="Search title, department, location…" /> +
+ + + +
+
+ + + )}
{viewing && ( setViewing(null)} - onEdit={() => { const j = viewing; setViewing(null); setEditing(j) }} onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }} - onReassign={() => { const j = viewing; setViewing(null); setReassigning(j) }} /> )} - - {editing !== undefined && ( - setEditing(undefined)} - onSave={(next, isEdit) => { - updateJobs((js) => (isEdit ? js.map((j) => (j.id === next.id ? next : j)) : [next, ...js])) - setEditing(undefined) - toast(isEdit ? 'Job updated successfully' : 'Job created successfully', 'success') - }} - onInvalid={() => toast('Please fix the highlighted fields', 'error')} - /> - )} - - {reassigning && ( - setReassigning(null)} - onSave={(name) => { - updateJobs((js) => js.map((j) => (j.id === reassigning.id ? { ...j, recruiter: name } : j))) - setReassigning(null) - toast('Recruiter reassigned', 'success') - }} - /> - )} - - {deleting && ( - setDeleting(null)} - footer={ - <> - - - - } - > -
- - - -
-

Delete “{deleting.title}”?

-

- This will permanently remove requisition {deleting.id} and its {deleting.applications} applications. - This action cannot be undone. -

-
-
-
- )}
) } @@ -224,21 +188,17 @@ const SECTION_LABEL = { textTransform: 'uppercase', marginBottom: 6, } -function JobDetail({ job: j, onClose, onEdit, onPublish, onReassign }) { - const r = getRecruiterByName(j.recruiter) - const loadCls = r ? (r.workload > 80 ? 'b-red' : r.workload > 60 ? 'b-amber' : 'b-green') : '' - +function JobDetail({ job: j, onClose, onPublish }) { return ( - } > @@ -248,273 +208,40 @@ function JobDetail({ job: j, onClose, onEdit, onPublish, onReassign }) {
{j.title}
-
{j.id} · {j.department} · {j.businessUnit}
+
{[j.department, j.location].filter(Boolean).join(' · ') || '—'}
{j.status}
-
Hiring Manager
{j.manager}
-
-
Assigned Recruiter
-
- {j.recruiter} - {r && {r.workload}% load} - -
-
-
Location
{j.location}
-
Employment Type
{j.type}
-
Grade
{j.grade}
-
Vacancies
{j.vacancies}
-
Salary Range
{moneyK(j.salaryMin)} – {moneyK(j.salaryMax)}
-
Experience
{j.experience}
-
Education
{j.education}
-
Deadline
{fmtDate(j.deadline)}
+
Department
{j.department || '—'}
+
Location
{j.location || '—'}
+
Employment Type
{j.type || '—'}
+
Platform
{j.platform || '—'}
+
Vacancies
{j.vacancies ?? '—'}
+
Salary
{j.salary || '—'}
+
Experience
{j.experience || '—'}
+
Created
{j.created ? fmtShort(j.created) : '—'}
+
Created by
{j.createdByName || '—'}
+
Assigned Recruiter
{j.recruiter || '—'}
+
Closed at
{j.closedAt ? fmtShort(j.closedAt) : '—'}
-
-
-
Description
-

{j.description}

-
-
-
Key Responsibilities
-
    - {j.responsibilities.map((x) =>
  • {x}
  • )} -
-
-
-
Required Skills
-
{j.skills.map((s) => {s})}
-
-
-
Benefits
-
{j.benefits.map((s) => {s})}
-
- -
-
- Hiring progress -
- {j.progress}% -
- - ) -} - -function Reassign({ job, recruiters, onClose, onSave }) { - const [name, setName] = useState(job.recruiter) - return ( - - - +
+
+
Description
+

{j.description}

+
- } - > -
- - -
-

- Workload is recalculated automatically across the recruiter’s assigned requisitions. -

- - ) -} - -function JobForm({ job, managers, recruiters, count, onClose, onSave, onInvalid }) { - const isEdit = Boolean(job) - const form = useFormState({ - title: job?.title ?? '', - department: job?.department ?? departments[0], - businessUnit: job?.businessUnit ?? businessUnits[0], - grade: job?.grade ?? grades[0], - type: job?.type ?? empTypes[0], - manager: job?.manager ?? managers[0]?.name ?? '', - recruiter: job?.recruiter ?? recruiters[0]?.name ?? '', - salaryMin: job?.salaryMin ?? '', - salaryMax: job?.salaryMax ?? '', - experience: job?.experience ?? '', - education: job?.education ?? educationLevels[0], - location: job?.location ?? locations[0], - vacancies: job?.vacancies ?? 1, - description: job?.description ?? '', - responsibilities: job ? job.responsibilities.join('\n') : '', - skills: job ? job.skills.join(', ') : '', - benefits: job ? job.benefits.join(', ') : '', - deadline: '', - status: job?.status ?? 'Open', - }) - - function submit() { - const v = form.values - const errors = {} - if (!v.title.trim()) errors.title = 'Job title is required' - if (!v.description.trim()) errors.description = 'Description is required' - if (!v.salaryMin || Number(v.salaryMin) <= 0) errors.salaryMin = 'Enter a valid amount' - form.setErrors(errors) - if (Object.keys(errors).length) { - onInvalid() - return - } - - const skills = v.skills.split(',').map((s) => s.trim()).filter(Boolean) - const benefits = v.benefits.split(',').map((s) => s.trim()).filter(Boolean) - const responsibilities = v.responsibilities.split('\n').map((s) => s.trim()).filter(Boolean) - const salaryMin = Number(v.salaryMin) - const salaryMax = Number(v.salaryMax) || salaryMin + 20000 - - if (isEdit) { - onSave( - { - ...job, - title: v.title, department: v.department, businessUnit: v.businessUnit, grade: v.grade, - type: v.type, manager: v.manager, recruiter: v.recruiter, salaryMin, salaryMax, - experience: v.experience, education: v.education, location: v.location, - vacancies: Number(v.vacancies) || 1, description: v.description, responsibilities, - skills: skills.length ? skills : job.skills, - benefits: benefits.length ? benefits : job.benefits, - status: v.status, - }, - true, - ) - return - } - - onSave( - { - id: `JOB-${1001 + count}`, - title: v.title, department: v.department, businessUnit: v.businessUnit, grade: v.grade, - manager: v.manager, managerId: '', recruiter: v.recruiter, recruiterId: '', - location: v.location, type: v.type, vacancies: Number(v.vacancies) || 1, - applications: 0, status: v.status, created: new Date(TODAY), - deadline: v.deadline ? new Date(v.deadline) : new Date('2026-08-09'), - salaryMin, salaryMax, - experience: v.experience || '3+ years', education: v.education, - skills, benefits, description: v.description, - responsibilities: responsibilities.length ? responsibilities : ['Own key projects'], - progress: 0, - }, - false, - ) - } - - const field = (name) => ({ - value: form.values[name], - onChange: (e) => form.setField(name, e.target.value), - }) - - return ( - - - - - } - > -
{ e.preventDefault(); submit() }}> -
-
- - - {form.errors.title} -
- -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
- - - {form.errors.salaryMin} -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
- -