/jobs page done

pull/14/head
ahmed.mujtaba 2026-08-12 19:47:03 +05:00
parent 1478e8052e
commit 7ded865ceb
10 changed files with 288 additions and 394 deletions

View File

@ -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(...),

View File

@ -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)

View File

@ -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,
}

View File

@ -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

View File

@ -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>

61
frontend/src/api/jobs.js Normal file
View File

@ -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,
}
}

View File

@ -29,7 +29,7 @@ export const qk = {
},
jobs: {
all: () => ['jobs'],
list: () => ['jobs', 'list'],
list: (p = {}) => ['jobs', 'list', p],
},
candidates: {
all: () => ['candidates'],

View File

@ -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])),

View File

@ -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('')

View File

@ -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="Couldnt 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 recruiters 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>
)
}