tested department
parent
3c0421c2ec
commit
433b2086b7
|
|
@ -99,6 +99,22 @@ async def search_requisitions(
|
|||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/forms/requisition/open-count")
|
||||
async def count_open_requisitions(
|
||||
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)),
|
||||
session:AsyncSession=Depends(get_session),
|
||||
):
|
||||
"""Open requisitions: unlinked, or linked to a job post that is still open."""
|
||||
try:
|
||||
service=RequisitionForm(session=session)
|
||||
data=await service.count_open(current_user)
|
||||
return JSONResponse(content={"data":{"open":data},"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/forms/requisition/fetch")
|
||||
async def fetch_requisition_form(
|
||||
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import uuid
|
|||
from datetime import datetime, date as Date, timezone
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import DateTime, Enum as SAEnum, JSON, func, or_
|
||||
from sqlalchemy import DateTime, Enum as SAEnum, JSON, and_, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, Relationship, SQLModel, select
|
||||
|
||||
|
|
@ -130,6 +130,32 @@ class Requisition(SQLModel, table=True):
|
|||
result = await session.execute(statement)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def count_open(cls, session: AsyncSession, created_by=None) -> int:
|
||||
"""Open requisitions: not linked to a live job post, or linked to one whose
|
||||
requisition_status is still open. A closed / on-hold / completed job closes
|
||||
its requisition. job_posts.requisition_id is 1:1, so the join never fans out.
|
||||
"""
|
||||
from job.job_post.enums import RequisitionStatus
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
statement = (
|
||||
select(func.count())
|
||||
.select_from(cls)
|
||||
.outerjoin(
|
||||
JobPosts,
|
||||
and_(JobPosts.requisition_id == cls.id, JobPosts.is_deleted == False), # noqa: E712
|
||||
)
|
||||
.where(
|
||||
cls.is_deleted == False, # noqa: E712
|
||||
or_(JobPosts.id.is_(None), JobPosts.requisition_status == RequisitionStatus.OPEN.value),
|
||||
)
|
||||
)
|
||||
if created_by is not None:
|
||||
statement = statement.where(cls.created_by == created_by)
|
||||
result = await session.execute(statement)
|
||||
return int(result.scalar_one())
|
||||
|
||||
@classmethod
|
||||
async def insert_form(cls, session: AsyncSession, fields: dict):
|
||||
position = fields.get("position") if fields.get("position") else {}
|
||||
|
|
|
|||
|
|
@ -435,6 +435,11 @@ class RequisitionForm:
|
|||
rows = await Requisition.get_form_by_id(self.session, created_by=created_by)
|
||||
return [serialize_requisition(r) for r in rows]
|
||||
|
||||
async def count_open(self, current_user):
|
||||
# Same scope as the requisition list: admins count every row, others their own.
|
||||
created_by = None if is_admin(current_user) else _user_id(current_user)
|
||||
return await Requisition.count_open(self.session, created_by=created_by)
|
||||
|
||||
async def search(self, q, top=50, job_post_id=None):
|
||||
rows = await Requisition.search(
|
||||
self.session, q, top=top, job_post_id=job_post_id,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ export function list() {
|
|||
return request('/forms/requisition/fetch')
|
||||
}
|
||||
|
||||
/** GET /forms/requisition/open-count — `{data:{open}}`: unlinked, or linked job still open. */
|
||||
export function countOpen() {
|
||||
return request('/forms/requisition/open-count')
|
||||
}
|
||||
|
||||
export function getById(formId) {
|
||||
return request('/forms/requisition/fetch', { params: { form_id: formId } })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ export const qk = {
|
|||
requisitions: {
|
||||
all: () => ['requisitions'],
|
||||
list: () => ['requisitions', 'list'],
|
||||
openCount: () => ['requisitions', 'open-count'],
|
||||
detail: (id) => ['requisitions', 'detail', id],
|
||||
search: (q = '', jobPostId = null) => ['requisitions', 'search', q, jobPostId || null],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { qk } from '../lib/queryKeys'
|
|||
import { friendlyAuthError } from '../lib/errors'
|
||||
import { exportStyledWorkbook } from '../lib/exportXlsx'
|
||||
import * as departmentsApi from '../api/departments'
|
||||
import * as requisitionsApi from '../api/requisitions'
|
||||
|
||||
// app.roles id of `department_head` — the backend's default for /department/heads/fetch.
|
||||
const DEPARTMENT_HEAD_ROLE_ID = 5
|
||||
|
|
@ -64,7 +65,7 @@ function buildExportSheets(rows, stats, exportedAt) {
|
|||
rows: [
|
||||
{ metric: 'Total Departments', value: stats.total, notes: 'Across the organization' },
|
||||
{ metric: 'Candidates Applied', value: stats.candidates, notes: 'Applicants on jobs linked to departments' },
|
||||
{ metric: 'Open Requisitions', value: stats.openRoles, notes: 'Job posts with requisition status Open' },
|
||||
{ metric: 'Open Requisitions', value: stats.openRequisitions ?? '—', notes: 'Not linked to a job post, or linked to a job post that is still open' },
|
||||
{ metric: 'Avg. Open Job Posts', value: stats.openShareLabel, notes: `${stats.openRoles} open of ${stats.jobPosts} job posts` },
|
||||
],
|
||||
},
|
||||
|
|
@ -121,6 +122,7 @@ export default function Departments() {
|
|||
const canCreate = can('department.create')
|
||||
const canEdit = can('department.edit')
|
||||
const canExport = can('department.export')
|
||||
const canViewRequisitions = can('requisitions.view')
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: qk.departments.list(),
|
||||
|
|
@ -133,7 +135,16 @@ export default function Departments() {
|
|||
// { row: null | department, editable: boolean }
|
||||
const [editor, setEditor] = useState(null)
|
||||
|
||||
const stats = useMemo(() => departmentStats(rowsAll), [rowsAll])
|
||||
const requisitionsQuery = useQuery({
|
||||
queryKey: qk.requisitions.openCount(),
|
||||
queryFn: async () => (await requisitionsApi.countOpen())?.data?.open ?? 0,
|
||||
enabled: canViewRequisitions,
|
||||
})
|
||||
const openRequisitions = requisitionsQuery.data ?? null
|
||||
const stats = useMemo(
|
||||
() => ({ ...departmentStats(rowsAll), openRequisitions }),
|
||||
[rowsAll, openRequisitions],
|
||||
)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
async function exportDepartments() {
|
||||
|
|
@ -197,7 +208,13 @@ export default function Departments() {
|
|||
<div className="grid g-kpi mb-18">
|
||||
<KpiCard label="Total Departments" value={pending ? '—' : stats.total} icon="layers" tone="i-indigo" foot="Across the organization" />
|
||||
<KpiCard label="Candidates Applied" value={pending ? '—' : stats.candidates} icon="users" tone="i-teal" foot="To jobs in these departments" />
|
||||
<KpiCard label="Open Requisitions" value={pending ? '—' : stats.openRoles} icon="briefcase" tone="i-amber" foot="Open job posts linked to departments" />
|
||||
<KpiCard
|
||||
label="Open Requisitions"
|
||||
value={openRequisitions ?? '—'}
|
||||
icon="briefcase"
|
||||
tone="i-amber"
|
||||
foot={!canViewRequisitions ? 'Requires requisitions.view' : requisitionsQuery.isError ? 'Couldn’t load requisitions' : 'Unlinked, or job still open'}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Avg. Open Job Posts"
|
||||
value={pending ? '—' : stats.openShareLabel}
|
||||
|
|
|
|||
|
|
@ -507,7 +507,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
|||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>
|
||||
<label className="hf-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.jd_available}
|
||||
|
|
@ -642,7 +642,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
|||
</div>
|
||||
<div className="hf-sign">
|
||||
<span className="hf-sign-role">Approved by · Director HR</span>
|
||||
<label className="hf-note" style={{ margin: 0 }}>
|
||||
<label className="hf-note hf-check" style={{ margin: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.approved_by_hr}
|
||||
|
|
@ -658,7 +658,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
|||
</div>
|
||||
<div className="hf-sign">
|
||||
<span className="hf-sign-role">Approved by · VP</span>
|
||||
<label className="hf-note" style={{ margin: 0 }}>
|
||||
<label className="hf-note hf-check" style={{ margin: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.approved_by_vp}
|
||||
|
|
@ -674,7 +674,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
|||
</div>
|
||||
<div className="hf-sign">
|
||||
<span className="hf-sign-role">Approved by · SVP</span>
|
||||
<label className="hf-note" style={{ margin: 0 }}>
|
||||
<label className="hf-note hf-check" style={{ margin: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.approved_by_svp}
|
||||
|
|
|
|||
|
|
@ -1937,6 +1937,10 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.hf-block-title { margin-bottom: 12px; display: flex; align-items: center; gap: 10px; }
|
||||
.hf-block-title label { display: flex; align-items: center; gap: 8px; cursor: pointer; text-transform: none; letter-spacing: 0; font-size: 13px; font-weight: 600; color: var(--text-2); }
|
||||
.hf-note { font-size: 12.5px; color: var(--text-3); margin: 2px 0 10px; }
|
||||
/* Checkbox + text on one line, box flush left. Overrides `.form-field input`
|
||||
(width:100% + padding), which otherwise stretches the checkbox and centres it. */
|
||||
.hf-check { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; cursor: pointer; }
|
||||
.hf-check input[type="checkbox"] { width: auto; padding: 0; margin: 0; flex: none; }
|
||||
|
||||
/* Rating table: the paper grid — scale header, radio-dot cells, average foot */
|
||||
.hf-rate { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue