tested department

Department_Module
ahmed.mujtaba 2026-09-14 19:12:02 +05:00
parent 3c0421c2ec
commit 433b2086b7
8 changed files with 82 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ? 'Couldnt load requisitions' : 'Unlinked, or job still open'}
/>
<KpiCard
label="Avg. Open Job Posts"
value={pending ? '—' : stats.openShareLabel}

View File

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

View File

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