commit with reqxdepoart realtion
Deploy to S3 / deploy (push) Successful in 33s
Details
Deploy to S3 / deploy (push) Successful in 33s
Details
parent
b841677bc3
commit
3838634e82
|
|
@ -4,127 +4,311 @@ Copy everything below the line into any LLM session before asking it to write or
|
|||
|
||||
---
|
||||
|
||||
You are coding inside **HR-ATS-Portal** (`backend/`). You must follow this house style **exactly**. Mirror neighboring files. Do not invent alternate patterns, layers, or response shapes. Prefer matching existing code over “cleaner” industry defaults.
|
||||
You are coding inside **HR-ATS-Portal** (`backend/`). Follow this house style **exactly**. Mirror the reference flow below line-for-line in shape. Do not invent layers, response shapes, flags, guards, or "defensive" checks the reference does not have. Write **no more and no less** than the reference does for the same job.
|
||||
|
||||
## Goal
|
||||
## Reference flow (the canonical example)
|
||||
|
||||
Every change must look like it was written by the same author as `backend/users/` and `backend/inbox/`.
|
||||
`POST /forms/requisition/create` in `backend/candidate_forms/`. Every new endpoint copies this shape.
|
||||
|
||||
### 1. `enums.py` — enums + nested payload blocks
|
||||
|
||||
```python
|
||||
class EmploymentType(str,Enum):
|
||||
PERMANENT = "permanent"
|
||||
CONTRACT = "contract"
|
||||
|
||||
class Position(BaseModel):
|
||||
department_id:uuid.UUID
|
||||
department:Optional[str]
|
||||
title:Optional[str]
|
||||
type:Optional[EmploymentType]
|
||||
period_from:Optional[date]=None
|
||||
```
|
||||
|
||||
- `str, Enum` classes and the nested `BaseModel` blocks a request body is built from live here.
|
||||
|
||||
### 2. `app.py` — request models inline + thin route
|
||||
|
||||
```python
|
||||
class RequisitionFormCreate(BaseModel):
|
||||
form_type: str = "requisition"
|
||||
position:Position
|
||||
replacement_for:Optional[ReplacementFor]
|
||||
initiated_by:Optional[str]
|
||||
approved_by_hr:Optional[bool]
|
||||
|
||||
|
||||
class RequisitionFormUpdate(BaseModel):
|
||||
position:Optional[Position]=None
|
||||
initiated_by:Optional[str]=None
|
||||
approved_by_hr:Optional[bool]=None
|
||||
|
||||
|
||||
@router.post("/forms/requisition/create")
|
||||
async def create_requisition_form(
|
||||
payload: RequisitionFormCreate,
|
||||
current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_CREATE)),
|
||||
session:AsyncSession=Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = RequisitionForm(session=session)
|
||||
data = await service.create_form(payload.model_dump(exclude_unset=True), current_user)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
```
|
||||
|
||||
- `router = APIRouter()`; mounted in `main.py` via `app.include_router(...)`.
|
||||
- Paths are verb-in-path: `/<domain>/create`, `/<domain>/fetch`, `/<domain>/update`, `/<domain>/delete`, `/<domain>/search`. No `/api/v1`, no REST-resource-only paths.
|
||||
- Methods: `post` create, `get` fetch/search/count, `patch` update, `delete` delete.
|
||||
- Param order: `payload` → `current_user` → query params → `session`.
|
||||
- Record id comes as a query param: `form_id:str=Query(...)` (required) or `Query(None)` (fetch one-or-all).
|
||||
- Body goes to the service as `payload.model_dump(exclude_unset=True)`.
|
||||
- Protection: `current_user:dict=Depends(require_permission(PermissionTag.X))`. Several tags: `require_permission(A, B, require_all=False)`.
|
||||
- The route body is **only** the try block above: build service, one `await`, `JSONResponse`. Nothing else.
|
||||
- Envelope: `{"data": data, "status_code": 200}`. List with count: add `"total"`. A scalar goes inside `data` as a dict (`{"data":{"open":data},...}`).
|
||||
- Request models stay inline in `app.py` — Create has required fields without defaults, Update has every field `Optional[...]=None`.
|
||||
|
||||
### 3. `views.py` — service class
|
||||
|
||||
```python
|
||||
class RequisitionForm:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def create_form(self, payload, current_user):
|
||||
payload["position"]["department_id"] = _as_uuid(payload["position"]["department_id"])
|
||||
payload["created_by"] = _user_id(current_user)
|
||||
row = await Requisition.insert_form(self.session, payload)
|
||||
return serialize_requisition(row)
|
||||
|
||||
async def update_form(self, form_id, payload, current_user):
|
||||
_user_id(current_user)
|
||||
row = await Requisition.get_form_by_id(self.session, form_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Form not found")
|
||||
if not payload:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
updated = await Requisition.update_form(self.session, form_id, payload)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Form not found")
|
||||
return serialize_requisition(updated)
|
||||
|
||||
async def get_form_by_id(self, form_id, current_user):
|
||||
created_by = None if is_admin(current_user) else _user_id(current_user)
|
||||
if form_id:
|
||||
row = await Requisition.get_form_by_id(self.session, record_id=form_id, created_by=created_by)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Form not found")
|
||||
return serialize_requisition(row)
|
||||
rows = await Requisition.get_form_by_id(self.session, created_by=created_by)
|
||||
return [serialize_requisition(r) for r in rows]
|
||||
```
|
||||
|
||||
- One class per resource, `__init__(self, session: AsyncSession)` only.
|
||||
- Method parameters are **untyped**.
|
||||
- A create method is: adjust the payload dict in place (coerce ids with plugin helpers, stamp `created_by`) → call **one** model classmethod → return the serializer. That is the whole method.
|
||||
- Business errors: `raise HTTPException(status_code=..., detail="...")` — 404 not found, 400 empty update, 401 auth, 422 invalid value.
|
||||
- Scope reads with `created_by = None if is_admin(current_user) else _user_id(current_user)`.
|
||||
- Always return serialized dicts / lists of dicts, never ORM rows.
|
||||
|
||||
### 4. `plugins.py` — shared helpers
|
||||
|
||||
```python
|
||||
def _as_uuid(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _user_id(current_user):
|
||||
if not current_user or not current_user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
uid = _as_uuid(current_user["id"])
|
||||
if uid is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid user id")
|
||||
return uid
|
||||
```
|
||||
|
||||
- Every helper function and constant (`_as_uuid`, `_user_id`, `_aware`, validators, `FORM_TYPES`, definitions payloads) lives in `<domain>/plugins.py` and is imported by name into `views.py`. Never define helpers at the top of `views.py`, `models.py`, `app.py`, or `serializers.py`.
|
||||
- Helpers may raise `HTTPException` when they validate request data.
|
||||
- Before writing a helper, check the domain's `plugins.py` and reuse what exists.
|
||||
|
||||
### 5. `models.py` — SQLModel table + classmethod accessors
|
||||
|
||||
```python
|
||||
class Requisition(SQLModel, table=True):
|
||||
__tablename__ = "requisitions"
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
department_id: Optional[uuid.UUID] = Field(default=None, foreign_key="departments.id")
|
||||
department_ref: Optional["Department"] = Relationship(
|
||||
back_populates="requisitions",
|
||||
sa_relationship_kwargs={"uselist": False, "lazy": "selectin"},
|
||||
)
|
||||
position_title: Optional[str] = None
|
||||
created_by: Optional[uuid.UUID] = Field(foreign_key="users.id")
|
||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
is_deleted: bool = Field(default=False)
|
||||
|
||||
@classmethod
|
||||
async def get_form_by_id(cls, session: AsyncSession, record_id=None, created_by=None):
|
||||
qry = select(cls).where(cls.is_deleted == False) # noqa: E712
|
||||
if created_by is not None:
|
||||
qry = qry.where(cls.created_by == created_by)
|
||||
if record_id not in (None, ""):
|
||||
try:
|
||||
uid = uuid.UUID(str(record_id))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
qry = qry.where(cls.id == uid)
|
||||
result = await session.execute(qry)
|
||||
return result.scalars().first()
|
||||
result = await session.execute(qry.order_by(cls.created_at.desc(),cls.id.desc()))
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def insert_form(cls, session: AsyncSession, fields: dict):
|
||||
position = fields.get("position") if fields.get("position") else {}
|
||||
row = cls(
|
||||
department_id=position.get("department_id") if position.get("department_id") else None,
|
||||
position_title=position.get("title") if position.get("title") else None,
|
||||
employment_type=EmploymentType(position.get("type")) if position.get("type") else None,
|
||||
jd_available=position.get("jd_available") if position.get("jd_available") is not None else None,
|
||||
initiated_by=fields.get("initiated_by") if fields.get("initiated_by") else None,
|
||||
created_by=fields.get("created_by") if fields.get("created_by") else None,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return await cls.get_form_by_id(session, row.id)
|
||||
|
||||
@classmethod
|
||||
async def update_form(cls, session: AsyncSession, record_id, fields: dict):
|
||||
row = await cls.get_form_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
if "position" in fields:
|
||||
position = fields.get("position") if fields.get("position") else {}
|
||||
if "title" in position:
|
||||
row.position_title = position.get("title") if position.get("title") else None
|
||||
if "initiated_by" in fields:
|
||||
row.initiated_by = fields.get("initiated_by") if fields.get("initiated_by") else None
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def soft_delete_form(cls, session: AsyncSession, record_id):
|
||||
row = await cls.get_form_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
row.is_deleted = True
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return row
|
||||
```
|
||||
|
||||
- Standard columns on every table: `id` uuid4 PK, `created_by` FK `users.id`, `created_at` / `updated_at` tz-aware via `_now`, `is_deleted`.
|
||||
- All DB access is `@classmethod async def` taking `session` first. No free functions, no repository class.
|
||||
- **One accessor does one whole job.** Insert maps the full (nested) payload dict to columns, adds, commits, and re-fetches in the same method. Never split a create into several helper calls or a second "create child" function when the mapping fits inline.
|
||||
- **One reader for one-or-many:** `get_form_by_id` returns a row when `record_id` is given, a list otherwise. Do not add a separate `fetch_all`.
|
||||
- Column mapping idiom: `x=src.get("k") if src.get("k") else None`; booleans use `is not None`; enums wrap `Enum(value)`.
|
||||
- Update idiom: `if "k" in fields:` per field (nested blocks: `if "block" in fields:` then per-key), then `row.updated_at = _now()`, add, commit, refresh, return row. Return `None` when missing — the view raises.
|
||||
- Soft delete only: `is_deleted = True` (+ `updated_at`). Reads always filter `cls.is_deleted == False # noqa: E712`.
|
||||
- Cross-domain relations: model name in quotes, import under `if TYPE_CHECKING:`, `back_populates` on both sides, and a bottom-of-file `import <other>.models as _<other>_models # noqa: E402, F401`.
|
||||
- Commits happen inside write accessors; views never commit.
|
||||
|
||||
### 6. `serializers.py` — hand-built dicts
|
||||
|
||||
```python
|
||||
def _date(value):
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def serialize_requisition(row) -> dict:
|
||||
return {
|
||||
"id": str(row.id) if row.id else None,
|
||||
"position": {
|
||||
"department_id": str(row.department_id) if row.department_id else None,
|
||||
"title": row.position_title,
|
||||
"date": _date(row.date),
|
||||
"type": _enum(row.employment_type),
|
||||
},
|
||||
"initiated_by": row.initiated_by,
|
||||
"created_by": str(row.created_by) if row.created_by else None,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
```
|
||||
|
||||
- `serialize_<resource>(row) -> dict`. UUIDs `str(...) if ... else None`, dates `.isoformat()`, enums via `_enum`.
|
||||
- The response shape mirrors the request shape (nested blocks back out as nested dicts), so the frontend sends and receives the same structure.
|
||||
- No DB access, no Pydantic response models. Never include passwords.
|
||||
|
||||
### 7. Schema change → manual SQL migration
|
||||
|
||||
- New column / FK / index: add `backend/migrations/manual/<next_number>_<snake_name>.sql` with a header comment explaining why, idempotent DDL (`ADD COLUMN IF NOT EXISTS`, guarded `ADD CONSTRAINT`, `CREATE INDEX IF NOT EXISTS`), schema `app.`. Never edit an already-applied migration file.
|
||||
|
||||
## Package layout (every domain)
|
||||
|
||||
```
|
||||
backend/<domain>/
|
||||
app.py # routes only — HTTP in/out
|
||||
views.py # service class — business logic
|
||||
models.py # SQLModel table + classmethod DB accessors
|
||||
serializers.py # hand-rolled dict builders (no Pydantic response models)
|
||||
plugins.py # pure helpers (hash, JWT, clean payload) — NO FastAPI imports
|
||||
permissions.py # OAuth2 scheme + Depends aliases (auth domains only)
|
||||
app.py # routes + inline request models
|
||||
views.py # service class
|
||||
models.py # SQLModel table + classmethod accessors
|
||||
serializers.py # serialize_* dict builders
|
||||
enums.py # str Enums + nested request BaseModel blocks
|
||||
plugins.py # helpers + constants
|
||||
permissions.py # auth domains only
|
||||
```
|
||||
|
||||
- Bare `router = APIRouter()`; mount in `main.py` with `app.include_router(...)`.
|
||||
- No package `__init__.py`. Run from `backend/` so imports are top-level (`users.app`, `db_setup`).
|
||||
- Non-DB config: module-level `load_dotenv()` + `os.getenv(...)`. Do **not** extend `db_setup.Settings` for app secrets.
|
||||
- No `__init__.py`. Run from `backend/`; imports are top-level (`candidate_forms.views`, `db_setup`, `users.permissions`).
|
||||
- Config: module-level `load_dotenv()` + `os.getenv(...)`. Do not extend `db_setup.Settings` for app secrets.
|
||||
|
||||
## Layer duties (non-negotiable)
|
||||
## Layer duties
|
||||
|
||||
| Layer | Owns | Must NOT do |
|
||||
| Layer | Owns | Must NOT |
|
||||
|---|---|---|
|
||||
| `app.py` | Routes, inline request Pydantic models, `JSONResponse`, HTTP token envelope via serializers, inject `session` / `CurrentUser` | Business rules, SQL, JWT crypto beyond calling plugin functions |
|
||||
| `views.py` | Business checks, call models, raise `HTTPException`, return ORM user (auth) or serialized dict (CRUD) | Call `serialize_token` or build login HTTP payloads |
|
||||
| `models.py` | Fields, queries, inserts/updates/soft-delete, `selectinload` when needed | HTTPException, FastAPI, serializers |
|
||||
| `serializers.py` | `serialize_*` → plain `dict` | DB, Depends |
|
||||
| `plugins.py` | Pure helpers; raise library errors (`jwt.*`) | Import FastAPI / raise HTTPException |
|
||||
| `permissions.py` | `OAuth2PasswordBearer`, `get_current_user`, `CurrentUser` alias, `require_permission` | Route handlers |
|
||||
|
||||
## Exact route pattern (`app.py`)
|
||||
|
||||
- Paths are verb-in-path: `/users/create`, `/users/fetch`, `/users/login` — **not** `/auth/token`, not REST-resource-only.
|
||||
- Standard wrapper on every handler:
|
||||
|
||||
```python
|
||||
try:
|
||||
service=User(session=session)
|
||||
data=await service.some_method(...)
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
```
|
||||
|
||||
- List: `{"data":items,"total":total,"status_code":200}`. By id: include `"total":1`.
|
||||
- Login / refresh — **service returns ORM user**; route mints tokens and serializes:
|
||||
|
||||
```python
|
||||
user=await service.authenticate_user(form_data.username,form_data.password)
|
||||
tokens=serialize_token(create_access_token(user),create_refresh_token(user),user)
|
||||
return JSONResponse(content={**tokens,"status_code":200})
|
||||
```
|
||||
|
||||
- Request body models stay **inline in `app.py`** (`UserCreate`, `UserUpdate`, `TokenRefresh`). Never move them into `serializers.py`.
|
||||
- Use `session: AsyncSession = Depends(get_session)` by default. Use `Annotated` only when required (`OAuth2PasswordRequestForm`, `CurrentUser` before other defaulted params).
|
||||
- Preserve tight local spacing: `service=User(session=session)`, `detail=str(e)`. Do not pretty-reformat unrelated code.
|
||||
|
||||
## Exact service pattern (`views.py`)
|
||||
|
||||
```python
|
||||
class User:
|
||||
def __init__(self,session:AsyncSession):
|
||||
self.session=session
|
||||
|
||||
async def create_user(self,payload):
|
||||
...
|
||||
return serialize_user(user)
|
||||
```
|
||||
|
||||
- Leave service method parameters untyped (match existing).
|
||||
- Raise `HTTPException(status_code=...,detail="...")` for domain errors.
|
||||
- `authenticate_user` / `refresh_access_token` return the **Users ORM instance only**.
|
||||
- If you loaded via an accessor that does not `selectinload(role)`, re-fetch with `get_user_by_id` before serialization that touches `user.role`. (`get_user_by_email` and `get_user_by_id` both eager-load `role` today.)
|
||||
|
||||
## Exact model pattern (`models.py`)
|
||||
|
||||
- SQLModel `table=True`; accessors as `@classmethod async def`.
|
||||
- Soft delete sets `is_deleted=True` and `is_active=False`.
|
||||
- `selectinload` relations that serializers read.
|
||||
- Commits happen inside write accessors (existing convention).
|
||||
|
||||
## Serializers
|
||||
|
||||
- Hand-built dicts only. `str(uuid)`, `.isoformat()` for datetimes. Never include `password`.
|
||||
- Token response: OAuth2 fields at **root** (`access_token`, `refresh_token`, `token_type`, `expires_in`); user record under `data`.
|
||||
|
||||
## Auth (when touching users auth)
|
||||
|
||||
- PyJWT access + refresh with a `type` claim; `decode_token(..., expected_type=...)` rejects mismatches.
|
||||
- Protect `/users/*` with `current_user: CurrentUser` except `/users/login` and `/users/refresh`. Prefer `Depends(require_permission(...))` on mutating/list routes that need a specific tag; keep `/users/me` on plain `CurrentUser` so users can discover a missing-role state.
|
||||
- `get_current_user`: decode access → DB by `sub` → reject missing/deleted/inactive → return `serialize_user(user, with_permissions=True)`.
|
||||
- Login: `OAuth2PasswordRequestForm` (username = email). `tokenUrl="users/login"` (no leading slash).
|
||||
- JWT `iat`/`exp` use `datetime.now(timezone.utc)` only — never naive `datetime.now()`.
|
||||
|
||||
## Dependencies / env
|
||||
|
||||
- Add pins to `backend/requirements.txt` under banner comments with a trailing `# why` comment.
|
||||
- Put secrets in `backend/.env`; keep key names in `backend/.env.example`.
|
||||
|
||||
## Hard bans
|
||||
|
||||
1. No repository / use-case / DTO layers beyond inline request models.
|
||||
2. No Pydantic response models; no alternate envelopes; no `/api/v1` prefix.
|
||||
3. No `serialize_token` inside `views.py`.
|
||||
4. No FastAPI imports in `plugins.py`.
|
||||
5. No drive-by refactors, renames, or whole-file reformats.
|
||||
6. RBAC exists in `users/permissions.py`; do not invent a second scheme.
|
||||
7. Do not edit unrelated domains (`inbox/` vs `users/`) unless asked.
|
||||
8. Do not add `__init__.py` to make packages.
|
||||
| `app.py` | Routes, inline request models, `Depends`, `JSONResponse` envelope | Business rules, SQL, helper functions |
|
||||
| `views.py` | Payload prep, business checks, `HTTPException`, call model, return serializer | SQL, commits, helper definitions |
|
||||
| `models.py` | Columns, relationships, queries, insert/update/soft-delete + commit | `HTTPException`, serializers |
|
||||
| `serializers.py` | `serialize_*` → `dict` | DB, `Depends` |
|
||||
| `enums.py` | Enums, nested request blocks | Logic |
|
||||
| `plugins.py` | Every helper and constant | Routes, DB writes |
|
||||
|
||||
## Workflow when adding an endpoint
|
||||
|
||||
1. Model accessor (if DB).
|
||||
2. Service method in `views.py`.
|
||||
3. `serialize_*` if new shape.
|
||||
4. Route in `app.py` with the standard try/except + `JSONResponse`.
|
||||
5. Add `current_user: CurrentUser` or `Depends(require_permission(...))` if the route is protected.
|
||||
1. Migration SQL if the schema changes.
|
||||
2. Columns + one classmethod accessor in `models.py`.
|
||||
3. Enum / nested block in `enums.py` if the body has one.
|
||||
4. Helper in `plugins.py` only if a view needs one that does not already exist.
|
||||
5. `serialize_*` in `serializers.py` if the shape is new.
|
||||
6. Service method in `views.py`.
|
||||
7. Inline request model + route in `app.py` with the exact try/except + `JSONResponse` wrapper and `require_permission`.
|
||||
|
||||
Before finishing, re-read the touched files and confirm they still match a sibling file’s structure, naming, spacing, and response shape.
|
||||
## Auth (only when touching `users/`)
|
||||
|
||||
- Login / refresh: service returns the **Users ORM row**; the route mints tokens and calls `serialize_token(...)` — never inside `views.py`. Tokens at the root, user under `data`.
|
||||
- PyJWT access + refresh with a `type` claim; `iat`/`exp` use `datetime.now(timezone.utc)`.
|
||||
- RBAC lives in `users/permissions.py` (`require_permission`, `is_admin`, `CurrentUser`). Do not invent a second scheme.
|
||||
|
||||
## Hard bans
|
||||
|
||||
1. No extra flags, params, guards, try/excepts, logging, or validations the reference flow does not have.
|
||||
2. No splitting one create/update into multiple helper functions or extra model calls when one classmethod does it.
|
||||
3. No helpers defined outside `plugins.py`; no duplicated helpers — import the existing one.
|
||||
4. No repository / use-case / DTO layers; no Pydantic response models; no alternate envelopes; no `/api/v1`.
|
||||
5. No hard deletes.
|
||||
6. No drive-by refactors, renames, reformatting, or edits to unrelated domains.
|
||||
7. No `__init__.py`.
|
||||
8. Dependencies: pin in `backend/requirements.txt` with a `# why` comment; secret names in `backend/.env.example`.
|
||||
|
||||
Before finishing, put the new code next to the reference flow above and remove anything the reference would not have.
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ async def search_requisitions(
|
|||
require_all=False,
|
||||
)
|
||||
),
|
||||
q: str | None = Query(None),
|
||||
search: str | None = Query(None),
|
||||
top: int = Query(50, ge=1, le=100),
|
||||
job_post_id: uuid.UUID | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
|
|
@ -91,7 +91,7 @@ async def search_requisitions(
|
|||
"""
|
||||
try:
|
||||
service = RequisitionForm(session=session)
|
||||
data = await service.search(q, top=top, job_post_id=job_post_id)
|
||||
data = await service.search(search, top=top, job_post_id=job_post_id)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from enum import Enum
|
|||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import date
|
||||
|
||||
import uuid
|
||||
class EmploymentType(str,Enum):
|
||||
PERMANENT = "permanent"
|
||||
CONTRACT = "contract"
|
||||
|
|
@ -10,6 +10,7 @@ class EmploymentType(str,Enum):
|
|||
INTERNEE="internee"
|
||||
|
||||
class Position(BaseModel):
|
||||
department_id:uuid.UUID
|
||||
department:Optional[str]
|
||||
title:Optional[str]
|
||||
date:Optional[date]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from sqlmodel import Field, Relationship, SQLModel, select
|
|||
|
||||
from candidate_forms.enums import EmploymentType
|
||||
if TYPE_CHECKING:
|
||||
from department.models import Department
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
|
||||
|
|
@ -19,6 +20,11 @@ class Requisition(SQLModel, table=True):
|
|||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
|
||||
department: Optional[str] = None
|
||||
department_id: Optional[uuid.UUID] = Field(default=None, foreign_key="departments.id")
|
||||
department_ref: Optional["Department"] = Relationship(
|
||||
back_populates="requisitions",
|
||||
sa_relationship_kwargs={"uselist": False, "lazy": "selectin"},
|
||||
)
|
||||
position_title: Optional[str] = None
|
||||
date: Optional[Date] = None
|
||||
date_needed: Optional[Date] = None
|
||||
|
|
@ -93,7 +99,7 @@ class Requisition(SQLModel, table=True):
|
|||
async def search(
|
||||
cls,
|
||||
session: AsyncSession,
|
||||
q: str | None = None,
|
||||
search: str | None = None,
|
||||
*,
|
||||
top: int = 50,
|
||||
job_post_id=None,
|
||||
|
|
@ -119,7 +125,7 @@ class Requisition(SQLModel, table=True):
|
|||
if except_uid is not None:
|
||||
held = held.where(JobPosts.id != except_uid)
|
||||
statement = statement.where(cls.id.notin_(held))
|
||||
term = (q or "").strip()
|
||||
term = (search or "").strip()
|
||||
if term:
|
||||
like = f"%{term}%"
|
||||
statement = statement.where(
|
||||
|
|
@ -163,6 +169,7 @@ class Requisition(SQLModel, table=True):
|
|||
referral = fields.get("refferal_by") if fields.get("refferal_by") else {}
|
||||
row = cls(
|
||||
department=position.get("department") if position.get("department") else None,
|
||||
department_id=position.get("department_id") if position.get("department_id") else None,
|
||||
position_title=position.get("title") if position.get("title") else None,
|
||||
date=position.get("date") if position.get("date") else None,
|
||||
date_needed=position.get("date_needed") if position.get("date_needed") else None,
|
||||
|
|
@ -206,6 +213,8 @@ class Requisition(SQLModel, table=True):
|
|||
position = fields.get("position") if fields.get("position") else {}
|
||||
if "department" in position:
|
||||
row.department = position.get("department") if position.get("department") else None
|
||||
if "department_id" in position:
|
||||
row.department_id = position.get("department_id") if position.get("department_id") else None
|
||||
if "title" in position:
|
||||
row.position_title = position.get("title") if position.get("title") else None
|
||||
if "date" in position:
|
||||
|
|
@ -416,3 +425,4 @@ class CandidateForms(SQLModel, table=True):
|
|||
|
||||
|
||||
import users.models as _users_models # noqa: E402, F401
|
||||
import department.models as _department_models # noqa: E402, F401
|
||||
|
|
@ -1,11 +1,63 @@
|
|||
"""Pure helpers for the hiring forms domain — no FastAPI, no DB.
|
||||
import uuid
|
||||
from fastapi import HTTPException
|
||||
from datetime import timezone
|
||||
|
||||
def _as_uuid(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _user_id(current_user):
|
||||
if not current_user or not current_user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
uid = _as_uuid(current_user["id"])
|
||||
if uid is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid user id")
|
||||
return uid
|
||||
|
||||
|
||||
def _aware(value):
|
||||
if value is not None and getattr(value, "tzinfo", None) is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value
|
||||
|
||||
|
||||
def _stage_value(status) -> str:
|
||||
return str(getattr(status, "value", status) or "").upper()
|
||||
|
||||
|
||||
def _recommendation(form_type, value):
|
||||
definition = FORM_DEFINITIONS.get(form_type) or {}
|
||||
if not definition.get("has_recommendation"):
|
||||
return None
|
||||
if value in (None, ""):
|
||||
return None
|
||||
value = str(value).strip()
|
||||
if value not in RECOMMENDATIONS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"recommendation must be one of {', '.join(RECOMMENDATIONS)}",
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _score_sections(form_type, sections):
|
||||
try:
|
||||
return normalize_sections(form_type, sections)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
|
||||
def _score_fields(form_type, fields):
|
||||
try:
|
||||
return normalize_fields(form_type, fields)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
FORM_DEFINITIONS is the single authority for section/criterion/field keys AND
|
||||
their on-screen labels, which reproduce the paper annexures verbatim (Annexure A
|
||||
Employee Requisition Form, Annexure E Interview Evaluation Form). The frontend
|
||||
renders labels from /forms/definitions, and criterion labels are denormalized
|
||||
into every saved row so historical records survive future renames.
|
||||
"""
|
||||
|
||||
FORM_TYPES = ("requisition", "interview_analysis", "cultural_fit")
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ def serialize_requisition_option(row) -> dict:
|
|||
"id": str(row.id) if row.id else None,
|
||||
"title": row.position_title,
|
||||
"department": row.department,
|
||||
"department_id": str(row.department_id) if row.department_id else None,
|
||||
"label": f"{title or 'Untitled'} - {department or '—'}",
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +81,7 @@ def serialize_requisition(row) -> dict:
|
|||
"id": str(row.id) if row.id else None,
|
||||
"position": {
|
||||
"department": row.department,
|
||||
"department_id": str(row.department_id) if row.department_id else None,
|
||||
"title": row.position_title,
|
||||
"date": _date(row.date),
|
||||
"date_needed": _date(row.date_needed),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ from candidate_forms.plugins import (
|
|||
combined_summary,
|
||||
normalize_fields,
|
||||
normalize_sections,
|
||||
_as_uuid,
|
||||
_user_id,
|
||||
_aware,
|
||||
_stage_value,
|
||||
_recommendation,
|
||||
_score_sections,
|
||||
_score_fields,
|
||||
)
|
||||
from candidate_forms.serializers import (
|
||||
serialize_form,
|
||||
|
|
@ -32,63 +39,6 @@ from users.permissions import is_admin, is_hiring_manager
|
|||
logger = logging.getLogger("candidate_forms")
|
||||
|
||||
|
||||
def _as_uuid(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _user_id(current_user):
|
||||
if not current_user or not current_user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
uid = _as_uuid(current_user["id"])
|
||||
if uid is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid user id")
|
||||
return uid
|
||||
|
||||
|
||||
def _aware(value):
|
||||
if value is not None and getattr(value, "tzinfo", None) is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value
|
||||
|
||||
|
||||
def _stage_value(status) -> str:
|
||||
return str(getattr(status, "value", status) or "").upper()
|
||||
|
||||
|
||||
def _recommendation(form_type, value):
|
||||
definition = FORM_DEFINITIONS.get(form_type) or {}
|
||||
if not definition.get("has_recommendation"):
|
||||
return None
|
||||
if value in (None, ""):
|
||||
return None
|
||||
value = str(value).strip()
|
||||
if value not in RECOMMENDATIONS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"recommendation must be one of {', '.join(RECOMMENDATIONS)}",
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _score_sections(form_type, sections):
|
||||
try:
|
||||
return normalize_sections(form_type, sections)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
|
||||
def _score_fields(form_type, fields):
|
||||
try:
|
||||
return normalize_fields(form_type, fields)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
|
||||
class CandidateForm:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
|
@ -406,6 +356,7 @@ class RequisitionForm:
|
|||
self.session = session
|
||||
|
||||
async def create_form(self, payload, current_user):
|
||||
payload["position"]["department_id"] = _as_uuid(payload["position"]["department_id"])
|
||||
payload["created_by"] = _user_id(current_user)
|
||||
row = await Requisition.insert_form(self.session, payload)
|
||||
return serialize_requisition(row)
|
||||
|
|
@ -440,8 +391,8 @@ class RequisitionForm:
|
|||
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, search, top=50, job_post_id=None):
|
||||
rows = await Requisition.search(
|
||||
self.session, q, top=top, job_post_id=job_post_id,
|
||||
self.session, search=search, top=top, job_post_id=job_post_id,
|
||||
)
|
||||
return [serialize_requisition_option(r) for r in rows]
|
||||
|
|
@ -149,6 +149,8 @@ async def fetch_department_names(
|
|||
PermissionTag.DEPARTMENT_VIEW,
|
||||
PermissionTag.JOB_BOARD_CREATE,
|
||||
PermissionTag.JOBS_EDIT,
|
||||
PermissionTag.REQUISITIONS_CREATE,
|
||||
PermissionTag.REQUISITIONS_EDIT,
|
||||
require_all=False,
|
||||
)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from sqlmodel import Field, Relationship, SQLModel, select
|
|||
from department.plugins import as_uuid, now_utc
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from candidate_forms.models import Requisition
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
class Department(SQLModel, table=True):
|
||||
|
|
@ -27,6 +28,8 @@ class Department(SQLModel, table=True):
|
|||
# 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"})
|
||||
|
||||
requisitions: List["Requisition"] = 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)
|
||||
|
|
@ -171,3 +174,5 @@ class Department(SQLModel, table=True):
|
|||
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()}
|
||||
|
||||
import candidate_forms.models as _candidate_forms_models # noqa: E402, F401
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
-- 041_requisition_department_id.sql
|
||||
-- Restores requisitions.department_id: many requisitions -> one department. This
|
||||
-- is the FK behind Requisition.department_id / Requisition.department_ref
|
||||
-- (backend/candidate_forms/models.py) and Department.requisitions
|
||||
-- (backend/department/models.py). department_ref/requisitions are ORM
|
||||
-- relationships only — they add no column, the FK below is the whole schema
|
||||
-- change, and a requisition is linked by writing department_id.
|
||||
--
|
||||
-- 039 added this column and 040 dropped it again when the department link moved
|
||||
-- to job_posts; job_posts.department_id from 040 stays. A new file rather than
|
||||
-- an edit to either: manual migrations run once and are recorded in
|
||||
-- manual_migrations, so edits to an applied file never reach an existing
|
||||
-- database. Applied at startup by alembic_setup.run_manual_sql().
|
||||
--
|
||||
-- The legacy free-text requisitions.department column is kept and only read here
|
||||
-- to backfill; 040 wrote the department name back into it before dropping the
|
||||
-- FK, so rows linked under 039 recover their link. Unmatched rows stay NULL.
|
||||
|
||||
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: match a
|
||||
-- department's name or short code, case-insensitive and trimmed.
|
||||
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 $$;
|
||||
|
|
@ -14,6 +14,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta
|
|||
import AiFieldAssist from '../ui/AiFieldAssist'
|
||||
import DataTable from '../ui/DataTable'
|
||||
import Modal from '../ui/Modal'
|
||||
import SearchSelect from '../ui/SearchSelect'
|
||||
import PageHeader from '../ui/PageHeader'
|
||||
import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
|
|
@ -397,91 +398,6 @@ function fmtWhen(value) {
|
|||
return fmtDateTime(value) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable picker: type to filter, click a row to store the id.
|
||||
* Not free-text — the value is always an option id (or '' when allowEmpty).
|
||||
*/
|
||||
function SearchSelect({
|
||||
options = [],
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search…',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
allowEmpty = false,
|
||||
emptyLabel = 'Unassigned',
|
||||
error = false,
|
||||
onQueryChange,
|
||||
}) {
|
||||
const [q, setQ] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const root = useRef(null)
|
||||
const selected = options.find((o) => String(o.id) === String(value || ''))
|
||||
|
||||
useEffect(() => {
|
||||
function onDoc(e) {
|
||||
if (root.current && !root.current.contains(e.target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
return () => document.removeEventListener('mousedown', onDoc)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!onQueryChange || !open) return
|
||||
onQueryChange(q)
|
||||
}, [q, open, onQueryChange])
|
||||
|
||||
const term = q.trim().toLowerCase()
|
||||
const filtered = onQueryChange
|
||||
? options
|
||||
: options.filter((o) => {
|
||||
if (!term) return true
|
||||
const hay = [o.name, o.email, o.role_name].filter(Boolean).join(' ').toLowerCase()
|
||||
return hay.includes(term)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={`dropdown${open ? ' open' : ''}`} ref={root} style={{ width: '100%' }}>
|
||||
<input
|
||||
className={error ? 'err' : ''}
|
||||
value={open ? q : (selected?.name || '')}
|
||||
disabled={disabled || loading}
|
||||
placeholder={loading ? 'Loading…' : placeholder}
|
||||
autoComplete="off"
|
||||
onFocus={() => { setOpen(true); setQ('') }}
|
||||
onChange={(e) => { setQ(e.target.value); setOpen(true) }}
|
||||
/>
|
||||
{open && !disabled && !loading && (
|
||||
<div className="dropdown-menu" style={{ left: 0, right: 'auto', width: '100%', minWidth: 0, maxHeight: 240, overflowY: 'auto' }}>
|
||||
{allowEmpty && (
|
||||
<button
|
||||
type="button"
|
||||
className="dropdown-link"
|
||||
onClick={() => { onChange(''); setQ(''); setOpen(false) }}
|
||||
>
|
||||
{emptyLabel}
|
||||
</button>
|
||||
)}
|
||||
{filtered.length === 0 && (
|
||||
<div className="dropdown-link" style={{ color: 'var(--text-3)' }}>No matches</div>
|
||||
)}
|
||||
{filtered.map((o) => (
|
||||
<button
|
||||
type="button"
|
||||
key={o.id}
|
||||
className="dropdown-link"
|
||||
onClick={() => { onChange(String(o.id)); setQ(''); setOpen(false) }}
|
||||
>
|
||||
{o.name}
|
||||
{o.email ? <span className="cell-sub" style={{ marginLeft: 8 }}>{o.email}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function sameIdList(a, b) {
|
||||
const x = [...(a || [])].map(String)
|
||||
const y = [...(b || [])].map(String)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|||
|
||||
import DataTable from '../ui/DataTable'
|
||||
import Modal from '../ui/Modal'
|
||||
import DepartmentSelect from '../ui/DepartmentSelect'
|
||||
import PageHeader from '../ui/PageHeader'
|
||||
import { Badge, EmptyState, FieldError, Icon, KpiCard, SkeletonRows } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
|
|
@ -269,7 +270,8 @@ export default function Requisitions() {
|
|||
|
||||
function blankForm() {
|
||||
return {
|
||||
department: '',
|
||||
department_id: '',
|
||||
department_name: '',
|
||||
title: '',
|
||||
date: toDateInput(new Date().toISOString()),
|
||||
date_needed: '',
|
||||
|
|
@ -308,7 +310,8 @@ function fromRow(row) {
|
|||
const rep = row.replacement_for || {}
|
||||
const ref = row.refferal_by || {}
|
||||
return {
|
||||
department: pos.department || '',
|
||||
department_id: pos.department_id ? String(pos.department_id) : '',
|
||||
department_name: pos.department || '',
|
||||
title: pos.title || '',
|
||||
date: toDateInput(pos.date),
|
||||
date_needed: toDateInput(pos.date_needed),
|
||||
|
|
@ -345,7 +348,7 @@ function fromRow(row) {
|
|||
function toPayload(f) {
|
||||
const body = {
|
||||
position: {
|
||||
department: emptyToNull(f.department),
|
||||
department_id: emptyToNull(f.department_id),
|
||||
title: emptyToNull(f.title),
|
||||
date: emptyToNull(f.date),
|
||||
date_needed: emptyToNull(f.date_needed),
|
||||
|
|
@ -453,7 +456,11 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
|
|||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>From (Dept.)</label>
|
||||
<input value={fields.department} onChange={(e) => set('department', e.target.value)} />
|
||||
<DepartmentSelect
|
||||
value={fields.department_id}
|
||||
onChange={(id) => set('department_id', id)}
|
||||
fallbackName={fields.department_name}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Job title <span className="req">*</span></label>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
/* ============================================================
|
||||
DepartmentSelect — the "From (Dept.)" / "Department" picker.
|
||||
|
||||
Wraps SearchSelect over GET /department/names (`{data:[{id,name}]}`) and
|
||||
stores the department's id, never its name. The server filters on `search`,
|
||||
so typing is debounced and SearchSelect is told not to filter again locally.
|
||||
============================================================ */
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
|
||||
import SearchSelect from './SearchSelect'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import * as departmentsApi from '../api/departments'
|
||||
|
||||
/**
|
||||
* @param value selected department id, '' when none
|
||||
* @param onChange (id) => void — receives '' when cleared
|
||||
* @param fallbackName name of the saved department, used only to label it when
|
||||
* the list does not contain it (see below)
|
||||
*/
|
||||
export default function DepartmentSelect({ value, onChange, fallbackName = '', disabled = false, error = false }) {
|
||||
const [q, setQ] = useState('')
|
||||
const [term, setTerm] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setTerm(q.trim()), 250)
|
||||
return () => clearTimeout(t)
|
||||
}, [q])
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: qk.departments.names(term),
|
||||
queryFn: async () => departmentsApi.toRows(await departmentsApi.listNames({ search: term })),
|
||||
placeholderData: keepPreviousData,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
// Two ways the saved department is missing from `query.data`: it was since
|
||||
// deactivated (the endpoint returns active only), or the user has typed a
|
||||
// search that excludes it. Keep it in the list either way so opening the form
|
||||
// — or typing and then clearing — never silently drops the selection.
|
||||
const options = useMemo(() => {
|
||||
const rows = query.data ?? []
|
||||
const picked = String(value || '')
|
||||
if (picked && !rows.some((d) => String(d.id) === picked)) {
|
||||
return [{ id: picked, name: fallbackName || 'Current department' }, ...rows]
|
||||
}
|
||||
return rows
|
||||
}, [query.data, value, fallbackName])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchSelect
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onQueryChange={setQ}
|
||||
placeholder="Search departments…"
|
||||
disabled={disabled}
|
||||
loading={query.isPending && !query.data}
|
||||
error={error}
|
||||
allowEmpty
|
||||
emptyLabel="No department"
|
||||
/>
|
||||
{query.isError && <p className="text-muted text-sm">Could not load departments.</p>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/* ============================================================
|
||||
SearchSelect — type-to-filter picker whose value is always an option id.
|
||||
|
||||
Moved out of screens/Jobs.jsx so Requisitions can use the same control.
|
||||
Options are `{ id, name, email?, role_name? }`. Pass `onQueryChange` when the
|
||||
caller filters server-side; without it the list is filtered in place.
|
||||
============================================================ */
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Searchable picker: type to filter, click a row to store the id.
|
||||
* Not free-text — the value is always an option id (or '' when allowEmpty).
|
||||
*/
|
||||
export default function SearchSelect({
|
||||
options = [],
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search…',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
allowEmpty = false,
|
||||
emptyLabel = 'Unassigned',
|
||||
error = false,
|
||||
onQueryChange,
|
||||
}) {
|
||||
const [q, setQ] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const root = useRef(null)
|
||||
const selected = options.find((o) => String(o.id) === String(value || ''))
|
||||
|
||||
useEffect(() => {
|
||||
function onDoc(e) {
|
||||
if (root.current && !root.current.contains(e.target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
return () => document.removeEventListener('mousedown', onDoc)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!onQueryChange || !open) return
|
||||
onQueryChange(q)
|
||||
}, [q, open, onQueryChange])
|
||||
|
||||
const term = q.trim().toLowerCase()
|
||||
const filtered = onQueryChange
|
||||
? options
|
||||
: options.filter((o) => {
|
||||
if (!term) return true
|
||||
const hay = [o.name, o.email, o.role_name].filter(Boolean).join(' ').toLowerCase()
|
||||
return hay.includes(term)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={`dropdown${open ? ' open' : ''}`} ref={root} style={{ width: '100%' }}>
|
||||
<input
|
||||
className={error ? 'err' : ''}
|
||||
value={open ? q : (selected?.name || '')}
|
||||
disabled={disabled || loading}
|
||||
placeholder={loading ? 'Loading…' : placeholder}
|
||||
autoComplete="off"
|
||||
onFocus={() => { setOpen(true); setQ('') }}
|
||||
onChange={(e) => { setQ(e.target.value); setOpen(true) }}
|
||||
/>
|
||||
{open && !disabled && !loading && (
|
||||
<div className="dropdown-menu" style={{ left: 0, right: 'auto', width: '100%', minWidth: 0, maxHeight: 240, overflowY: 'auto' }}>
|
||||
{allowEmpty && (
|
||||
<button
|
||||
type="button"
|
||||
className="dropdown-link"
|
||||
onClick={() => { onChange(''); setQ(''); setOpen(false) }}
|
||||
>
|
||||
{emptyLabel}
|
||||
</button>
|
||||
)}
|
||||
{filtered.length === 0 && (
|
||||
<div className="dropdown-link" style={{ color: 'var(--text-3)' }}>No matches</div>
|
||||
)}
|
||||
{filtered.map((o) => (
|
||||
<button
|
||||
type="button"
|
||||
key={o.id}
|
||||
className="dropdown-link"
|
||||
onClick={() => { onChange(String(o.id)); setQ(''); setOpen(false) }}
|
||||
>
|
||||
{o.name}
|
||||
{o.email ? <span className="cell-sub" style={{ marginLeft: 8 }}>{o.email}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue