job post and deaprtrment li kage
parent
eee2071ac2
commit
f86c908076
|
|
@ -2,7 +2,6 @@ from enum import Enum
|
|||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import date
|
||||
import uuid
|
||||
|
||||
class EmploymentType(str,Enum):
|
||||
PERMANENT = "permanent"
|
||||
|
|
@ -11,7 +10,7 @@ class EmploymentType(str,Enum):
|
|||
INTERNEE="internee"
|
||||
|
||||
class Position(BaseModel):
|
||||
department_id:Optional[uuid.UUID]=None
|
||||
department:Optional[str]
|
||||
title:Optional[str]
|
||||
date:Optional[date]
|
||||
date_needed:Optional[date]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from sqlmodel import Field, Relationship, SQLModel, select
|
|||
|
||||
from candidate_forms.enums import EmploymentType
|
||||
if TYPE_CHECKING:
|
||||
from department.models import Department
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
|
||||
|
|
@ -19,8 +18,7 @@ class Requisition(SQLModel, table=True):
|
|||
__tablename__ = "requisitions"
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
|
||||
department_id: Optional[uuid.UUID] = Field(default=None, foreign_key="departments.id")
|
||||
department: Optional["Department"] = Relationship(back_populates="requisitions", sa_relationship_kwargs={"lazy": "selectin"})
|
||||
department: Optional[str] = None
|
||||
position_title: Optional[str] = None
|
||||
date: Optional[Date] = None
|
||||
date_needed: Optional[Date] = None
|
||||
|
|
@ -110,7 +108,6 @@ class Requisition(SQLModel, table=True):
|
|||
live job post. Pass `job_post_id` when editing so that job's current
|
||||
requisition stays in the list until the link is cleared.
|
||||
"""
|
||||
from department.models import Department
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
statement = select(cls).where(cls.is_deleted == False) # noqa: E712
|
||||
|
|
@ -126,7 +123,7 @@ class Requisition(SQLModel, table=True):
|
|||
if term:
|
||||
like = f"%{term}%"
|
||||
statement = statement.where(
|
||||
or_(cls.position_title.ilike(like), cls.department.has(Department.name.ilike(like)))
|
||||
or_(cls.position_title.ilike(like), cls.department.ilike(like))
|
||||
)
|
||||
limit = max(1, min(int(top or 50), 100))
|
||||
statement = statement.order_by(cls.created_at.desc(), cls.id.desc()).limit(limit)
|
||||
|
|
@ -139,7 +136,7 @@ class Requisition(SQLModel, table=True):
|
|||
replacement = fields.get("replacement_for") if fields.get("replacement_for") else {}
|
||||
referral = fields.get("refferal_by") if fields.get("refferal_by") else {}
|
||||
row = cls(
|
||||
department_id=position.get("department_id") if position.get("department_id") else None,
|
||||
department=position.get("department") if position.get("department") else None,
|
||||
position_title=position.get("title") if position.get("title") else None,
|
||||
date=position.get("date") if position.get("date") else None,
|
||||
date_needed=position.get("date_needed") if position.get("date_needed") else None,
|
||||
|
|
@ -181,8 +178,8 @@ class Requisition(SQLModel, table=True):
|
|||
return None
|
||||
if "position" in fields:
|
||||
position = fields.get("position") if fields.get("position") else {}
|
||||
if "department_id" in position:
|
||||
row.department_id = position.get("department_id") if position.get("department_id") else None
|
||||
if "department" in position:
|
||||
row.department = position.get("department") if position.get("department") else None
|
||||
if "title" in position:
|
||||
row.position_title = position.get("title") if position.get("title") else None
|
||||
if "date" in position:
|
||||
|
|
@ -247,8 +244,6 @@ class Requisition(SQLModel, table=True):
|
|||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
# department_id may have changed; reload the relationship so the response names the new department.
|
||||
await session.refresh(row, ["department"])
|
||||
return row
|
||||
|
||||
class CandidateForms(SQLModel, table=True):
|
||||
|
|
|
|||
|
|
@ -66,12 +66,11 @@ def _enum(value):
|
|||
def serialize_requisition_option(row) -> dict:
|
||||
"""Compact row for a searchable picker: `{job title} - {department}`."""
|
||||
title = (row.position_title or "").strip()
|
||||
department = (row.department.name if row.department else "").strip()
|
||||
department = (row.department or "").strip()
|
||||
return {
|
||||
"id": str(row.id) if row.id else None,
|
||||
"title": row.position_title,
|
||||
"department_id": str(row.department_id) if row.department_id else None,
|
||||
"department": row.department.name if row.department else None,
|
||||
"department": row.department,
|
||||
"label": f"{title or 'Untitled'} - {department or '—'}",
|
||||
}
|
||||
|
||||
|
|
@ -80,8 +79,7 @@ def serialize_requisition(row) -> dict:
|
|||
return {
|
||||
"id": str(row.id) if row.id else None,
|
||||
"position": {
|
||||
"department_id": str(row.department_id) if row.department_id else None,
|
||||
"department": row.department.name if row.department else None,
|
||||
"department": row.department,
|
||||
"title": row.position_title,
|
||||
"date": _date(row.date),
|
||||
"date_needed": _date(row.date_needed),
|
||||
|
|
|
|||
|
|
@ -147,8 +147,8 @@ async def fetch_department_names(
|
|||
current_user: dict = Depends(
|
||||
require_permission(
|
||||
PermissionTag.DEPARTMENT_VIEW,
|
||||
PermissionTag.REQUISITIONS_CREATE,
|
||||
PermissionTag.REQUISITIONS_EDIT,
|
||||
PermissionTag.JOB_BOARD_CREATE,
|
||||
PermissionTag.JOBS_EDIT,
|
||||
require_all=False,
|
||||
)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ class Department(SQLModel, table=True):
|
|||
name: str = Field(index=True, unique=True)
|
||||
short_code: str = Field(index=True, unique=True, max_length=10)
|
||||
|
||||
requisitions: List["JobPosts"] = Relationship(back_populates="department", sa_relationship_kwargs={"lazy": "selectin"})
|
||||
# noload: selectin here would load every job post of a department whenever any job
|
||||
# post loads its department_ref. Query JobPosts by department_id instead.
|
||||
job_posts: List["JobPosts"] = Relationship(back_populates="department_ref", sa_relationship_kwargs={"lazy": "noload"})
|
||||
|
||||
subtitle: Optional[str] = Field(default=None)
|
||||
description: Optional[str] = Field(default=None)
|
||||
|
|
@ -149,26 +151,17 @@ class Department(SQLModel, table=True):
|
|||
|
||||
@classmethod
|
||||
async def job_posts_for(cls, session: AsyncSession, department_ids):
|
||||
"""(department_id, job_post_id, requisition_status) for non-deleted job posts linked
|
||||
to these departments. job_posts.department is free text, so the link is a
|
||||
case-insensitive match on the department's name or short code.
|
||||
"""(department_id, job_post_id, requisition_status) for non-deleted job posts
|
||||
linked to these departments through job_posts.department_id.
|
||||
"""
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
ids = [i for i in (department_ids or []) if i]
|
||||
if not ids:
|
||||
return []
|
||||
job_department = func.lower(func.btrim(JobPosts.department))
|
||||
result = await session.execute(
|
||||
select(cls.id, JobPosts.id, JobPosts.requisition_status)
|
||||
.join(
|
||||
JobPosts,
|
||||
or_(
|
||||
job_department == func.lower(cls.name),
|
||||
job_department == func.lower(cls.short_code),
|
||||
),
|
||||
)
|
||||
.where(cls.id.in_(ids), JobPosts.is_deleted == False) # noqa: E712
|
||||
select(JobPosts.department_id, JobPosts.id, JobPosts.requisition_status)
|
||||
.where(JobPosts.department_id.in_(ids), JobPosts.is_deleted == False) # noqa: E712
|
||||
)
|
||||
return result.all()
|
||||
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ class HiringCostCreate(BaseModel):
|
|||
class JobUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
department: str | None = None
|
||||
department_id: UUID | None = None
|
||||
location: str | None = None
|
||||
employment_type: str | None = None
|
||||
vacancies: int | None = None
|
||||
|
|
|
|||
|
|
@ -26,8 +26,11 @@ class JobPosts(SQLModel, table=True):
|
|||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
title: str = Field(index=True)
|
||||
|
||||
department_id: Optional[uuid.UUID] = Field(default=None, foreign_key="departments.id")
|
||||
department: Optional["Department"] = Relationship(back_populates="job_posts", sa_relationship_kwargs={"lazy": "selectin"})
|
||||
# Many job posts -> one department. `department` (below) stays the free-text name that
|
||||
# analytics / filters / talent pool key off; set both together (see JobPost views).
|
||||
# The relationship is `department_ref` because `department` is already that column.
|
||||
department_id: Optional[uuid.UUID] = Field(default=None, foreign_key="departments.id", index=True)
|
||||
department_ref: Optional["Department"] = Relationship(back_populates="job_posts", sa_relationship_kwargs={"lazy": "selectin"})
|
||||
|
||||
user: Optional["Users"] = Relationship(
|
||||
back_populates="job_posts",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ def serialize_job_post(row, *, names=None) -> dict:
|
|||
"title": row.title,
|
||||
# Talent Pool / candidate filters key off attached job_posts.department.
|
||||
"department": row.department or None,
|
||||
"department_id": str(row.department_id) if row.department_id else None,
|
||||
"employment_type": row.employment_type,
|
||||
"location": row.location,
|
||||
"experience_min": row.experience_min,
|
||||
|
|
@ -83,6 +84,7 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na
|
|||
"id": str(row.id),
|
||||
"title": row.title,
|
||||
"department": row.department or None,
|
||||
"department_id": str(row.department_id) if row.department_id else None,
|
||||
"location": row.location,
|
||||
"employment_type": row.employment_type,
|
||||
"vacancies": row.vacancies,
|
||||
|
|
@ -104,7 +106,7 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na
|
|||
"hiring_manager_name": hiring_manager_name,
|
||||
"requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
|
||||
"requisition_title": req.position_title if req else None,
|
||||
"requisition_department": req.department.name if req and req.department else None,
|
||||
"requisition_department": req.department if req else None,
|
||||
"applicant_count": applicant_count,
|
||||
"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,
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ class JobPostCreate(BaseModel):
|
|||
location: str | None = None
|
||||
employment_type: str | None = None
|
||||
department: str | None = None
|
||||
department_id: UUID | None = None
|
||||
vacancies: int = 1
|
||||
description: str | None = None
|
||||
platform: str | None = None
|
||||
|
|
@ -181,6 +182,7 @@ class JobPost:
|
|||
"salary":payload.get("salary") or "Anonymous",
|
||||
# department is NOT NULL with a server_default of "" — pass "", never None.
|
||||
"department":payload.get("department") or "",
|
||||
"department_id":None,
|
||||
"vacancies":payload.get("vacancies") or 1,
|
||||
"description":payload.get("description"),
|
||||
"post_text":text,
|
||||
|
|
@ -191,6 +193,10 @@ class JobPost:
|
|||
# Only set platform when it is actually known: passing None would override the
|
||||
# column default and break the NOT NULL constraint. Buffer's channelService
|
||||
# replaces this with the authoritative value once the post is created.
|
||||
if payload.get("department_id"):
|
||||
department=await self._require_department(payload.get("department_id"))
|
||||
fields["department_id"]=department.id
|
||||
fields["department"]=department.name
|
||||
known_platform=service or normalize_platform(payload.get("platform"),aliases)
|
||||
if known_platform:
|
||||
fields["platform"]=known_platform
|
||||
|
|
@ -424,6 +430,13 @@ class JobPost:
|
|||
hiring_manager_name=names.get(str(row.hiring_manager_id)),
|
||||
)
|
||||
|
||||
async def _require_department(self,department_id):
|
||||
from department.models import Department
|
||||
department=await Department.get_by_id(self.session,department_id)
|
||||
if not department:
|
||||
raise HTTPException(status_code=404,detail="Department not found")
|
||||
return department
|
||||
|
||||
async def update_job(self,job_post_id,payload,current_user):
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401,detail="Not authenticated")
|
||||
|
|
@ -444,6 +457,14 @@ class JobPost:
|
|||
fields["salary"]=str(high)
|
||||
if "department" in fields and fields["department"] is None:
|
||||
fields["department"]=""
|
||||
if "department_id" in payload:
|
||||
if payload.get("department_id"):
|
||||
department=await self._require_department(payload.get("department_id"))
|
||||
fields["department_id"]=department.id
|
||||
fields["department"]=department.name
|
||||
else:
|
||||
fields["department_id"]=None
|
||||
fields["department"]=""
|
||||
|
||||
assignment=Assignment(self.session)
|
||||
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
-- 040_job_post_department_id.sql
|
||||
-- Move the department link from requisitions to job posts: many job posts -> one
|
||||
-- department. Reverses 039 (requisitions.department_id) and adds
|
||||
-- job_posts.department_id, the FK behind JobPosts.department_id /
|
||||
-- JobPosts.department_ref and Department.job_posts.
|
||||
--
|
||||
-- job_posts.department (free text) stays: analytics, filters and the talent pool
|
||||
-- key off it, and the app now writes the department's name there whenever
|
||||
-- department_id is set.
|
||||
--
|
||||
-- Idempotent; applied at startup by alembic_setup.run_manual_sql() after 039.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. requisitions: drop the 039 link, keeping the department name as text
|
||||
-- =============================================================================
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'app' AND table_name = 'requisitions' AND column_name = 'department_id'
|
||||
) THEN
|
||||
-- Rows created while the link existed wrote only department_id; carry the name back.
|
||||
UPDATE app.requisitions r
|
||||
SET department = d.name
|
||||
FROM app.departments d
|
||||
WHERE r.department_id = d.id
|
||||
AND (r.department IS NULL OR btrim(r.department) = '');
|
||||
|
||||
ALTER TABLE app.requisitions
|
||||
DROP CONSTRAINT IF EXISTS fk_requisitions_department_id_departments;
|
||||
DROP INDEX IF EXISTS app.ix_requisitions_department_id;
|
||||
ALTER TABLE app.requisitions DROP COLUMN department_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. job_posts.department_id -> departments.id
|
||||
-- =============================================================================
|
||||
ALTER TABLE app.job_posts
|
||||
ADD COLUMN IF NOT EXISTS department_id uuid;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'fk_job_posts_department_id_departments'
|
||||
) THEN
|
||||
ALTER TABLE app.job_posts
|
||||
ADD CONSTRAINT fk_job_posts_department_id_departments
|
||||
FOREIGN KEY (department_id) REFERENCES app.departments (id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_job_posts_department_id
|
||||
ON app.job_posts (department_id);
|
||||
|
||||
-- Backfill: job posts whose text department equals a department's name or short
|
||||
-- code (case-insensitive, trimmed). Unmatched rows stay NULL.
|
||||
UPDATE app.job_posts j
|
||||
SET department_id = d.id
|
||||
FROM app.departments d
|
||||
WHERE j.department_id IS NULL
|
||||
AND lower(btrim(j.department)) IN (lower(d.name), lower(d.short_code));
|
||||
|
|
@ -90,6 +90,7 @@ export function toJobView(row) {
|
|||
id: row.id,
|
||||
title: row.title,
|
||||
department: row.department,
|
||||
departmentId: row.department_id || null,
|
||||
location: row.location,
|
||||
type: row.employment_type,
|
||||
vacancies: row.vacancies,
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ export const qk = {
|
|||
list: (p = {}) => ['departments', 'list', p],
|
||||
heads: (p = {}) => ['departments', 'heads', p],
|
||||
locations: () => ['departments', 'locations'],
|
||||
names: () => ['departments', 'names'],
|
||||
names: (q = '') => ['departments', 'names', q],
|
||||
},
|
||||
interviews: {
|
||||
all: () => ['interviews'],
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import * as assignmentsApi from '../api/assignments'
|
|||
import * as tasksApi from '../api/tasks'
|
||||
import * as usersApi from '../api/users'
|
||||
import * as requisitionsApi from '../api/requisitions'
|
||||
import * as departmentsApi from '../api/departments'
|
||||
import * as offersApi from '../api/offers'
|
||||
import { fmtDate, fmtDateTime, fmtShort, toDate } from '../lib/format'
|
||||
import { empTypes } from '../data/seed'
|
||||
|
|
@ -410,7 +411,6 @@ export default function Jobs() {
|
|||
{editing && (
|
||||
<EditJobForm
|
||||
job={editing}
|
||||
departmentOptions={departmentOptions}
|
||||
busy={updateJob.isPending}
|
||||
onClose={() => setEditing(null)}
|
||||
onSubmit={(body) => updateJob.mutate({ id: editing.id, body })}
|
||||
|
|
@ -419,7 +419,6 @@ export default function Jobs() {
|
|||
|
||||
{creating && (
|
||||
<JobForm
|
||||
departmentOptions={departmentOptions}
|
||||
busy={createJob.isPending}
|
||||
onClose={() => setCreating(false)}
|
||||
onSubmit={(payload, imageFile) => createJob.mutate({ payload, imageFile })}
|
||||
|
|
@ -656,6 +655,49 @@ function useRecruiterDirectory() {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Department search dropdown over GET /department/names?search=. Options are
|
||||
* `{id, name}`; the picked row is kept in the list so a selection made from an
|
||||
* earlier search (or the job's saved department) still renders its name.
|
||||
*/
|
||||
function useDepartmentPicker(initialPicked = null) {
|
||||
const [deptQ, setDeptQ] = useState('')
|
||||
const [debouncedDeptQ, setDebouncedDeptQ] = useState('')
|
||||
const [pickedDept, setPickedDept] = useState(initialPicked)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedDeptQ(deptQ.trim()), 250)
|
||||
return () => clearTimeout(t)
|
||||
}, [deptQ])
|
||||
|
||||
const departmentsQuery = useQuery({
|
||||
queryKey: qk.departments.names(debouncedDeptQ),
|
||||
queryFn: async () => departmentsApi.toRows(await departmentsApi.listNames({ search: debouncedDeptQ })),
|
||||
placeholderData: keepPreviousData,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const departmentOptions = useMemo(() => {
|
||||
const rows = departmentsQuery.data ?? []
|
||||
if (pickedDept && !rows.some((o) => String(o.id) === String(pickedDept.id))) {
|
||||
return [pickedDept, ...rows]
|
||||
}
|
||||
return rows
|
||||
}, [departmentsQuery.data, pickedDept])
|
||||
|
||||
/** Pick by name — the requisition picker only knows its department as text. */
|
||||
async function pickByName(name) {
|
||||
const term = String(name || '').trim().toLowerCase()
|
||||
if (!term) return null
|
||||
const rows = departmentsApi.toRows(await departmentsApi.listNames({ search: name.trim() }))
|
||||
const match = rows.find((d) => String(d.name).trim().toLowerCase() === term)
|
||||
if (match) setPickedDept(match)
|
||||
return match || null
|
||||
}
|
||||
|
||||
return { departmentOptions, departmentsQuery, setDeptQ, pickedDept, setPickedDept, pickByName }
|
||||
}
|
||||
|
||||
function useRequisitionPicker(initialPicked = null, jobPostId = null) {
|
||||
const [reqQ, setReqQ] = useState('')
|
||||
const [debouncedReqQ, setDebouncedReqQ] = useState('')
|
||||
|
|
@ -693,17 +735,43 @@ function useRequisitionPicker(initialPicked = null, jobPostId = null) {
|
|||
return { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq }
|
||||
}
|
||||
|
||||
function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
||||
/** The Department field on both job forms: search dropdown, stores department_id. */
|
||||
function DepartmentSearchSelect({ dept, value, onChange, disabled }) {
|
||||
return (
|
||||
<>
|
||||
<SearchSelect
|
||||
options={dept.departmentOptions}
|
||||
value={value}
|
||||
onChange={(id) => {
|
||||
onChange(id)
|
||||
dept.setPickedDept(dept.departmentOptions.find((o) => String(o.id) === String(id)) || null)
|
||||
}}
|
||||
onQueryChange={dept.setDeptQ}
|
||||
placeholder="Search departments…"
|
||||
disabled={disabled}
|
||||
loading={dept.departmentsQuery.isPending && !dept.departmentsQuery.data}
|
||||
allowEmpty
|
||||
emptyLabel="No department"
|
||||
/>
|
||||
{dept.departmentsQuery.isError && (
|
||||
<p className="text-muted text-sm">Could not load departments.</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function JobForm({ busy, onClose, onSubmit }) {
|
||||
const managersQuery = useManagerDirectory()
|
||||
const recruitersQuery = useRecruiterDirectory()
|
||||
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker()
|
||||
const dept = useDepartmentPicker()
|
||||
|
||||
const form = useFormState({
|
||||
hiring_manager_id: '',
|
||||
current_recruiter_ids: [],
|
||||
requisition_id: '',
|
||||
title: '',
|
||||
department: '',
|
||||
department_id: '',
|
||||
location: '',
|
||||
employment_type: empTypes[0] || 'Full-time',
|
||||
vacancies: '1',
|
||||
|
|
@ -778,7 +846,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
// The cover image is uploaded separately right after the row exists.
|
||||
onSubmit({
|
||||
title: v.title.trim(),
|
||||
department: v.department.trim() || null,
|
||||
department_id: v.department_id || null,
|
||||
location: v.location.trim() || null,
|
||||
employment_type: v.employment_type || null,
|
||||
vacancies,
|
||||
|
|
@ -802,7 +870,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
// field itself and empty values before building the prompt.
|
||||
const assistContext = () => ({
|
||||
title: form.values.title,
|
||||
department: form.values.department,
|
||||
department: dept.pickedDept?.name || '',
|
||||
location: form.values.location,
|
||||
employment_type: form.values.employment_type,
|
||||
experience_min: form.values.experience_min,
|
||||
|
|
@ -856,7 +924,9 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
if (opt) {
|
||||
setPickedReq(opt)
|
||||
if (!form.values.title.trim() && opt.title) form.setField('title', opt.title)
|
||||
if (!form.values.department.trim() && opt.department) form.setField('department', opt.department)
|
||||
if (!form.values.department_id && opt.department) {
|
||||
dept.pickByName(opt.department).then((d) => { if (d) form.setField('department_id', d.id) })
|
||||
}
|
||||
}
|
||||
}}
|
||||
onQueryChange={setReqQ}
|
||||
|
|
@ -881,7 +951,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
|
||||
<label>Hiring manager</label>
|
||||
<SearchSelect
|
||||
options={managersQuery.data ?? []}
|
||||
value={form.values.hiring_manager_id}
|
||||
|
|
@ -914,16 +984,8 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
<div className="form-field">
|
||||
<div className="field-label-row">
|
||||
<label>Department</label>
|
||||
{assist('department')}
|
||||
</div>
|
||||
<input
|
||||
{...field('department')}
|
||||
list="job-department-options"
|
||||
placeholder="e.g. Engineering"
|
||||
/>
|
||||
<datalist id="job-department-options">
|
||||
{departmentOptions.map((d) => <option key={d} value={d} />)}
|
||||
</datalist>
|
||||
<DepartmentSearchSelect dept={dept} value={form.values.department_id} onChange={(id) => form.setField('department_id', id)} disabled={busy} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<div className="field-label-row">
|
||||
|
|
@ -1059,7 +1121,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
)
|
||||
}
|
||||
|
||||
function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||
function EditJobForm({ job: j, busy, onClose, onSubmit }) {
|
||||
const managersQuery = useManagerDirectory()
|
||||
const recruitersQuery = useRecruiterDirectory()
|
||||
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker(
|
||||
|
|
@ -1073,10 +1135,11 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
: null,
|
||||
j.id,
|
||||
)
|
||||
const dept = useDepartmentPicker(j.departmentId ? { id: j.departmentId, name: j.department || 'Current department' } : null)
|
||||
const form = useFormState({
|
||||
requisition_id: j.requisitionId || '',
|
||||
title: j.title || '',
|
||||
department: j.department || '',
|
||||
department_id: j.departmentId || '',
|
||||
location: j.location || '',
|
||||
employment_type: j.type || '',
|
||||
vacancies: j.vacancies != null ? String(j.vacancies) : '1',
|
||||
|
|
@ -1089,7 +1152,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
|
||||
const assistContext = () => ({
|
||||
title: form.values.title,
|
||||
department: form.values.department,
|
||||
department: dept.pickedDept?.name || '',
|
||||
location: form.values.location,
|
||||
employment_type: form.values.employment_type,
|
||||
experience_min: form.values.experience_min,
|
||||
|
|
@ -1118,7 +1181,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
if (Object.keys(errors).length) return
|
||||
onSubmit({
|
||||
title,
|
||||
department: form.values.department.trim() || null,
|
||||
department_id: form.values.department_id || null,
|
||||
location: form.values.location.trim() || null,
|
||||
employment_type: form.values.employment_type || null,
|
||||
vacancies: Number(form.values.vacancies) || 1,
|
||||
|
|
@ -1163,7 +1226,9 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
if (opt) {
|
||||
setPickedReq(opt)
|
||||
if (!form.values.title.trim() && opt.title) form.setField('title', opt.title)
|
||||
if (!form.values.department.trim() && opt.department) form.setField('department', opt.department)
|
||||
if (!form.values.department_id && opt.department) {
|
||||
dept.pickByName(opt.department).then((d) => { if (d) form.setField('department_id', d.id) })
|
||||
}
|
||||
}
|
||||
}}
|
||||
onQueryChange={setReqQ}
|
||||
|
|
@ -1186,7 +1251,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
<FieldError>{form.errors.title}</FieldError>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
|
||||
<label>Hiring manager</label>
|
||||
<SearchSelect
|
||||
options={managersQuery.data ?? []}
|
||||
value={form.values.hiring_manager_id}
|
||||
|
|
@ -1212,10 +1277,8 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
<div className="form-field">
|
||||
<div className="field-label-row">
|
||||
<label>Department</label>
|
||||
{assist('department')}
|
||||
</div>
|
||||
<input list="edit-job-depts" value={form.values.department} onChange={(e) => form.setField('department', e.target.value)} disabled={busy} />
|
||||
<datalist id="edit-job-depts">{departmentOptions.map((d) => <option key={d} value={d} />)}</datalist>
|
||||
<DepartmentSearchSelect dept={dept} value={form.values.department_id} onChange={(id) => form.setField('department_id', id)} disabled={busy} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<div className="field-label-row">
|
||||
|
|
@ -1284,7 +1347,7 @@ function JobOwnership({ job, canEdit }) {
|
|||
<div style={SECTION_LABEL}>Ownership</div>
|
||||
<div className="form-grid" style={{ marginBottom: 12 }}>
|
||||
<div className="form-field">
|
||||
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
|
||||
<label>Hiring manager</label>
|
||||
{canEdit ? (
|
||||
<SearchSelect
|
||||
options={managersQuery.data ?? []}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import { useAuth } from '../auth/AuthContext'
|
|||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as requisitionsApi from '../api/requisitions'
|
||||
import * as departmentsApi from '../api/departments'
|
||||
import { EMPLOYMENT_TYPES, EMPLOYMENT_TYPE_LABEL, approvalStatus } from '../api/requisitions'
|
||||
import { fmtShort, toDateInput } from '../lib/format'
|
||||
|
||||
|
|
@ -270,7 +269,7 @@ export default function Requisitions() {
|
|||
|
||||
function blankForm() {
|
||||
return {
|
||||
department_id: '',
|
||||
department: '',
|
||||
title: '',
|
||||
date: toDateInput(new Date().toISOString()),
|
||||
date_needed: '',
|
||||
|
|
@ -309,7 +308,7 @@ function fromRow(row) {
|
|||
const rep = row.replacement_for || {}
|
||||
const ref = row.refferal_by || {}
|
||||
return {
|
||||
department_id: pos.department_id || '',
|
||||
department: pos.department || '',
|
||||
title: pos.title || '',
|
||||
date: toDateInput(pos.date),
|
||||
date_needed: toDateInput(pos.date_needed),
|
||||
|
|
@ -346,7 +345,7 @@ function fromRow(row) {
|
|||
function toPayload(f) {
|
||||
const body = {
|
||||
position: {
|
||||
department_id: emptyToNull(f.department_id),
|
||||
department: emptyToNull(f.department),
|
||||
title: emptyToNull(f.title),
|
||||
date: emptyToNull(f.date),
|
||||
date_needed: emptyToNull(f.date_needed),
|
||||
|
|
@ -405,21 +404,6 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
|||
const [errors, setErrors] = useState({})
|
||||
const set = (k, v) => setFields((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const departmentsQuery = useQuery({
|
||||
queryKey: qk.departments.names(),
|
||||
queryFn: async () => departmentsApi.toRows(await departmentsApi.listNames()),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
const departmentOptions = useMemo(() => {
|
||||
const opts = departmentsQuery.data ?? []
|
||||
// Keep a saved department selectable even if it has since been deactivated.
|
||||
const pos = row?.position
|
||||
if (pos?.department_id && !opts.some((d) => d.id === pos.department_id)) {
|
||||
return [{ id: pos.department_id, name: pos.department || 'Current department' }, ...opts]
|
||||
}
|
||||
return opts
|
||||
}, [departmentsQuery.data, row])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const body = toPayload(fields)
|
||||
|
|
@ -469,14 +453,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
|||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>From (Dept.)</label>
|
||||
<select value={fields.department_id} onChange={(e) => set('department_id', e.target.value)}>
|
||||
<option value="">
|
||||
{departmentsQuery.isFetching ? 'Loading…' : departmentsQuery.isError ? 'Couldn’t load departments' : '—'}
|
||||
</option>
|
||||
{departmentOptions.map((d) => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<input value={fields.department} onChange={(e) => set('department', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Job title <span className="req">*</span></label>
|
||||
|
|
|
|||
|
|
@ -769,7 +769,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.form-section-title { margin: var(--space-5) 0 var(--space-1); grid-column: 1/-1; }
|
||||
|
||||
/* AI field assist (ui/AiFieldAssist.jsx) */
|
||||
.field-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.field-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 22px; }
|
||||
.ai-assist { position: relative; display: inline-flex; }
|
||||
.ai-assist-btn { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 6px; color: var(--primary); background: transparent; transition: .15s; }
|
||||
.ai-assist-btn svg { width: 14px; height: 14px; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue