Compare commits
4 Commits
664b33b94b
...
4cb1961373
| Author | SHA1 | Date |
|---|---|---|
|
|
4cb1961373 | |
|
|
6eefaa2fad | |
|
|
b3483d9a6e | |
|
|
2bdf82afc3 |
|
|
@ -12,6 +12,7 @@ from job.app import router as candidate_router
|
|||
from notifications.app import router as confirmation_router
|
||||
from analytics.app import router as analytics_router
|
||||
from offer.app import router as offer_router
|
||||
from tasks.app import router as tasks_router
|
||||
|
||||
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
|
||||
logger=logging.getLogger("main")
|
||||
|
|
@ -83,3 +84,4 @@ app.include_router(confirmation_router)
|
|||
app.include_router(candidate_router)
|
||||
app.include_router(analytics_router)
|
||||
app.include_router(offer_router)
|
||||
app.include_router(tasks_router)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
-- 004_tasks_rbac.sql
|
||||
-- Manual one-shot: the `tasks` permission module (8 tags), a `tasks_management`
|
||||
-- bundle holding them, and the bundle attached to the staff roles that work the
|
||||
-- task list. Mirrors 001's idempotent pattern; applied automatically at startup
|
||||
-- by alembic_setup.run_manual_sql() and recorded in manual_migrations.
|
||||
--
|
||||
-- The all_access bundle is a fixed id list seeded before this module existed,
|
||||
-- so system_administrator gets tasks access through THIS bundle, not that one.
|
||||
-- Users must log in again after this applies — permissions are resolved from
|
||||
-- the DB per request, but the frontend caches the list from /users/me.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. The 8 tasks.* permission tags
|
||||
-- =============================================================================
|
||||
INSERT INTO app.permission_tags
|
||||
(tag_name, module, action, description, created_at, updated_at, is_active, is_deleted)
|
||||
VALUES
|
||||
('tasks.view', 'tasks', 'view', NULL, NOW(), NOW(), true, false),
|
||||
('tasks.create', 'tasks', 'create', NULL, NOW(), NOW(), true, false),
|
||||
('tasks.edit', 'tasks', 'edit', NULL, NOW(), NOW(), true, false),
|
||||
('tasks.delete', 'tasks', 'delete', NULL, NOW(), NOW(), true, false),
|
||||
('tasks.approve', 'tasks', 'approve', NULL, NOW(), NOW(), true, false),
|
||||
('tasks.export', 'tasks', 'export', NULL, NOW(), NOW(), true, false),
|
||||
('tasks.manage', 'tasks', 'manage', NULL, NOW(), NOW(), true, false),
|
||||
('tasks.configure', 'tasks', 'configure', NULL, NOW(), NOW(), true, false)
|
||||
ON CONFLICT (tag_name) DO NOTHING;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Bundle holding all eight tasks tags
|
||||
-- =============================================================================
|
||||
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
|
||||
SELECT
|
||||
'tasks_management',
|
||||
'Recruiting task list: view, create, complete and manage tasks',
|
||||
(
|
||||
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
|
||||
FROM app.permission_tags
|
||||
WHERE is_deleted = false
|
||||
AND module = 'tasks'
|
||||
),
|
||||
true,
|
||||
NOW(),
|
||||
NOW(),
|
||||
true,
|
||||
false
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM app.permissions WHERE name = 'tasks_management'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Attach the bundle to the staff roles (idempotent; same role list as 001)
|
||||
-- =============================================================================
|
||||
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 = 'tasks_management'
|
||||
AND r.role_name IN (
|
||||
'system_administrator',
|
||||
'hr_administrator',
|
||||
'recruiter',
|
||||
'hiring_manager',
|
||||
'department_head',
|
||||
'ceo'
|
||||
)
|
||||
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
-- 005_tasks_rbac_restrict.sql
|
||||
-- Manual one-shot: restrict task CREATION in the permission database to
|
||||
-- system_administrator / hr_administrator / recruiter. 004 attached the full
|
||||
-- tasks_management bundle (all 8 tags) to six roles; here hiring_manager,
|
||||
-- department_head and ceo swap it for a view-only bundle so the permission DB
|
||||
-- itself says who may create — the tasks service additionally enforces the
|
||||
-- creator-role check at request time. Idempotent; auto-applied at startup by
|
||||
-- alembic_setup.run_manual_sql().
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. View-only bundle (tasks.view, tasks.export)
|
||||
-- =============================================================================
|
||||
INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
|
||||
SELECT
|
||||
'tasks_viewer',
|
||||
'Recruiting task list: read-only access',
|
||||
(
|
||||
SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
|
||||
FROM app.permission_tags
|
||||
WHERE is_deleted = false
|
||||
AND tag_name IN ('tasks.view', 'tasks.export')
|
||||
),
|
||||
true,
|
||||
NOW(),
|
||||
NOW(),
|
||||
true,
|
||||
false
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM app.permissions WHERE name = 'tasks_viewer'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Remove the full tasks_management bundle from the non-creator roles
|
||||
-- (jsonb arrays hold numbers, so `-` text removal does not apply — rebuild)
|
||||
-- =============================================================================
|
||||
UPDATE app.roles r
|
||||
SET permissions = (
|
||||
SELECT COALESCE(jsonb_agg(elem), '[]'::jsonb)
|
||||
FROM jsonb_array_elements(COALESCE(r.permissions, '[]'::jsonb)) elem
|
||||
WHERE elem <> to_jsonb(p.id)
|
||||
),
|
||||
updated_at = NOW()
|
||||
FROM app.permissions p
|
||||
WHERE p.name = 'tasks_management'
|
||||
AND r.role_name IN ('hiring_manager', 'department_head', 'ceo')
|
||||
AND COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id);
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Attach the view-only bundle to those roles
|
||||
-- =============================================================================
|
||||
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 = 'tasks_viewer'
|
||||
AND r.role_name IN (
|
||||
'hiring_manager',
|
||||
'department_head',
|
||||
'ceo'
|
||||
)
|
||||
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db_setup import get_session
|
||||
from tasks.views import Task
|
||||
from users.permissions import PermissionTag, require_permission
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
title: str
|
||||
priority: str | None = None
|
||||
due_date: datetime | None = None
|
||||
assignee_id: str | None = None # defaults to the caller server-side
|
||||
inbox_id: int | None = None
|
||||
job_post_id: str | None = None
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
status: str | None = None # open | done — the complete/reopen flip
|
||||
priority: str | None = None
|
||||
due_date: datetime | None = None
|
||||
assignee_id: str | None = None
|
||||
inbox_id: int | None = None
|
||||
job_post_id: str | None = None
|
||||
|
||||
|
||||
@router.get("/tasks/fetch")
|
||||
async def fetch_tasks(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.TASKS_VIEW)),
|
||||
status: str | None = Query(None),
|
||||
priority: str | None = Query(None),
|
||||
assignee_id: str | None = Query(None),
|
||||
top: int | None = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = Task(session=session)
|
||||
data, total = await service.get_tasks(status, priority, assignee_id, top, skip)
|
||||
return JSONResponse(content={"data": data, "total": total, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/tasks/assignees/fetch")
|
||||
async def fetch_task_assignees(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.TASKS_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Assignee picker: active recruiter-role users (role id resolved from the
|
||||
roles table). Separate from /users/fetch so assigning a task never requires
|
||||
rbac_users.view."""
|
||||
try:
|
||||
service = Task(session=session)
|
||||
data, total = await service.get_assignees()
|
||||
return JSONResponse(content={"data": data, "total": total, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/tasks/create")
|
||||
async def create_task(
|
||||
payload: TaskCreate,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.TASKS_CREATE)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = Task(session=session)
|
||||
data = await service.create_task(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.patch("/tasks/update")
|
||||
async def update_task(
|
||||
payload: TaskUpdate,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.TASKS_EDIT)),
|
||||
task_id: str = Query(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = Task(session=session)
|
||||
data = await service.update_task(task_id, 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.delete("/tasks/delete")
|
||||
async def delete_task(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.TASKS_DELETE)),
|
||||
task_id: str = Query(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = Task(session=session)
|
||||
data = await service.delete_task(task_id, 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))
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, SQLModel, select
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Tasks(SQLModel, table=True):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
title: str
|
||||
status: str = Field(default="open") # open | done
|
||||
priority: str = Field(default="medium") # high | medium | low
|
||||
due_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||
# The assignee is a real users row; views rejects candidate-role accounts.
|
||||
assignee_id: uuid.UUID = Field(index=True, foreign_key="users.id")
|
||||
# Optional links to the work the task is about. Schema-only for now — the
|
||||
# Tasks screen does not surface them yet.
|
||||
inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
|
||||
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
|
||||
created_by: 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)
|
||||
|
||||
@staticmethod
|
||||
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||
if record_id in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(record_id))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_task_by_id(cls, session: AsyncSession, record_id):
|
||||
uid = cls._as_uuid(record_id)
|
||||
if uid is None:
|
||||
return None
|
||||
result = await session.execute(
|
||||
select(cls).where(cls.id == uid, cls.is_deleted == False) # noqa: E712
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def fetch_tasks(
|
||||
cls,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
status: str | None = None,
|
||||
priority: str | None = None,
|
||||
assignee_id: uuid.UUID | None = None,
|
||||
top: int | None = None,
|
||||
skip: int = 0,
|
||||
):
|
||||
statement = select(cls).where(cls.is_deleted == False) # noqa: E712
|
||||
if status:
|
||||
statement = statement.where(cls.status == status)
|
||||
if priority:
|
||||
statement = statement.where(cls.priority == priority)
|
||||
if assignee_id is not None:
|
||||
statement = statement.where(cls.assignee_id == assignee_id)
|
||||
count_statement = select(func.count()).select_from(statement.subquery())
|
||||
total = (await session.execute(count_statement)).scalar_one()
|
||||
statement = statement.order_by(cls.created_at.desc())
|
||||
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()), total
|
||||
|
||||
@classmethod
|
||||
async def insert_task(cls, session: AsyncSession, fields: dict):
|
||||
row = cls(**fields)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return await cls.get_task_by_id(session, row.id)
|
||||
|
||||
@classmethod
|
||||
async def update_task(cls, session: AsyncSession, record_id, fields: dict):
|
||||
row = await cls.get_task_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
setattr(row, key, value)
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def soft_delete_task(cls, session: AsyncSession, record_id):
|
||||
row = await cls.get_task_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
|
||||
|
||||
|
||||
import users.models as _users_models # noqa: E402, F401
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
def serialize_task(row, users_by_id=None) -> dict:
|
||||
"""`users_by_id` is one batched Users lookup done in views — never a lazy
|
||||
per-row load. Assignee name/role ride along so the screen can render the
|
||||
real user without a second request."""
|
||||
users_by_id = users_by_id or {}
|
||||
assignee = users_by_id.get(row.assignee_id)
|
||||
return {
|
||||
"id": str(row.id) if row.id else None,
|
||||
"title": row.title,
|
||||
"status": row.status,
|
||||
"priority": row.priority,
|
||||
"due_date": row.due_date.isoformat() if row.due_date else None,
|
||||
"assignee_id": str(row.assignee_id) if row.assignee_id else None,
|
||||
"assignee_name": assignee.name if assignee else None,
|
||||
"assignee_role": assignee.role.role_name if assignee and assignee.role else None,
|
||||
"inbox_id": row.inbox_id,
|
||||
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
|
||||
"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,
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
import uuid
|
||||
from datetime import timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from role.models import Roles
|
||||
from tasks.models import Tasks
|
||||
from tasks.serializers import serialize_task
|
||||
from users.models import Users
|
||||
|
||||
VALID_STATUS = ("open", "done")
|
||||
VALID_PRIORITY = ("high", "medium", "low")
|
||||
|
||||
# Role names, not ids: the ids are resolved from the roles table at request
|
||||
# time so a re-seeded database that renumbers roles cannot silently shift who
|
||||
# may do what. recruiter is role_id 3 on the current seed.
|
||||
ASSIGNEE_ROLE = "recruiter"
|
||||
CREATOR_ROLES = ("system_administrator", "hr_administrator", "recruiter")
|
||||
|
||||
|
||||
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):
|
||||
"""Date-only payloads parse to naive midnight; stored naive, asyncpg would
|
||||
silently read them as local-shifted UTC (see backend/README timezone note)."""
|
||||
if value is not None and value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value
|
||||
|
||||
|
||||
class Task:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def _users_map(self, ids):
|
||||
ids = [i for i in set(ids) if i]
|
||||
if not ids:
|
||||
return {}
|
||||
result = await self.session.execute(
|
||||
select(Users).options(selectinload(Users.role)).where(Users.id.in_(ids))
|
||||
)
|
||||
return {u.id: u for u in result.scalars().all()}
|
||||
|
||||
async def _validate_assignee(self, assignee_id):
|
||||
"""Assignees must be recruiter-role accounts (role resolved from the DB):
|
||||
existing, not deleted, role_name == ASSIGNEE_ROLE."""
|
||||
user = await Users.get_user_by_id(self.session, str(assignee_id))
|
||||
if user is None or user.is_deleted:
|
||||
raise HTTPException(status_code=422, detail="Assignee not found")
|
||||
role_name = user.role.role_name if user.role else None
|
||||
if role_name != ASSIGNEE_ROLE:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Tasks can only be assigned to {ASSIGNEE_ROLE} accounts",
|
||||
)
|
||||
return user
|
||||
|
||||
async def _require_creator_role(self, current_user):
|
||||
"""Creation is limited to system admin / HR admin / recruiter. The role
|
||||
ids are looked up from the roles table, and the permission-tag guard on
|
||||
the route (tasks.create) still applies on top of this."""
|
||||
result = await self.session.execute(
|
||||
select(Roles).where(Roles.role_name.in_(CREATOR_ROLES))
|
||||
)
|
||||
allowed_ids = {r.id for r in result.scalars().all()}
|
||||
if current_user.get("role_id") not in allowed_ids:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only system administrators, HR administrators and recruiters can create tasks",
|
||||
)
|
||||
|
||||
async def get_assignees(self):
|
||||
"""The assignee picker: every active recruiter-role user. Separate from
|
||||
/users/fetch so callers do not need rbac_users.view to assign a task."""
|
||||
role = await Roles.get_role_by_name(self.session, ASSIGNEE_ROLE)
|
||||
if role is None:
|
||||
raise HTTPException(status_code=500, detail=f"Role {ASSIGNEE_ROLE} is not seeded")
|
||||
rows = await Users.get_users(self.session, top=500, role_id=role.id)
|
||||
data = [
|
||||
{
|
||||
"id": str(u.id),
|
||||
"name": u.name,
|
||||
"email": u.email,
|
||||
"role_id": u.role_id,
|
||||
"role_name": role.role_name,
|
||||
"is_active": u.is_active,
|
||||
}
|
||||
for u in rows
|
||||
]
|
||||
return data, len(data)
|
||||
|
||||
async def get_tasks(self, status=None, priority=None, assignee_id=None, top=None, skip=0):
|
||||
if status and status not in VALID_STATUS:
|
||||
raise HTTPException(status_code=422, detail=f"status must be one of {', '.join(VALID_STATUS)}")
|
||||
if priority and priority not in VALID_PRIORITY:
|
||||
raise HTTPException(status_code=422, detail=f"priority must be one of {', '.join(VALID_PRIORITY)}")
|
||||
aid = None
|
||||
if assignee_id:
|
||||
aid = _as_uuid(assignee_id)
|
||||
if aid is None:
|
||||
raise HTTPException(status_code=422, detail="Invalid assignee_id")
|
||||
rows, total = await Tasks.fetch_tasks(
|
||||
self.session,
|
||||
status=status,
|
||||
priority=priority,
|
||||
assignee_id=aid,
|
||||
top=top,
|
||||
skip=skip or 0,
|
||||
)
|
||||
users_by_id = await self._users_map([r.assignee_id for r in rows])
|
||||
return [serialize_task(r, users_by_id) for r in rows], total
|
||||
|
||||
async def create_task(self, payload, current_user):
|
||||
await self._require_creator_role(current_user)
|
||||
title = (payload.get("title") or "").strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=422, detail="title is required")
|
||||
priority = payload.get("priority") or "medium"
|
||||
if priority not in VALID_PRIORITY:
|
||||
raise HTTPException(status_code=422, detail=f"priority must be one of {', '.join(VALID_PRIORITY)}")
|
||||
creator = _user_id(current_user)
|
||||
# Assignee must be a recruiter. Omitting it only works when the caller
|
||||
# IS a recruiter (assign to self); an admin must pick one explicitly.
|
||||
if payload.get("assignee_id"):
|
||||
assignee = _as_uuid(payload["assignee_id"])
|
||||
if assignee is None:
|
||||
raise HTTPException(status_code=422, detail="Invalid assignee_id")
|
||||
elif current_user.get("role_name") == ASSIGNEE_ROLE:
|
||||
assignee = creator
|
||||
else:
|
||||
raise HTTPException(status_code=422, detail="assignee_id is required")
|
||||
await self._validate_assignee(assignee)
|
||||
|
||||
fields = {
|
||||
"title": title,
|
||||
"priority": priority,
|
||||
"status": "open",
|
||||
"assignee_id": assignee,
|
||||
"created_by": creator,
|
||||
}
|
||||
if payload.get("due_date") is not None:
|
||||
fields["due_date"] = _aware(payload["due_date"])
|
||||
if payload.get("inbox_id") is not None:
|
||||
fields["inbox_id"] = int(payload["inbox_id"])
|
||||
if payload.get("job_post_id"):
|
||||
jid = _as_uuid(payload["job_post_id"])
|
||||
if jid is None:
|
||||
raise HTTPException(status_code=422, detail="Invalid job_post_id")
|
||||
fields["job_post_id"] = jid
|
||||
|
||||
row = await Tasks.insert_task(self.session, fields)
|
||||
users_by_id = await self._users_map([row.assignee_id])
|
||||
return serialize_task(row, users_by_id)
|
||||
|
||||
async def update_task(self, task_id, payload, current_user):
|
||||
row = await Tasks.get_task_by_id(self.session, task_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
_user_id(current_user)
|
||||
|
||||
fields = {}
|
||||
for key in ("title", "status", "priority", "due_date", "assignee_id", "inbox_id", "job_post_id"):
|
||||
if key not in payload:
|
||||
continue
|
||||
value = payload[key]
|
||||
if key == "title":
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
raise HTTPException(status_code=422, detail="title cannot be blank")
|
||||
elif key == "status":
|
||||
if value not in VALID_STATUS:
|
||||
raise HTTPException(status_code=422, detail=f"status must be one of {', '.join(VALID_STATUS)}")
|
||||
elif key == "priority":
|
||||
if value not in VALID_PRIORITY:
|
||||
raise HTTPException(status_code=422, detail=f"priority must be one of {', '.join(VALID_PRIORITY)}")
|
||||
elif key == "assignee_id":
|
||||
value = _as_uuid(value)
|
||||
if value is None:
|
||||
raise HTTPException(status_code=422, detail="Invalid assignee_id")
|
||||
await self._validate_assignee(value)
|
||||
elif key == "due_date":
|
||||
value = _aware(value)
|
||||
elif key == "job_post_id" and value is not None:
|
||||
value = _as_uuid(value)
|
||||
if value is None:
|
||||
raise HTTPException(status_code=422, detail="Invalid job_post_id")
|
||||
fields[key] = value
|
||||
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
updated = await Tasks.update_task(self.session, task_id, fields)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
users_by_id = await self._users_map([updated.assignee_id])
|
||||
return serialize_task(updated, users_by_id)
|
||||
|
||||
async def delete_task(self, task_id, current_user):
|
||||
_user_id(current_user)
|
||||
row = await Tasks.soft_delete_task(self.session, task_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return {"id": str(row.id), "deleted": True}
|
||||
|
|
@ -38,6 +38,7 @@ class PermissionModule(str, Enum):
|
|||
JOB_BOARD = "job_board"
|
||||
SETTINGS = "settings"
|
||||
RBAC_USERS = "rbac_users"
|
||||
TASKS = "tasks"
|
||||
|
||||
|
||||
class PermissionAction(str, Enum):
|
||||
|
|
@ -156,6 +157,14 @@ class PermissionTag(str, Enum):
|
|||
RBAC_USERS_EXPORT = "rbac_users.export"
|
||||
RBAC_USERS_MANAGE = "rbac_users.manage"
|
||||
RBAC_USERS_CONFIGURE = "rbac_users.configure"
|
||||
TASKS_VIEW = "tasks.view"
|
||||
TASKS_CREATE = "tasks.create"
|
||||
TASKS_EDIT = "tasks.edit"
|
||||
TASKS_DELETE = "tasks.delete"
|
||||
TASKS_APPROVE = "tasks.approve"
|
||||
TASKS_EXPORT = "tasks.export"
|
||||
TASKS_MANAGE = "tasks.manage"
|
||||
TASKS_CONFIGURE = "tasks.configure"
|
||||
|
||||
|
||||
def _assert_vocabulary_complete() -> None:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
|
||||
/**
|
||||
* Tasks — backend/tasks/app.py.
|
||||
* Permissioned with TASKS_VIEW / TASKS_CREATE / TASKS_EDIT / TASKS_DELETE.
|
||||
* Assignees are real `users` rows; the server rejects candidate-role accounts.
|
||||
*/
|
||||
|
||||
export function list({ status, priority, assigneeId, top, skip } = {}) {
|
||||
return request('/tasks/fetch', {
|
||||
params: { status, priority, assignee_id: assigneeId, top, skip },
|
||||
})
|
||||
}
|
||||
|
||||
export function create(body) {
|
||||
return request('/tasks/create', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function update(taskId, body) {
|
||||
return request('/tasks/update', { method: 'PATCH', params: { task_id: taskId }, body })
|
||||
}
|
||||
|
||||
export function remove(taskId) {
|
||||
return request('/tasks/delete', { method: 'DELETE', params: { task_id: taskId } })
|
||||
}
|
||||
|
||||
/**
|
||||
* The assignee picker — active recruiter-role users only (role id resolved
|
||||
* from the roles table server-side). A dedicated endpoint so assigning a task
|
||||
* never needs rbac_users.view.
|
||||
*/
|
||||
export function listAssignees() {
|
||||
return request('/tasks/assignees/fetch')
|
||||
}
|
||||
|
||||
/**
|
||||
* tasks row -> the shape Tasks.jsx renders. `done` folds the open|done status,
|
||||
* `due` becomes a Date for the overdue comparison (null = no deadline), and the
|
||||
* assignee is the real user's name with their role riding along.
|
||||
*/
|
||||
export function toTaskView(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
done: row.status === 'done',
|
||||
priority: row.priority ? row.priority[0].toUpperCase() + row.priority.slice(1) : 'Medium',
|
||||
due: row.due_date ? new Date(row.due_date) : null,
|
||||
assignee: row.assignee_name || '—',
|
||||
assigneeRole: row.assignee_role || null,
|
||||
assigneeId: row.assignee_id,
|
||||
created: row.created_at ? new Date(row.created_at) : null,
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ export const ROUTES = [
|
|||
{ path: 'import', title: 'CV Import', icon: 'upload', group: 'Recruiting', permission: 'candidates.create' },
|
||||
{ path: 'jobboard', title: 'Job Board', icon: 'layers', group: 'Recruiting', permission: 'job_board.view' },
|
||||
{ path: 'recruiterhub', title: 'Recruiter Hub', icon: 'check-circle', group: 'Recruiting', permission: 'analytics.view' },
|
||||
{ path: 'tasks', title: 'Tasks', icon: 'check-square', group: 'Recruiting', permission: null, badge: 'tasks' },
|
||||
{ path: 'tasks', title: 'Tasks', icon: 'check-square', group: 'Recruiting', permission: 'tasks.view', badge: 'tasks' },
|
||||
{ path: 'aiassistant', title: 'AI Assistant', icon: 'sparkles', group: 'Recruiting', permission: null, tag: 'AI' },
|
||||
|
||||
// --- Hiring ---
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||
import { seedQuery } from '../data/seedQueries'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import * as inboxApi from '../api/inbox'
|
||||
import * as tasksApi from '../api/tasks'
|
||||
|
||||
const SIDEBAR_KEY = 'tf-sidebar'
|
||||
|
||||
|
|
@ -88,7 +89,6 @@ export function useHotkeys({ onEscape }) {
|
|||
export function useBadges() {
|
||||
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
|
||||
const { data: notifications = [] } = useQuery(seedQuery('notifications'))
|
||||
const { data: tasks = [] } = useQuery(seedQuery('tasks'))
|
||||
const { data: inbox = [] } = useQuery(seedQuery('inbox'))
|
||||
const { data: matchingTotal = 0 } = useQuery({
|
||||
queryKey: qk.mailbox.assignments({ assigned: false }),
|
||||
|
|
@ -97,11 +97,24 @@ export function useBadges() {
|
|||
return res?.total ?? 0
|
||||
},
|
||||
})
|
||||
// Live: open tasks across the team (same semantics the seed badge had). A
|
||||
// 403 for users without tasks.view resolves to 0 rather than an error badge.
|
||||
const { data: tasksTotal = 0 } = useQuery({
|
||||
queryKey: qk.tasks.list({ badge: 'open' }),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const res = await tasksApi.list({ status: 'open', top: 1 })
|
||||
return res?.total ?? 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
jobs: jobs.filter((j) => j.status === 'Open').length,
|
||||
notifications: notifications.filter((n) => n.unread).length,
|
||||
tasks: tasks.filter((t) => !t.done).length,
|
||||
tasks: tasksTotal,
|
||||
inbox: inbox.filter((i) => i.unread).length,
|
||||
matching: matchingTotal,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@ export const qk = {
|
|||
offers: { all: () => ['offers'], list: (p = {}) => ['offers', 'list', p] },
|
||||
interviews: { all: () => ['interviews'], range: (p = {}) => ['interviews', 'range', p] },
|
||||
activity: { all: () => ['activity'], feed: (p = {}) => ['activity', 'feed', p] },
|
||||
tasks: {
|
||||
all: () => ['tasks'],
|
||||
list: (p = {}) => ['tasks', 'list', p],
|
||||
assignees: () => ['tasks', 'assignees'],
|
||||
},
|
||||
|
||||
// --- seed-backed buckets ---
|
||||
// These are not "server state" — the cache IS the store for them, so every
|
||||
|
|
|
|||
|
|
@ -1,29 +1,67 @@
|
|||
/* ============================================================
|
||||
Tasks — the recruiting task list, on live backend data.
|
||||
|
||||
Rows come from GET /tasks/fetch via toTaskView; create/complete/reopen go
|
||||
through POST /tasks/create and PATCH /tasks/update. Assignees are RECRUITER
|
||||
accounts only: the picker lists GET /tasks/assignees/fetch (role resolved
|
||||
from the roles table server-side) and the server 422s any other role.
|
||||
Creation is limited to system_administrator / hr_administrator / recruiter —
|
||||
enforced server-side against the roles table, mirrored here so the button
|
||||
doesn't invite a 403. Fields the backend does not carry (task type, notes,
|
||||
candidate link) are gone rather than rendered as placeholders — the Inbox
|
||||
screen precedent. Saved Searches stays decorative seed chrome.
|
||||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { useFormState } from '../components/AuthLayout'
|
||||
import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
||||
import { fmtDate, fmtShort, getCandidate, savedSearches, TODAY } from '../data/seed'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as tasksApi from '../api/tasks'
|
||||
import { fmtDate, fmtShort, savedSearches } from '../data/seed'
|
||||
|
||||
const FILTERS = ['All', 'Open', 'Completed', 'Overdue', 'High', 'Medium', 'Low']
|
||||
const PRIORITY_CLASS = { High: 'b-red', Medium: 'b-amber', Low: 'b-gray' }
|
||||
// Mirrors backend/tasks/views.py CREATOR_ROLES — the server is the enforcer,
|
||||
// this only keeps the button honest.
|
||||
const CREATOR_ROLES = ['system_administrator', 'hr_administrator', 'recruiter']
|
||||
|
||||
async function fetchTasks() {
|
||||
const res = await tasksApi.list()
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(tasksApi.toTaskView)
|
||||
}
|
||||
|
||||
async function fetchAssignees() {
|
||||
const res = await tasksApi.listAssignees()
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
}
|
||||
|
||||
export default function Tasks() {
|
||||
const { toast } = useToast()
|
||||
const { can, user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const { data: tasks = [] } = useQuery(seedQuery('tasks'))
|
||||
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
||||
const updateTasks = useSeedMutation('tasks')
|
||||
const qc = useQueryClient()
|
||||
|
||||
const canCreate = can('tasks.create') && CREATOR_ROLES.includes(user?.role_name)
|
||||
const canEdit = can('tasks.edit')
|
||||
|
||||
const tasksQuery = useQuery({ queryKey: qk.tasks.list(), queryFn: fetchTasks })
|
||||
const assigneesQuery = useQuery({ queryKey: qk.tasks.assignees(), queryFn: fetchAssignees })
|
||||
const tasks = tasksQuery.data ?? []
|
||||
|
||||
const [filter, setFilter] = useState('All')
|
||||
const [detail, setDetail] = useState(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const isOverdue = (t) => !t.done && t.due < TODAY
|
||||
const now = new Date()
|
||||
const isOverdue = (t) => !t.done && t.due && t.due < now
|
||||
|
||||
const list = useMemo(() => {
|
||||
if (filter === 'Open') return tasks.filter((t) => !t.done)
|
||||
|
|
@ -31,28 +69,62 @@ export default function Tasks() {
|
|||
if (filter === 'Overdue') return tasks.filter(isOverdue)
|
||||
if (['High', 'Medium', 'Low'].includes(filter)) return tasks.filter((t) => t.priority === filter)
|
||||
return tasks
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tasks, filter])
|
||||
|
||||
const openCount = tasks.filter((t) => !t.done).length
|
||||
const overdueCount = tasks.filter(isOverdue).length
|
||||
|
||||
// Writing to the cache is what makes the sidebar badge update — the prototype
|
||||
// had to remember to call App.updateBadges() at each of these call sites.
|
||||
function toggle(id) {
|
||||
let nowDone = false
|
||||
updateTasks((ts) =>
|
||||
ts.map((t) => {
|
||||
if (t.id !== id) return t
|
||||
nowDone = !t.done
|
||||
return { ...t, done: nowDone }
|
||||
}),
|
||||
)
|
||||
toast(nowDone ? 'Task completed' : 'Task reopened', nowDone ? 'success' : 'info')
|
||||
}
|
||||
// Optimistic flip with rollback: the checkbox must not lag the click, but a
|
||||
// 403/422 must snap it back rather than lie.
|
||||
const flip = useMutation({
|
||||
mutationFn: ({ id, done }) => tasksApi.update(id, { status: done ? 'done' : 'open' }),
|
||||
onMutate: async ({ id, done }) => {
|
||||
await qc.cancelQueries({ queryKey: qk.tasks.all() })
|
||||
const previous = qc.getQueryData(qk.tasks.list())
|
||||
qc.setQueryData(qk.tasks.list(), (old = []) =>
|
||||
old.map((t) => (t.id === id ? { ...t, done } : t)),
|
||||
)
|
||||
return { previous }
|
||||
},
|
||||
onError: (err, _vars, ctx) => {
|
||||
if (ctx?.previous) qc.setQueryData(qk.tasks.list(), ctx.previous)
|
||||
toast(friendlyAuthError(err, 'Could not update the task.'), 'error')
|
||||
},
|
||||
onSuccess: (_res, { done }) => {
|
||||
toast(done ? 'Task completed' : 'Task reopened', done ? 'success' : 'info')
|
||||
},
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: qk.tasks.all() }),
|
||||
})
|
||||
|
||||
function complete(id) {
|
||||
updateTasks((ts) => ts.map((t) => (t.id === id ? { ...t, done: true } : t)))
|
||||
toast('Task completed', 'success')
|
||||
const createTask = useMutation({
|
||||
mutationFn: (body) => tasksApi.create(body),
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not create the task.'), 'error'),
|
||||
onSuccess: () => {
|
||||
setAdding(false)
|
||||
toast('Task created', 'success')
|
||||
},
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: qk.tasks.all() }),
|
||||
})
|
||||
|
||||
// Soft delete server-side: the row keeps living in Postgres with is_deleted,
|
||||
// it just leaves every fetch. Guarded by tasks.delete.
|
||||
const deleteTask = useMutation({
|
||||
mutationFn: (id) => tasksApi.remove(id),
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the task.'), 'error'),
|
||||
onSuccess: () => {
|
||||
setDetail(null)
|
||||
toast('Task deleted', 'success')
|
||||
},
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: qk.tasks.all() }),
|
||||
})
|
||||
|
||||
function toggle(task) {
|
||||
if (!canEdit) {
|
||||
toast('Requires tasks.edit', 'info')
|
||||
return
|
||||
}
|
||||
flip.mutate({ id: task.id, done: !task.done })
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -63,7 +135,12 @@ export default function Tasks() {
|
|||
<p className="page-sub">{openCount} open · {overdueCount} overdue</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<button className="btn btn-primary" onClick={() => setAdding(true)}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={!canCreate}
|
||||
title={!canCreate ? 'Requires tasks.create' : undefined}
|
||||
onClick={() => setAdding(true)}
|
||||
>
|
||||
<Icon name="plus" /> New Task
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -82,7 +159,13 @@ export default function Tasks() {
|
|||
</div>
|
||||
<div className="card-body">
|
||||
<div className="list-tight">
|
||||
{list.length === 0 ? (
|
||||
{tasksQuery.isPending ? (
|
||||
<EmptyState icon="check-square" title="Loading…">Fetching tasks from the server.</EmptyState>
|
||||
) : tasksQuery.isError ? (
|
||||
<EmptyState icon="alert" title="Couldn’t load tasks">
|
||||
{friendlyAuthError(tasksQuery.error, 'Request failed')}
|
||||
</EmptyState>
|
||||
) : list.length === 0 ? (
|
||||
<EmptyState icon="check-square" title="All caught up">No tasks in this view.</EmptyState>
|
||||
) : (
|
||||
list.map((t) => {
|
||||
|
|
@ -91,11 +174,11 @@ export default function Tasks() {
|
|||
<div className="list-row" style={{ alignItems: 'center' }} key={t.id}>
|
||||
<span
|
||||
className={`checkbox ${t.done ? 'on' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); toggle(t.id) }}
|
||||
onClick={(e) => { e.stopPropagation(); toggle(t) }}
|
||||
role="checkbox"
|
||||
aria-checked={t.done}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(t.id) } }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(t) } }}
|
||||
>
|
||||
<Icon name="check" />
|
||||
</span>
|
||||
|
|
@ -106,7 +189,10 @@ export default function Tasks() {
|
|||
>
|
||||
{t.title}
|
||||
</div>
|
||||
<div className="lr-sub"><Icon name="users" /> {t.assignee} · {t.type}</div>
|
||||
<div className="lr-sub">
|
||||
<Icon name="users" /> {t.assignee}
|
||||
{t.assigneeRole ? ` · ${t.assigneeRole.replace(/_/g, ' ')}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lr-right">
|
||||
<Badge className={PRIORITY_CLASS[t.priority]}>{t.priority}</Badge>
|
||||
|
|
@ -114,7 +200,7 @@ export default function Tasks() {
|
|||
className="lr-sub"
|
||||
style={{ marginTop: 4, ...(overdue ? { color: 'var(--danger)', fontWeight: 600 } : {}) }}
|
||||
>
|
||||
{overdue ? 'Overdue · ' : 'Due '}{fmtShort(t.due)}
|
||||
{t.due ? `${overdue ? 'Overdue · ' : 'Due '}${fmtShort(t.due)}` : 'No due date'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -152,81 +238,108 @@ export default function Tasks() {
|
|||
{detail && (
|
||||
<TaskDetail
|
||||
task={detail}
|
||||
canEdit={canEdit}
|
||||
canDelete={can('tasks.delete')}
|
||||
completing={flip.isPending}
|
||||
deleting={deleteTask.isPending}
|
||||
onClose={() => setDetail(null)}
|
||||
onComplete={() => { complete(detail.id); setDetail(null) }}
|
||||
onViewCandidate={(id) => { setDetail(null); navigate('/candidates', { state: { openCandidate: id } }) }}
|
||||
onComplete={() => {
|
||||
if (!canEdit) return
|
||||
flip.mutate({ id: detail.id, done: true })
|
||||
setDetail(null)
|
||||
}}
|
||||
onDelete={() => deleteTask.mutate(detail.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{adding && (
|
||||
<AddTask
|
||||
recruiters={recruiters}
|
||||
count={tasks.length}
|
||||
assignees={assigneesQuery.data ?? []}
|
||||
me={user}
|
||||
pending={createTask.isPending}
|
||||
onClose={() => setAdding(false)}
|
||||
onSave={(task) => {
|
||||
updateTasks((ts) => [task, ...ts])
|
||||
setAdding(false)
|
||||
toast('Task created', 'success')
|
||||
}}
|
||||
onSave={(body) => createTask.mutate(body)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskDetail({ task: t, onClose, onComplete, onViewCandidate }) {
|
||||
const c = t.candidateId ? getCandidate(t.candidateId) : null
|
||||
function TaskDetail({ task: t, canEdit, canDelete, completing, deleting, onClose, onComplete, onDelete }) {
|
||||
// Two-click delete: first click arms the button, second click fires. Cheaper
|
||||
// than a nested confirm modal and impossible to trigger by accident.
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
return (
|
||||
<Modal
|
||||
title={t.title}
|
||||
subtitle={`${t.id} · ${t.type}`}
|
||||
subtitle={t.done ? 'Completed task' : 'Open task'}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
className={`btn ${confirmDelete ? 'btn-danger' : 'btn-ghost'}`}
|
||||
style={{ marginRight: 'auto' }}
|
||||
disabled={!canDelete || deleting}
|
||||
title={!canDelete ? 'Requires tasks.delete' : undefined}
|
||||
onClick={() => (confirmDelete ? onDelete() : setConfirmDelete(true))}
|
||||
>
|
||||
<Icon name="trash" /> {deleting ? 'Deleting…' : confirmDelete ? 'Confirm delete?' : 'Delete'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Close</button>
|
||||
{c && (
|
||||
<button className="btn btn-secondary" onClick={() => onViewCandidate(c.id)}>View Candidate</button>
|
||||
{!t.done && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={!canEdit || completing}
|
||||
title={!canEdit ? 'Requires tasks.edit' : undefined}
|
||||
onClick={onComplete}
|
||||
>
|
||||
<Icon name="check" /> Mark Complete
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={onComplete}><Icon name="check" /> Mark Complete</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="info-grid" style={{ marginBottom: 16 }}>
|
||||
<div className="info-item"><div className="il">Assignee</div><div className="iv">{t.assignee}</div></div>
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<div className="il">Assignee</div>
|
||||
<div className="iv">
|
||||
{t.assignee}{t.assigneeRole ? ` (${t.assigneeRole.replace(/_/g, ' ')})` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="info-item"><div className="il">Priority</div><div className="iv">{t.priority}</div></div>
|
||||
<div className="info-item"><div className="il">Due Date</div><div className="iv">{fmtDate(t.due)}</div></div>
|
||||
<div className="info-item">
|
||||
<div className="il">Due Date</div>
|
||||
<div className="iv">{t.due ? fmtDate(t.due) : 'No due date'}</div>
|
||||
</div>
|
||||
<div className="info-item"><div className="il">Status</div><div className="iv">{t.done ? 'Completed' : 'Open'}</div></div>
|
||||
{c && <div className="info-item"><div className="il">Candidate</div><div className="iv">{c.name}</div></div>}
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Notes</label>
|
||||
<textarea placeholder="Add task notes…" />
|
||||
{t.created && (
|
||||
<div className="info-item"><div className="il">Created</div><div className="iv">{fmtDate(t.created)}</div></div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function AddTask({ recruiters, count, onClose, onSave }) {
|
||||
const form = useFormState({
|
||||
title: '', priority: 'Medium', type: 'Interview',
|
||||
assignee: recruiters[0]?.name ?? '', due: '',
|
||||
})
|
||||
function AddTask({ assignees, me, pending, onClose, onSave }) {
|
||||
// Assignees are recruiter accounts only. '' means "assign to me" and is only
|
||||
// offered when the caller IS a recruiter (the server 422s otherwise).
|
||||
const meIsRecruiter = me?.role_name === 'recruiter'
|
||||
const form = useFormState({ title: '', priority: 'Medium', assignee: '', due: '' })
|
||||
|
||||
function submit() {
|
||||
if (!form.values.title.trim()) {
|
||||
form.setErrors({ title: 'Required' })
|
||||
return
|
||||
if (pending) return
|
||||
const errors = {}
|
||||
if (!form.values.title.trim()) errors.title = 'Required'
|
||||
if (!form.values.assignee && !meIsRecruiter) errors.assignee = 'Pick a recruiter'
|
||||
form.setErrors(errors)
|
||||
if (Object.keys(errors).length) return
|
||||
const body = {
|
||||
title: form.values.title.trim(),
|
||||
priority: form.values.priority.toLowerCase(),
|
||||
}
|
||||
onSave({
|
||||
id: `TSK-${50001 + count}`,
|
||||
title: form.values.title,
|
||||
candidateId: null,
|
||||
priority: form.values.priority,
|
||||
due: form.values.due ? new Date(form.values.due) : new Date('2026-07-16'),
|
||||
assignee: form.values.assignee,
|
||||
done: false,
|
||||
type: form.values.type,
|
||||
})
|
||||
if (form.values.assignee) body.assignee_id = form.values.assignee
|
||||
if (form.values.due) body.due_date = form.values.due
|
||||
onSave(body)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -236,8 +349,10 @@ function AddTask({ recruiters, count, onClose, onSave }) {
|
|||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={submit}><Icon name="check" /> Create Task</button>
|
||||
<button className="btn btn-secondary" onClick={onClose} disabled={pending}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={submit} disabled={pending}>
|
||||
<Icon name="check" /> {pending ? 'Creating…' : 'Create Task'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
|
@ -260,16 +375,24 @@ function AddTask({ recruiters, count, onClose, onSave }) {
|
|||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Type</label>
|
||||
<select value={form.values.type} onChange={(e) => form.setField('type', e.target.value)}>
|
||||
{['Interview', 'Review', 'Offer', 'Admin'].map((o) => <option key={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Assignee</label>
|
||||
<select value={form.values.assignee} onChange={(e) => form.setField('assignee', e.target.value)}>
|
||||
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}
|
||||
<label>Assignee <span className="req">*</span></label>
|
||||
<select
|
||||
className={form.errors.assignee ? 'err' : ''}
|
||||
value={form.values.assignee}
|
||||
onChange={(e) => form.setField('assignee', e.target.value)}
|
||||
>
|
||||
{meIsRecruiter
|
||||
? <option value="">Me{me?.name ? ` (${me.name})` : ''}</option>
|
||||
: <option value="">Select recruiter…</option>}
|
||||
{assignees
|
||||
.filter((u) => u.id !== me?.id)
|
||||
.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name || u.email} — recruiter
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<FieldError>{form.errors.assignee}</FieldError>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Due Date</label>
|
||||
|
|
|
|||
Loading…
Reference in New Issue