job post and deaprtrment li kage

Department_Module
ahmed.mujtaba 2026-09-14 18:13:27 +05:00
parent eee2071ac2
commit f86c908076
15 changed files with 208 additions and 92 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ?? []}

View File

@ -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 ? 'Couldnt 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>

View File

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