/jobs page done
parent
1478e8052e
commit
7ded865ceb
|
|
@ -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(...),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-DklP7LS8.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-C_hfQYIJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ export const qk = {
|
|||
},
|
||||
jobs: {
|
||||
all: () => ['jobs'],
|
||||
list: () => ['jobs', 'list'],
|
||||
list: (p = {}) => ['jobs', 'list', p],
|
||||
},
|
||||
candidates: {
|
||||
all: () => ['candidates'],
|
||||
|
|
|
|||
|
|
@ -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])),
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
|
|
|
|||
|
|
@ -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) => <span className="cell-mono">{j.id}</span> },
|
||||
{
|
||||
key: 'title', label: 'Job Title', sortable: true,
|
||||
render: (j) => (
|
||||
<>
|
||||
<div className="cell-primary">{j.title}</div>
|
||||
<div className="cell-sub">{j.businessUnit} · {j.grade}</div>
|
||||
<div className="cell-sub">{j.department || '—'}</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ key: 'department', label: 'Department', sortable: true },
|
||||
{
|
||||
key: 'manager', label: 'Hiring Manager', sortable: true,
|
||||
render: (j) => (
|
||||
<div className="user-cell"><Avatar name={j.manager} /><span>{j.manager}</span></div>
|
||||
),
|
||||
},
|
||||
{ key: 'location', label: 'Location', sortable: true, render: (j) => <span className="text-muted">{j.location}</span> },
|
||||
{ key: 'type', label: 'Type', render: (j) => <Badge className="b-gray">{j.type}</Badge> },
|
||||
{ key: 'applications', label: 'Apps', sortable: true, align: 'center', render: (j) => <b>{j.applications}</b> },
|
||||
{ key: 'department', label: 'Department', sortable: true, render: (j) => j.department || '—' },
|
||||
{ key: 'location', label: 'Location', sortable: true, render: (j) => <span className="text-muted">{j.location || '—'}</span> },
|
||||
{ key: 'type', label: 'Type', render: (j) => j.type ? <Badge className="b-gray">{j.type}</Badge> : '—' },
|
||||
{ key: 'platform', label: 'Platform', sortable: true, render: (j) => j.platform ? <Badge className="b-gray">{j.platform}</Badge> : '—' },
|
||||
{ key: 'vacancies', label: 'Vacancies', sortable: true, align: 'center', render: (j) => <b>{j.vacancies ?? '—'}</b> },
|
||||
{ key: 'status', label: 'Status', sortable: true, render: (j) => <Badge>{j.status}</Badge> },
|
||||
{ key: 'created', label: 'Created', sortable: true, sortValue: (j) => j.created.getTime(), render: (j) => <span className="text-muted">{fmtShort(j.created)}</span> },
|
||||
{
|
||||
key: 'created', label: 'Created', sortable: true,
|
||||
sortValue: (j) => (j.created ? j.created.getTime() : 0),
|
||||
render: (j) => <span className="text-muted">{j.created ? fmtShort(j.created) : '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: '_a', label: 'Actions', align: 'right',
|
||||
render: (j) => (
|
||||
<div className="row-actions">
|
||||
<button className="act-btn" data-tip="View" onClick={() => setViewing(j)}><Icon name="eye" /></button>
|
||||
<button className="act-btn" data-tip="Publish" onClick={() => navigate('/jobboard', { state: { publishJob: j.id } })}><Icon name="send" /></button>
|
||||
<button className="act-btn" data-tip="Edit" onClick={() => setEditing(j)}><Icon name="edit" /></button>
|
||||
<button className="act-btn danger" data-tip="Delete" onClick={() => setDeleting(j)}><Icon name="trash" /></button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
@ -112,109 +124,61 @@ export default function Jobs() {
|
|||
<button className="btn btn-secondary" onClick={() => toast('Jobs exported to CSV', 'success')}>
|
||||
<Icon name="download" /> Export
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => setEditing(null)}>
|
||||
<Icon name="plus" /> Create Job
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-body" style={{ paddingBottom: 0 }}>
|
||||
<div className="toolbar">
|
||||
<div className="toolbar-search">
|
||||
<Icon name="search" />
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search jobs, IDs, managers…" />
|
||||
</div>
|
||||
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
|
||||
<option value="">All Departments</option>
|
||||
{departments.map((d) => <option key={d}>{d}</option>)}
|
||||
</select>
|
||||
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="">All Status</option>
|
||||
{jobStatuses.map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
|
||||
<option value="">All Types</option>
|
||||
{empTypes.map((t) => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
{jobsQuery.isPending && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="briefcase" title="Loading…">Fetching requisitions from the server.</EmptyState>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable columns={columns} rows={rows} pageSize={8} />
|
||||
)}
|
||||
{jobsQuery.isError && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="briefcase" title="Couldn’t load jobs">
|
||||
{friendlyAuthError(jobsQuery.error, 'Request failed')}
|
||||
</EmptyState>
|
||||
</div>
|
||||
)}
|
||||
{!jobsQuery.isPending && !jobsQuery.isError && (
|
||||
<>
|
||||
<div className="card-body" style={{ paddingBottom: 0 }}>
|
||||
<div className="toolbar">
|
||||
<div className="toolbar-search">
|
||||
<Icon name="search" />
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search title, department, location…" />
|
||||
</div>
|
||||
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
|
||||
<option value="">All Departments</option>
|
||||
{departmentOptions.map((d) => <option key={d}>{d}</option>)}
|
||||
</select>
|
||||
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="">All Status</option>
|
||||
{JOB_STATUSES.map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
|
||||
<option value="">All Types</option>
|
||||
{typeOptions.map((t) => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
pageSize={8}
|
||||
empty="No requisitions match these filters."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{viewing && (
|
||||
<JobDetail
|
||||
job={viewing}
|
||||
onClose={() => 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 && (
|
||||
<JobForm
|
||||
job={editing}
|
||||
managers={managers}
|
||||
recruiters={recruiters}
|
||||
count={jobs.length}
|
||||
onClose={() => 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 && (
|
||||
<Reassign
|
||||
job={reassigning}
|
||||
recruiters={recruiters}
|
||||
onClose={() => setReassigning(null)}
|
||||
onSave={(name) => {
|
||||
updateJobs((js) => js.map((j) => (j.id === reassigning.id ? { ...j, recruiter: name } : j)))
|
||||
setReassigning(null)
|
||||
toast('Recruiter reassigned', 'success')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleting && (
|
||||
<Modal
|
||||
title="Confirm Deletion"
|
||||
onClose={() => setDeleting(null)}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={() => setDeleting(null)}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => {
|
||||
updateJobs((js) => js.filter((j) => j.id !== deleting.id))
|
||||
setDeleting(null)
|
||||
toast('Job deleted', 'success')
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" /> Delete Job
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-16 items-center">
|
||||
<span className="kpi-icn i-red" style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}>
|
||||
<Icon name="trash" />
|
||||
</span>
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, fontSize: 15 }}>Delete “{deleting.title}”?</p>
|
||||
<p className="text-muted" style={{ marginTop: 4 }}>
|
||||
This will permanently remove requisition {deleting.id} and its {deleting.applications} applications.
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Modal
|
||||
title="Job Details"
|
||||
subtitle={j.id}
|
||||
subtitle={j.department || undefined}
|
||||
size="modal-lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Close</button>
|
||||
<button className="btn btn-secondary" onClick={onPublish}><Icon name="send" /> Publish</button>
|
||||
<button className="btn btn-primary" onClick={onEdit}><Icon name="edit" /> Edit Job</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
|
@ -248,273 +208,40 @@ function JobDetail({ job: j, onClose, onEdit, onPublish, onReassign }) {
|
|||
</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 19, fontWeight: 700 }}>{j.title}</div>
|
||||
<div className="text-muted">{j.id} · {j.department} · {j.businessUnit}</div>
|
||||
<div className="text-muted">{[j.department, j.location].filter(Boolean).join(' · ') || '—'}</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto' }}><Badge>{j.status}</Badge></div>
|
||||
</div>
|
||||
|
||||
<div className="info-grid mb-18">
|
||||
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.manager}</div></div>
|
||||
<div className="info-item">
|
||||
<div className="il">Assigned Recruiter</div>
|
||||
<div className="iv flex items-center gap-8">
|
||||
{j.recruiter}
|
||||
{r && <span className={`badge ${loadCls} badge-plain`} style={{ fontSize: 10 }}>{r.workload}% load</span>}
|
||||
<button className="link-btn" onClick={onReassign}>Reassign</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="info-item"><div className="il">Location</div><div className="iv">{j.location}</div></div>
|
||||
<div className="info-item"><div className="il">Employment Type</div><div className="iv">{j.type}</div></div>
|
||||
<div className="info-item"><div className="il">Grade</div><div className="iv">{j.grade}</div></div>
|
||||
<div className="info-item"><div className="il">Vacancies</div><div className="iv">{j.vacancies}</div></div>
|
||||
<div className="info-item"><div className="il">Salary Range</div><div className="iv">{moneyK(j.salaryMin)} – {moneyK(j.salaryMax)}</div></div>
|
||||
<div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience}</div></div>
|
||||
<div className="info-item"><div className="il">Education</div><div className="iv">{j.education}</div></div>
|
||||
<div className="info-item"><div className="il">Deadline</div><div className="iv">{fmtDate(j.deadline)}</div></div>
|
||||
<div className="info-item"><div className="il">Department</div><div className="iv">{j.department || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Location</div><div className="iv">{j.location || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Employment Type</div><div className="iv">{j.type || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Platform</div><div className="iv">{j.platform || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Vacancies</div><div className="iv">{j.vacancies ?? '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Salary</div><div className="iv">{j.salary || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Created</div><div className="iv">{j.created ? fmtShort(j.created) : '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Created by</div><div className="iv">{j.createdByName || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{j.recruiter || '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Closed at</div><div className="iv">{j.closedAt ? fmtShort(j.closedAt) : '—'}</div></div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={SECTION_LABEL}>Description</div>
|
||||
<p style={{ color: 'var(--text-2)' }}>{j.description}</p>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={SECTION_LABEL}>Key Responsibilities</div>
|
||||
<ul style={{ paddingLeft: 18, color: 'var(--text-2)' }}>
|
||||
{j.responsibilities.map((x) => <li key={x}>{x}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={SECTION_LABEL}>Required Skills</div>
|
||||
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={SECTION_LABEL}>Benefits</div>
|
||||
<div className="k-tags">{j.benefits.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
<div className="flex items-center gap-12">
|
||||
<span className="text-muted text-sm">Hiring progress</span>
|
||||
<div style={{ flex: 1 }}><ProgressBar pct={j.progress} /></div>
|
||||
<span className="fw-600">{j.progress}%</span>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function Reassign({ job, recruiters, onClose, onSave }) {
|
||||
const [name, setName] = useState(job.recruiter)
|
||||
return (
|
||||
<Modal
|
||||
title="Reassign Recruiter"
|
||||
subtitle={job.title}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
{j.description && (
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={() => onSave(name)}>Reassign</button>
|
||||
<div className="divider" />
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={SECTION_LABEL}>Description</div>
|
||||
<p style={{ color: 'var(--text-2)' }}>{j.description}</p>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="form-field">
|
||||
<label>Assigned Recruiter</label>
|
||||
<select value={name} onChange={(e) => setName(e.target.value)}>
|
||||
{recruiters.map((r) => (
|
||||
<option key={r.id} value={r.name}>{r.name} — {r.workload}% load</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-muted text-sm" style={{ marginTop: 10 }}>
|
||||
Workload is recalculated automatically across the recruiter’s assigned requisitions.
|
||||
</p>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
title={isEdit ? 'Edit Job' : 'Create New Job'}
|
||||
subtitle={isEdit ? job.id : 'Fill in the details to post a requisition'}
|
||||
size="modal-lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={submit}>
|
||||
<Icon name="check" /> {isEdit ? 'Save Changes' : 'Create Job'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
|
||||
<div className="form-grid">
|
||||
<div className="form-field col-span-2">
|
||||
<label>Job Title <span className="req">*</span></label>
|
||||
<input {...field('title')} className={form.errors.title ? 'err' : ''} placeholder="e.g. Senior Product Designer" />
|
||||
<FieldError>{form.errors.title}</FieldError>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label>Department <span className="req">*</span></label>
|
||||
<select {...field('department')}>{departments.map((d) => <option key={d}>{d}</option>)}</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Business Unit</label>
|
||||
<select {...field('businessUnit')}>{businessUnits.map((b) => <option key={b}>{b}</option>)}</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Grade</label>
|
||||
<select {...field('grade')}>{grades.map((g) => <option key={g}>{g}</option>)}</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Employment Type</label>
|
||||
<select {...field('type')}>{empTypes.map((t) => <option key={t}>{t}</option>)}</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Hiring Manager <span className="req">*</span></label>
|
||||
<select {...field('manager')}>{managers.map((m) => <option key={m.id}>{m.name}</option>)}</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Recruiter <span className="req">*</span></label>
|
||||
<select {...field('recruiter')}>{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}</select>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label>Salary Min ($) <span className="req">*</span></label>
|
||||
<input type="number" {...field('salaryMin')} className={form.errors.salaryMin ? 'err' : ''} placeholder="90000" />
|
||||
<FieldError>{form.errors.salaryMin}</FieldError>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Salary Max ($)</label>
|
||||
<input type="number" {...field('salaryMax')} placeholder="130000" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Experience</label>
|
||||
<input {...field('experience')} placeholder="5+ years" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Education</label>
|
||||
<select {...field('education')}>{educationLevels.map((e) => <option key={e}>{e}</option>)}</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Location <span className="req">*</span></label>
|
||||
<select {...field('location')}>{locations.map((l) => <option key={l}>{l}</option>)}</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Vacancies</label>
|
||||
<input type="number" min="1" {...field('vacancies')} />
|
||||
</div>
|
||||
|
||||
<div className="form-field col-span-2">
|
||||
<label>Job Description <span className="req">*</span></label>
|
||||
<textarea {...field('description')} className={form.errors.description ? 'err' : ''} placeholder="Describe the role…" />
|
||||
<FieldError>{form.errors.description}</FieldError>
|
||||
</div>
|
||||
<div className="form-field col-span-2">
|
||||
<label>Responsibilities</label>
|
||||
<textarea {...field('responsibilities')} placeholder="One per line…" />
|
||||
</div>
|
||||
<div className="form-field col-span-2">
|
||||
<label>Required Skills</label>
|
||||
<input {...field('skills')} placeholder="React, TypeScript, System Design" />
|
||||
</div>
|
||||
<div className="form-field col-span-2">
|
||||
<label>Benefits</label>
|
||||
<input {...field('benefits')} placeholder="Equity, 401(k), Unlimited PTO" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Deadline</label>
|
||||
<input type="date" {...field('deadline')} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Status</label>
|
||||
<select {...field('status')}>{jobStatuses.map((s) => <option key={s}>{s}</option>)}</select>
|
||||
</div>
|
||||
)}
|
||||
{!!(j.skills && j.skills.length) && (
|
||||
<div>
|
||||
<div style={SECTION_LABEL}>Required Skills</div>
|
||||
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue