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) => ( <>Delete “{deleting.title}”?
-- This will permanently remove requisition {deleting.id} and its {deleting.applications} applications. - This action cannot be undone. -
-{j.description}
-{j.description}
+- Workload is recalculated automatically across the recruiter’s assigned requisitions. -
-