Compare commits
No commits in common. "Department_Module" and "main" have entirely different histories.
Department
...
main
|
|
@ -3,7 +3,7 @@ name: Deploy to S3
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev_main
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
|
@ -24,7 +24,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
apt-get update -y
|
apt-get update -y
|
||||||
apt-get install -y zip
|
apt-get install -y zip
|
||||||
zip -r utopia-ai-hr-ats-portal-dev.zip . \
|
zip -r utopia-ai-hr-ats-portal.zip . \
|
||||||
-x ".git/*" \
|
-x ".git/*" \
|
||||||
-x ".gitea/*" \
|
-x ".gitea/*" \
|
||||||
-x ".gitignore/*" \
|
-x ".gitignore/*" \
|
||||||
|
|
@ -46,7 +46,7 @@ jobs:
|
||||||
AWS_DEFAULT_REGION: us-east-1
|
AWS_DEFAULT_REGION: us-east-1
|
||||||
run: |
|
run: |
|
||||||
echo "Uploading repo contents to S3..."
|
echo "Uploading repo contents to S3..."
|
||||||
aws s3 cp utopia-ai-hr-ats-portal-dev.zip s3://utopia-ai-s3-bucket/utopia-ai-hr-ats-portal-dev.zip
|
aws s3 cp utopia-ai-hr-ats-portal.zip s3://utopia-ai-s3-repo-bucket/utopia-ai-hr-ats-portal.zip
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,4 @@ frontend/dist/index.html
|
||||||
frontend/dist/**
|
frontend/dist/**
|
||||||
nginx.conf
|
nginx.conf
|
||||||
smoke.test.mjs
|
smoke.test.mjs
|
||||||
**.docx
|
Annex**
|
||||||
**.docs
|
|
||||||
Annex**
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,835 +0,0 @@
|
||||||
# RBAC Context Prompt — HR-ATS-Portal (TalentFlow)
|
|
||||||
|
|
||||||
> Paste this whole file as context for an engineer or LLM that must reproduce this system's
|
|
||||||
> role-based access control exactly. Everything below is taken from the code on branch
|
|
||||||
> `Department_Module`. File references point back to the source of truth.
|
|
||||||
> Where the code has a quirk or gap, it is written down as-is under **Known behaviour to
|
|
||||||
> reproduce (or consciously fix)**. Do not tidy these away silently.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Your task
|
|
||||||
|
|
||||||
You are implementing an access-control layer that must behave **identically** to the one
|
|
||||||
described here. That means the same:
|
|
||||||
|
|
||||||
- data model (tags → bundles → roles → users),
|
|
||||||
- permission vocabulary (136 `module.action` tags),
|
|
||||||
- resolution algorithm (live, per request, deny-by-default),
|
|
||||||
- enforcement order and HTTP status codes and error strings,
|
|
||||||
- rules against privilege escalation,
|
|
||||||
- row-level data scoping (who sees which jobs, candidates, offers, requisitions),
|
|
||||||
- role-name-based business rules (tasks, assignments, hiring-manager portal),
|
|
||||||
- frontend gating (routes, sidebar, buttons) and the Access Control matrix editor.
|
|
||||||
|
|
||||||
When this document and your instincts disagree, follow this document.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Core concepts in one paragraph
|
|
||||||
|
|
||||||
A **permission tag** is one atomic `module.action` string, such as `candidates.view`. A
|
|
||||||
**permission bundle** (table `permissions`) is a named JSONB array of tag ids. A **role** is a
|
|
||||||
named JSONB array of bundle ids. A **user** has zero or one role (`users.role_id`, nullable).
|
|
||||||
On every authenticated request the server walks role → bundles → tags and builds a flat list of
|
|
||||||
tag names. Route guards check that list. Service code then narrows which rows are visible
|
|
||||||
using a few helper predicates, some of which look at tags and some at the role name. Tags
|
|
||||||
never go into the JWT, so a permission change takes effect on the server on the very next
|
|
||||||
request.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Data model
|
|
||||||
|
|
||||||
PostgreSQL, schema `app`. SQLModel/SQLAlchemy async. Every table soft-deletes
|
|
||||||
(`is_deleted`) and has an `is_active` flag.
|
|
||||||
|
|
||||||
### 2.1 `permission_tags` — [backend/role/models.py](backend/role/models.py)
|
|
||||||
|
|
||||||
| column | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | int PK | seed order matters: it sets matrix ordering and resolution ordering |
|
|
||||||
| `tag_name` | varchar(64) unique, indexed | `"{module}.{action}"` |
|
|
||||||
| `module` | varchar(32) indexed | |
|
|
||||||
| `action` | varchar(32) | |
|
|
||||||
| `description` | text null | |
|
|
||||||
| `is_active` / `is_deleted` | bool | |
|
|
||||||
| `created_at` / `updated_at` | timestamptz | |
|
|
||||||
|
|
||||||
Unique constraint `uq_permission_tags_module_action` on (`module`, `action`).
|
|
||||||
|
|
||||||
### 2.2 `permissions` (bundles)
|
|
||||||
|
|
||||||
| column | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | int PK | |
|
|
||||||
| `name` | varchar(64) unique | |
|
|
||||||
| `description` | text null | |
|
|
||||||
| `permission_tags` | JSONB int[] | ids from `permission_tags.id`, **no FK** |
|
|
||||||
| `is_system` | bool | system bundles cannot be renamed |
|
|
||||||
| `is_active` / `is_deleted` / timestamps | | |
|
|
||||||
|
|
||||||
### 2.3 `roles`
|
|
||||||
|
|
||||||
| column | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | int PK | |
|
|
||||||
| `role_name` | varchar(64) unique | free text (initial Alembic revision used an enum; the model is a varchar) |
|
|
||||||
| `description` | text null | |
|
|
||||||
| `permissions` | JSONB int[] | ids from `permissions.id`, **no FK** |
|
|
||||||
| `is_system` | bool | system roles cannot be renamed or deleted |
|
|
||||||
| `is_active` / `is_deleted` / timestamps | | |
|
|
||||||
|
|
||||||
### 2.4 `users` — [backend/users/models.py](backend/users/models.py)
|
|
||||||
|
|
||||||
Only the RBAC-relevant columns:
|
|
||||||
|
|
||||||
| column | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | uuid PK | the JWT `sub` |
|
|
||||||
| `email` | unique | |
|
|
||||||
| `role_id` | int FK → `roles.id`, **nullable** | `role` relationship is `lazy="selectin"` |
|
|
||||||
| `is_active` | bool, default **false** | set true by email confirmation |
|
|
||||||
| `is_approved` | bool, default **false** | set true by an admin (or at admin creation) |
|
|
||||||
| `is_deleted` | bool | |
|
|
||||||
|
|
||||||
### 2.5 System role keys — `EnumRoles`
|
|
||||||
|
|
||||||
```
|
|
||||||
system_administrator hr_administrator recruiter hiring_manager
|
|
||||||
department_head interviewer ceo candidate
|
|
||||||
```
|
|
||||||
|
|
||||||
Seed ids follow that order: 1 system_administrator, 2 hr_administrator, 3 recruiter,
|
|
||||||
4 hiring_manager, … , 8 candidate. **Some code hardcodes ids 4 and 8** (see §12).
|
|
||||||
|
|
||||||
Migration `026` soft-deletes `hr_administrator`, `interviewer` and `ceo` when no live user
|
|
||||||
holds them. The organisation runs four staff roles (`system_administrator`, `recruiter`,
|
|
||||||
`hiring_manager`, `department_head`) plus `candidate`. Migration `027` moves members of a
|
|
||||||
hand-made role named `Manager` onto `hiring_manager` and soft-deletes it. Code still accepts the
|
|
||||||
names `manager` and `admin` (see §6).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Permission vocabulary — 17 modules × 8 actions = 136 tags
|
|
||||||
|
|
||||||
Source: `PermissionModule`, `PermissionAction`, `PermissionTag` in
|
|
||||||
[backend/users/permissions.py](backend/users/permissions.py).
|
|
||||||
|
|
||||||
**Modules:** `dashboard, inbox, jobs, candidates, pipeline, department, interviews,
|
|
||||||
assessments, offers, reports, analytics, job_board, settings, rbac_users, tasks, talent,
|
|
||||||
requisitions`
|
|
||||||
|
|
||||||
**Actions:** `view, create, edit, delete, approve, export, manage, configure`
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
1. `PermissionTag` is a `str` Enum listing every combination explicitly. At import time,
|
|
||||||
`_assert_vocabulary_complete()` raises `RuntimeError("PermissionTag vocabulary drift:
|
|
||||||
missing=[...] extra=[...]")` unless the enum equals the full modules × actions cross-product.
|
|
||||||
The server will not boot with a partial vocabulary.
|
|
||||||
2. Always serialise with `.value`. `f"{PermissionTag.X}"` renders the enum repr.
|
|
||||||
3. DB rows are seeded idempotently (`ON CONFLICT (tag_name) DO NOTHING`) by manual SQL
|
|
||||||
migrations: `001` (first 13 modules = 104 tags), `004` tasks, `007` talent,
|
|
||||||
`019` requisitions, `038` department. Comments in the code that say "104" or "120" tags are
|
|
||||||
stale. The real total is 136.
|
|
||||||
4. Two actions carry special meaning beyond "may use the screen":
|
|
||||||
- `*.manage` on `requisitions`, `candidates` and `offers` **removes row scoping**. See §6.
|
|
||||||
- `requisitions.configure` is an **opt-in to scoping**, not a screen permission. See §6.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Resolution algorithm — `Roles.resolve_tags(session, role)`
|
|
||||||
|
|
||||||
```
|
|
||||||
if role is None or not role.is_active or role.is_deleted: return ()
|
|
||||||
perm_ids = role.permissions
|
|
||||||
if not perm_ids or not a list: return ()
|
|
||||||
bundles = SELECT permissions WHERE id IN perm_ids AND is_active AND NOT is_deleted
|
|
||||||
tag_ids = concat(bundle.permission_tags for each bundle whose permission_tags is a non-empty list)
|
|
||||||
if not tag_ids: return ()
|
|
||||||
tags = SELECT permission_tags WHERE id IN tag_ids AND is_active AND NOT is_deleted
|
|
||||||
sort tags by (id, tag_name); de-duplicate by tag_name keeping first
|
|
||||||
return tuple(tag_name ...)
|
|
||||||
```
|
|
||||||
|
|
||||||
Properties you must preserve:
|
|
||||||
|
|
||||||
- **Deny by default, never error.** Dangling ids, inactive or deleted bundles, and inactive or
|
|
||||||
deleted tags simply add nothing.
|
|
||||||
- **Union.** Tags from every attached bundle are merged. There are no negative grants.
|
|
||||||
- **Live.** It runs on every request inside `get_current_user`. Nothing is cached server-side
|
|
||||||
and nothing is put in the token.
|
|
||||||
- **Stable order.** The output is ordered by tag id, which is seed order.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Authentication and enforcement pipeline
|
|
||||||
|
|
||||||
### 5.1 Tokens — [backend/users/plugins.py](backend/users/plugins.py)
|
|
||||||
|
|
||||||
PyJWT HS256. Every token carries `sub`, `type`, `iat`, `exp`, `jti`.
|
|
||||||
`decode_token(token, expected_type=...)` rejects a token whose `type` does not match.
|
|
||||||
|
|
||||||
| type | default lifetime | extra claims |
|
|
||||||
|---|---|---|
|
|
||||||
| `access` | 30 min | `email`, `role_id` |
|
|
||||||
| `refresh` | 7 days | — |
|
|
||||||
| `reset` | 10 min | `crid` |
|
|
||||||
|
|
||||||
The token's `role_id` is informational only. Authorization always reloads the user from the DB.
|
|
||||||
There is no logout endpoint, no denylist and no `jti` tracking.
|
|
||||||
|
|
||||||
Login, signup, refresh and `/users/create` return:
|
|
||||||
`{access_token, refresh_token, token_type:"bearer", expires_in, data: <user without permissions>, status_code}`.
|
|
||||||
|
|
||||||
### 5.2 `get_current_user` (dependency alias `CurrentUser`)
|
|
||||||
|
|
||||||
The checks run in this order:
|
|
||||||
|
|
||||||
1. HTTP Bearer header is missing → FastAPI `HTTPBearer` returns **401** `"Not authenticated"` (FastAPI 0.136.1 behaviour).
|
|
||||||
2. Decode fails or type is not `access` → **401** `"Could not validate credentials"` with `WWW-Authenticate: Bearer`.
|
|
||||||
3. Load the user by `sub` via `Users.get_user_by_id`. That query **excludes `role_id = 8` (candidate)**.
|
|
||||||
If the user is missing, `is_deleted`, or `!is_active` → **401** `"User is inactive or does not exist"`.
|
|
||||||
4. `!is_approved` → **403** `"Your Approval is at Pending"`.
|
|
||||||
5. `permissions = resolve_tags(user.role)`.
|
|
||||||
6. Return `serialize_user(user, with_permissions=True, permissions=...)`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "id": "uuid", "name": "...", "email": "...", "role_id": 3, "role_name": "recruiter",
|
|
||||||
"role_description": "...", "linkedin_url": null, "is_active": true, "is_approved": true,
|
|
||||||
"is_deleted": false, "created_at": "...", "updated_at": "...",
|
|
||||||
"permissions": ["dashboard.view", "..."] }
|
|
||||||
```
|
|
||||||
|
|
||||||
`GET /users/me` returns exactly this and requires only `CurrentUser`, with no tag. That way a user
|
|
||||||
with no role can still discover their state.
|
|
||||||
|
|
||||||
### 5.3 `require_permission(*tags, require_all=True)`
|
|
||||||
|
|
||||||
A FastAPI dependency factory that runs after `get_current_user`:
|
|
||||||
|
|
||||||
1. `current_user.role_id is None` → **403** `"User has no role assigned"`. This check runs before any tag check.
|
|
||||||
2. `has_permission(granted, *tags, require_all)`:
|
|
||||||
- `require_all=True` → required ⊆ granted (AND)
|
|
||||||
- `require_all=False` → required ∩ granted ≠ ∅ (OR)
|
|
||||||
3. On failure → **403** with one of these exact details:
|
|
||||||
- one tag, AND: `"Missing required permission: candidates.view"`
|
|
||||||
- several tags, AND: `"Missing required permissions: a, b"`
|
|
||||||
- OR: `"Missing any of required permissions: a, b"`
|
|
||||||
4. On success it returns `current_user`, and handlers use it as `current_user: dict`.
|
|
||||||
|
|
||||||
A user whose role is soft-deleted or inactive still has a `role_id`. They pass step 1, resolve
|
|
||||||
to zero tags, and fail step 3.
|
|
||||||
|
|
||||||
### 5.4 Login and account-state rules — [backend/users/views.py](backend/users/views.py)
|
|
||||||
|
|
||||||
- `authenticate_user`: the email lookup excludes role 8. A bad email or password returns **401**
|
|
||||||
`"Incorrect email or password"`. Then `is_deleted` → 401 `"User is inactive"`, then
|
|
||||||
`!is_active` → 401 `"Please confirm your email address to activate your account"`, then
|
|
||||||
`!is_approved` → 403 `"Your Approval is at Pending"`.
|
|
||||||
- `refresh_access_token`: runs the same active, deleted and approved checks, with 401
|
|
||||||
`"Invalid or expired refresh token"` on a decode failure.
|
|
||||||
- **Signup** (`POST /users/signup`, public): hardcodes `role_id = 4` and `is_approved = false`,
|
|
||||||
lands with `is_active = false`, and emails a confirmation link that sets `is_active`.
|
|
||||||
An admin must then approve the account.
|
|
||||||
- **Admin create** (`POST /users/create`, needs `rbac_users.create`): sets `is_approved = true`.
|
|
||||||
`is_active` comes from the payload (default true). The escalation check in §5.5 applies.
|
|
||||||
- **Approval queue**: `GET /users/pending-approvals` (needs `settings.view`) lists users who are
|
|
||||||
active, not approved, not deleted and not role 8. `PUT /users/approve?record_id=` (needs
|
|
||||||
`rbac_users.edit`) returns 400 when the user is not active: `"User must confirm their email
|
|
||||||
before approval"`.
|
|
||||||
- **Candidate accounts (role 8)** cannot log in or resolve through `get_current_user`. They are
|
|
||||||
data records for applicants, not portal users.
|
|
||||||
|
|
||||||
### 5.5 Anti-escalation on role assignment — `User._check_role_assignment`
|
|
||||||
|
|
||||||
Used by `POST /users/create`, `PUT /users/assign-role` and `PUT /users/remove-role`. Both
|
|
||||||
assign and remove are also route-guarded by `rbac_users.edit`.
|
|
||||||
|
|
||||||
```
|
|
||||||
if new_role_id == existing_role_id: return # no-op, no checks
|
|
||||||
if 'rbac_users.manage' not in caller.perms: 403 "Assigning a role requires rbac_users.manage"
|
|
||||||
if new_role_id is None: return # removal needs only manage
|
|
||||||
role = roles[new_role_id]
|
|
||||||
if role missing or is_deleted: 404 "Role not found"
|
|
||||||
if not role.is_active: 400 "Role is not active"
|
|
||||||
missing = resolve_tags(role) - caller.perms
|
|
||||||
if missing: 403 "Cannot assign a role with permissions you do not hold: a, b"
|
|
||||||
```
|
|
||||||
|
|
||||||
So a caller can only hand out a role whose effective tags are a subset of their own.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Row-level scoping (data visibility on top of tags)
|
|
||||||
|
|
||||||
Tags decide **whether** a user may call an endpoint. These predicates decide **which rows**
|
|
||||||
they get back. They are pure functions of the `current_user` dict.
|
|
||||||
Backend: [backend/users/permissions.py](backend/users/permissions.py). The frontend mirror is
|
|
||||||
in [frontend/src/auth/permissions.js](frontend/src/auth/permissions.js) and must stay
|
|
||||||
byte-for-byte equivalent in logic.
|
|
||||||
|
|
||||||
```python
|
|
||||||
def is_hiring_manager(u): # "hiring-manager portal" user
|
|
||||||
return lower(strip(u.role_name)) in {"hiring_manager", "manager"}
|
|
||||||
|
|
||||||
def is_admin(u): # org-wide staff
|
|
||||||
return lower(strip(u.role_name)) in {"system_administrator", "hr_administrator", "admin"} \
|
|
||||||
or "requisitions.manage" in u.permissions
|
|
||||||
|
|
||||||
def sees_all_candidates(u):
|
|
||||||
return is_admin(u) or "candidates.manage" in u.permissions
|
|
||||||
|
|
||||||
def sees_all_offers(u):
|
|
||||||
return is_admin(u) or "offers.manage" in u.permissions
|
|
||||||
|
|
||||||
def scopes_to_own_requisitions(u): # evaluate in this exact order
|
|
||||||
if is_hiring_manager(u): return True # wins even over admin tags
|
|
||||||
if is_admin(u) or sees_all_candidates(u): return False
|
|
||||||
return "requisitions.configure" in u.permissions
|
|
||||||
```
|
|
||||||
|
|
||||||
Design rule stated in the code: **custom roles must be able to opt in through Access Control
|
|
||||||
tags. Never key scoping off `role_id`.** Role-name checks exist only for the seeded
|
|
||||||
hiring-manager and admin identities.
|
|
||||||
|
|
||||||
### 6.1 Job ownership sets — [backend/job/job_post/models.py](backend/job/job_post/models.py)
|
|
||||||
|
|
||||||
- `JobPosts.ids_for_manager(user_id)` returns non-deleted job posts where
|
|
||||||
`hiring_manager_id = user`, **unioned with** jobs whose `requisition_id` points at a
|
|
||||||
non-deleted requisition with `created_by = user`.
|
|
||||||
- `JobPosts.ids_for_creator(user_id, created_by=False)`:
|
|
||||||
- `created_by=True` returns jobs with `created_by = user`.
|
|
||||||
- Otherwise it returns jobs where the user is in `current_recruiter_ids` or is
|
|
||||||
`current_recruiter_id`, **or** (the job has no recruiters **and** `created_by = user`).
|
|
||||||
|
|
||||||
### 6.2 `owned_job_ids_for_candidate_scope(session, user, created_by=False)` — [backend/job/candidate/views.py](backend/job/candidate/views.py)
|
|
||||||
|
|
||||||
```
|
|
||||||
if scopes_to_own_requisitions(user): return ids_for_manager(user.id)
|
|
||||||
if sees_all_candidates(user): return None # None = unscoped
|
|
||||||
return ids_for_creator(user.id, created_by)
|
|
||||||
```
|
|
||||||
|
|
||||||
`job_post_ids_for_candidate_list` intersects a caller-requested job filter with that set.
|
|
||||||
`None` means unscoped and `[]` means nothing is visible.
|
|
||||||
|
|
||||||
### 6.3 `assert_manager_candidate_access(session, user, user_id|job_post_id|inbox_id|manual_id)`
|
|
||||||
|
|
||||||
```
|
|
||||||
if sees_all_candidates(user) and not scopes_to_own_requisitions(user): allow
|
|
||||||
owned = owned_job_ids_for_candidate_scope(...) or []
|
|
||||||
if not owned: 403 <scope detail>
|
|
||||||
resolve job_id (and candidate uid) from inbox_id / manual_id when not given
|
|
||||||
if job_id is None and uid is not None:
|
|
||||||
allow if any job the candidate is assigned to ∈ owned, else 403
|
|
||||||
if job_id not in owned: 403 <scope detail>
|
|
||||||
```
|
|
||||||
|
|
||||||
The scope detail is `MANAGER_SCOPE_DETAIL` when requisition-scoped and `CREATOR_SCOPE_DETAIL` otherwise.
|
|
||||||
|
|
||||||
### 6.4 Where scoping is applied
|
|
||||||
|
|
||||||
| Area | Rule |
|
|
||||||
|---|---|
|
|
||||||
| Candidates list / detail / applications / notes / forms | `owned_job_ids_for_candidate_scope` + `assert_manager_candidate_access` |
|
|
||||||
| `GET /candidate/fetch/users` and `/count` | Hiring-manager users get **403** `"Hiring managers can only list candidates on their requisitions"` |
|
|
||||||
| Candidate detail without `user_id` | Hiring manager → 403 `MANAGER_SCOPE_DETAIL` |
|
|
||||||
| Job posts list (`fetch_job_posts`) | If `scopes_to_own_requisitions`, restrict to `ids_for_manager`. Requested ids outside that set are dropped, and an empty set returns `[]` |
|
|
||||||
| Offers (candidate picker, create, sent) | `sees_all_offers` → unscoped. Otherwise use owned job ids, and an out-of-scope job returns 403 `"This offer is outside your assigned jobs"` |
|
|
||||||
| Requisition forms (`get_form_by_id`) | `is_admin` → all rows. Otherwise `created_by = me` |
|
|
||||||
| Candidate hiring forms list | Hiring manager with no `form_id`, `inbox_id`, `manual_upload_candidate_id` or `job_post_id` → 403 `"Hiring managers can only load forms for candidates on their requisitions"`. Otherwise `assert_manager_candidate_access` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Role-name business rules (not tag-driven)
|
|
||||||
|
|
||||||
These rules look up role **names** and resolve ids from the `roles` table at request time.
|
|
||||||
|
|
||||||
| Rule | Where | Behaviour |
|
|
||||||
|---|---|---|
|
|
||||||
| Task creators | [backend/tasks/views.py](backend/tasks/views.py) | Route requires `tasks.create` **and** the caller's `role_id` must be one of the ids for `system_administrator`, `hr_administrator` or `recruiter`, else 403 `"Only system administrators, HR administrators and recruiters can create tasks"` |
|
|
||||||
| Task assignee | tasks | Must be an existing, non-deleted user with role `recruiter`, else 422 `"Tasks can only be assigned to recruiter accounts"`. Omitting the assignee is allowed only when the caller is a recruiter, who then self-assigns |
|
|
||||||
| Task assignee picker | `GET /tasks/assignees/fetch` (`tasks.view`) | All `recruiter` users, so the caller does not need `rbac_users.view` |
|
|
||||||
| Job assignment roles | [backend/job/assignment/views.py](backend/job/assignment/views.py) | `primary_recruiter` → user must hold `recruiter`; `hiring_manager` → user must hold `hiring_manager`. Otherwise 422 `"{field} must be a {role}"`. The user must also be active and not deleted |
|
|
||||||
| Application assignment | same | Assignee must be `recruiter` |
|
|
||||||
| Job post recruiters / HM | [backend/job/job_post/views.py](backend/job/job_post/views.py) | `current_recruiter_ids` must be recruiters; `hiring_manager_id` must be a hiring_manager |
|
|
||||||
| Inbox assign-recruiter | [backend/inbox/views.py](backend/inbox/views.py) | `recruiter_id` must be a `recruiter`, else 422 |
|
|
||||||
| Hiring-manager directory | `GET /managers/fetch` (`jobs.view OR candidates.view OR job_board.create`) | Users with role `hiring_manager`. Returns 500 if that role is not seeded |
|
|
||||||
| Recruiter performance | analytics | Iterates users whose role is `recruiter` |
|
|
||||||
| Admin notifications | [backend/notifications/views.py](backend/notifications/views.py) | Recipients are users with role `system_administrator` |
|
|
||||||
| Candidate identity | inbox / candidate / search models | Applicants are users with role `candidate` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Seeded bundles and who gets them
|
|
||||||
|
|
||||||
The **initial** roles and the original bundle set, including `all_access` for
|
|
||||||
`system_administrator` (a fixed id list), were seeded outside this repo. Do not assume their
|
|
||||||
contents; export them (see §10). The manual migrations below are in the repo and all run
|
|
||||||
idempotently at startup via `alembic_setup.run_manual_sql()`.
|
|
||||||
|
|
||||||
| Bundle (`is_system=true`) | Tags | Attached to |
|
|
||||||
|---|---|---|
|
|
||||||
| `analytics_dashboard` (001) | all `dashboard.*`, `analytics.*`, `offers.*` + `interviews.view` | sysadmin, hr_admin, recruiter, hiring_manager, department_head, ceo |
|
|
||||||
| `tasks_management` (004) | all `tasks.*` | sysadmin, hr_admin, recruiter (005 removed it from hiring_manager, department_head, ceo) |
|
|
||||||
| `tasks_viewer` (005) | `tasks.view`, `tasks.export` | hiring_manager, department_head, ceo |
|
|
||||||
| `talent_sourcing` (007) | all `talent.*` | the six staff roles |
|
|
||||||
| `hiring_forms` (008) | `interviews.create`, `interviews.edit`, `interviews.delete` | the six staff roles |
|
|
||||||
| `requisitions_management` (019) | all `requisitions.*` (**includes `.manage` and `.configure`**) | the six staff roles |
|
|
||||||
| `manager_candidates` (024/025) | `candidates.view`, `candidates.create`, `candidates.edit` | hiring_manager (and a legacy `manager` role) |
|
|
||||||
| `requisitions_self` (028) | `requisitions.view`, `requisitions.create`, `requisitions.edit` | none. Meant for custom roles |
|
|
||||||
| `interviews_tab` (028) | `interviews.view`, `interviews.create`, `interviews.edit` | none. Meant for custom roles |
|
|
||||||
| `department_management` (038) | all `department.*` | sysadmin, hr_admin |
|
|
||||||
|
|
||||||
"The six staff roles" means `system_administrator, hr_administrator, recruiter, hiring_manager,
|
|
||||||
department_head, ceo`.
|
|
||||||
|
|
||||||
Pattern for adding a module (copy it exactly):
|
|
||||||
|
|
||||||
1. Add the module to `PermissionModule` **and** all 8 `PermissionTag` members (the startup assertion enforces this).
|
|
||||||
2. Add the module to `MODULES` in `frontend/src/auth/permissions.js`.
|
|
||||||
3. Write a manual SQL migration that inserts the 8 tags (`ON CONFLICT DO NOTHING`), creates a
|
|
||||||
`<module>_management` bundle with `jsonb_agg(id ORDER BY id)` over that module, and
|
|
||||||
appends the bundle id to the chosen roles guarded by
|
|
||||||
`NOT (permissions @> jsonb_build_array(id))`.
|
|
||||||
4. Guard the routes with `require_permission(PermissionTag.<MODULE>_<ACTION>)`.
|
|
||||||
5. Add the route to `frontend/src/app/routes.js` with `permission: '<module>.view'`.
|
|
||||||
6. Users must re-fetch `/users/me` (log in again) before the UI reflects the change.
|
|
||||||
|
|
||||||
**Consequence of the seed, if nobody has edited the matrix:** `requisitions_management` gives
|
|
||||||
`requisitions.manage` to recruiter, hiring_manager and department_head. `is_admin()` is
|
|
||||||
therefore true for recruiter and department_head, so they see every candidate, offer and
|
|
||||||
requisition. Hiring managers stay scoped only because `is_hiring_manager` is checked first in
|
|
||||||
`scopes_to_own_requisitions`. Verify this against the live export before relying on it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Access Control editing (how grants change at runtime)
|
|
||||||
|
|
||||||
### 9.1 Endpoints — [backend/role/app.py](backend/role/app.py), [backend/role/views.py](backend/role/views.py)
|
|
||||||
|
|
||||||
| Method | Path | Tag | Behaviour |
|
|
||||||
|---|---|---|---|
|
|
||||||
| GET | `/roles/fetch[?record_id]` | `rbac_users.view` | Each role is expanded to `{..., permissions:[bundle ids], bundles:[bundle payloads with tag_names], effective_permissions:[resolved tag names]}` |
|
|
||||||
| POST | `/roles/create` | `rbac_users.create` | `role_name` required (400); duplicate → 409 `"Role name already exists"`; always `is_system=false` |
|
|
||||||
| PUT | `/roles/update?record_id` | `rbac_users.edit` | Partial update. Renaming a system role → 409 `"System roles cannot be renamed"`. May replace `permissions` (bundle ids) |
|
|
||||||
| DELETE | `/roles/delete?record_id` | `rbac_users.delete` | Soft delete. System role → 409 `"System roles cannot be deleted"` |
|
|
||||||
| PUT | `/roles/matrix/update?record_id` | `rbac_users.edit` | **Matrix save**, see §9.2 |
|
|
||||||
| GET | `/permissions/fetch` | `rbac_users.view` | Bundles with `tag_names` |
|
|
||||||
| POST | `/permissions/create` | `rbac_users.manage` | Always `is_system=false`; name clash → 409 |
|
|
||||||
| PUT | `/permissions/update?record_id` | `rbac_users.manage` | Renaming a system bundle → 409 |
|
|
||||||
| PUT | `/roles/permission-tags/update` | `rbac_users.manage` | Body `{id: bundleId, permission_tags:[...], name?, description?, is_active?}` sets the exact tag set on one shared bundle. Unknown or inactive tag ids → 422 `"Unknown or inactive permission tag ids: [...]"`. Every role holding the bundle is affected immediately |
|
|
||||||
| GET | `/permission-tags/fetch` | `rbac_users.view` | Ordered by module, action |
|
|
||||||
|
|
||||||
All list endpoints accept `search`, `top`, `skip` and return `{data, total, status_code}`.
|
|
||||||
404s: `"Role not found"`, `"Permission bundle not found"`, `"Permission tag not found"`.
|
|
||||||
|
|
||||||
### 9.2 Matrix save — `Role.set_role_matrix(role_id, tag_ids)`
|
|
||||||
|
|
||||||
```
|
|
||||||
role must exist and not be deleted (404)
|
|
||||||
tag_ids = sorted(unique(tag_ids))
|
|
||||||
unknown or inactive ids → 422 "Unknown or inactive permission tag ids: [...]"
|
|
||||||
overlay = bundle named f"role_{role.id}_matrix"
|
|
||||||
if overlay is missing: create it (is_system=false, description "Access Control matrix for {role_name}")
|
|
||||||
else: overwrite its permission_tags
|
|
||||||
role.permissions = [overlay.id] # REPLACES every other bundle on the role
|
|
||||||
return the role payload
|
|
||||||
```
|
|
||||||
|
|
||||||
Shared system bundles are never mutated by the matrix. After the first save, a role's grant is
|
|
||||||
exactly the ticked cells. The seeded bundles no longer apply to that role, even though they
|
|
||||||
still exist.
|
|
||||||
|
|
||||||
### 9.3 Access Control screen — [frontend/src/screens/Rbac.jsx](frontend/src/screens/Rbac.jsx)
|
|
||||||
|
|
||||||
- Route `rbac`, gated on `rbac_users.view`.
|
|
||||||
- The role list hides `role_name === 'candidate'`. System roles show a "System role" badge and
|
|
||||||
have no delete action.
|
|
||||||
- The matrix has rows = modules and columns = actions. Both are derived from `/permission-tags/fetch`
|
|
||||||
in first-seen (id) order. A cell renders only if that tag exists; otherwise it shows `·`.
|
|
||||||
- The draft starts from `role.effective_permissions`. Toggles are disabled without
|
|
||||||
`rbac_users.edit`. Save is enabled only when the draft differs; it maps names to ids and
|
|
||||||
calls `PUT /roles/matrix/update`.
|
|
||||||
- Tooltip help: `requisitions.configure` = "Limit Jobs and Candidates to requisitions this user
|
|
||||||
created. Independent of Create."; `candidates.manage` = "See every candidate, not only jobs
|
|
||||||
this user owns."; `requisitions.manage` = "Org-wide requisition list (admin)."
|
|
||||||
- The New/Edit Role form edits name, description, active flag and **bundle** picks, and shows a
|
|
||||||
live preview of the union of `tag_names` from the picked bundles.
|
|
||||||
|
|
||||||
Settings screen: the *Approvals* tab lists `/users/pending-approvals` and its Approve button
|
|
||||||
requires `rbac_users.edit`. The *Users* tab shows Pending / Awaiting approval / Active badges.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Export the live grants before mirroring
|
|
||||||
|
|
||||||
Seeds and matrix edits diverge over time. Take the effective role → tag map from the database
|
|
||||||
rather than from §8:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT r.id, r.role_name, r.is_system, r.is_active, r.is_deleted,
|
|
||||||
string_agg(DISTINCT p.name, ', ') AS bundles,
|
|
||||||
count(DISTINCT t.id) AS tag_count,
|
|
||||||
string_agg(DISTINCT t.tag_name, ', ' ORDER BY t.tag_name) AS effective_tags
|
|
||||||
FROM app.roles r
|
|
||||||
LEFT JOIN LATERAL jsonb_array_elements_text(COALESCE(r.permissions, '[]'::jsonb)) rp(pid) ON true
|
|
||||||
LEFT JOIN app.permissions p
|
|
||||||
ON p.id = rp.pid::int AND p.is_active AND NOT p.is_deleted
|
|
||||||
LEFT JOIN LATERAL jsonb_array_elements_text(COALESCE(p.permission_tags, '[]'::jsonb)) pt(tid) ON true
|
|
||||||
LEFT JOIN app.permission_tags t
|
|
||||||
ON t.id = pt.tid::int AND t.is_active AND NOT t.is_deleted
|
|
||||||
WHERE r.is_active AND NOT r.is_deleted
|
|
||||||
GROUP BY r.id
|
|
||||||
ORDER BY r.id;
|
|
||||||
```
|
|
||||||
|
|
||||||
Also dump `app.permissions` (id, name, is_system, permission_tags) and `app.permission_tags`
|
|
||||||
(id, tag_name) **with their ids**. Bundle and role arrays reference ids, so the ids must be
|
|
||||||
preserved.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Frontend mirror (cosmetic gating; the server is authoritative)
|
|
||||||
|
|
||||||
### 11.1 Session and permission bootstrap — [frontend/src/auth/AuthProvider.jsx](frontend/src/auth/AuthProvider.jsx)
|
|
||||||
|
|
||||||
- The session (tokens + `data`) lives in `lib/tokenStore`. `GET /users/me` runs as a TanStack
|
|
||||||
Query (`staleTime` 5 min, `retry: false`) whenever an access token exists. Its result is merged
|
|
||||||
into the stored session so a page reload already has `permissions`.
|
|
||||||
- `status` is `anonymous` (no token), `error` (me failed), `authenticated` (me data or
|
|
||||||
cached permissions present), or `loading`.
|
|
||||||
- `can(tag)` comes from `makeCan(permissions)`: **a null or undefined tag is always allowed**,
|
|
||||||
otherwise it is set membership.
|
|
||||||
- Sign-in invalidates the `me` query so the previous user's permissions cannot leak. Sign-out
|
|
||||||
only clears client state. When a refresh fails, the user is sent to `/auth/login?expired=1`.
|
|
||||||
|
|
||||||
### 11.2 Route guard — [frontend/src/auth/RequireAuth.jsx](frontend/src/auth/RequireAuth.jsx)
|
|
||||||
|
|
||||||
`anonymous` → redirect to `/auth/login` (keeping `from`); `error` → `/auth/login?expired=1`;
|
|
||||||
`loading` → full-page spinner, so the nav does not flash; a `permission` the user lacks →
|
|
||||||
`<Forbidden/>` ("You don't have access to this page").
|
|
||||||
|
|
||||||
### 11.3 Route table — [frontend/src/app/routes.js](frontend/src/app/routes.js)
|
|
||||||
|
|
||||||
| path | permission | | path | permission |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| dashboard | `dashboard.view` | | interviews | `interviews.view` |
|
|
||||||
| inbox | `inbox.view` | | requisitions | `requisitions.view` |
|
|
||||||
| matching (hidden) | `candidates.view` | | assessments | `assessments.view` |
|
|
||||||
| jobs | `jobs.view` | | offers | `offers.view` |
|
|
||||||
| candidates | `candidates.view` | | managers | `jobs.view` |
|
|
||||||
| cvbank | `candidates.view` | | departments | `department.view` |
|
|
||||||
| pipeline | `pipeline.view` | | calendar | `interviews.view` |
|
|
||||||
| progress | `jobs.view` | | reports | `reports.view` |
|
|
||||||
| import | `candidates.create` | | analytics | `analytics.view` |
|
|
||||||
| jobboard | `job_board.view` | | aistudio | *null* |
|
|
||||||
| recruiterhub | `analytics.view` | | notifications | *null* |
|
|
||||||
| talent | `talent.view` | | rbac | `rbac_users.view` |
|
|
||||||
| tasks | `tasks.view` | | settings | `settings.view` |
|
|
||||||
| aiassistant | *null* | | help | *null* |
|
|
||||||
|
|
||||||
Candidate detail sub-routes in `App.jsx` also require `candidates.view`.
|
|
||||||
|
|
||||||
### 11.4 Sidebar — [frontend/src/app/Sidebar.jsx](frontend/src/app/Sidebar.jsx)
|
|
||||||
|
|
||||||
A route is shown when `!hidden && can(permission) && (!isHiringManager(user) ||
|
|
||||||
HIRING_MANAGER_NAV.has(path))`, where
|
|
||||||
`HIRING_MANAGER_NAV = {candidates, requisitions, interviews, calendar, help, aiassistant, aistudio, notifications}`.
|
|
||||||
A group heading renders only if at least one of its items survives.
|
|
||||||
|
|
||||||
### 11.5 Hiring-manager portal behaviour
|
|
||||||
|
|
||||||
- `Dashboard` redirects hiring managers to `/candidates`.
|
|
||||||
- `Candidates` renders `<HiringManagerCandidates/>` for them; other users get the scoped or
|
|
||||||
unscoped list via `seesAllCandidates` / `scopesToOwnRequisitions`.
|
|
||||||
- The Favorite button on the candidate profile is hidden for hiring managers.
|
|
||||||
|
|
||||||
### 11.6 In-screen action gating (examples to mirror)
|
|
||||||
|
|
||||||
`jobs.edit` / `jobs.delete` on Jobs; `job_board.create` for Post Job; `pipeline.edit` to move a
|
|
||||||
stage; `candidates.create` for notes and ATS re-run; `candidates.edit` for rating, favorite and
|
|
||||||
matching assignment; `interviews.create || candidates.create` to schedule an interview;
|
|
||||||
`offers.create` / `offers.edit` on the offer form; `assessments.create|edit|delete`;
|
|
||||||
`reports.create|delete|export`; `requisitions.create|edit`; `department.create|edit`;
|
|
||||||
`inbox.edit`; `talent.edit`; `settings.configure`; `rbac_users.edit` for approvals;
|
|
||||||
`tasks.view|edit` on Recruiter Hub.
|
|
||||||
Tasks "create" requires `can('tasks.create') && ['system_administrator','hr_administrator','recruiter'].includes(user.role_name)`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Known behaviour to reproduce (or consciously fix)
|
|
||||||
|
|
||||||
Mirror these exactly unless you have been told to change them, and record any deviation.
|
|
||||||
|
|
||||||
1. **Unauthenticated data routes:** `GET /email/fetch` and `GET /inbox/fetch` have no auth
|
|
||||||
dependency. `GET /jobs/alias` is public.
|
|
||||||
2. **No escalation check on role or bundle edits:** a holder of `rbac_users.edit` can set any
|
|
||||||
tags on any role, **including their own**, through `/roles/update` or `/roles/matrix/update`.
|
|
||||||
A holder of `rbac_users.manage` can do the same through the bundle endpoints. Only user↔role
|
|
||||||
assignment is subset-checked (§5.5).
|
|
||||||
3. `POST /users/create` **without** `role_id` skips the `rbac_users.manage` check entirely.
|
|
||||||
4. Hardcoded ids: signup assigns `role_id = 4`. `Users.get_users`, `count_users`,
|
|
||||||
`get_user_by_id`, `get_user_by_email` and `get_pending_approvals` exclude `role_id = 8`.
|
|
||||||
Other code resolves roles by name.
|
|
||||||
5. Name-based identities are matched case-insensitively and include legacy aliases: `manager`
|
|
||||||
→ hiring manager, `admin` → admin.
|
|
||||||
6. `requisitions.manage` makes a user an **admin** for candidate, offer and requisition scoping,
|
|
||||||
not only for requisitions.
|
|
||||||
7. No token revocation. A refresh token stays valid for 7 days after sign-out. Permission
|
|
||||||
changes apply server-side on the next request, but the UI updates only after `/users/me`
|
|
||||||
is refetched (re-login).
|
|
||||||
8. Users on a deleted or inactive role can still log in, but they hold zero tags.
|
|
||||||
9. Route handlers wrap unexpected exceptions as `HTTPException(500, detail=str(e))`, and the
|
|
||||||
frontend may present any error as a permissions problem.
|
|
||||||
10. Comments in `frontend/src/auth/permissions.js` and `routes.js` saying enforcement is
|
|
||||||
"cosmetic, only /users/*, /roles/*, /permissions/* are enforced" are **stale**. As the
|
|
||||||
table below shows, almost every route is now guarded server-side.
|
|
||||||
11. `/email/sync` accepts either a JWT whose user holds `inbox.edit`, or a static
|
|
||||||
`CRON_INBOX_SYNC_TOKEN` compared in constant time, for the scheduler.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Acceptance checks for a faithful mirror
|
|
||||||
|
|
||||||
- The server refuses to boot if the tag enum is not the full modules × actions product.
|
|
||||||
- With no role → 403 `"User has no role assigned"` on any guarded route, while `/users/me` still returns 200.
|
|
||||||
- Deactivating a bundle removes its tags from every role on the very next request, with no re-login.
|
|
||||||
- Saving the matrix for role R creates or updates `role_R_matrix` and sets `R.permissions = [that id]`.
|
|
||||||
- Assigning a role that holds a tag the caller lacks → 403, and the message lists the missing tags.
|
|
||||||
- A hiring manager with every admin tag is still requisition-scoped.
|
|
||||||
- A custom role with `requisitions.create` alone is **not** scoped. Adding `requisitions.configure` scopes it; adding `candidates.manage` unscopes it.
|
|
||||||
- A recruiter without `candidates.manage` or `requisitions.manage` sees only jobs where they are a current recruiter, or which they created and which have no recruiters.
|
|
||||||
- Task creation by `department_head`, even when holding `tasks.create`, → 403.
|
|
||||||
- A frontend `can(null)` is true, and hiring managers see only the 8 locked nav items.
|
|
||||||
- The frontend predicate tests in [frontend/permissions-scope.test.mjs](frontend/permissions-scope.test.mjs) pass against your implementation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Appendix A — Every backend route and its guard
|
|
||||||
|
|
||||||
`AND` = `require_all=True`; `OR` = `require_all=False`. Row scoping (§6) and role-name rules
|
|
||||||
(§7) apply on top of these guards. Generated from the `@router` decorators in `backend/*/app.py`.
|
|
||||||
|
|
||||||
| Domain | Method | Path | Required |
|
|
||||||
|---|---|---|---|
|
|
||||||
| analytics | GET | `/analytics/kpis/fetch` | analytics.view |
|
|
||||||
| analytics | GET | `/analytics/funnel/fetch` | analytics.view |
|
|
||||||
| analytics | GET | `/analytics/hiring-trend/fetch` | analytics.view |
|
|
||||||
| analytics | GET | `/analytics/source-performance/fetch` | analytics.view |
|
|
||||||
| analytics | GET | `/analytics/applications-per-job/fetch` | analytics.view |
|
|
||||||
| analytics | POST | `/analytics/ask` | analytics.view |
|
|
||||||
| analytics | GET | `/analytics/recruiter-performance/fetch` | analytics.view |
|
|
||||||
| assessments | GET | `/assessments/fetch` | assessments.view |
|
|
||||||
| assessments | GET | `/assessments/counts` | assessments.view |
|
|
||||||
| assessments | POST | `/assessments/create` | assessments.create |
|
|
||||||
| assessments | PATCH | `/assessments/update` | assessments.edit |
|
|
||||||
| assessments | DELETE | `/assessments/delete` | assessments.delete |
|
|
||||||
| assessments | POST | `/assessments/remind` | assessments.edit |
|
|
||||||
| candidate_forms | GET | `/forms/requisition/search` | requisitions.view OR job_board.create OR jobs.create |
|
|
||||||
| candidate_forms | GET | `/forms/requisition/fetch` | requisitions.view |
|
|
||||||
| candidate_forms | POST | `/forms/requisition/create` | requisitions.create |
|
|
||||||
| candidate_forms | PATCH | `/forms/requisition/update` | requisitions.edit |
|
|
||||||
| candidate_forms | GET | `/forms/definitions` | interviews.view |
|
|
||||||
| candidate_forms | GET | `/forms/fetch` | interviews.view |
|
|
||||||
| candidate_forms | POST | `/forms/create` | interviews.create |
|
|
||||||
| candidate_forms | PATCH | `/forms/update` | interviews.edit |
|
|
||||||
| candidate_forms | DELETE | `/forms/delete` | interviews.delete |
|
|
||||||
| department | GET | `/department/fetch` | department.view |
|
|
||||||
| department | POST | `/department/create` | department.create |
|
|
||||||
| department | PUT | `/department/update` | department.edit |
|
|
||||||
| department | GET | `/department/heads/fetch` | department.create OR department.edit |
|
|
||||||
| forget_password | POST | `/users/forget-password` | PUBLIC |
|
|
||||||
| forget_password | POST | `/users/forget-password/verify-code` | PUBLIC |
|
|
||||||
| forget_password | POST | `/users/forget-password/new-password` | PUBLIC |
|
|
||||||
| g_sheet | GET | `/sheet/health` | PUBLIC |
|
|
||||||
| g_sheet | GET | `/sheet/metadata` | settings.view |
|
|
||||||
| g_sheet | GET | `/sheet/tabs` | settings.view |
|
|
||||||
| g_sheet | GET | `/sheet/fetch` | settings.view |
|
|
||||||
| g_sheet | POST | `/sheet/import` | settings.edit |
|
|
||||||
| g_sheet | POST | `/sheet/{tab}/import` | settings.edit |
|
|
||||||
| g_sheet | GET | `/sheet/import/fetch` | settings.view |
|
|
||||||
| g_sheet | GET | `/sheet/form-data/sheets` | inbox.view OR settings.view |
|
|
||||||
| g_sheet | GET | `/sheet/form-data/fetch` | inbox.view OR settings.view |
|
|
||||||
| g_sheet | GET | `/sheet/form-data/counts` | inbox.view OR settings.view |
|
|
||||||
| g_sheet | GET | `/sheet/form-data/count` | inbox.view OR settings.view |
|
|
||||||
| g_sheet | GET | `/sheet/form-data/{record_id}` | inbox.view OR settings.view |
|
|
||||||
| g_sheet | PATCH | `/sheet/form-data/{record_id}/assign-job-post` | inbox.edit OR settings.edit |
|
|
||||||
| g_sheet | PATCH | `/sheet/form-data/{record_id}/processing-state` | inbox.edit OR settings.edit |
|
|
||||||
| g_sheet | PATCH | `/sheet/form-data/{record_id}/duplicate` | inbox.edit OR settings.edit |
|
|
||||||
| g_sheet | DELETE | `/sheet/form-data/{tab}/delete` | settings.delete |
|
|
||||||
| g_sheet | POST | `/sheet/{tab}/append` | settings.edit |
|
|
||||||
| g_sheet | PATCH | `/sheet/{tab}/update` | settings.edit |
|
|
||||||
| g_sheet | POST | `/sheet/{tab}/clear` | settings.edit |
|
|
||||||
| inbox | GET | `/email/fetch` | PUBLIC |
|
|
||||||
| inbox | POST | `/email/sync` | custom: inbox_sync_caller |
|
|
||||||
| inbox | GET | `/email/sync/fetch` | inbox.view |
|
|
||||||
| inbox | GET | `/inbox/fetch` | PUBLIC |
|
|
||||||
| inbox | POST | `/inbox/{record_id}/match` | inbox.edit |
|
|
||||||
| inbox | PATCH | `/inbox/{record_id}/assign-job-post` | inbox.edit |
|
|
||||||
| inbox | PATCH | `/inbox/{record_id}/assign-recruiter` | inbox.edit |
|
|
||||||
| inbox | POST | `/inbox/{record_id}/read` | inbox.edit |
|
|
||||||
| inbox | PATCH | `/inbox/read` | inbox.edit |
|
|
||||||
| inbox | PATCH | `/inbox/read-all` | inbox.edit |
|
|
||||||
| inbox | GET | `/inbox/{record_id}/read-status` | inbox.edit |
|
|
||||||
| inbox | GET | `/inbox/all-applications` | inbox.view |
|
|
||||||
| inbox | GET | `/inbox/all-applications/count` | inbox.view |
|
|
||||||
| inbox | GET | `/inbox/counts` | inbox.view |
|
|
||||||
| inbox | GET | `/inbox/triage` | inbox.view |
|
|
||||||
| inbox | PATCH | `/inbox/triage/{record_id}/override` | inbox.edit |
|
|
||||||
| inbox | PATCH | `/inbox/{record_id}/processing-state` | inbox.edit |
|
|
||||||
| inbox | PATCH | `/inbox/{record_id}/duplicate` | inbox.edit |
|
|
||||||
| inbox | POST | `/email/send` | inbox.edit |
|
|
||||||
| inbox | POST | `/email/reply` | inbox.edit |
|
|
||||||
| inbox | POST | `/inbox/rescan-on-hold` | inbox.edit |
|
|
||||||
| inbox | GET | `/inbox/rescan-on-hold` | inbox.view |
|
|
||||||
| interview | POST | `/interview/{interview_id}/calendar-event` | interviews.create |
|
|
||||||
| interview | PATCH | `/interview/{interview_id}/calendar-event/reschedule` | interviews.edit |
|
|
||||||
| interview | POST | `/interview/{interview_id}/calendar-event/cancel` | interviews.edit |
|
|
||||||
| job | GET | `/jobs/alias` | PUBLIC |
|
|
||||||
| job | POST | `/candidate/create/candidate` | candidates.create |
|
|
||||||
| job | GET | `/candidate/fetch/users` | candidates.view |
|
|
||||||
| job | GET | `/candidate/fetch/users/count` | candidates.view |
|
|
||||||
| job | POST | `/candidate/cv_upload` | candidates.create |
|
|
||||||
| job | POST | `/candidate/cv-bank/upload` | candidates.create |
|
|
||||||
| job | GET | `/candidate/cv-bank/fetch` | candidates.view |
|
|
||||||
| job | POST | `/candidate/cv-bank/score` | candidates.create |
|
|
||||||
| job | GET | `/candidate/cv-bank/suggestions` | candidates.view |
|
|
||||||
| job | GET | `/candidate/cv-bank/file` | candidates.view |
|
|
||||||
| job | DELETE | `/candidate/cv-bank/delete` | candidates.delete |
|
|
||||||
| job | GET | `/candidate/matching/fetch` | candidates.view |
|
|
||||||
| job | GET | `/candidate/matching/fetch_by_id` | candidates.view |
|
|
||||||
| job | POST | `/candidate/matching/assign` | candidates.edit |
|
|
||||||
| job | POST | `/candidate/inbox-match` | candidates.edit |
|
|
||||||
| job | POST | `/job/post-job` | job_board.create |
|
|
||||||
| job | POST | `/job/image/upload` | job_board.create OR jobs.edit |
|
|
||||||
| job | GET | `/job/image/fetch` | jobs.view OR job_board.view |
|
|
||||||
| job | POST | `/job/assist-field` | job_board.create OR jobs.edit |
|
|
||||||
| job | GET | `/job/buffer/channels` | job_board.view |
|
|
||||||
| job | POST | `/candidate/score` | candidates.create |
|
|
||||||
| job | POST | `/candidate/score_inbox` | candidates.create |
|
|
||||||
| job | POST | `/candidate/ats-rerun` | candidates.create |
|
|
||||||
| job | GET | `/candidate/scored/fetch` | candidates.view |
|
|
||||||
| job | GET | `/job/fetch` | job_board.view OR candidates.view OR talent.view |
|
|
||||||
| job | GET | `/job/stats/fetch` | jobs.view OR pipeline.view |
|
|
||||||
| job | GET | `/job/departments/fetch` | job_board.view OR candidates.view OR talent.view OR jobs.view |
|
|
||||||
| job | GET | `/jobs/requisition-statuses/fetch` | jobs.view |
|
|
||||||
| job | GET | `/jobs/status-history/fetch` | jobs.view |
|
|
||||||
| job | GET | `/jobs/fetch` | jobs.view |
|
|
||||||
| job | GET | `/jobs/export` | jobs.export |
|
|
||||||
| job | GET | `/candidate/fetch_by_id` | candidates.view |
|
|
||||||
| job | GET | `/candidate/manager/fetch` | candidates.view |
|
|
||||||
| job | GET | `/candidate/fetch` | candidates.view |
|
|
||||||
| job | GET | `/candidate/applications/fetch` | candidates.view |
|
|
||||||
| job | PATCH | `/candidate/update` | candidates.edit |
|
|
||||||
| job | GET | `/candidate/history/fetch` | candidates.view |
|
|
||||||
| job | GET | `/interview/fetch` | interviews.view OR candidates.view |
|
|
||||||
| job | POST | `/interview/create` | interviews.create OR candidates.create |
|
|
||||||
| job | PATCH | `/interview/update` | interviews.edit OR candidates.edit |
|
|
||||||
| job | GET | `/notes/fetch` | candidates.view |
|
|
||||||
| job | POST | `/notes/create` | candidates.create |
|
|
||||||
| job | PATCH | `/notes/update` | candidates.edit |
|
|
||||||
| job | GET | `/activity/fetch` | candidates.view |
|
|
||||||
| job | POST | `/activity/create` | candidates.create |
|
|
||||||
| job | GET | `/feedback/fetch` | candidates.view |
|
|
||||||
| job | POST | `/feedback/create` | candidates.create |
|
|
||||||
| job | PATCH | `/feedback/update` | candidates.edit |
|
|
||||||
| job | PATCH | `/candidate/stage` | pipeline.edit |
|
|
||||||
| job | GET | `/pipeline/candidates/fetch` | pipeline.view |
|
|
||||||
| job | GET | `/pipeline/candidate/score/fetch` | pipeline.view |
|
|
||||||
| job | GET | `/pipeline/transitions/fetch` | pipeline.view |
|
|
||||||
| job | GET | `/job/assignments/fetch` | jobs.view |
|
|
||||||
| job | POST | `/job/assignments/create` | jobs.edit |
|
|
||||||
| job | GET | `/candidate/assignments/fetch` | candidates.view |
|
|
||||||
| job | POST | `/candidate/assignments/create` | candidates.edit |
|
|
||||||
| job | GET | `/job/costs/fetch` | jobs.view |
|
|
||||||
| job | GET | `/job/costs/source-channels/fetch` | jobs.view |
|
|
||||||
| job | POST | `/job/costs/create` | jobs.edit |
|
|
||||||
| job | PATCH | `/jobs/update` | jobs.edit |
|
|
||||||
| job | DELETE | `/jobs/delete` | jobs.delete |
|
|
||||||
| job | PATCH | `/jobs/status` | jobs.edit |
|
|
||||||
| job | GET | `/feedback/templates/fetch` | candidates.view |
|
|
||||||
| job | POST | `/feedback/templates/create` | candidates.create |
|
|
||||||
| job | PATCH | `/feedback/templates/update` | candidates.edit |
|
|
||||||
| job | DELETE | `/feedback/templates/delete` | candidates.delete |
|
|
||||||
| job | GET | `/documents/download` | candidates.view |
|
|
||||||
| notifications | POST | `/users/confirm-email` | PUBLIC |
|
|
||||||
| notifications | POST | `/users/confirm-email/resend` | PUBLIC |
|
|
||||||
| notifications | GET | `/notifications/fetch` | any authenticated user |
|
|
||||||
| notifications | POST | `/notifications/{record_id}/read` | any authenticated user |
|
|
||||||
| notifications | POST | `/notifications/read-all` | any authenticated user |
|
|
||||||
| notifications | DELETE | `/notifications/delete` | any authenticated user |
|
|
||||||
| offer | GET | `/offers/fetch` | offers.view |
|
|
||||||
| offer | POST | `/offers/create` | offers.create |
|
|
||||||
| offer | PATCH | `/offers/update` | offers.edit |
|
|
||||||
| offer | POST | `/offers/issue` | offers.approve |
|
|
||||||
| offer | GET | `/offers/jobs/candidates/lists` | offers.view |
|
|
||||||
| offer | POST | `/offers/jobs/sent` | offers.create |
|
|
||||||
| org_settings | GET | `/org-settings/fetch` | settings.view |
|
|
||||||
| org_settings | PUT | `/org-settings/update` | settings.configure |
|
|
||||||
| org_settings | GET | `/org-settings/exclude-university/fetch` | settings.view |
|
|
||||||
| org_settings | POST | `/org-settings/exclude-university/create` | settings.configure |
|
|
||||||
| org_settings | POST | `/org-settings/exclude-university/create-batch` | settings.configure |
|
|
||||||
| org_settings | PATCH | `/org-settings/exclude-university/update` | settings.configure |
|
|
||||||
| org_settings | DELETE | `/org-settings/exclude-university/delete` | settings.configure |
|
|
||||||
| org_settings | GET | `/org-settings/exclude-company/fetch` | settings.view |
|
|
||||||
| org_settings | POST | `/org-settings/exclude-company/create` | settings.configure |
|
|
||||||
| org_settings | POST | `/org-settings/exclude-company/create-batch` | settings.configure |
|
|
||||||
| org_settings | PATCH | `/org-settings/exclude-company/update` | settings.configure |
|
|
||||||
| org_settings | DELETE | `/org-settings/exclude-company/delete` | settings.configure |
|
|
||||||
| reports | GET | `/reports/fetch` | reports.view |
|
|
||||||
| reports | POST | `/reports/create` | reports.create |
|
|
||||||
| reports | PATCH | `/reports/update` | reports.edit |
|
|
||||||
| reports | DELETE | `/reports/delete` | reports.delete |
|
|
||||||
| reports | POST | `/reports/run` | reports.view |
|
|
||||||
| reports | GET | `/reports/export` | reports.export |
|
|
||||||
| reports | GET | `/reports/runs/fetch` | reports.view |
|
|
||||||
| role | GET | `/roles/fetch` | rbac_users.view |
|
|
||||||
| role | POST | `/roles/create` | rbac_users.create |
|
|
||||||
| role | PUT | `/roles/update` | rbac_users.edit |
|
|
||||||
| role | DELETE | `/roles/delete` | rbac_users.delete |
|
|
||||||
| role | GET | `/permissions/fetch` | rbac_users.view |
|
|
||||||
| role | POST | `/permissions/create` | rbac_users.manage |
|
|
||||||
| role | PUT | `/permissions/update` | rbac_users.manage |
|
|
||||||
| role | PUT | `/roles/matrix/update` | rbac_users.edit |
|
|
||||||
| role | PUT | `/roles/permission-tags/update` | rbac_users.manage |
|
|
||||||
| role | GET | `/permission-tags/fetch` | rbac_users.view |
|
|
||||||
| s3 | GET | `/s3/health` | PUBLIC |
|
|
||||||
| s3 | POST | `/s3/upload` | candidates.create OR settings.edit |
|
|
||||||
| s3 | GET | `/s3/url` | candidates.view OR settings.view |
|
|
||||||
| s3 | GET | `/s3/open` | candidates.view OR settings.view OR inbox.view |
|
|
||||||
| s3 | GET | `/s3/download` | candidates.view OR settings.view OR inbox.view |
|
|
||||||
| s3 | POST | `/s3/delete` | candidates.delete OR settings.delete |
|
|
||||||
| saved_search | GET | `/saved-searches/fetch` | any authenticated user |
|
|
||||||
| saved_search | POST | `/saved-searches/create` | any authenticated user |
|
|
||||||
| saved_search | PATCH | `/saved-searches/update` | any authenticated user |
|
|
||||||
| saved_search | DELETE | `/saved-searches/delete` | any authenticated user |
|
|
||||||
| search | GET | `/search/fetch` | jobs.view OR candidates.view |
|
|
||||||
| talent | POST | `/talent/runs/start` | talent.create |
|
|
||||||
| talent | GET | `/talent/runs/status` | talent.view |
|
|
||||||
| talent | GET | `/talent/account` | talent.view |
|
|
||||||
| talent | GET | `/talent/runs/fetch` | talent.view |
|
|
||||||
| talent | GET | `/talent/profiles/fetch` | talent.view |
|
|
||||||
| talent | GET | `/talent/profiles/fetch_by_id` | talent.view |
|
|
||||||
| talent | PATCH | `/talent/profiles/outreach` | talent.edit |
|
|
||||||
| talent | DELETE | `/talent/profiles/delete` | talent.delete |
|
|
||||||
| tasks | GET | `/tasks/fetch` | tasks.view |
|
|
||||||
| tasks | GET | `/tasks/assignees/fetch` | tasks.view |
|
|
||||||
| tasks | POST | `/tasks/create` | tasks.create |
|
|
||||||
| tasks | PATCH | `/tasks/update` | tasks.edit |
|
|
||||||
| tasks | DELETE | `/tasks/delete` | tasks.delete |
|
|
||||||
| users | POST | `/users/login` | PUBLIC |
|
|
||||||
| users | POST | `/users/signup` | PUBLIC |
|
|
||||||
| users | POST | `/users/refresh` | PUBLIC |
|
|
||||||
| users | GET | `/users/me` | any authenticated user |
|
|
||||||
| users | POST | `/users/create` | rbac_users.create |
|
|
||||||
| users | GET | `/users/pending-approvals` | settings.view |
|
|
||||||
| users | PUT | `/users/approve` | rbac_users.edit |
|
|
||||||
| users | GET | `/users/fetch` | rbac_users.view |
|
|
||||||
| users | PUT | `/users/update` | rbac_users.edit |
|
|
||||||
| users | PUT | `/users/assign-role` | rbac_users.edit |
|
|
||||||
| users | PUT | `/users/remove-role` | rbac_users.edit |
|
|
||||||
| users | DELETE | `/users/delete` | rbac_users.delete |
|
|
||||||
| users | GET | `/managers/fetch` | jobs.view OR candidates.view OR job_board.create |
|
|
||||||
12
appspec.yml
12
appspec.yml
|
|
@ -1,12 +0,0 @@
|
||||||
version: 0.0
|
|
||||||
os: linux
|
|
||||||
files:
|
|
||||||
- source: /
|
|
||||||
destination: /opt/codedeploy-extracted-utopia-ai-hr-ats-portal-dev
|
|
||||||
file_exists_behavior: OVERWRITE
|
|
||||||
|
|
||||||
hooks:
|
|
||||||
AfterInstall:
|
|
||||||
- location: deploy_utopia-ai-hr-ats-portal-dev.sh
|
|
||||||
timeout: 600
|
|
||||||
runas: root
|
|
||||||
|
|
@ -99,22 +99,6 @@ async def search_requisitions(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
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")
|
@router.get("/forms/requisition/fetch")
|
||||||
async def fetch_requisition_form(
|
async def fetch_requisition_form(
|
||||||
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)),
|
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)),
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@ import uuid
|
||||||
from datetime import datetime, date as Date, timezone
|
from datetime import datetime, date as Date, timezone
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Enum as SAEnum, JSON, and_, func, or_
|
from sqlalchemy import DateTime, Enum as SAEnum, JSON, func, or_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import Field, Relationship, SQLModel, select
|
from sqlmodel import Field, Relationship, SQLModel, select
|
||||||
|
|
||||||
from candidate_forms.enums import EmploymentType
|
from candidate_forms.enums import EmploymentType
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from job.job_post.models import JobPosts
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
|
|
@ -130,32 +131,6 @@ class Requisition(SQLModel, table=True):
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return list(result.scalars().all())
|
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
|
@classmethod
|
||||||
async def insert_form(cls, session: AsyncSession, fields: dict):
|
async def insert_form(cls, session: AsyncSession, fields: dict):
|
||||||
position = fields.get("position") if fields.get("position") else {}
|
position = fields.get("position") if fields.get("position") else {}
|
||||||
|
|
|
||||||
|
|
@ -435,11 +435,6 @@ class RequisitionForm:
|
||||||
rows = await Requisition.get_form_by_id(self.session, created_by=created_by)
|
rows = await Requisition.get_form_by_id(self.session, created_by=created_by)
|
||||||
return [serialize_requisition(r) for r in rows]
|
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):
|
async def search(self, q, top=50, job_post_id=None):
|
||||||
rows = await Requisition.search(
|
rows = await Requisition.search(
|
||||||
self.session, q, top=top, job_post_id=job_post_id,
|
self.session, q, top=top, job_post_id=job_post_id,
|
||||||
|
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from typing import Optional
|
|
||||||
from db_setup import get_session
|
|
||||||
from department.views import DepartmentService
|
|
||||||
from users.permissions import PermissionTag, require_permission
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class DepartmentCreate(BaseModel):
|
|
||||||
name: str = Field(min_length=1, max_length=120)
|
|
||||||
short_code: str = Field(min_length=1, max_length=10)
|
|
||||||
subtitle: str | None = Field(default=None, max_length=160)
|
|
||||||
description: str | None = None
|
|
||||||
is_active: bool = True
|
|
||||||
department_head_id: uuid.UUID | None = None
|
|
||||||
parent_department_id: uuid.UUID | None = None
|
|
||||||
location: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class DepartmentUpdate(BaseModel):
|
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
|
||||||
short_code: str | None = Field(default=None, min_length=1, max_length=10)
|
|
||||||
subtitle: str | None = Field(default=None, max_length=160)
|
|
||||||
description: str | None = None
|
|
||||||
is_active: bool | None = None
|
|
||||||
department_head_id: uuid.UUID | None = None
|
|
||||||
parent_department_id: uuid.UUID | None = None
|
|
||||||
location: list[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/department/fetch")
|
|
||||||
async def fetch_departments(
|
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.DEPARTMENT_VIEW)),
|
|
||||||
record_id: str | None = Query(None),
|
|
||||||
search: str | None = Query(None),
|
|
||||||
is_active: bool | None = Query(None),
|
|
||||||
top: int | None = Query(None, ge=1),
|
|
||||||
skip: int = Query(0, ge=0),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
service = DepartmentService(session=session)
|
|
||||||
if record_id is not None:
|
|
||||||
item = await service.get_department(record_id)
|
|
||||||
return JSONResponse(content={"data": item, "total": 1, "status_code": 200})
|
|
||||||
items, total = await service.get_departments(top, skip, search, is_active)
|
|
||||||
return JSONResponse(content={"data": items, "total": total, "status_code": 200})
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/department/create")
|
|
||||||
async def create_department(
|
|
||||||
body: DepartmentCreate,
|
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.DEPARTMENT_CREATE)),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
service = DepartmentService(session=session)
|
|
||||||
item = await service.create_department(body.model_dump(), current_user.get("id"))
|
|
||||||
return JSONResponse(status_code=201, content={"data": item, "status_code": 201})
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/department/update")
|
|
||||||
async def update_department(
|
|
||||||
body: DepartmentUpdate,
|
|
||||||
record_id: str = Query(...),
|
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.DEPARTMENT_EDIT)),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
service = DepartmentService(session=session)
|
|
||||||
item = await service.update_department(
|
|
||||||
record_id, body.model_dump(exclude_unset=True), current_user.get("id")
|
|
||||||
)
|
|
||||||
return JSONResponse(content={"data": item, "status_code": 200})
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/department/heads/fetch")
|
|
||||||
async def fetch_department_heads(
|
|
||||||
role_id:Optional[int] = Query(5),
|
|
||||||
top: Optional[int]=Query(None, ge=1),
|
|
||||||
skip: Optional[int]=Query(0, ge=0),
|
|
||||||
search: Optional[str]=Query(None),
|
|
||||||
current_user: dict = Depends(
|
|
||||||
require_permission(
|
|
||||||
PermissionTag.DEPARTMENT_CREATE,
|
|
||||||
PermissionTag.DEPARTMENT_EDIT,
|
|
||||||
require_all=False,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
service = DepartmentService(session=session)
|
|
||||||
items = await service.get_head_options(role_id,top,skip,search)
|
|
||||||
return JSONResponse(content={"data": items, "total": len(items), "status_code": 200})
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/department/locations/fetch")
|
|
||||||
async def fetch_department_locations(
|
|
||||||
search: Optional[str]=Query(None),
|
|
||||||
current_user: dict = Depends(
|
|
||||||
require_permission(
|
|
||||||
PermissionTag.DEPARTMENT_CREATE,
|
|
||||||
PermissionTag.DEPARTMENT_EDIT,
|
|
||||||
require_all=False,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
service = DepartmentService(session=session)
|
|
||||||
items = await service.get_location_options(search)
|
|
||||||
return JSONResponse(content={"data": items, "total": len(items), "status_code": 200})
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/department/names")
|
|
||||||
async def fetch_department_names(
|
|
||||||
search: Optional[str]=Query(None),
|
|
||||||
current_user: dict = Depends(
|
|
||||||
require_permission(
|
|
||||||
PermissionTag.DEPARTMENT_VIEW,
|
|
||||||
PermissionTag.JOB_BOARD_CREATE,
|
|
||||||
PermissionTag.JOBS_EDIT,
|
|
||||||
require_all=False,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
service = DepartmentService(session=session)
|
|
||||||
items = await service.get_department_names(search)
|
|
||||||
return JSONResponse(content={"data": items, "total": len(items), "status_code": 200})
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional, TYPE_CHECKING, List
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, func, or_
|
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlmodel import Field, SQLModel, select
|
|
||||||
from sqlmodel import Field, Relationship, SQLModel, select
|
|
||||||
from department.plugins import as_uuid, now_utc
|
|
||||||
from users.models import Users
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from job.job_post.models import JobPosts
|
|
||||||
|
|
||||||
class Department(SQLModel, table=True):
|
|
||||||
__tablename__ = "departments"
|
|
||||||
|
|
||||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
||||||
name: str = Field(index=True, unique=True)
|
|
||||||
short_code: str = Field(index=True, unique=True, max_length=10)
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
is_active: bool = Field(default=True)
|
|
||||||
parent_department_id: Optional[uuid.UUID] = Field(
|
|
||||||
default=None, index=True, foreign_key="departments.id"
|
|
||||||
)
|
|
||||||
department_head_id: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id")
|
|
||||||
location: list[str] = Field(
|
|
||||||
default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"}
|
|
||||||
)
|
|
||||||
created_at: datetime = Field(default_factory=now_utc, sa_type=DateTime(timezone=True))
|
|
||||||
updated_at: datetime = Field(default_factory=now_utc, sa_type=DateTime(timezone=True))
|
|
||||||
created_by: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id")
|
|
||||||
updated_by: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_department_names(cls, session: AsyncSession, search: str | None):
|
|
||||||
statement = select(cls.id, cls.name).where(cls.is_active == True)
|
|
||||||
if search:
|
|
||||||
pattern = f"%{search}%"
|
|
||||||
statement = statement.where(
|
|
||||||
or_(
|
|
||||||
cls.name.ilike(pattern),
|
|
||||||
cls.short_code.ilike(pattern),
|
|
||||||
cls.subtitle.ilike(pattern),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
statement = statement.order_by(cls.created_at.desc(),cls.id.desc())
|
|
||||||
result = await session.execute(statement)
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _filters(cls, search: str | None, is_active: bool | None):
|
|
||||||
clauses = []
|
|
||||||
if search:
|
|
||||||
pattern = f"%{search}%"
|
|
||||||
clauses.append(
|
|
||||||
or_(
|
|
||||||
cls.name.ilike(pattern),
|
|
||||||
cls.short_code.ilike(pattern),
|
|
||||||
cls.subtitle.ilike(pattern),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if is_active is not None:
|
|
||||||
clauses.append(cls.is_active == is_active)
|
|
||||||
return clauses
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_by_id(cls, session: AsyncSession, record_id) -> "Department | None":
|
|
||||||
uid = as_uuid(record_id)
|
|
||||||
if uid is None:
|
|
||||||
return None
|
|
||||||
result = await session.execute(select(cls).where(cls.id == uid))
|
|
||||||
return result.scalars().first()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_departments(
|
|
||||||
cls,
|
|
||||||
session: AsyncSession,
|
|
||||||
top: int | None = None,
|
|
||||||
skip: int = 0,
|
|
||||||
search: str | None = None,
|
|
||||||
is_active: bool | None = None,
|
|
||||||
) -> list["Department"]:
|
|
||||||
statement = select(cls).where(*cls._filters(search, is_active)).order_by(cls.name)
|
|
||||||
if skip:
|
|
||||||
statement = statement.offset(skip)
|
|
||||||
if top is not None:
|
|
||||||
statement = statement.limit(top)
|
|
||||||
result = await session.execute(statement)
|
|
||||||
return list(result.scalars().all())
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def count_departments(
|
|
||||||
cls,
|
|
||||||
session: AsyncSession,
|
|
||||||
search: str | None = None,
|
|
||||||
is_active: bool | None = None,
|
|
||||||
) -> int:
|
|
||||||
statement = select(func.count()).select_from(cls).where(*cls._filters(search, is_active))
|
|
||||||
result = await session.execute(statement)
|
|
||||||
return int(result.scalar_one())
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _commit(cls, session: AsyncSession, department: "Department") -> "Department":
|
|
||||||
"""Name/short-code uniqueness is the DB's unique indexes; IntegrityError propagates."""
|
|
||||||
session.add(department)
|
|
||||||
try:
|
|
||||||
await session.commit()
|
|
||||||
except IntegrityError:
|
|
||||||
await session.rollback()
|
|
||||||
raise
|
|
||||||
await session.refresh(department)
|
|
||||||
return department
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def insert_department(cls, session: AsyncSession, fields: dict) -> "Department":
|
|
||||||
return await cls._commit(session, cls(**fields))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def update_department(
|
|
||||||
cls, session: AsyncSession, record_id, fields: dict
|
|
||||||
) -> "Department | None":
|
|
||||||
department = await cls.get_by_id(session, record_id)
|
|
||||||
if not department:
|
|
||||||
return None
|
|
||||||
for key, value in fields.items():
|
|
||||||
setattr(department, key, value)
|
|
||||||
department.updated_at = now_utc()
|
|
||||||
return await cls._commit(session, department)
|
|
||||||
|
|
||||||
# @classmethod
|
|
||||||
# async def head_options(cls, session: AsyncSession):
|
|
||||||
# """Active users for the Department Head picker. COLUMN select, not the Users entity."""
|
|
||||||
# result = await session.execute(
|
|
||||||
# select(Users.id, Users.name, Users.email)
|
|
||||||
# .where(Users.is_deleted == False, Users.is_active == True) # noqa: E712
|
|
||||||
# .order_by(Users.name)
|
|
||||||
# )
|
|
||||||
# return result.all()
|
|
||||||
|
|
||||||
@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 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 []
|
|
||||||
result = await session.execute(
|
|
||||||
select(JobPosts.department_id, JobPosts.id, JobPosts.requisition_status)
|
|
||||||
.where(JobPosts.department_id.in_(ids), JobPosts.is_deleted == False) # noqa: E712
|
|
||||||
)
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def names_by_ids(cls, session: AsyncSession, ids) -> dict[uuid.UUID, str]:
|
|
||||||
uids = {i for i in (ids or []) if i}
|
|
||||||
if not uids:
|
|
||||||
return {}
|
|
||||||
result = await session.execute(select(cls.id, cls.name).where(cls.id.in_(uids)))
|
|
||||||
return {row[0]: row[1] for row in result.all()}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
|
|
||||||
def now_utc() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def as_uuid(record_id) -> uuid.UUID | None:
|
|
||||||
"""Parse a UUID from any id-ish value; None when blank or malformed."""
|
|
||||||
if record_id in (None, ""):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return uuid.UUID(str(record_id))
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def location_options(search=None) -> list[str]:
|
|
||||||
"""`{City} - {Country}` for every city in global_cities.Countries, optionally filtered."""
|
|
||||||
from global_cities import Countries
|
|
||||||
|
|
||||||
term = (search or "").strip().lower()
|
|
||||||
seen = set()
|
|
||||||
options = []
|
|
||||||
for country, cities in Countries.items():
|
|
||||||
for city in cities:
|
|
||||||
label = f"{city} - {country}"
|
|
||||||
if label in seen or (term and term not in label.lower()):
|
|
||||||
continue
|
|
||||||
seen.add(label)
|
|
||||||
options.append(label)
|
|
||||||
return options
|
|
||||||
|
|
||||||
|
|
||||||
def department_job_metrics(job_rows, applicants_by_job) -> dict:
|
|
||||||
"""Roll job-level rows up to {department_id: {job_posts, open_roles, candidates}}.
|
|
||||||
|
|
||||||
`job_rows` are (department_id, job_post_id, requisition_status); `applicants_by_job`
|
|
||||||
maps str(job_post_id) -> unique applicants on that job. Open roles are job posts
|
|
||||||
whose requisition_status is "open", the same meaning analytics uses.
|
|
||||||
"""
|
|
||||||
metrics = {}
|
|
||||||
for department_id, job_post_id, requisition_status in job_rows or []:
|
|
||||||
m = metrics.setdefault(department_id, {"job_posts": 0, "open_roles": 0, "candidates": 0})
|
|
||||||
m["job_posts"] += 1
|
|
||||||
if requisition_status == "open":
|
|
||||||
m["open_roles"] += 1
|
|
||||||
m["candidates"] += int(applicants_by_job.get(str(job_post_id), 0))
|
|
||||||
return metrics
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
from department.models import Department
|
|
||||||
|
|
||||||
|
|
||||||
def serialize_head_option(user) -> dict:
|
|
||||||
return {"id": str(user.id), "name": user.name, "email": user.email}
|
|
||||||
|
|
||||||
|
|
||||||
def serialize_department(
|
|
||||||
department: Department,
|
|
||||||
*,
|
|
||||||
head_name: str | None = None,
|
|
||||||
parent_name: str | None = None,
|
|
||||||
metrics: dict | None = None,
|
|
||||||
) -> dict:
|
|
||||||
return {
|
|
||||||
"id": str(department.id),
|
|
||||||
"name": department.name,
|
|
||||||
"short_code": department.short_code,
|
|
||||||
"subtitle": department.subtitle,
|
|
||||||
"description": department.description,
|
|
||||||
"is_active": department.is_active,
|
|
||||||
"parent_department_id": (
|
|
||||||
str(department.parent_department_id) if department.parent_department_id else None
|
|
||||||
),
|
|
||||||
"parent_department_name": parent_name,
|
|
||||||
"department_head_id": (
|
|
||||||
str(department.department_head_id) if department.department_head_id else None
|
|
||||||
),
|
|
||||||
"department_head_name": head_name,
|
|
||||||
"location": list(department.location or []),
|
|
||||||
"job_posts": (metrics or {}).get("job_posts", 0),
|
|
||||||
"open_roles": (metrics or {}).get("open_roles", 0),
|
|
||||||
"candidates": (metrics or {}).get("candidates", 0),
|
|
||||||
"created_by": str(department.created_by) if department.created_by else None,
|
|
||||||
"updated_by": str(department.updated_by) if department.updated_by else None,
|
|
||||||
"created_at": department.created_at.isoformat() if department.created_at else None,
|
|
||||||
"updated_at": department.updated_at.isoformat() if department.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
from fastapi import HTTPException
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from department.models import Department
|
|
||||||
from department.plugins import as_uuid,department_job_metrics,location_options
|
|
||||||
from department.serializers import serialize_department,serialize_head_option
|
|
||||||
from job.job_post.models import JobPosts
|
|
||||||
from users.models import Users
|
|
||||||
|
|
||||||
def serialize_department_name(row):
|
|
||||||
return {"id":str(row.id),"name":row.name}
|
|
||||||
|
|
||||||
class DepartmentService:
|
|
||||||
def __init__(self,session:AsyncSession):
|
|
||||||
self.session=session
|
|
||||||
|
|
||||||
async def get_department_names(self,search):
|
|
||||||
rows=await Department.get_department_names(self.session,search)
|
|
||||||
return [serialize_department_name(row) for row in rows]
|
|
||||||
|
|
||||||
async def _serialize_many(self,departments):
|
|
||||||
head_names=await Users.names_by_ids(self.session,[d.department_head_id for d in departments])
|
|
||||||
parent_names=await Department.names_by_ids(self.session,[d.parent_department_id for d in departments])
|
|
||||||
job_rows=await Department.job_posts_for(self.session,[d.id for d in departments])
|
|
||||||
job_ids=list({str(r[1]) for r in job_rows})
|
|
||||||
applicants={}
|
|
||||||
if job_ids:
|
|
||||||
stats,_=await JobPosts.fetch_job_stats(self.session,ids=job_ids)
|
|
||||||
applicants={str(row["job_post_id"]):row["total_applicants"] for row in stats}
|
|
||||||
metrics=department_job_metrics(job_rows,applicants)
|
|
||||||
return [
|
|
||||||
serialize_department(
|
|
||||||
d,
|
|
||||||
head_name=head_names.get(str(d.department_head_id)),
|
|
||||||
parent_name=parent_names.get(d.parent_department_id),
|
|
||||||
metrics=metrics.get(d.id),
|
|
||||||
)
|
|
||||||
for d in departments
|
|
||||||
]
|
|
||||||
|
|
||||||
async def get_department(self,record_id):
|
|
||||||
department=await Department.get_by_id(self.session,record_id)
|
|
||||||
if not department:
|
|
||||||
raise HTTPException(status_code=404,detail="Department not found")
|
|
||||||
return (await self._serialize_many([department]))[0]
|
|
||||||
|
|
||||||
async def get_departments(self,top,skip,search,is_active):
|
|
||||||
departments=await Department.get_departments(self.session,top,skip,search,is_active)
|
|
||||||
total=await Department.count_departments(self.session,search,is_active)
|
|
||||||
return await self._serialize_many(departments),total
|
|
||||||
|
|
||||||
async def create_department(self,payload,user_id):
|
|
||||||
actor=as_uuid(user_id)
|
|
||||||
fields={**payload,"created_by":actor,"updated_by":actor}
|
|
||||||
try:
|
|
||||||
department=await Department.insert_department(self.session,fields)
|
|
||||||
except IntegrityError as e:
|
|
||||||
raise HTTPException(status_code=409,detail=str(e.orig)) from e
|
|
||||||
return (await self._serialize_many([department]))[0]
|
|
||||||
|
|
||||||
async def update_department(self,record_id,payload,user_id):
|
|
||||||
fields={**payload,"updated_by":as_uuid(user_id)}
|
|
||||||
try:
|
|
||||||
department=await Department.update_department(self.session,record_id,fields)
|
|
||||||
except IntegrityError as e:
|
|
||||||
raise HTTPException(status_code=409,detail=str(e.orig)) from e
|
|
||||||
if not department:
|
|
||||||
raise HTTPException(status_code=404,detail="Department not found")
|
|
||||||
return (await self._serialize_many([department]))[0]
|
|
||||||
|
|
||||||
async def get_head_options(self,role_id,top,skip,search):
|
|
||||||
rows=await Users.get_users(self.session,top,skip,search,role_id)
|
|
||||||
return [serialize_head_option(row) for row in rows]
|
|
||||||
|
|
||||||
async def get_location_options(self,search):
|
|
||||||
return location_options(search)
|
|
||||||
|
|
@ -143,7 +143,6 @@ class HiringCostCreate(BaseModel):
|
||||||
class JobUpdate(BaseModel):
|
class JobUpdate(BaseModel):
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
department: str | None = None
|
department: str | None = None
|
||||||
department_id: UUID | None = None
|
|
||||||
location: str | None = None
|
location: str | None = None
|
||||||
employment_type: str | None = None
|
employment_type: str | None = None
|
||||||
vacancies: int | None = None
|
vacancies: int | None = None
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ from job.job_post.enums import RequisitionStatus
|
||||||
|
|
||||||
if TYPE_CHECKING: # runtime import would be circular: users.models imports this module
|
if TYPE_CHECKING: # runtime import would be circular: users.models imports this module
|
||||||
from candidate_forms.models import Requisition
|
from candidate_forms.models import Requisition
|
||||||
from department.models import Department
|
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -26,11 +25,6 @@ class JobPosts(SQLModel, table=True):
|
||||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
title: str = Field(index=True)
|
title: str = Field(index=True)
|
||||||
|
|
||||||
# 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(
|
user: Optional["Users"] = Relationship(
|
||||||
back_populates="job_posts",
|
back_populates="job_posts",
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ def serialize_job_post(row, *, names=None) -> dict:
|
||||||
"title": row.title,
|
"title": row.title,
|
||||||
# Talent Pool / candidate filters key off attached job_posts.department.
|
# Talent Pool / candidate filters key off attached job_posts.department.
|
||||||
"department": row.department or None,
|
"department": row.department or None,
|
||||||
"department_id": str(row.department_id) if row.department_id else None,
|
|
||||||
"employment_type": row.employment_type,
|
"employment_type": row.employment_type,
|
||||||
"location": row.location,
|
"location": row.location,
|
||||||
"experience_min": row.experience_min,
|
"experience_min": row.experience_min,
|
||||||
|
|
@ -83,8 +82,7 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
"title": row.title,
|
"title": row.title,
|
||||||
"department": row.department_ref.name if row.department_ref else None,
|
"department": row.department or None,
|
||||||
"department_id": str(row.department_id) if row.department_id else None,
|
|
||||||
"location": row.location,
|
"location": row.location,
|
||||||
"employment_type": row.employment_type,
|
"employment_type": row.employment_type,
|
||||||
"vacancies": row.vacancies,
|
"vacancies": row.vacancies,
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,6 @@ class JobPostCreate(BaseModel):
|
||||||
location: str | None = None
|
location: str | None = None
|
||||||
employment_type: str | None = None
|
employment_type: str | None = None
|
||||||
department: str | None = None
|
department: str | None = None
|
||||||
department_id: UUID | None = None
|
|
||||||
vacancies: int = 1
|
vacancies: int = 1
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
platform: str | None = None
|
platform: str | None = None
|
||||||
|
|
@ -182,7 +181,6 @@ class JobPost:
|
||||||
"salary":payload.get("salary") or "Anonymous",
|
"salary":payload.get("salary") or "Anonymous",
|
||||||
# department is NOT NULL with a server_default of "" — pass "", never None.
|
# department is NOT NULL with a server_default of "" — pass "", never None.
|
||||||
"department":payload.get("department") or "",
|
"department":payload.get("department") or "",
|
||||||
"department_id":None,
|
|
||||||
"vacancies":payload.get("vacancies") or 1,
|
"vacancies":payload.get("vacancies") or 1,
|
||||||
"description":payload.get("description"),
|
"description":payload.get("description"),
|
||||||
"post_text":text,
|
"post_text":text,
|
||||||
|
|
@ -193,10 +191,6 @@ class JobPost:
|
||||||
# Only set platform when it is actually known: passing None would override the
|
# Only set platform when it is actually known: passing None would override the
|
||||||
# column default and break the NOT NULL constraint. Buffer's channelService
|
# column default and break the NOT NULL constraint. Buffer's channelService
|
||||||
# replaces this with the authoritative value once the post is created.
|
# 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)
|
known_platform=service or normalize_platform(payload.get("platform"),aliases)
|
||||||
if known_platform:
|
if known_platform:
|
||||||
fields["platform"]=known_platform
|
fields["platform"]=known_platform
|
||||||
|
|
@ -430,13 +424,6 @@ class JobPost:
|
||||||
hiring_manager_name=names.get(str(row.hiring_manager_id)),
|
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):
|
async def update_job(self,job_post_id,payload,current_user):
|
||||||
if not current_user:
|
if not current_user:
|
||||||
raise HTTPException(status_code=401,detail="Not authenticated")
|
raise HTTPException(status_code=401,detail="Not authenticated")
|
||||||
|
|
@ -457,16 +444,6 @@ class JobPost:
|
||||||
fields["salary"]=str(high)
|
fields["salary"]=str(high)
|
||||||
if "department" in fields and fields["department"] is None:
|
if "department" in fields and fields["department"] is None:
|
||||||
fields["department"]=""
|
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_ref"]=department
|
|
||||||
fields["department"]=department.name
|
|
||||||
else:
|
|
||||||
fields["department_id"]=None
|
|
||||||
fields["department_ref"]=None
|
|
||||||
fields["department"]=""
|
|
||||||
|
|
||||||
assignment=Assignment(self.session)
|
assignment=Assignment(self.session)
|
||||||
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
|
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ from talent.app import router as talent_router
|
||||||
from candidate_forms.app import router as candidate_forms_router
|
from candidate_forms.app import router as candidate_forms_router
|
||||||
from g_sheet.app import router as g_sheet_router
|
from g_sheet.app import router as g_sheet_router
|
||||||
from s3.app import router as s3_router
|
from s3.app import router as s3_router
|
||||||
from department.app import router as department_router
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
|
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
|
||||||
logger=logging.getLogger("main")
|
logger=logging.getLogger("main")
|
||||||
|
|
@ -137,4 +136,3 @@ app.include_router(talent_router)
|
||||||
app.include_router(candidate_forms_router)
|
app.include_router(candidate_forms_router)
|
||||||
app.include_router(g_sheet_router)
|
app.include_router(g_sheet_router)
|
||||||
app.include_router(s3_router)
|
app.include_router(s3_router)
|
||||||
app.include_router(department_router)
|
|
||||||
|
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
-- 038_departments.sql
|
|
||||||
-- Departments as a managed entity (backend/department/models.py): name, short
|
|
||||||
-- code, subtitle (replaces the design's "Cost Center"), description, status,
|
|
||||||
-- head, parent department and region/location list. Plus the `department`
|
|
||||||
-- permission module (8 tags), a `department_management` bundle holding them,
|
|
||||||
-- and that bundle attached to the admin roles.
|
|
||||||
--
|
|
||||||
-- Idempotent, applied automatically at startup by alembic_setup.run_manual_sql()
|
|
||||||
-- and recorded in manual_migrations. Needed because prod boots with
|
|
||||||
-- DB_AUTOGENERATE=false and never autogenerates new tables. Index names match
|
|
||||||
-- the db_setup NAMING_CONVENTION so a dev DB that autogenerated first is a no-op.
|
|
||||||
-- Users must log in again afterwards — the frontend caches /users/me permissions.
|
|
||||||
|
|
||||||
-- =============================================================================
|
|
||||||
-- 1. Table
|
|
||||||
-- =============================================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS app.departments (
|
|
||||||
id uuid PRIMARY KEY,
|
|
||||||
name varchar NOT NULL,
|
|
||||||
short_code varchar(10) NOT NULL,
|
|
||||||
subtitle varchar,
|
|
||||||
description varchar,
|
|
||||||
is_active boolean NOT NULL DEFAULT true,
|
|
||||||
parent_department_id uuid REFERENCES app.departments(id),
|
|
||||||
department_head_id uuid REFERENCES app.users(id),
|
|
||||||
location jsonb NOT NULL DEFAULT '[]'::jsonb,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT NOW(),
|
|
||||||
created_by uuid REFERENCES app.users(id),
|
|
||||||
updated_by uuid REFERENCES app.users(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS ix_departments_name
|
|
||||||
ON app.departments (name);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS ix_departments_short_code
|
|
||||||
ON app.departments (short_code);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_departments_parent_department_id
|
|
||||||
ON app.departments (parent_department_id);
|
|
||||||
|
|
||||||
-- =============================================================================
|
|
||||||
-- 2. The 8 department.* permission tags
|
|
||||||
-- =============================================================================
|
|
||||||
INSERT INTO app.permission_tags
|
|
||||||
(tag_name, module, action, description, created_at, updated_at, is_active, is_deleted)
|
|
||||||
VALUES
|
|
||||||
('department.view', 'department', 'view', NULL, NOW(), NOW(), true, false),
|
|
||||||
('department.create', 'department', 'create', NULL, NOW(), NOW(), true, false),
|
|
||||||
('department.edit', 'department', 'edit', NULL, NOW(), NOW(), true, false),
|
|
||||||
('department.delete', 'department', 'delete', NULL, NOW(), NOW(), true, false),
|
|
||||||
('department.approve', 'department', 'approve', NULL, NOW(), NOW(), true, false),
|
|
||||||
('department.export', 'department', 'export', NULL, NOW(), NOW(), true, false),
|
|
||||||
('department.manage', 'department', 'manage', NULL, NOW(), NOW(), true, false),
|
|
||||||
('department.configure', 'department', 'configure', NULL, NOW(), NOW(), true, false)
|
|
||||||
ON CONFLICT (tag_name) DO NOTHING;
|
|
||||||
|
|
||||||
-- =============================================================================
|
|
||||||
-- 3. Bundle holding all eight department tags
|
|
||||||
-- =============================================================================
|
|
||||||
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
|
|
||||||
SELECT
|
|
||||||
'department_management',
|
|
||||||
'Departments: view, create, edit and manage departments',
|
|
||||||
(
|
|
||||||
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
|
|
||||||
FROM app.permission_tags
|
|
||||||
WHERE is_deleted = false
|
|
||||||
AND module = 'department'
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
NOW(),
|
|
||||||
NOW(),
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM app.permissions WHERE name = 'department_management'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =============================================================================
|
|
||||||
-- 4. Attach the bundle to the admin roles (idempotent)
|
|
||||||
-- =============================================================================
|
|
||||||
UPDATE app.roles r
|
|
||||||
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
|
|
||||||
updated_at = NOW()
|
|
||||||
FROM app.permissions p
|
|
||||||
WHERE p.name = 'department_management'
|
|
||||||
AND r.role_name IN (
|
|
||||||
'system_administrator',
|
|
||||||
'hr_administrator'
|
|
||||||
)
|
|
||||||
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
-- 039_requisition_department_id.sql
|
|
||||||
-- Many requisitions -> one department. Adds requisitions.department_id, the FK
|
|
||||||
-- behind Requisition.department_id / Requisition.department and
|
|
||||||
-- Department.requisitions (backend/candidate_forms/models.py,
|
|
||||||
-- backend/department/models.py).
|
|
||||||
--
|
|
||||||
-- A new file, not an edit to 020 or 038: manual migrations run once and are
|
|
||||||
-- recorded in manual_migrations, so changes to an applied file never reach an
|
|
||||||
-- existing database. Applied at startup by alembic_setup.run_manual_sql()
|
|
||||||
-- after 038, so app.departments already exists.
|
|
||||||
--
|
|
||||||
-- The legacy free-text requisitions.department column is kept and only read
|
|
||||||
-- here to backfill: rows whose text equals a department's name or short code
|
|
||||||
-- (case-insensitive, trimmed) get that department's id. Unmatched rows stay
|
|
||||||
-- NULL. Drop the text column in a later migration once nothing reads it.
|
|
||||||
|
|
||||||
ALTER TABLE app.requisitions
|
|
||||||
ADD COLUMN IF NOT EXISTS department_id uuid;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_constraint
|
|
||||||
WHERE conname = 'fk_requisitions_department_id_departments'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE app.requisitions
|
|
||||||
ADD CONSTRAINT fk_requisitions_department_id_departments
|
|
||||||
FOREIGN KEY (department_id) REFERENCES app.departments (id);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_requisitions_department_id
|
|
||||||
ON app.requisitions (department_id);
|
|
||||||
|
|
||||||
-- Backfill from the legacy text column, if it is still there.
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'app'
|
|
||||||
AND table_name = 'requisitions'
|
|
||||||
AND column_name = 'department'
|
|
||||||
) THEN
|
|
||||||
UPDATE app.requisitions r
|
|
||||||
SET department_id = d.id
|
|
||||||
FROM app.departments d
|
|
||||||
WHERE r.department_id IS NULL
|
|
||||||
AND lower(btrim(r.department)) IN (lower(d.name), lower(d.short_code));
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
-- 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));
|
|
||||||
|
|
@ -31,6 +31,11 @@ class Users(SQLModel, table=True):
|
||||||
role: Roles | None = Relationship(back_populates="users",
|
role: Roles | None = Relationship(back_populates="users",
|
||||||
sa_relationship_kwargs={"lazy": "selectin"}
|
sa_relationship_kwargs={"lazy": "selectin"}
|
||||||
)
|
)
|
||||||
|
# selectin, not joined: this is a one-to-many, so a joined load would repeat the
|
||||||
|
# user row once per post. Without an explicit strategy the default is a lazy load,
|
||||||
|
# which raises MissingGreenlet the moment anything touches it under asyncio.
|
||||||
|
# foreign_keys must match the other side: job_posts also has current_recruiter_id
|
||||||
|
# and hiring_manager_id into this table, so this relation has to say created_by.
|
||||||
job_posts: List[JobPosts] = Relationship(
|
job_posts: List[JobPosts] = Relationship(
|
||||||
back_populates="user",
|
back_populates="user",
|
||||||
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"},
|
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"},
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ class PermissionModule(str, Enum):
|
||||||
JOBS = "jobs"
|
JOBS = "jobs"
|
||||||
CANDIDATES = "candidates"
|
CANDIDATES = "candidates"
|
||||||
PIPELINE = "pipeline"
|
PIPELINE = "pipeline"
|
||||||
DEPARTMENT = "department"
|
|
||||||
INTERVIEWS = "interviews"
|
INTERVIEWS = "interviews"
|
||||||
ASSESSMENTS = "assessments"
|
ASSESSMENTS = "assessments"
|
||||||
OFFERS = "offers"
|
OFFERS = "offers"
|
||||||
|
|
@ -72,16 +71,6 @@ class PermissionTag(str, Enum):
|
||||||
DASHBOARD_EXPORT = "dashboard.export"
|
DASHBOARD_EXPORT = "dashboard.export"
|
||||||
DASHBOARD_MANAGE = "dashboard.manage"
|
DASHBOARD_MANAGE = "dashboard.manage"
|
||||||
DASHBOARD_CONFIGURE = "dashboard.configure"
|
DASHBOARD_CONFIGURE = "dashboard.configure"
|
||||||
|
|
||||||
DEPARTMENT_VIEW = "department.view"
|
|
||||||
DEPARTMENT_CREATE = "department.create"
|
|
||||||
DEPARTMENT_EDIT = "department.edit"
|
|
||||||
DEPARTMENT_DELETE = "department.delete"
|
|
||||||
DEPARTMENT_APPROVE = "department.approve"
|
|
||||||
DEPARTMENT_EXPORT = "department.export"
|
|
||||||
DEPARTMENT_MANAGE = "department.manage"
|
|
||||||
DEPARTMENT_CONFIGURE = "department.configure"
|
|
||||||
|
|
||||||
INBOX_VIEW = "inbox.view"
|
INBOX_VIEW = "inbox.view"
|
||||||
INBOX_CREATE = "inbox.create"
|
INBOX_CREATE = "inbox.create"
|
||||||
INBOX_EDIT = "inbox.edit"
|
INBOX_EDIT = "inbox.edit"
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Where CodeDeploy extracted the zip package
|
|
||||||
EXTRACT_DIR="/opt/codedeploy-extracted-utopia-ai-hr-ats-portal-dev"
|
|
||||||
|
|
||||||
# Dynamically set the true destination path based on the CodeDeploy group name
|
|
||||||
if [ -z "${DEPLOYMENT_GROUP_NAME}" ]; then
|
|
||||||
TARGET_DIR="utopia-ai-hr-ats-portal-dev-deployment-group"
|
|
||||||
else
|
|
||||||
TARGET_DIR="${DEPLOYMENT_GROUP_NAME}"
|
|
||||||
fi
|
|
||||||
FINAL_DIR="/home/ec2-user/${TARGET_DIR}"
|
|
||||||
|
|
||||||
echo "===== Moving files safely to ${FINAL_DIR} ====="
|
|
||||||
# Ensure the final folder exists (won't alter it if it does)
|
|
||||||
mkdir -p "${FINAL_DIR}"
|
|
||||||
|
|
||||||
# Copy everything from the extraction directory to your app folder
|
|
||||||
# This will overwrite code files, but leave your untracked .env safe and untouched!
|
|
||||||
cp -r "${EXTRACT_DIR}/." "${FINAL_DIR}/"
|
|
||||||
|
|
||||||
# Change into the actual app directory
|
|
||||||
cd "${FINAL_DIR}"
|
|
||||||
|
|
||||||
# Fix ownership so ec2-user owns the codebase
|
|
||||||
chown -R ec2-user:ec2-user "${FINAL_DIR}"
|
|
||||||
|
|
||||||
echo "--------- Checking/Installing Docker ---------"
|
|
||||||
if ! command -v docker &> /dev/null; then
|
|
||||||
echo "Installing Docker..."
|
|
||||||
dnf install docker -y
|
|
||||||
fi
|
|
||||||
|
|
||||||
systemctl start docker
|
|
||||||
systemctl enable docker
|
|
||||||
usermod -aG docker ec2-user
|
|
||||||
|
|
||||||
echo "Docker: $(docker --version)"
|
|
||||||
|
|
||||||
echo "--------- Installing Latest Docker Compose ---------"
|
|
||||||
mkdir -p /usr/libexec/docker/cli-plugins
|
|
||||||
curl -SL "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m)" \
|
|
||||||
-o /usr/libexec/docker/cli-plugins/docker-compose
|
|
||||||
chmod +x /usr/libexec/docker/cli-plugins/docker-compose
|
|
||||||
echo "Docker Compose: $(docker compose version)"
|
|
||||||
|
|
||||||
echo "--------- Installing Latest Docker Buildx ---------"
|
|
||||||
ARCH=$(uname -m)
|
|
||||||
if [ "$ARCH" = "x86_64" ]; then
|
|
||||||
BUILDX_ARCH="amd64"
|
|
||||||
elif [ "$ARCH" = "aarch64" ]; then
|
|
||||||
BUILDX_ARCH="arm64"
|
|
||||||
else
|
|
||||||
BUILDX_ARCH="$ARCH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Fetch latest release tag (e.g. v0.21.1)
|
|
||||||
BUILDX_TAG=$(curl -s https://api.github.com/repos/docker/buildx/releases/latest | grep '"tag_name"' | cut -d'"' -f4)
|
|
||||||
|
|
||||||
echo "Downloading Buildx version ${BUILDX_TAG} for ${BUILDX_ARCH}..."
|
|
||||||
|
|
||||||
# Remove any existing broken binary before downloading
|
|
||||||
rm -f /usr/libexec/docker/cli-plugins/docker-buildx
|
|
||||||
|
|
||||||
curl -SL "https://github.com/docker/buildx/releases/download/${BUILDX_TAG}/buildx-${BUILDX_TAG}.linux-${BUILDX_ARCH}" \
|
|
||||||
-o /usr/libexec/docker/cli-plugins/docker-buildx
|
|
||||||
chmod +x /usr/libexec/docker/cli-plugins/docker-buildx
|
|
||||||
echo "Docker Buildx: $(docker buildx version)"
|
|
||||||
|
|
||||||
if [ ! -f "docker-compose.yml" ]; then
|
|
||||||
echo "ERROR: docker-compose.yml not found in ${FINAL_DIR}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "--------- Stopping existing containers ---------"
|
|
||||||
docker compose down --remove-orphans || true
|
|
||||||
|
|
||||||
echo "--------- Building and starting containers ---------"
|
|
||||||
docker compose --env-file ./backend/.env build --no-cache
|
|
||||||
docker compose --env-file ./backend/.env up -d
|
|
||||||
|
|
||||||
echo "--------- Running containers ---------"
|
|
||||||
docker ps
|
|
||||||
|
|
||||||
# Optional: Clean up extraction directory to keep disk space clean
|
|
||||||
rm -rf "${EXTRACT_DIR:?}/*"
|
|
||||||
|
|
||||||
echo "===== Deployment completed successfully ====="
|
|
||||||
|
|
@ -8,13 +8,6 @@ server {
|
||||||
# CV / multipart uploads (MAX_PDF_SIZE_MB is 10; leave headroom for form fields).
|
# CV / multipart uploads (MAX_PDF_SIZE_MB is 10; leave headroom for form fields).
|
||||||
client_max_body_size 25m;
|
client_max_body_size 25m;
|
||||||
|
|
||||||
# Resolve backend-api through Docker's embedded DNS on each request instead of
|
|
||||||
# once at startup. `docker compose up` recreates backend-api with a new IP;
|
|
||||||
# a static upstream keeps the old one and every API call 502s until nginx restarts.
|
|
||||||
# proxy_pass with a variable and no URI forwards the original request URI unchanged.
|
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
|
||||||
set $backend_api http://backend-api:8000;
|
|
||||||
|
|
||||||
# Security headers on every response.
|
# Security headers on every response.
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
add_header X-Frame-Options DENY always;
|
add_header X-Frame-Options DENY always;
|
||||||
|
|
@ -30,7 +23,7 @@ server {
|
||||||
|
|
||||||
# SPA page roots that also prefix API calls — sub-path required.
|
# SPA page roots that also prefix API calls — sub-path required.
|
||||||
location ~ ^/(jobs|inbox|pipeline|tasks|assessments|offers|managers|analytics|notifications)/ {
|
location ~ ^/(jobs|inbox|pipeline|tasks|assessments|offers|managers|analytics|notifications)/ {
|
||||||
proxy_pass $backend_api;
|
proxy_pass http://backend-api:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|
@ -44,8 +37,8 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
# API-only prefixes (no SPA page at the bare path).
|
# API-only prefixes (no SPA page at the bare path).
|
||||||
location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3|forms|department)(/|$) {
|
location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3|forms)(/|$) {
|
||||||
proxy_pass $backend_api;
|
proxy_pass http://backend-api:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ const SCREENS = {
|
||||||
assessments: lazy(() => import('./screens/Assessments')),
|
assessments: lazy(() => import('./screens/Assessments')),
|
||||||
offers: lazy(() => import('./screens/Offers')),
|
offers: lazy(() => import('./screens/Offers')),
|
||||||
managers: lazy(() => import('./screens/Managers')),
|
managers: lazy(() => import('./screens/Managers')),
|
||||||
departments: lazy(() => import('./screens/Departments')),
|
|
||||||
calendar: lazy(() => import('./screens/Calendar')),
|
calendar: lazy(() => import('./screens/Calendar')),
|
||||||
reports: lazy(() => import('./screens/Reports')),
|
reports: lazy(() => import('./screens/Reports')),
|
||||||
analytics: lazy(() => import('./screens/Analytics')),
|
analytics: lazy(() => import('./screens/Analytics')),
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { request } from '../lib/apiClient'
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
departments.js — backend/department/app.py routes.
|
|
||||||
|
|
||||||
created_by / updated_by come from the JWT on the server — do not send them.
|
|
||||||
`location` is a string[]; the form edits it as comma-separated text.
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
export function list({ search, isActive, top, skip } = {}) {
|
|
||||||
return request('/department/fetch', {
|
|
||||||
params: { search: search || undefined, is_active: isActive, top, skip },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getById(recordId) {
|
|
||||||
return request('/department/fetch', { params: { record_id: recordId } })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function create(body) {
|
|
||||||
return request('/department/create', { method: 'POST', body })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function update(recordId, body) {
|
|
||||||
return request('/department/update', { method: 'PUT', params: { record_id: recordId }, body })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Department Head picker — `{data:[{id,name,email}]}`. The server defaults
|
|
||||||
* `role_id` to the department_head role when it is not sent. */
|
|
||||||
export function listHeads({ roleId, search, top, skip } = {}) {
|
|
||||||
return request('/department/heads/fetch', {
|
|
||||||
params: { role_id: roleId, search: search || undefined, top, skip },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Region / Location picker — `{data:["Karachi - Pakistan", …]}` from global_cities.py. */
|
|
||||||
export function listLocations({ search } = {}) {
|
|
||||||
return request('/department/locations/fetch', { params: { search: search || undefined } })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Active departments for pickers (Requisitions "From (Dept.)") — `{data:[{id,name}]}`. */
|
|
||||||
export function listNames({ search } = {}) {
|
|
||||||
return request('/department/names', { params: { search: search || undefined } })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toRows(res) {
|
|
||||||
const data = res?.data
|
|
||||||
if (Array.isArray(data)) return data
|
|
||||||
if (data) return [data]
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
@ -90,7 +90,6 @@ export function toJobView(row) {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
department: row.department,
|
department: row.department,
|
||||||
departmentId: row.department_id || null,
|
|
||||||
location: row.location,
|
location: row.location,
|
||||||
type: row.employment_type,
|
type: row.employment_type,
|
||||||
vacancies: row.vacancies,
|
vacancies: row.vacancies,
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,6 @@ export function list() {
|
||||||
return request('/forms/requisition/fetch')
|
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) {
|
export function getById(formId) {
|
||||||
return request('/forms/requisition/fetch', { params: { form_id: formId } })
|
return request('/forms/requisition/fetch', { params: { form_id: formId } })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ export const ROUTES = [
|
||||||
{ path: 'assessments', title: 'Assessments', icon: 'check-square', group: 'Hiring', permission: 'assessments.view' },
|
{ path: 'assessments', title: 'Assessments', icon: 'check-square', group: 'Hiring', permission: 'assessments.view' },
|
||||||
{ path: 'offers', title: 'Offers', icon: 'offers', group: 'Hiring', permission: 'offers.view' },
|
{ path: 'offers', title: 'Offers', icon: 'offers', group: 'Hiring', permission: 'offers.view' },
|
||||||
{ path: 'managers', title: 'Hiring Managers', icon: 'managers', group: 'Hiring', permission: 'jobs.view' },
|
{ path: 'managers', title: 'Hiring Managers', icon: 'managers', group: 'Hiring', permission: 'jobs.view' },
|
||||||
{ path: 'departments', title: 'Departments', icon: 'layers', group: 'Hiring', permission: 'department.view', tag: 'NEW' },
|
|
||||||
{ path: 'calendar', title: 'Calendar', icon: 'calendar', group: 'Hiring', permission: 'interviews.view' },
|
{ path: 'calendar', title: 'Calendar', icon: 'calendar', group: 'Hiring', permission: 'interviews.view' },
|
||||||
|
|
||||||
// --- Insights ---
|
// --- Insights ---
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
export const MODULES = [
|
export const MODULES = [
|
||||||
'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
|
'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
|
||||||
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks',
|
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks',
|
||||||
'talent', 'requisitions', 'department',
|
'talent', 'requisitions',
|
||||||
]
|
]
|
||||||
|
|
||||||
export const ACTIONS = [
|
export const ACTIONS = [
|
||||||
|
|
|
||||||
|
|
@ -143,17 +143,9 @@ export const qk = {
|
||||||
requisitions: {
|
requisitions: {
|
||||||
all: () => ['requisitions'],
|
all: () => ['requisitions'],
|
||||||
list: () => ['requisitions', 'list'],
|
list: () => ['requisitions', 'list'],
|
||||||
openCount: () => ['requisitions', 'open-count'],
|
|
||||||
detail: (id) => ['requisitions', 'detail', id],
|
detail: (id) => ['requisitions', 'detail', id],
|
||||||
search: (q = '', jobPostId = null) => ['requisitions', 'search', q, jobPostId || null],
|
search: (q = '', jobPostId = null) => ['requisitions', 'search', q, jobPostId || null],
|
||||||
},
|
},
|
||||||
departments: {
|
|
||||||
all: () => ['departments'],
|
|
||||||
list: (p = {}) => ['departments', 'list', p],
|
|
||||||
heads: (p = {}) => ['departments', 'heads', p],
|
|
||||||
locations: () => ['departments', 'locations'],
|
|
||||||
names: (q = '') => ['departments', 'names', q],
|
|
||||||
},
|
|
||||||
interviews: {
|
interviews: {
|
||||||
all: () => ['interviews'],
|
all: () => ['interviews'],
|
||||||
range: (p = {}) => ['interviews', 'range', p],
|
range: (p = {}) => ['interviews', 'range', p],
|
||||||
|
|
|
||||||
|
|
@ -1,728 +0,0 @@
|
||||||
/* ============================================================
|
|
||||||
Departments — app.departments (backend/department/app.py).
|
|
||||||
|
|
||||||
List is GET /department/fetch, rendered as the card grid from the
|
|
||||||
"Departments — List" design. Create / edit share one modal from the
|
|
||||||
"Departments — Create" design, with the design's Cost Center field
|
|
||||||
replaced by Subtitle (the tagline under the name on each card).
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
||||||
|
|
||||||
import Modal from '../ui/Modal'
|
|
||||||
import PageHeader from '../ui/PageHeader'
|
|
||||||
import { Avatar, EmptyState, FieldError, Icon, KpiCard, SkeletonRows } from '../ui/primitives'
|
|
||||||
import { useToast } from '../ui/Toast'
|
|
||||||
import { useAuth } from '../auth/AuthContext'
|
|
||||||
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
|
|
||||||
|
|
||||||
// Card icon tints cycle through the soft theme tokens so adjacent cards differ.
|
|
||||||
const TONES = ['success', 'primary', 'warning', 'purple', 'info']
|
|
||||||
|
|
||||||
function toneFor(id = '') {
|
|
||||||
let h = 0
|
|
||||||
for (let i = 0; i < id.length; i += 1) h = (h * 31 + id.charCodeAt(i)) >>> 0
|
|
||||||
return TONES[h % TONES.length]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** KPI numbers — shared by the card row and the export's Summary sheet. */
|
|
||||||
function departmentStats(rows) {
|
|
||||||
const sum = (key) => rows.reduce((n, r) => n + (Number(r[key]) || 0), 0)
|
|
||||||
const jobPosts = sum('job_posts')
|
|
||||||
const openRoles = sum('open_roles')
|
|
||||||
return {
|
|
||||||
total: rows.length,
|
|
||||||
candidates: sum('candidates'),
|
|
||||||
openRoles,
|
|
||||||
jobPosts,
|
|
||||||
// Avg. open job posts = all open job posts / all job posts linked to departments.
|
|
||||||
openShareLabel: jobPosts ? `${Math.round((openRoles / jobPosts) * 100)}%` : '—',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sheet 1: the KPI cards. Sheet 2: one row per department with everything the page shows. */
|
|
||||||
function buildExportSheets(rows, stats, exportedAt) {
|
|
||||||
const subtitle = `Exported ${exportedAt.toLocaleString()}`
|
|
||||||
const when = (iso) => (iso ? new Date(iso).toLocaleString() : '')
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: 'Summary',
|
|
||||||
title: 'Departments — Summary',
|
|
||||||
subtitle,
|
|
||||||
columns: [
|
|
||||||
{ header: 'Metric', key: 'metric', width: 30 },
|
|
||||||
{ header: 'Value', key: 'value', width: 16 },
|
|
||||||
{ header: 'Notes', key: 'notes', width: 52 },
|
|
||||||
],
|
|
||||||
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.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` },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Departments',
|
|
||||||
title: 'Departments',
|
|
||||||
subtitle,
|
|
||||||
columns: [
|
|
||||||
{ header: 'Department', key: 'name', width: 28 },
|
|
||||||
{ header: 'Short Code', key: 'short_code', width: 12 },
|
|
||||||
{ header: 'Subtitle', key: 'subtitle', width: 26 },
|
|
||||||
{ header: 'Description', key: 'description', width: 44 },
|
|
||||||
{ header: 'Status', key: 'status', width: 10 },
|
|
||||||
{ header: 'Department Head', key: 'head', width: 22 },
|
|
||||||
{ header: 'Parent Department', key: 'parent', width: 22 },
|
|
||||||
{ header: 'Open Roles', key: 'open_roles', width: 12 },
|
|
||||||
{ header: 'Total Job Posts', key: 'job_posts', width: 14 },
|
|
||||||
{ header: 'Candidates', key: 'candidates', width: 12 },
|
|
||||||
{ header: 'Regions', key: 'region_count', width: 10 },
|
|
||||||
{ header: 'Region / Location', key: 'locations', width: 44 },
|
|
||||||
{ header: 'Created', key: 'created_at', width: 20 },
|
|
||||||
{ header: 'Updated', key: 'updated_at', width: 20 },
|
|
||||||
],
|
|
||||||
rows: rows.map((r) => ({
|
|
||||||
name: r.name,
|
|
||||||
short_code: r.short_code,
|
|
||||||
subtitle: r.subtitle || '',
|
|
||||||
description: r.description || '',
|
|
||||||
status: r.is_active ? 'Active' : 'Inactive',
|
|
||||||
head: r.department_head_name || '',
|
|
||||||
parent: r.parent_department_name || '',
|
|
||||||
open_roles: r.open_roles ?? 0,
|
|
||||||
job_posts: r.job_posts ?? 0,
|
|
||||||
candidates: r.candidates ?? 0,
|
|
||||||
region_count: (r.location || []).length,
|
|
||||||
locations: (r.location || []).join('; '),
|
|
||||||
created_at: when(r.created_at),
|
|
||||||
updated_at: when(r.updated_at),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchDepartments() {
|
|
||||||
const res = await departmentsApi.list()
|
|
||||||
return departmentsApi.toRows(res)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Departments() {
|
|
||||||
const { toast } = useToast()
|
|
||||||
const { can } = useAuth()
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
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(),
|
|
||||||
queryFn: fetchDepartments,
|
|
||||||
})
|
|
||||||
const rowsAll = listQuery.data ?? []
|
|
||||||
|
|
||||||
const [q, setQ] = useState('')
|
|
||||||
const [status, setStatus] = useState('')
|
|
||||||
// { row: null | department, editable: boolean }
|
|
||||||
const [editor, setEditor] = useState(null)
|
|
||||||
|
|
||||||
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() {
|
|
||||||
setExporting(true)
|
|
||||||
try {
|
|
||||||
await exportStyledWorkbook({
|
|
||||||
filename: `departments-${new Date().toISOString().slice(0, 10)}`,
|
|
||||||
sheets: buildExportSheets(rowsAll, stats, new Date()),
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
toast(friendlyAuthError(err, 'Could not export departments.'), 'error')
|
|
||||||
} finally {
|
|
||||||
setExporting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = useMemo(
|
|
||||||
() =>
|
|
||||||
rowsAll.filter((r) => {
|
|
||||||
if (status === 'active' && !r.is_active) return false
|
|
||||||
if (status === 'inactive' && r.is_active) return false
|
|
||||||
if (!q) return true
|
|
||||||
const hay = [r.name, r.short_code, r.subtitle, r.department_head_name]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')
|
|
||||||
.toLowerCase()
|
|
||||||
return hay.includes(q.toLowerCase())
|
|
||||||
}),
|
|
||||||
[rowsAll, q, status],
|
|
||||||
)
|
|
||||||
|
|
||||||
const pending = listQuery.isPending
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<PageHeader
|
|
||||||
title="Departments"
|
|
||||||
sub="Manage departments, ownership, and hiring context used across Jobs, Candidates, and the Inbox filters."
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary"
|
|
||||||
disabled={!canExport || pending || !rowsAll.length || exporting}
|
|
||||||
title={!canExport ? 'Requires department.export' : undefined}
|
|
||||||
onClick={exportDepartments}
|
|
||||||
>
|
|
||||||
<Icon name="download" /> {exporting ? 'Exporting…' : 'Export'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={!canCreate}
|
|
||||||
title={!canCreate ? 'Requires department.create' : undefined}
|
|
||||||
onClick={() => setEditor({ row: null, editable: true })}
|
|
||||||
>
|
|
||||||
<Icon name="plus" /> Create Department
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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={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}
|
|
||||||
icon="reports"
|
|
||||||
tone="i-purple"
|
|
||||||
foot={pending ? undefined : `${stats.openRoles} open of ${stats.jobPosts} job posts`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pending && (
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-body">
|
|
||||||
<SkeletonRows rows={6} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{listQuery.isError && (
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-body">
|
|
||||||
<EmptyState icon="layers" title="Couldn’t load departments">
|
|
||||||
{friendlyAuthError(listQuery.error, 'Request failed')}
|
|
||||||
{' '}This screen needs the <code>department.view</code> permission.
|
|
||||||
</EmptyState>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!pending && !listQuery.isError && (
|
|
||||||
<>
|
|
||||||
<div className="toolbar">
|
|
||||||
<div className="toolbar-search">
|
|
||||||
<Icon name="search" />
|
|
||||||
<input
|
|
||||||
value={q}
|
|
||||||
onChange={(e) => setQ(e.target.value)}
|
|
||||||
placeholder="Search name, code, subtitle, or head…"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
|
||||||
<option value="">All statuses</option>
|
|
||||||
<option value="active">Active</option>
|
|
||||||
<option value="inactive">Inactive</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-body">
|
|
||||||
<EmptyState icon="layers" title={rowsAll.length ? 'No departments match' : 'No departments yet'}>
|
|
||||||
{rowsAll.length
|
|
||||||
? 'Try adjusting your search or status filter.'
|
|
||||||
: 'Create the first department to use it across Jobs, Candidates, and the Inbox.'}
|
|
||||||
</EmptyState>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid g-3">
|
|
||||||
{rows.map((r) => (
|
|
||||||
<DepartmentCard
|
|
||||||
key={r.id}
|
|
||||||
row={r}
|
|
||||||
canEdit={canEdit}
|
|
||||||
onView={() => setEditor({ row: r, editable: false })}
|
|
||||||
onEdit={() => setEditor({ row: r, editable: true })}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editor && (
|
|
||||||
<DepartmentEditor
|
|
||||||
row={editor.row}
|
|
||||||
allowed={editor.editable && (editor.row ? canEdit : canCreate)}
|
|
||||||
departments={rowsAll}
|
|
||||||
onClose={() => setEditor(null)}
|
|
||||||
onEdit={canEdit && editor.row && !editor.editable ? () => setEditor({ ...editor, editable: true }) : null}
|
|
||||||
onSaved={async () => {
|
|
||||||
await qc.invalidateQueries({ queryKey: qk.departments.all() })
|
|
||||||
setEditor(null)
|
|
||||||
}}
|
|
||||||
toast={toast}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DepartmentCard({ row, canEdit, onView, onEdit }) {
|
|
||||||
const tone = toneFor(row.id)
|
|
||||||
const regions = (row.location || []).length
|
|
||||||
return (
|
|
||||||
<div className={`card dept-card tone-${tone}`}>
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="dept-card-top">
|
|
||||||
<div className="dept-card-id">
|
|
||||||
<div className="dept-icn"><Icon name="layers" /></div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="dept-name">
|
|
||||||
<span>{row.name}</span>
|
|
||||||
<span className="code-chip">{row.short_code}</span>
|
|
||||||
</div>
|
|
||||||
<div className="cell-sub">{row.subtitle || '—'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{canEdit && (
|
|
||||||
<button className="act-btn" data-tip="Edit" aria-label={`Edit ${row.name}`} onClick={onEdit}>
|
|
||||||
<Icon name="edit" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="dept-desc">{row.description || <span className="text-muted">No description</span>}</p>
|
|
||||||
|
|
||||||
<div className="dept-stats">
|
|
||||||
<div className="dept-stat">
|
|
||||||
<div className="dept-stat-v">{row.open_roles ?? 0}</div>
|
|
||||||
<div className="dept-stat-l">Open Roles</div>
|
|
||||||
</div>
|
|
||||||
<div className="dept-stat">
|
|
||||||
<div className="dept-stat-v">{row.candidates ?? 0}</div>
|
|
||||||
<div className="dept-stat-l">Candidates</div>
|
|
||||||
</div>
|
|
||||||
<div className="dept-stat">
|
|
||||||
<div className="dept-stat-v">{regions}</div>
|
|
||||||
<div className="dept-stat-l">{regions === 1 ? 'Region' : 'Regions'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="divider" />
|
|
||||||
|
|
||||||
<div className="dept-card-foot">
|
|
||||||
<div className="dept-head">
|
|
||||||
{row.department_head_name ? (
|
|
||||||
<>
|
|
||||||
<Avatar name={row.department_head_name} className="dept-avatar" />
|
|
||||||
<span>{row.department_head_name}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted">No department head</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button className="btn btn-secondary btn-sm" onClick={onView}>View</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function blankForm() {
|
|
||||||
return {
|
|
||||||
name: '',
|
|
||||||
short_code: '',
|
|
||||||
is_active: true,
|
|
||||||
description: '',
|
|
||||||
department_head_id: '',
|
|
||||||
location: [],
|
|
||||||
subtitle: '',
|
|
||||||
parent_department_id: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromRow(row) {
|
|
||||||
return {
|
|
||||||
name: row.name || '',
|
|
||||||
short_code: row.short_code || '',
|
|
||||||
is_active: row.is_active !== false,
|
|
||||||
description: row.description || '',
|
|
||||||
department_head_id: row.department_head_id || '',
|
|
||||||
location: row.location || [],
|
|
||||||
subtitle: row.subtitle || '',
|
|
||||||
parent_department_id: row.parent_department_id || '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toPayload(f) {
|
|
||||||
return {
|
|
||||||
name: f.name.trim(),
|
|
||||||
short_code: f.short_code.trim().toUpperCase(),
|
|
||||||
is_active: f.is_active,
|
|
||||||
description: f.description.trim() || null,
|
|
||||||
department_head_id: f.department_head_id || null,
|
|
||||||
location: f.location,
|
|
||||||
subtitle: f.subtitle.trim() || null,
|
|
||||||
parent_department_id: f.parent_department_id || null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ids of `rootId` and everything under it — none of them may become its parent. */
|
|
||||||
function selfAndDescendants(rootId, departments) {
|
|
||||||
const out = new Set([rootId])
|
|
||||||
let grew = true
|
|
||||||
while (grew) {
|
|
||||||
grew = false
|
|
||||||
departments.forEach((d) => {
|
|
||||||
if (d.parent_department_id && out.has(d.parent_department_id) && !out.has(d.id)) {
|
|
||||||
out.add(d.id)
|
|
||||||
grew = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function DepartmentEditor({ row, allowed, departments, onClose, onEdit, onSaved, toast }) {
|
|
||||||
const [fields, setFields] = useState(() => (row ? fromRow(row) : blankForm()))
|
|
||||||
const [errors, setErrors] = useState({})
|
|
||||||
const set = (k, v) => setFields((f) => ({ ...f, [k]: v }))
|
|
||||||
|
|
||||||
// Loaded when the Department Head dropdown is first opened, not on modal mount.
|
|
||||||
const [headsRequested, setHeadsRequested] = useState(false)
|
|
||||||
const headsQuery = useQuery({
|
|
||||||
queryKey: qk.departments.heads({ roleId: DEPARTMENT_HEAD_ROLE_ID }),
|
|
||||||
queryFn: async () =>
|
|
||||||
departmentsApi.toRows(await departmentsApi.listHeads({ roleId: DEPARTMENT_HEAD_ROLE_ID })),
|
|
||||||
enabled: allowed && headsRequested,
|
|
||||||
})
|
|
||||||
const requestHeads = () => {
|
|
||||||
if (!headsRequested) setHeadsRequested(true)
|
|
||||||
else if (headsQuery.isError) headsQuery.refetch()
|
|
||||||
}
|
|
||||||
|
|
||||||
const headOptions = useMemo(() => {
|
|
||||||
const opts = headsQuery.data ?? []
|
|
||||||
// Keep the saved head selectable even when the picker list is unavailable.
|
|
||||||
if (row?.department_head_id && !opts.some((o) => o.id === row.department_head_id)) {
|
|
||||||
return [{ id: row.department_head_id, name: row.department_head_name || 'Current head' }, ...opts]
|
|
||||||
}
|
|
||||||
return opts
|
|
||||||
}, [headsQuery.data, row])
|
|
||||||
|
|
||||||
const parentOptions = useMemo(() => {
|
|
||||||
const blocked = row ? selfAndDescendants(row.id, departments) : new Set()
|
|
||||||
return departments.filter((d) => !blocked.has(d.id))
|
|
||||||
}, [departments, row])
|
|
||||||
|
|
||||||
const save = useMutation({
|
|
||||||
mutationFn: () => {
|
|
||||||
const body = toPayload(fields)
|
|
||||||
if (row) return departmentsApi.update(row.id, body)
|
|
||||||
return departmentsApi.create(body)
|
|
||||||
},
|
|
||||||
onSuccess: async () => {
|
|
||||||
toast(row ? 'Department updated' : 'Department created', 'success')
|
|
||||||
await onSaved()
|
|
||||||
},
|
|
||||||
onError: (err) => toast(friendlyAuthError(err, 'Could not save the department.'), 'error'),
|
|
||||||
})
|
|
||||||
|
|
||||||
function submit(e) {
|
|
||||||
e.preventDefault()
|
|
||||||
const next = {}
|
|
||||||
if (!fields.name.trim()) next.name = 'Enter the department name'
|
|
||||||
const code = fields.short_code.trim()
|
|
||||||
if (!code) next.short_code = 'Enter a short code'
|
|
||||||
else if (code.length > 10) next.short_code = 'Use 10 characters or fewer'
|
|
||||||
setErrors(next)
|
|
||||||
if (Object.keys(next).length) return
|
|
||||||
save.mutate()
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = !row ? 'Create Department' : allowed ? 'Edit Department' : row.name
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={title}
|
|
||||||
subtitle="Departments power reporting, requisitions, and the Department filter on Candidates & Inbox."
|
|
||||||
onClose={onClose}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<button className="btn btn-secondary" type="button" disabled={save.isPending} onClick={onClose}>
|
|
||||||
{allowed ? 'Cancel' : 'Close'}
|
|
||||||
</button>
|
|
||||||
{!allowed && onEdit && (
|
|
||||||
<button className="btn btn-primary" type="button" onClick={onEdit}>
|
|
||||||
<Icon name="edit" /> Edit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{allowed && (
|
|
||||||
<button className="btn btn-primary" form="department-form" type="submit" disabled={save.isPending}>
|
|
||||||
<Icon name="check" /> {save.isPending ? 'Saving…' : 'Save Department'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<form id="department-form" noValidate onSubmit={submit}>
|
|
||||||
<fieldset disabled={!allowed} style={{ border: 0, margin: 0, padding: 0 }}>
|
|
||||||
<div className="form-grid">
|
|
||||||
<div className="form-field col-span-2">
|
|
||||||
<label htmlFor="dept-name">Department Name <span className="req">*</span></label>
|
|
||||||
<input
|
|
||||||
id="dept-name"
|
|
||||||
className={errors.name ? 'err' : ''}
|
|
||||||
value={fields.name}
|
|
||||||
maxLength={120}
|
|
||||||
placeholder="e.g. Global Emerging Operations"
|
|
||||||
onChange={(e) => set('name', e.target.value)}
|
|
||||||
/>
|
|
||||||
<FieldError>{errors.name}</FieldError>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-field">
|
|
||||||
<label htmlFor="dept-code">Short Code <span className="req">*</span></label>
|
|
||||||
<input
|
|
||||||
id="dept-code"
|
|
||||||
className={errors.short_code ? 'err' : ''}
|
|
||||||
value={fields.short_code}
|
|
||||||
maxLength={10}
|
|
||||||
placeholder="e.g. GEO"
|
|
||||||
onChange={(e) => set('short_code', e.target.value.toUpperCase())}
|
|
||||||
/>
|
|
||||||
<FieldError>{errors.short_code}</FieldError>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-field">
|
|
||||||
<label htmlFor="dept-status">Status</label>
|
|
||||||
<div className="dept-status-toggle">
|
|
||||||
<label className="switch">
|
|
||||||
<input
|
|
||||||
id="dept-status"
|
|
||||||
type="checkbox"
|
|
||||||
checked={fields.is_active}
|
|
||||||
onChange={(e) => set('is_active', e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span className="switch-track" />
|
|
||||||
</label>
|
|
||||||
<span>{fields.is_active ? 'Active' : 'Inactive'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-field col-span-2">
|
|
||||||
<label htmlFor="dept-desc">Description</label>
|
|
||||||
<textarea
|
|
||||||
id="dept-desc"
|
|
||||||
rows={3}
|
|
||||||
style={{ minHeight: 72 }}
|
|
||||||
value={fields.description}
|
|
||||||
placeholder="What this department does and the roles it hires for."
|
|
||||||
onChange={(e) => set('description', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-field">
|
|
||||||
<label htmlFor="dept-head">Department Head</label>
|
|
||||||
<select
|
|
||||||
id="dept-head"
|
|
||||||
value={fields.department_head_id}
|
|
||||||
onChange={(e) => set('department_head_id', e.target.value)}
|
|
||||||
onMouseDown={requestHeads}
|
|
||||||
onFocus={requestHeads}
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
{headsQuery.isFetching ? 'Loading…' : headsQuery.isError ? 'Couldn’t load department heads' : 'Not assigned'}
|
|
||||||
</option>
|
|
||||||
{headOptions.map((u) => (
|
|
||||||
<option key={u.id} value={u.id}>{u.email ? `${u.name} — ${u.email}` : u.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-field">
|
|
||||||
<label htmlFor="dept-parent">Parent Department</label>
|
|
||||||
<select
|
|
||||||
id="dept-parent"
|
|
||||||
value={fields.parent_department_id}
|
|
||||||
onChange={(e) => set('parent_department_id', e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">None (top-level)</option>
|
|
||||||
{parentOptions.map((d) => (
|
|
||||||
<option key={d.id} value={d.id}>{d.name} ({d.short_code})</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-field col-span-2">
|
|
||||||
<label htmlFor="dept-subtitle">Subtitle</label>
|
|
||||||
<input
|
|
||||||
id="dept-subtitle"
|
|
||||||
value={fields.subtitle}
|
|
||||||
maxLength={160}
|
|
||||||
placeholder="e.g. Store Operations"
|
|
||||||
onChange={(e) => set('subtitle', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-field col-span-2">
|
|
||||||
<label htmlFor="dept-location">Region / Location</label>
|
|
||||||
<LocationMultiSelect
|
|
||||||
id="dept-location"
|
|
||||||
value={fields.location}
|
|
||||||
disabled={!allowed}
|
|
||||||
onChange={(next) => set('location', next)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rendering all ~800 cities at once makes typing lag; the search narrows it.
|
|
||||||
const LOCATION_RENDER_LIMIT = 100
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chip + search picker over GET /department/locations/fetch ("{City} - {Country}").
|
|
||||||
* Same markup as Jobs' RecruiterMultiSelect. Value is the list of selected labels;
|
|
||||||
* the options load the first time the field is focused.
|
|
||||||
*/
|
|
||||||
function LocationMultiSelect({ id, value = [], onChange, disabled = false }) {
|
|
||||||
const [q, setQ] = useState('')
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const root = useRef(null)
|
|
||||||
const listRef = useRef(null)
|
|
||||||
|
|
||||||
const locationsQuery = useQuery({
|
|
||||||
queryKey: qk.departments.locations(),
|
|
||||||
queryFn: async () => departmentsApi.toRows(await departmentsApi.listLocations()),
|
|
||||||
enabled: open,
|
|
||||||
staleTime: Infinity,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function onDoc(e) {
|
|
||||||
if (root.current && !root.current.contains(e.target)) setOpen(false)
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', onDoc)
|
|
||||||
return () => document.removeEventListener('mousedown', onDoc)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Scroll the modal so the opened list is visible, not below the fold.
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) listRef.current?.scrollIntoView({ block: 'nearest' })
|
|
||||||
}, [open, locationsQuery.isSuccess])
|
|
||||||
|
|
||||||
const selected = value || []
|
|
||||||
const term = q.trim().toLowerCase()
|
|
||||||
const filtered = (locationsQuery.data ?? []).filter(
|
|
||||||
(label) => !selected.includes(label) && (!term || label.toLowerCase().includes(term)),
|
|
||||||
)
|
|
||||||
|
|
||||||
function add(label) {
|
|
||||||
onChange([...selected, label])
|
|
||||||
setQ('')
|
|
||||||
}
|
|
||||||
|
|
||||||
function remove(label) {
|
|
||||||
onChange(selected.filter((x) => x !== label))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="job-recruiter-multi" ref={root}>
|
|
||||||
{selected.length > 0 && (
|
|
||||||
<div className="job-recruiter-chips">
|
|
||||||
{selected.map((label) => (
|
|
||||||
<span className="job-recruiter-chip" key={label}>
|
|
||||||
{label}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="job-recruiter-chip-x"
|
|
||||||
aria-label={`Remove ${label}`}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => remove(label)}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
value={open ? q : ''}
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder={selected.length ? 'Add another location…' : 'Search city or country…'}
|
|
||||||
autoComplete="off"
|
|
||||||
onFocus={() => { setOpen(true); setQ('') }}
|
|
||||||
onChange={(e) => { setQ(e.target.value); setOpen(true) }}
|
|
||||||
/>
|
|
||||||
{open && !disabled && (
|
|
||||||
// In the normal flow, not position:absolute — .modal-body scrolls, so an
|
|
||||||
// absolute menu is clipped at its edge. This list grows the modal instead.
|
|
||||||
<div className="dept-location-list" ref={listRef} role="listbox">
|
|
||||||
{locationsQuery.isFetching && <div className="dept-location-note">Loading…</div>}
|
|
||||||
{locationsQuery.isError && (
|
|
||||||
<button type="button" className="dept-location-option text-muted" onClick={() => locationsQuery.refetch()}>
|
|
||||||
Couldn’t load locations — retry
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{locationsQuery.isSuccess && filtered.length === 0 && (
|
|
||||||
<div className="dept-location-note">No matches</div>
|
|
||||||
)}
|
|
||||||
{filtered.slice(0, LOCATION_RENDER_LIMIT).map((label) => (
|
|
||||||
<button type="button" role="option" key={label} className="dept-location-option" onClick={() => add(label)}>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{filtered.length > LOCATION_RENDER_LIMIT && (
|
|
||||||
<div className="dept-location-note">Type to narrow {filtered.length - LOCATION_RENDER_LIMIT} more…</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -29,7 +29,6 @@ import * as assignmentsApi from '../api/assignments'
|
||||||
import * as tasksApi from '../api/tasks'
|
import * as tasksApi from '../api/tasks'
|
||||||
import * as usersApi from '../api/users'
|
import * as usersApi from '../api/users'
|
||||||
import * as requisitionsApi from '../api/requisitions'
|
import * as requisitionsApi from '../api/requisitions'
|
||||||
import * as departmentsApi from '../api/departments'
|
|
||||||
import * as offersApi from '../api/offers'
|
import * as offersApi from '../api/offers'
|
||||||
import { fmtDate, fmtDateTime, fmtShort, toDate } from '../lib/format'
|
import { fmtDate, fmtDateTime, fmtShort, toDate } from '../lib/format'
|
||||||
import { empTypes } from '../data/seed'
|
import { empTypes } from '../data/seed'
|
||||||
|
|
@ -44,7 +43,7 @@ async function fetchJobs() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function deptValue(j) {
|
function deptValue(j) {
|
||||||
return String(j.department || '').trim()
|
return String(j.requisitionDepartment || j.department || '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function deptLabel(j) {
|
function deptLabel(j) {
|
||||||
|
|
@ -411,6 +410,7 @@ export default function Jobs() {
|
||||||
{editing && (
|
{editing && (
|
||||||
<EditJobForm
|
<EditJobForm
|
||||||
job={editing}
|
job={editing}
|
||||||
|
departmentOptions={departmentOptions}
|
||||||
busy={updateJob.isPending}
|
busy={updateJob.isPending}
|
||||||
onClose={() => setEditing(null)}
|
onClose={() => setEditing(null)}
|
||||||
onSubmit={(body) => updateJob.mutate({ id: editing.id, body })}
|
onSubmit={(body) => updateJob.mutate({ id: editing.id, body })}
|
||||||
|
|
@ -419,6 +419,7 @@ export default function Jobs() {
|
||||||
|
|
||||||
{creating && (
|
{creating && (
|
||||||
<JobForm
|
<JobForm
|
||||||
|
departmentOptions={departmentOptions}
|
||||||
busy={createJob.isPending}
|
busy={createJob.isPending}
|
||||||
onClose={() => setCreating(false)}
|
onClose={() => setCreating(false)}
|
||||||
onSubmit={(payload, imageFile) => createJob.mutate({ payload, imageFile })}
|
onSubmit={(payload, imageFile) => createJob.mutate({ payload, imageFile })}
|
||||||
|
|
@ -655,49 +656,6 @@ 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) {
|
function useRequisitionPicker(initialPicked = null, jobPostId = null) {
|
||||||
const [reqQ, setReqQ] = useState('')
|
const [reqQ, setReqQ] = useState('')
|
||||||
const [debouncedReqQ, setDebouncedReqQ] = useState('')
|
const [debouncedReqQ, setDebouncedReqQ] = useState('')
|
||||||
|
|
@ -735,43 +693,17 @@ function useRequisitionPicker(initialPicked = null, jobPostId = null) {
|
||||||
return { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq }
|
return { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The Department field on both job forms: search dropdown, stores department_id. */
|
function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
||||||
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 managersQuery = useManagerDirectory()
|
||||||
const recruitersQuery = useRecruiterDirectory()
|
const recruitersQuery = useRecruiterDirectory()
|
||||||
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker()
|
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker()
|
||||||
const dept = useDepartmentPicker()
|
|
||||||
|
|
||||||
const form = useFormState({
|
const form = useFormState({
|
||||||
hiring_manager_id: '',
|
hiring_manager_id: '',
|
||||||
current_recruiter_ids: [],
|
current_recruiter_ids: [],
|
||||||
requisition_id: '',
|
requisition_id: '',
|
||||||
title: '',
|
title: '',
|
||||||
department_id: '',
|
department: '',
|
||||||
location: '',
|
location: '',
|
||||||
employment_type: empTypes[0] || 'Full-time',
|
employment_type: empTypes[0] || 'Full-time',
|
||||||
vacancies: '1',
|
vacancies: '1',
|
||||||
|
|
@ -846,7 +778,7 @@ function JobForm({ busy, onClose, onSubmit }) {
|
||||||
// The cover image is uploaded separately right after the row exists.
|
// The cover image is uploaded separately right after the row exists.
|
||||||
onSubmit({
|
onSubmit({
|
||||||
title: v.title.trim(),
|
title: v.title.trim(),
|
||||||
department_id: v.department_id || null,
|
department: v.department.trim() || null,
|
||||||
location: v.location.trim() || null,
|
location: v.location.trim() || null,
|
||||||
employment_type: v.employment_type || null,
|
employment_type: v.employment_type || null,
|
||||||
vacancies,
|
vacancies,
|
||||||
|
|
@ -870,7 +802,7 @@ function JobForm({ busy, onClose, onSubmit }) {
|
||||||
// field itself and empty values before building the prompt.
|
// field itself and empty values before building the prompt.
|
||||||
const assistContext = () => ({
|
const assistContext = () => ({
|
||||||
title: form.values.title,
|
title: form.values.title,
|
||||||
department: dept.pickedDept?.name || '',
|
department: form.values.department,
|
||||||
location: form.values.location,
|
location: form.values.location,
|
||||||
employment_type: form.values.employment_type,
|
employment_type: form.values.employment_type,
|
||||||
experience_min: form.values.experience_min,
|
experience_min: form.values.experience_min,
|
||||||
|
|
@ -924,9 +856,7 @@ function JobForm({ busy, onClose, onSubmit }) {
|
||||||
if (opt) {
|
if (opt) {
|
||||||
setPickedReq(opt)
|
setPickedReq(opt)
|
||||||
if (!form.values.title.trim() && opt.title) form.setField('title', opt.title)
|
if (!form.values.title.trim() && opt.title) form.setField('title', opt.title)
|
||||||
if (!form.values.department_id && opt.department) {
|
if (!form.values.department.trim() && opt.department) form.setField('department', opt.department)
|
||||||
dept.pickByName(opt.department).then((d) => { if (d) form.setField('department_id', d.id) })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onQueryChange={setReqQ}
|
onQueryChange={setReqQ}
|
||||||
|
|
@ -951,7 +881,7 @@ function JobForm({ busy, onClose, onSubmit }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<label>Hiring manager</label>
|
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
|
||||||
<SearchSelect
|
<SearchSelect
|
||||||
options={managersQuery.data ?? []}
|
options={managersQuery.data ?? []}
|
||||||
value={form.values.hiring_manager_id}
|
value={form.values.hiring_manager_id}
|
||||||
|
|
@ -984,8 +914,16 @@ function JobForm({ busy, onClose, onSubmit }) {
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<div className="field-label-row">
|
<div className="field-label-row">
|
||||||
<label>Department</label>
|
<label>Department</label>
|
||||||
|
{assist('department')}
|
||||||
</div>
|
</div>
|
||||||
<DepartmentSearchSelect dept={dept} value={form.values.department_id} onChange={(id) => form.setField('department_id', id)} disabled={busy} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<div className="field-label-row">
|
<div className="field-label-row">
|
||||||
|
|
@ -1121,7 +1059,7 @@ function JobForm({ busy, onClose, onSubmit }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditJobForm({ job: j, busy, onClose, onSubmit }) {
|
function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||||
const managersQuery = useManagerDirectory()
|
const managersQuery = useManagerDirectory()
|
||||||
const recruitersQuery = useRecruiterDirectory()
|
const recruitersQuery = useRecruiterDirectory()
|
||||||
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker(
|
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker(
|
||||||
|
|
@ -1135,11 +1073,10 @@ function EditJobForm({ job: j, busy, onClose, onSubmit }) {
|
||||||
: null,
|
: null,
|
||||||
j.id,
|
j.id,
|
||||||
)
|
)
|
||||||
const dept = useDepartmentPicker(j.departmentId ? { id: j.departmentId, name: j.department || 'Current department' } : null)
|
|
||||||
const form = useFormState({
|
const form = useFormState({
|
||||||
requisition_id: j.requisitionId || '',
|
requisition_id: j.requisitionId || '',
|
||||||
title: j.title || '',
|
title: j.title || '',
|
||||||
department_id: j.departmentId || '',
|
department: j.department || '',
|
||||||
location: j.location || '',
|
location: j.location || '',
|
||||||
employment_type: j.type || '',
|
employment_type: j.type || '',
|
||||||
vacancies: j.vacancies != null ? String(j.vacancies) : '1',
|
vacancies: j.vacancies != null ? String(j.vacancies) : '1',
|
||||||
|
|
@ -1152,7 +1089,7 @@ function EditJobForm({ job: j, busy, onClose, onSubmit }) {
|
||||||
|
|
||||||
const assistContext = () => ({
|
const assistContext = () => ({
|
||||||
title: form.values.title,
|
title: form.values.title,
|
||||||
department: dept.pickedDept?.name || '',
|
department: form.values.department,
|
||||||
location: form.values.location,
|
location: form.values.location,
|
||||||
employment_type: form.values.employment_type,
|
employment_type: form.values.employment_type,
|
||||||
experience_min: form.values.experience_min,
|
experience_min: form.values.experience_min,
|
||||||
|
|
@ -1181,7 +1118,7 @@ function EditJobForm({ job: j, busy, onClose, onSubmit }) {
|
||||||
if (Object.keys(errors).length) return
|
if (Object.keys(errors).length) return
|
||||||
onSubmit({
|
onSubmit({
|
||||||
title,
|
title,
|
||||||
department_id: form.values.department_id || null,
|
department: form.values.department.trim() || null,
|
||||||
location: form.values.location.trim() || null,
|
location: form.values.location.trim() || null,
|
||||||
employment_type: form.values.employment_type || null,
|
employment_type: form.values.employment_type || null,
|
||||||
vacancies: Number(form.values.vacancies) || 1,
|
vacancies: Number(form.values.vacancies) || 1,
|
||||||
|
|
@ -1226,9 +1163,7 @@ function EditJobForm({ job: j, busy, onClose, onSubmit }) {
|
||||||
if (opt) {
|
if (opt) {
|
||||||
setPickedReq(opt)
|
setPickedReq(opt)
|
||||||
if (!form.values.title.trim() && opt.title) form.setField('title', opt.title)
|
if (!form.values.title.trim() && opt.title) form.setField('title', opt.title)
|
||||||
if (!form.values.department_id && opt.department) {
|
if (!form.values.department.trim() && opt.department) form.setField('department', opt.department)
|
||||||
dept.pickByName(opt.department).then((d) => { if (d) form.setField('department_id', d.id) })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onQueryChange={setReqQ}
|
onQueryChange={setReqQ}
|
||||||
|
|
@ -1251,7 +1186,7 @@ function EditJobForm({ job: j, busy, onClose, onSubmit }) {
|
||||||
<FieldError>{form.errors.title}</FieldError>
|
<FieldError>{form.errors.title}</FieldError>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<label>Hiring manager</label>
|
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
|
||||||
<SearchSelect
|
<SearchSelect
|
||||||
options={managersQuery.data ?? []}
|
options={managersQuery.data ?? []}
|
||||||
value={form.values.hiring_manager_id}
|
value={form.values.hiring_manager_id}
|
||||||
|
|
@ -1277,8 +1212,10 @@ function EditJobForm({ job: j, busy, onClose, onSubmit }) {
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<div className="field-label-row">
|
<div className="field-label-row">
|
||||||
<label>Department</label>
|
<label>Department</label>
|
||||||
|
{assist('department')}
|
||||||
</div>
|
</div>
|
||||||
<DepartmentSearchSelect dept={dept} value={form.values.department_id} onChange={(id) => form.setField('department_id', id)} disabled={busy} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<div className="field-label-row">
|
<div className="field-label-row">
|
||||||
|
|
@ -1347,7 +1284,7 @@ function JobOwnership({ job, canEdit }) {
|
||||||
<div style={SECTION_LABEL}>Ownership</div>
|
<div style={SECTION_LABEL}>Ownership</div>
|
||||||
<div className="form-grid" style={{ marginBottom: 12 }}>
|
<div className="form-grid" style={{ marginBottom: 12 }}>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<label>Hiring manager</label>
|
<label>Hiring manager <span className="text-muted text-sm">optional</span></label>
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<SearchSelect
|
<SearchSelect
|
||||||
options={managersQuery.data ?? []}
|
options={managersQuery.data ?? []}
|
||||||
|
|
|
||||||
|
|
@ -507,7 +507,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<label className="hf-check">
|
<label>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={fields.jd_available}
|
checked={fields.jd_available}
|
||||||
|
|
@ -642,7 +642,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
||||||
</div>
|
</div>
|
||||||
<div className="hf-sign">
|
<div className="hf-sign">
|
||||||
<span className="hf-sign-role">Approved by · Director HR</span>
|
<span className="hf-sign-role">Approved by · Director HR</span>
|
||||||
<label className="hf-note hf-check" style={{ margin: 0 }}>
|
<label className="hf-note" style={{ margin: 0 }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={fields.approved_by_hr}
|
checked={fields.approved_by_hr}
|
||||||
|
|
@ -658,7 +658,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
||||||
</div>
|
</div>
|
||||||
<div className="hf-sign">
|
<div className="hf-sign">
|
||||||
<span className="hf-sign-role">Approved by · VP</span>
|
<span className="hf-sign-role">Approved by · VP</span>
|
||||||
<label className="hf-note hf-check" style={{ margin: 0 }}>
|
<label className="hf-note" style={{ margin: 0 }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={fields.approved_by_vp}
|
checked={fields.approved_by_vp}
|
||||||
|
|
@ -674,7 +674,7 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
||||||
</div>
|
</div>
|
||||||
<div className="hf-sign">
|
<div className="hf-sign">
|
||||||
<span className="hf-sign-role">Approved by · SVP</span>
|
<span className="hf-sign-role">Approved by · SVP</span>
|
||||||
<label className="hf-note hf-check" style={{ margin: 0 }}>
|
<label className="hf-note" style={{ margin: 0 }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={fields.approved_by_svp}
|
checked={fields.approved_by_svp}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
.form-section-title { margin: var(--space-5) 0 var(--space-1); grid-column: 1/-1; }
|
||||||
|
|
||||||
/* AI field assist (ui/AiFieldAssist.jsx) */
|
/* AI field assist (ui/AiFieldAssist.jsx) */
|
||||||
.field-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 22px; }
|
.field-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
.ai-assist { position: relative; display: inline-flex; }
|
.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 { 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; }
|
.ai-assist-btn svg { width: 14px; height: 14px; }
|
||||||
|
|
@ -1937,10 +1937,6 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
||||||
.hf-block-title { margin-bottom: 12px; display: flex; align-items: center; gap: 10px; }
|
.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-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; }
|
.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 */
|
/* Rating table: the paper grid — scale header, radio-dot cells, average foot */
|
||||||
.hf-rate { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
.hf-rate { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
||||||
|
|
@ -2293,35 +2289,3 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
||||||
@media (max-width: 400px) {
|
@media (max-width: 400px) {
|
||||||
.hf-summary { grid-template-columns: 1fr; }
|
.hf-summary { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================= DEPARTMENTS ================= */
|
|
||||||
.dept-card { --tone: var(--primary); --tone-soft: var(--primary-soft); display: flex; flex-direction: column; }
|
|
||||||
.dept-card.tone-success { --tone: var(--success); --tone-soft: var(--success-soft); }
|
|
||||||
.dept-card.tone-warning { --tone: var(--warning); --tone-soft: var(--warning-soft); }
|
|
||||||
.dept-card.tone-purple { --tone: var(--purple); --tone-soft: var(--purple-soft); }
|
|
||||||
.dept-card.tone-info { --tone: var(--info); --tone-soft: var(--info-soft); }
|
|
||||||
.dept-card > .card-body { display: flex; flex-direction: column; flex: 1; }
|
|
||||||
.dept-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
|
|
||||||
.dept-card-id { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
|
||||||
.dept-icn { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; flex: none; background: var(--tone-soft); color: var(--tone); }
|
|
||||||
.dept-icn svg { width: 20px; height: 20px; }
|
|
||||||
.dept-name { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; font-size: var(--fs-md); font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; }
|
|
||||||
.code-chip { font-size: 11px; font-weight: 700; letter-spacing: .4px; padding: 2px 8px; border-radius: 20px; background: var(--tone-soft); color: var(--tone); }
|
|
||||||
.dept-desc { font-size: var(--fs-sm); color: var(--text-2); line-height: 1.5; margin: 0 0 16px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
||||||
.dept-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: auto; }
|
|
||||||
.dept-stat { text-align: center; min-width: 0; }
|
|
||||||
.dept-stat-v { font-size: 18px; font-weight: 600; line-height: 1.3; }
|
|
||||||
.dept-stat-v.is-on, .dept-stat-v.is-off { font-size: var(--fs-sm); line-height: 23px; }
|
|
||||||
.dept-stat-v.is-on { color: var(--success); }
|
|
||||||
.dept-stat-v.is-off { color: var(--text-3); }
|
|
||||||
.dept-stat-l { font-size: 11px; color: var(--text-3); margin-top: 2px; }
|
|
||||||
.dept-card .divider { margin: 14px 0; }
|
|
||||||
.dept-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
|
||||||
.dept-head { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: var(--fs-sm); color: var(--text-2); }
|
|
||||||
.dept-head > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.dept-avatar { width: 26px; height: 26px; font-size: 10px; }
|
|
||||||
.dept-status-toggle { display: flex; align-items: center; gap: 10px; padding: 7px 12px; border: 1px solid var(--border-strong); border-radius: 9px; font-size: var(--fs-sm); min-height: 40px; }
|
|
||||||
.dept-location-list { max-height: 320px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-elev); box-shadow: var(--shadow-sm); padding: 4px; }
|
|
||||||
.dept-location-option { display: block; width: 100%; text-align: left; padding: 8px 10px; border-radius: 7px; font-size: var(--fs-sm); color: var(--text); }
|
|
||||||
.dept-location-option:hover, .dept-location-option:focus-visible { background: var(--bg-sunken); outline: none; }
|
|
||||||
.dept-location-note { padding: 8px 10px; font-size: var(--fs-sm); color: var(--text-3); }
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export default defineConfig({
|
||||||
// VITE_API_TARGET repoints the proxy when the API runs elsewhere
|
// VITE_API_TARGET repoints the proxy when the API runs elsewhere
|
||||||
// (e.g. 8001 locally because another service holds 8000).
|
// (e.g. 8001 locally because another service holds 8000).
|
||||||
proxy: {
|
proxy: {
|
||||||
'^/(health|users|roles|permissions|permission-tags|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search|documents|sheet|managers|inbox|s3|forms|requisitions|department)(/|$)': {
|
'^/(health|users|roles|permissions|permission-tags|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search|documents|sheet|managers|inbox|s3|forms|requisitions)(/|$)': {
|
||||||
target: process.env.VITE_API_TARGET || 'http://127.0.0.1:8000',
|
target: process.env.VITE_API_TARGET || 'http://127.0.0.1:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
// Several API prefixes double as SPA routes (/jobs, /inbox, …).
|
// Several API prefixes double as SPA routes (/jobs, /inbox, …).
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue