Tasks domain: real backend + live screen with DB users/roles as assignees

- New backend/tasks/ package (offer/ house style): tasks table
  (title/status/priority/due_date/assignee_id -> users.id, optional
  inbox_id/job_post_id links, soft delete), routes /tasks/fetch|create|
  update|delete guarded by new tasks.* permission tags
- Assignees validated against users+roles: must exist, not deleted, not
  candidate-role; omitted assignee defaults to the caller; responses carry
  assignee_name/assignee_role from one batched join
- TASKS permission module (104 -> 112 tags); manual/004_tasks_rbac.sql
  seeds the tags + tasks_management bundle onto 6 staff roles (auto-applies
  at startup)
- Tasks.jsx cut over from seed to live: real create/complete/reopen with
  optimistic flip, two-click delete in the detail modal, assignee picker
  listing real users with roles; live sidebar badge (open-task total);
  route now requires tasks.view

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/15/head^2
Talha Ahmed 2026-08-13 16:27:19 +05:00
parent 2bdf82afc3
commit b3483d9a6e
12 changed files with 736 additions and 79 deletions

View File

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

View File

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

100
backend/tasks/app.py Normal file
View File

@ -0,0 +1,100 @@
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.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))

111
backend/tasks/models.py Normal file
View File

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

View File

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

171
backend/tasks/views.py Normal file
View File

@ -0,0 +1,171 @@
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 tasks.models import Tasks
from tasks.serializers import serialize_task
from users.models import Users
VALID_STATUS = ("open", "done")
VALID_PRIORITY = ("high", "medium", "low")
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 are real staff accounts: existing, not deleted, and not the
candidate role a candidate cannot work a recruiting task."""
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 == "candidate":
raise HTTPException(status_code=422, detail="A candidate account cannot be a task assignee")
return user
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):
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)
# No assignee picked -> the caller works it themselves.
assignee = _as_uuid(payload.get("assignee_id")) if payload.get("assignee_id") else creator
if assignee is None:
raise HTTPException(status_code=422, detail="Invalid assignee_id")
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}

View File

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

44
frontend/src/api/tasks.js Normal file
View File

@ -0,0 +1,44 @@
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 } })
}
/**
* 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,
}
}

View File

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

View File

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

View File

@ -55,6 +55,7 @@ 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] },
// --- seed-backed buckets ---
// These are not "server state" — the cache IS the store for them, so every

View File

@ -1,29 +1,72 @@
/* ============================================================
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 REAL user
accounts (users roles): the picker lists /users/fetch minus candidate-role
rows and submits assignee_id. A caller without rbac_users.view cannot list
users the picker then collapses to "assign to me", which matches the
server's default. 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 * as usersApi from '../api/users'
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' }
async function fetchTasks() {
const res = await tasksApi.list()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(tasksApi.toTaskView)
}
/* Staff only: the server rejects candidate-role assignees, so don't offer them.
403 (no rbac_users.view) degrades to [] and the form falls back to
"assign to me" the server-side default for an omitted assignee_id. */
async function fetchAssignees() {
try {
const res = await usersApi.list({ top: 200 })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.filter((u) => u.role_name !== 'candidate' && !u.is_deleted)
} catch {
return []
}
}
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')
const canEdit = can('tasks.edit')
const tasksQuery = useQuery({ queryKey: qk.tasks.list(), queryFn: fetchTasks })
const assigneesQuery = useQuery({ queryKey: qk.users.list({ pick: '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 +74,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 +140,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 +164,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="Couldnt 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 +179,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 +194,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 +205,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 +243,106 @@ 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 }) {
// '' = "assign to me" the server defaults an omitted assignee_id to the
// caller, which is also the only option when /users/fetch is 403 for us.
const form = useFormState({ title: '', priority: 'Medium', assignee: '', due: '' })
function submit() {
if (pending) return
if (!form.values.title.trim()) {
form.setErrors({ title: 'Required' })
return
}
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,
})
const body = {
title: form.values.title.trim(),
priority: form.values.priority.toLowerCase(),
}
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 +352,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>
</>
}
>
@ -259,16 +377,17 @@ function AddTask({ recruiters, count, onClose, onSave }) {
<option>High</option><option>Medium</option><option>Low</option>
</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>)}
<option value="">Me{me?.name ? ` (${me.name})` : ''}</option>
{assignees
.filter((u) => u.id !== me?.id)
.map((u) => (
<option key={u.id} value={u.id}>
{u.name || u.email}{u.role_name ? `${u.role_name.replace(/_/g, ' ')}` : ''}
</option>
))}
</select>
</div>
<div className="form-field">