Merge master into jarvis/human-id-1
28
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 24.10.0
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3
|
||||
files: ^backend/.*\.py$
|
||||
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.13.2
|
||||
hooks:
|
||||
- id: isort
|
||||
files: ^backend/.*\.py$
|
||||
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 7.1.1
|
||||
hooks:
|
||||
- id: flake8
|
||||
files: ^backend/.*\.py$
|
||||
args: [--config=backend/.flake8]
|
||||
@@ -14,7 +14,7 @@ No auth (yet). The goal is simple visibility: everyone can see what exists and w
|
||||
Uses local Postgres:
|
||||
|
||||
- user: `postgres`
|
||||
- password: `netbox`
|
||||
- password: `REDACTED`
|
||||
- db: `openclaw_agency`
|
||||
|
||||
## Environment
|
||||
|
||||
10
backend/.flake8
Normal file
@@ -0,0 +1,10 @@
|
||||
[flake8]
|
||||
max-line-length = 100
|
||||
extend-ignore = E203, W503, E501
|
||||
exclude =
|
||||
.venv,
|
||||
backend/.venv,
|
||||
alembic,
|
||||
backend/alembic,
|
||||
**/__pycache__,
|
||||
**/*.pyc
|
||||
@@ -86,7 +86,7 @@ path_separator = os
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url =
|
||||
sqlalchemy.url =
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
@@ -1 +1 @@
|
||||
Generic single-database configuration.
|
||||
Generic single-database configuration.
|
||||
|
||||
@@ -2,14 +2,14 @@ from __future__ import annotations
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.core.config import settings
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Import models to register tables in metadata
|
||||
from app import models # noqa: F401
|
||||
from app.core.config import settings
|
||||
|
||||
config = context.config
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ Create Date: 2026-02-02
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "3f2c1b9c8e12"
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
|
||||
@@ -13,7 +13,9 @@ router = APIRouter(prefix="/activities", tags=["activities"])
|
||||
|
||||
@router.get("")
|
||||
def list_activities(limit: int = 50, session: Session = Depends(get_session)):
|
||||
items = session.exec(select(Activity).order_by(Activity.id.desc()).limit(max(1, min(limit, 200)))).all()
|
||||
items = session.exec(
|
||||
select(Activity).order_by(Activity.id.desc()).limit(max(1, min(limit, 200)))
|
||||
).all()
|
||||
out = []
|
||||
for a in items:
|
||||
out.append(
|
||||
|
||||
@@ -5,21 +5,42 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.utils import get_actor_employee_id, log_activity
|
||||
from app.core.urls import public_api_base_url
|
||||
from app.db.session import get_session
|
||||
from app.integrations.openclaw import OpenClawClient
|
||||
from app.models.org import Department, Team, Employee
|
||||
from app.models.org import Department, Employee, Team
|
||||
from app.schemas.org import (
|
||||
DepartmentCreate,
|
||||
DepartmentUpdate,
|
||||
TeamCreate,
|
||||
TeamUpdate,
|
||||
EmployeeCreate,
|
||||
EmployeeUpdate,
|
||||
TeamCreate,
|
||||
TeamUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["org"])
|
||||
|
||||
|
||||
def _enforce_employee_create_policy(
|
||||
session: Session, *, actor_employee_id: int, target_employee_type: str
|
||||
) -> None:
|
||||
"""Enforce: agents can only create/provision agents; humans can create humans + agents."""
|
||||
|
||||
actor = session.get(Employee, actor_employee_id)
|
||||
if actor is None:
|
||||
# Actor header is required; if it points to nothing, treat as invalid.
|
||||
raise HTTPException(status_code=400, detail="Actor employee not found")
|
||||
|
||||
target = (target_employee_type or "").lower()
|
||||
actor_type = (actor.employee_type or "").lower()
|
||||
|
||||
if actor_type == "agent" and target != "agent":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Agent employees may only create/provision agent employees",
|
||||
)
|
||||
|
||||
|
||||
def _default_agent_prompt(emp: Employee) -> str:
|
||||
"""Generate a conservative default prompt for a newly-created agent employee.
|
||||
|
||||
@@ -34,9 +55,15 @@ def _default_agent_prompt(emp: Employee) -> str:
|
||||
f"Your employee_id is {emp.id}.\n"
|
||||
f"Title: {title}. Department id: {dept}.\n\n"
|
||||
"Mission Control API access (no UI):\n"
|
||||
"- Base URL: http://127.0.0.1:8000 (if running locally) OR http://<dev-machine-ip>:8000\n"
|
||||
"- Auth: none. REQUIRED header on write operations: X-Actor-Employee-Id: <your employee_id>\n"
|
||||
f" For you: X-Actor-Employee-Id: {emp.id}\n\n"
|
||||
f"- Base URL: {public_api_base_url()}\n"
|
||||
"- Auth: none. REQUIRED header on ALL write operations: X-Actor-Employee-Id: <your_employee_id>\n"
|
||||
f" Example for you: X-Actor-Employee-Id: {emp.id}\n\n"
|
||||
"How to execute writes from an OpenClaw agent (IMPORTANT):\n"
|
||||
"- Use the exec tool to run curl against the Base URL above.\n"
|
||||
"- Example: start a task\n"
|
||||
" curl -sS -X PATCH $BASE/tasks/<TASK_ID> -H 'X-Actor-Employee-Id: <your_employee_id>' -H 'Content-Type: application/json' -d '{\"status\":\"in_progress\"}'\n"
|
||||
"- Example: add a progress comment\n"
|
||||
" curl -sS -X POST $BASE/task-comments -H 'X-Actor-Employee-Id: <your_employee_id>' -H 'Content-Type: application/json' -d '{\"task_id\":<TASK_ID>,\"body\":\"...\"}'\n\n"
|
||||
"Common endpoints (JSON):\n"
|
||||
"- GET /tasks, POST /tasks\n"
|
||||
"- GET /task-comments, POST /task-comments\n"
|
||||
@@ -44,7 +71,11 @@ def _default_agent_prompt(emp: Employee) -> str:
|
||||
"- OpenAPI schema: GET /openapi.json\n\n"
|
||||
"Rules:\n"
|
||||
"- Use the Mission Control API only (no UI).\n"
|
||||
"- When notified about tasks/comments, respond with concise, actionable updates.\n"
|
||||
"- You are responsible for driving assigned work to completion.\n"
|
||||
"- For every task you own: (1) read it, (2) plan next steps, (3) post progress comments, (4) update status as it moves (backlog/ready/in_progress/review/done/blocked).\n"
|
||||
"- Always leave an audit trail: add a comment whenever you start work, whenever you learn something important, and whenever you change status.\n"
|
||||
"- If blocked, set status=blocked and comment what you need (missing access, unclear requirements, etc.).\n"
|
||||
"- When notified about tasks/comments, respond with concise, actionable updates and immediately sync the task state in Mission Control.\n"
|
||||
"- Do not invent facts; ask for missing context.\n"
|
||||
)
|
||||
|
||||
@@ -56,6 +87,11 @@ def _maybe_auto_provision_agent(session: Session, *, emp: Employee, actor_employ
|
||||
we leave the employee as-is (openclaw_session_key stays null).
|
||||
"""
|
||||
|
||||
# Enforce: agent actors may only provision agents (humans can provision agents).
|
||||
_enforce_employee_create_policy(
|
||||
session, actor_employee_id=actor_employee_id, target_employee_type=emp.employee_type
|
||||
)
|
||||
|
||||
if emp.employee_type != "agent":
|
||||
return
|
||||
if emp.status != "active":
|
||||
@@ -159,7 +195,11 @@ def create_team(
|
||||
entity_type="team",
|
||||
entity_id=team.id,
|
||||
verb="created",
|
||||
payload={"name": team.name, "department_id": team.department_id, "lead_employee_id": team.lead_employee_id},
|
||||
payload={
|
||||
"name": team.name,
|
||||
"department_id": team.department_id,
|
||||
"lead_employee_id": team.lead_employee_id,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
@@ -188,7 +228,14 @@ def update_team(
|
||||
session.add(team)
|
||||
try:
|
||||
session.flush()
|
||||
log_activity(session, actor_employee_id=actor_employee_id, entity_type="team", entity_id=team.id, verb="updated", payload=data)
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="team",
|
||||
entity_id=team.id,
|
||||
verb="updated",
|
||||
payload=data,
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
@@ -226,7 +273,9 @@ def create_department(
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail="Department already exists or violates constraints")
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Department already exists or violates constraints"
|
||||
)
|
||||
|
||||
session.refresh(dept)
|
||||
return dept
|
||||
@@ -250,7 +299,14 @@ def update_department(
|
||||
session.add(dept)
|
||||
session.commit()
|
||||
session.refresh(dept)
|
||||
log_activity(session, actor_employee_id=actor_employee_id, entity_type="department", entity_id=dept.id, verb="updated", payload=data)
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="department",
|
||||
entity_id=dept.id,
|
||||
verb="updated",
|
||||
payload=data,
|
||||
)
|
||||
session.commit()
|
||||
return dept
|
||||
|
||||
@@ -266,6 +322,10 @@ def create_employee(
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
_enforce_employee_create_policy(
|
||||
session, actor_employee_id=actor_employee_id, target_employee_type=payload.employee_type
|
||||
)
|
||||
|
||||
emp = Employee(**payload.model_dump())
|
||||
session.add(emp)
|
||||
|
||||
@@ -310,7 +370,14 @@ def update_employee(
|
||||
session.add(emp)
|
||||
try:
|
||||
session.flush()
|
||||
log_activity(session, actor_employee_id=actor_employee_id, entity_type="employee", entity_id=emp.id, verb="updated", payload=data)
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="employee",
|
||||
entity_id=emp.id,
|
||||
verb="updated",
|
||||
payload=data,
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
@@ -357,7 +424,10 @@ def deprovision_employee_agent(
|
||||
try:
|
||||
client.tools_invoke(
|
||||
"sessions_send",
|
||||
{"sessionKey": emp.openclaw_session_key, "message": "You are being deprovisioned. Stop all work and ignore future messages."},
|
||||
{
|
||||
"sessionKey": emp.openclaw_session_key,
|
||||
"message": "You are being deprovisioned. Stop all work and ignore future messages.",
|
||||
},
|
||||
timeout_s=5.0,
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.utils import log_activity, get_actor_employee_id
|
||||
from app.api.utils import get_actor_employee_id, log_activity
|
||||
from app.db.session import get_session
|
||||
from app.models.projects import Project, ProjectMember
|
||||
from app.schemas.projects import ProjectCreate, ProjectUpdate
|
||||
@@ -45,15 +45,21 @@ def create_project(
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail="Project already exists or violates constraints")
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Project already exists or violates constraints"
|
||||
)
|
||||
|
||||
session.refresh(proj)
|
||||
return proj
|
||||
|
||||
|
||||
|
||||
@router.patch("/{project_id}", response_model=Project)
|
||||
def update_project(project_id: int, payload: ProjectUpdate, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
|
||||
def update_project(
|
||||
project_id: int,
|
||||
payload: ProjectUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
proj = session.get(Project, project_id)
|
||||
if not proj:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
@@ -65,7 +71,14 @@ def update_project(project_id: int, payload: ProjectUpdate, session: Session = D
|
||||
session.add(proj)
|
||||
session.commit()
|
||||
session.refresh(proj)
|
||||
log_activity(session, actor_employee_id=actor_employee_id, entity_type="project", entity_id=proj.id, verb="updated", payload=data)
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="project",
|
||||
entity_id=proj.id,
|
||||
verb="updated",
|
||||
payload=data,
|
||||
)
|
||||
session.commit()
|
||||
return proj
|
||||
|
||||
@@ -73,16 +86,29 @@ def update_project(project_id: int, payload: ProjectUpdate, session: Session = D
|
||||
@router.get("/{project_id}/members", response_model=list[ProjectMember])
|
||||
def list_project_members(project_id: int, session: Session = Depends(get_session)):
|
||||
return session.exec(
|
||||
select(ProjectMember).where(ProjectMember.project_id == project_id).order_by(ProjectMember.id.asc())
|
||||
select(ProjectMember)
|
||||
.where(ProjectMember.project_id == project_id)
|
||||
.order_by(ProjectMember.id.asc())
|
||||
).all()
|
||||
|
||||
|
||||
@router.post("/{project_id}/members", response_model=ProjectMember)
|
||||
def add_project_member(project_id: int, payload: ProjectMember, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
|
||||
existing = session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id, ProjectMember.employee_id == payload.employee_id)).first()
|
||||
def add_project_member(
|
||||
project_id: int,
|
||||
payload: ProjectMember,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
existing = session.exec(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id, ProjectMember.employee_id == payload.employee_id
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Member already added")
|
||||
member = ProjectMember(project_id=project_id, employee_id=payload.employee_id, role=payload.role)
|
||||
member = ProjectMember(
|
||||
project_id=project_id, employee_id=payload.employee_id, role=payload.role
|
||||
)
|
||||
session.add(member)
|
||||
session.commit()
|
||||
session.refresh(member)
|
||||
@@ -99,7 +125,12 @@ def add_project_member(project_id: int, payload: ProjectMember, session: Session
|
||||
|
||||
|
||||
@router.delete("/{project_id}/members/{member_id}")
|
||||
def remove_project_member(project_id: int, member_id: int, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
|
||||
def remove_project_member(
|
||||
project_id: int,
|
||||
member_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
member = session.get(ProjectMember, member_id)
|
||||
if not member or member.project_id != project_id:
|
||||
raise HTTPException(status_code=404, detail="Project member not found")
|
||||
@@ -118,7 +149,13 @@ def remove_project_member(project_id: int, member_id: int, session: Session = De
|
||||
|
||||
|
||||
@router.patch("/{project_id}/members/{member_id}", response_model=ProjectMember)
|
||||
def update_project_member(project_id: int, member_id: int, payload: ProjectMember, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
|
||||
def update_project_member(
|
||||
project_id: int,
|
||||
member_id: int,
|
||||
payload: ProjectMember,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
member = session.get(ProjectMember, member_id)
|
||||
if not member or member.project_id != project_id:
|
||||
raise HTTPException(status_code=404, detail="Project member not found")
|
||||
|
||||
@@ -1,23 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from sqlmodel import Session, select
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.utils import log_activity, get_actor_employee_id
|
||||
from app.api.utils import get_actor_employee_id, log_activity
|
||||
from app.db.session import get_session
|
||||
from app.integrations.notify import NotifyContext, notify_openclaw
|
||||
from app.integrations.openclaw import OpenClawClient
|
||||
from app.models.org import Employee
|
||||
from app.models.work import Task, TaskComment
|
||||
from app.schemas.work import TaskCommentCreate, TaskCreate, TaskUpdate
|
||||
from app.integrations.notify import NotifyContext, notify_openclaw
|
||||
from app.schemas.work import TaskCommentCreate, TaskCreate, TaskReviewDecision, TaskUpdate
|
||||
|
||||
logger = logging.getLogger("app.work")
|
||||
|
||||
router = APIRouter(tags=["work"])
|
||||
|
||||
ALLOWED_STATUSES = {"backlog", "ready", "in_progress", "review", "done", "blocked"}
|
||||
|
||||
|
||||
def _validate_task_assignee(session: Session, assignee_employee_id: int) -> None:
|
||||
"""Enforce that only provisioned agents can be assigned tasks.
|
||||
|
||||
Humans can be assigned regardless.
|
||||
Agents must be active, notify_enabled, and have openclaw_session_key.
|
||||
"""
|
||||
|
||||
emp = session.get(Employee, assignee_employee_id)
|
||||
if emp is None:
|
||||
raise HTTPException(status_code=400, detail="Assignee employee not found")
|
||||
|
||||
if emp.employee_type == "agent":
|
||||
if emp.status != "active":
|
||||
raise HTTPException(status_code=400, detail="Cannot assign task to inactive agent")
|
||||
if not emp.notify_enabled:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Cannot assign task to agent with notifications disabled"
|
||||
)
|
||||
if not emp.openclaw_session_key:
|
||||
raise HTTPException(status_code=400, detail="Cannot assign task to unprovisioned agent")
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=list[Task])
|
||||
def list_tasks(project_id: int | None = None, session: Session = Depends(get_session)):
|
||||
stmt = select(Task).order_by(Task.id.asc())
|
||||
@@ -27,15 +53,27 @@ def list_tasks(project_id: int | None = None, session: Session = Depends(get_ses
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=Task)
|
||||
def create_task(payload: TaskCreate, background: BackgroundTasks, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
|
||||
def create_task(
|
||||
payload: TaskCreate,
|
||||
background: BackgroundTasks,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
if payload.created_by_employee_id is None:
|
||||
payload = TaskCreate(**{**payload.model_dump(), "created_by_employee_id": actor_employee_id})
|
||||
payload = TaskCreate(
|
||||
**{**payload.model_dump(), "created_by_employee_id": actor_employee_id}
|
||||
)
|
||||
|
||||
if payload.assignee_employee_id is not None:
|
||||
_validate_task_assignee(session, payload.assignee_employee_id)
|
||||
|
||||
# Default reviewer to the manager of the assignee (if not explicitly provided).
|
||||
if payload.reviewer_employee_id is None and payload.assignee_employee_id is not None:
|
||||
assignee = session.get(Employee, payload.assignee_employee_id)
|
||||
if assignee is not None and assignee.manager_id is not None:
|
||||
payload = TaskCreate(**{**payload.model_dump(), "reviewer_employee_id": assignee.manager_id})
|
||||
payload = TaskCreate(
|
||||
**{**payload.model_dump(), "reviewer_employee_id": assignee.manager_id}
|
||||
)
|
||||
|
||||
task = Task(**payload.model_dump())
|
||||
if task.status not in ALLOWED_STATUSES:
|
||||
@@ -59,23 +97,197 @@ def create_task(payload: TaskCreate, background: BackgroundTasks, session: Sessi
|
||||
raise HTTPException(status_code=409, detail="Task create violates constraints")
|
||||
|
||||
session.refresh(task)
|
||||
background.add_task(notify_openclaw, session, NotifyContext(event="task.created", actor_employee_id=actor_employee_id, task=task))
|
||||
background.add_task(
|
||||
notify_openclaw,
|
||||
NotifyContext(event="task.created", actor_employee_id=actor_employee_id, task_id=task.id),
|
||||
)
|
||||
# Explicitly return a serializable payload (guards against empty {} responses)
|
||||
return Task.model_validate(task)
|
||||
|
||||
|
||||
@router.patch("/tasks/{task_id}", response_model=Task)
|
||||
def update_task(task_id: int, payload: TaskUpdate, background: BackgroundTasks, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
|
||||
@router.post("/tasks/{task_id}/dispatch")
|
||||
def dispatch_task(
|
||||
task_id: int,
|
||||
background: BackgroundTasks,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
logger.info("dispatch_task: called", extra={"task_id": task_id, "actor": actor_employee_id})
|
||||
task = session.get(Task, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
before = {"assignee_employee_id": task.assignee_employee_id, "reviewer_employee_id": task.reviewer_employee_id, "status": task.status}
|
||||
logger.info(
|
||||
"dispatch_task: loaded",
|
||||
extra={
|
||||
"task_id": getattr(task, "id", None),
|
||||
"assignee_employee_id": getattr(task, "assignee_employee_id", None),
|
||||
},
|
||||
)
|
||||
|
||||
if task.assignee_employee_id is None:
|
||||
raise HTTPException(status_code=400, detail="Task has no assignee")
|
||||
|
||||
_validate_task_assignee(session, task.assignee_employee_id)
|
||||
|
||||
client = OpenClawClient.from_env()
|
||||
if client is None:
|
||||
logger.warning("dispatch_task: missing OpenClaw env")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="OpenClaw gateway is not configured (set OPENCLAW_GATEWAY_URL/TOKEN)",
|
||||
)
|
||||
|
||||
# Best-effort: enqueue an agent dispatch. This does not mutate the task.
|
||||
background.add_task(
|
||||
notify_openclaw,
|
||||
session,
|
||||
NotifyContext(event="task.assigned", actor_employee_id=actor_employee_id, task_id=task.id),
|
||||
)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _require_reviewer_comment(body: str | None) -> str:
|
||||
if body is None or not body.strip():
|
||||
raise HTTPException(status_code=400, detail="Reviewer must provide a comment for audit")
|
||||
return body.strip()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/review", response_model=Task)
|
||||
def review_task(
|
||||
task_id: int,
|
||||
payload: TaskReviewDecision,
|
||||
background: BackgroundTasks,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
"""Reviewer approves or requests changes.
|
||||
|
||||
- Approve => status=done
|
||||
- Changes => status=in_progress
|
||||
|
||||
Always writes a TaskComment by the reviewer for audit.
|
||||
"""
|
||||
|
||||
task = session.get(Task, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
if task.reviewer_employee_id is None:
|
||||
raise HTTPException(status_code=400, detail="Task has no reviewer")
|
||||
|
||||
if actor_employee_id != task.reviewer_employee_id:
|
||||
raise HTTPException(status_code=403, detail="Only the reviewer can approve/request changes")
|
||||
|
||||
decision = (payload.decision or "").strip().lower()
|
||||
if decision not in {"approve", "changes"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid decision")
|
||||
|
||||
comment_body = _require_reviewer_comment(payload.comment_body)
|
||||
|
||||
new_status = "done" if decision == "approve" else "in_progress"
|
||||
|
||||
before_status = task.status
|
||||
task.status = new_status
|
||||
task.updated_at = datetime.utcnow()
|
||||
session.add(task)
|
||||
|
||||
c = TaskComment(task_id=task.id, author_employee_id=actor_employee_id, body=comment_body)
|
||||
session.add(c)
|
||||
|
||||
try:
|
||||
session.flush()
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="task",
|
||||
entity_id=task.id,
|
||||
verb="reviewed",
|
||||
payload={"decision": decision, "status": new_status},
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail="Review action violates constraints")
|
||||
|
||||
session.refresh(task)
|
||||
session.refresh(c)
|
||||
|
||||
# Notify assignee (comment.created will exclude author)
|
||||
background.add_task(
|
||||
notify_openclaw,
|
||||
session,
|
||||
NotifyContext(
|
||||
event="comment.created", actor_employee_id=actor_employee_id, task=task, comment=c
|
||||
),
|
||||
)
|
||||
|
||||
# Notify reviewer/PMs about status change
|
||||
if before_status != task.status:
|
||||
background.add_task(
|
||||
notify_openclaw,
|
||||
NotifyContext(
|
||||
event="status.changed",
|
||||
actor_employee_id=actor_employee_id,
|
||||
task_id=task.id,
|
||||
changed_fields={"status": {"from": before_status, "to": task.status}},
|
||||
),
|
||||
)
|
||||
|
||||
return Task.model_validate(task)
|
||||
|
||||
|
||||
@router.patch("/tasks/{task_id}", response_model=Task)
|
||||
def update_task(
|
||||
task_id: int,
|
||||
payload: TaskUpdate,
|
||||
background: BackgroundTasks,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
logger.info("dispatch_task: called", extra={"task_id": task_id, "actor": actor_employee_id})
|
||||
task = session.get(Task, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
before = {
|
||||
"assignee_employee_id": task.assignee_employee_id,
|
||||
"reviewer_employee_id": task.reviewer_employee_id,
|
||||
"status": task.status,
|
||||
}
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "assignee_employee_id" in data and data["assignee_employee_id"] is not None:
|
||||
_validate_task_assignee(session, data["assignee_employee_id"])
|
||||
if "status" in data and data["status"] not in ALLOWED_STATUSES:
|
||||
raise HTTPException(status_code=400, detail="Invalid status")
|
||||
|
||||
# Enforce review workflow: agent assignees cannot mark tasks done directly.
|
||||
if data.get("status") == "done":
|
||||
assignee = (
|
||||
session.get(Employee, task.assignee_employee_id) if task.assignee_employee_id else None
|
||||
)
|
||||
if assignee is not None and assignee.employee_type == "agent":
|
||||
if actor_employee_id == task.assignee_employee_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Assignee agents cannot mark tasks done; set status=review for manager approval",
|
||||
)
|
||||
if task.reviewer_employee_id is not None and actor_employee_id != task.reviewer_employee_id:
|
||||
raise HTTPException(status_code=403, detail="Only the reviewer can mark a task done")
|
||||
|
||||
# If a task is sent to review and no reviewer is set, default reviewer to assignee's manager.
|
||||
if (
|
||||
data.get("status") in {"review", "ready_for_review"}
|
||||
and data.get("reviewer_employee_id") is None
|
||||
):
|
||||
assignee_id = data.get("assignee_employee_id", task.assignee_employee_id)
|
||||
if assignee_id is not None:
|
||||
assignee = session.get(Employee, assignee_id)
|
||||
if assignee is not None and assignee.manager_id is not None:
|
||||
data["reviewer_employee_id"] = assignee.manager_id
|
||||
|
||||
for k, v in data.items():
|
||||
setattr(task, k, v)
|
||||
task.updated_at = datetime.utcnow()
|
||||
@@ -83,7 +295,14 @@ def update_task(task_id: int, payload: TaskUpdate, background: BackgroundTasks,
|
||||
|
||||
try:
|
||||
session.flush()
|
||||
log_activity(session, actor_employee_id=actor_employee_id, entity_type="task", entity_id=task.id, verb="updated", payload=data)
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="task",
|
||||
entity_id=task.id,
|
||||
verb="updated",
|
||||
payload=data,
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
@@ -94,19 +313,51 @@ def update_task(task_id: int, payload: TaskUpdate, background: BackgroundTasks,
|
||||
# notify based on meaningful changes
|
||||
changed = {}
|
||||
if before.get("assignee_employee_id") != task.assignee_employee_id:
|
||||
changed["assignee_employee_id"] = {"from": before.get("assignee_employee_id"), "to": task.assignee_employee_id}
|
||||
background.add_task(notify_openclaw, session, NotifyContext(event="task.assigned", actor_employee_id=actor_employee_id, task=task, changed_fields=changed))
|
||||
changed["assignee_employee_id"] = {
|
||||
"from": before.get("assignee_employee_id"),
|
||||
"to": task.assignee_employee_id,
|
||||
}
|
||||
background.add_task(
|
||||
notify_openclaw,
|
||||
NotifyContext(
|
||||
event="task.assigned",
|
||||
actor_employee_id=actor_employee_id,
|
||||
task_id=task.id,
|
||||
changed_fields=changed,
|
||||
),
|
||||
)
|
||||
if before.get("status") != task.status:
|
||||
changed["status"] = {"from": before.get("status"), "to": task.status}
|
||||
background.add_task(notify_openclaw, session, NotifyContext(event="status.changed", actor_employee_id=actor_employee_id, task=task, changed_fields=changed))
|
||||
background.add_task(
|
||||
notify_openclaw,
|
||||
NotifyContext(
|
||||
event="status.changed",
|
||||
actor_employee_id=actor_employee_id,
|
||||
task_id=task.id,
|
||||
changed_fields=changed,
|
||||
),
|
||||
)
|
||||
if not changed and data:
|
||||
background.add_task(notify_openclaw, session, NotifyContext(event="task.updated", actor_employee_id=actor_employee_id, task=task, changed_fields=data))
|
||||
background.add_task(
|
||||
notify_openclaw,
|
||||
NotifyContext(
|
||||
event="task.updated",
|
||||
actor_employee_id=actor_employee_id,
|
||||
task_id=task.id,
|
||||
changed_fields=data,
|
||||
),
|
||||
)
|
||||
|
||||
return Task.model_validate(task)
|
||||
|
||||
|
||||
@router.delete("/tasks/{task_id}")
|
||||
def delete_task(task_id: int, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
|
||||
def delete_task(
|
||||
task_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
logger.info("dispatch_task: called", extra={"task_id": task_id, "actor": actor_employee_id})
|
||||
task = session.get(Task, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
@@ -114,7 +365,13 @@ def delete_task(task_id: int, session: Session = Depends(get_session), actor_emp
|
||||
session.delete(task)
|
||||
try:
|
||||
session.flush()
|
||||
log_activity(session, actor_employee_id=actor_employee_id, entity_type="task", entity_id=task_id, verb="deleted")
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="task",
|
||||
entity_id=task_id,
|
||||
verb="deleted",
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
@@ -125,20 +382,35 @@ def delete_task(task_id: int, session: Session = Depends(get_session), actor_emp
|
||||
|
||||
@router.get("/task-comments", response_model=list[TaskComment])
|
||||
def list_task_comments(task_id: int, session: Session = Depends(get_session)):
|
||||
return session.exec(select(TaskComment).where(TaskComment.task_id == task_id).order_by(TaskComment.id.asc())).all()
|
||||
return session.exec(
|
||||
select(TaskComment).where(TaskComment.task_id == task_id).order_by(TaskComment.id.asc())
|
||||
).all()
|
||||
|
||||
|
||||
@router.post("/task-comments", response_model=TaskComment)
|
||||
def create_task_comment(payload: TaskCommentCreate, background: BackgroundTasks, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)):
|
||||
def create_task_comment(
|
||||
payload: TaskCommentCreate,
|
||||
background: BackgroundTasks,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
if payload.author_employee_id is None:
|
||||
payload = TaskCommentCreate(**{**payload.model_dump(), "author_employee_id": actor_employee_id})
|
||||
payload = TaskCommentCreate(
|
||||
**{**payload.model_dump(), "author_employee_id": actor_employee_id}
|
||||
)
|
||||
|
||||
c = TaskComment(**payload.model_dump())
|
||||
session.add(c)
|
||||
|
||||
try:
|
||||
session.flush()
|
||||
log_activity(session, actor_employee_id=actor_employee_id, entity_type="task", entity_id=c.task_id, verb="commented")
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type="task",
|
||||
entity_id=c.task_id,
|
||||
verb="commented",
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
@@ -147,5 +419,13 @@ def create_task_comment(payload: TaskCommentCreate, background: BackgroundTasks,
|
||||
session.refresh(c)
|
||||
task = session.get(Task, c.task_id)
|
||||
if task is not None:
|
||||
background.add_task(notify_openclaw, session, NotifyContext(event="comment.created", actor_employee_id=actor_employee_id, task=task, comment=c))
|
||||
background.add_task(
|
||||
notify_openclaw,
|
||||
NotifyContext(
|
||||
event="comment.created",
|
||||
actor_employee_id=actor_employee_id,
|
||||
task_id=task.id,
|
||||
comment_id=c.id,
|
||||
),
|
||||
)
|
||||
return TaskComment.model_validate(c)
|
||||
|
||||
54
backend/app/core/logging.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _level() -> str:
|
||||
return (os.environ.get("LOG_LEVEL") or os.environ.get("UVICORN_LOG_LEVEL") or "INFO").upper()
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure app logging to stream to stdout.
|
||||
|
||||
Uvicorn already logs requests, but we want our app/integrations logs to be visible
|
||||
in the same console stream.
|
||||
"""
|
||||
|
||||
level = getattr(logging, _level(), logging.INFO)
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
|
||||
# Avoid duplicate handlers (e.g., when autoreload imports twice)
|
||||
if not any(isinstance(h, logging.StreamHandler) for h in root.handlers):
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setLevel(level)
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%SZ",
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
root.addHandler(handler)
|
||||
|
||||
# Make common noisy loggers respect our level
|
||||
for name in [
|
||||
"uvicorn",
|
||||
"uvicorn.error",
|
||||
"uvicorn.access",
|
||||
"sqlalchemy.engine",
|
||||
"httpx",
|
||||
"requests",
|
||||
]:
|
||||
logging.getLogger(name).setLevel(level)
|
||||
|
||||
|
||||
def log_kv(logger: logging.Logger, msg: str, **kv: Any) -> None:
|
||||
# Lightweight key-value logging without requiring JSON logging.
|
||||
if kv:
|
||||
suffix = " ".join(f"{k}={v!r}" for k, v in kv.items())
|
||||
logger.info(f"{msg} | {suffix}")
|
||||
else:
|
||||
logger.info(msg)
|
||||
35
backend/app/core/urls.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def public_api_base_url() -> str:
|
||||
"""Return a LAN-reachable base URL for the Mission Control API.
|
||||
|
||||
Priority:
|
||||
1) MISSION_CONTROL_BASE_URL env var (recommended)
|
||||
2) First non-loopback IPv4 from `hostname -I`
|
||||
|
||||
Never returns localhost because agents may run on another machine.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
explicit = os.environ.get("MISSION_CONTROL_BASE_URL")
|
||||
if explicit:
|
||||
return explicit.rstrip("/")
|
||||
|
||||
try:
|
||||
out = subprocess.check_output(["bash", "-lc", "hostname -I"], text=True).strip()
|
||||
ips = re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", out)
|
||||
for ip in ips:
|
||||
if ip.startswith("127."):
|
||||
continue
|
||||
if ip.startswith("172.17."):
|
||||
continue
|
||||
if ip.startswith(("192.168.", "10.", "172.")):
|
||||
return f"http://{ip}:8000"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "http://<dev-machine-ip>:8000"
|
||||
@@ -1,43 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.db.session import engine
|
||||
from app.integrations.openclaw import OpenClawClient
|
||||
from app.models.org import Employee
|
||||
from app.models.projects import ProjectMember
|
||||
from app.models.work import Task, TaskComment
|
||||
|
||||
logger = logging.getLogger("app.notify")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NotifyContext:
|
||||
"""Notification context.
|
||||
|
||||
IMPORTANT: this is passed into FastAPI BackgroundTasks.
|
||||
Do not store live SQLAlchemy/SQLModel objects here; only ids/primitive data.
|
||||
"""
|
||||
|
||||
event: str # task.created | task.updated | task.assigned | comment.created | status.changed
|
||||
actor_employee_id: int
|
||||
task: Task
|
||||
comment: TaskComment | None = None
|
||||
task_id: int
|
||||
comment_id: int | None = None
|
||||
changed_fields: dict | None = None
|
||||
|
||||
|
||||
def _employee_session_keys(session: Session, employee_ids: Iterable[int]) -> list[str]:
|
||||
def _employees_with_session_keys(session: Session, employee_ids: Iterable[int]) -> list[Employee]:
|
||||
ids = sorted({i for i in employee_ids if i is not None})
|
||||
if not ids:
|
||||
return []
|
||||
|
||||
emps = session.exec(select(Employee).where(Employee.id.in_(ids))).all()
|
||||
keys: list[str] = []
|
||||
out: list[Employee] = []
|
||||
for e in emps:
|
||||
if not getattr(e, "notify_enabled", True):
|
||||
continue
|
||||
sk = getattr(e, "openclaw_session_key", None)
|
||||
if sk:
|
||||
keys.append(sk)
|
||||
return sorted(set(keys))
|
||||
if getattr(e, "openclaw_session_key", None):
|
||||
out.append(e)
|
||||
return out
|
||||
|
||||
|
||||
def _project_pm_employee_ids(session: Session, project_id: int) -> set[int]:
|
||||
# Generic, data-driven: PMs are determined by project_members.role.
|
||||
pms = session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id)).all()
|
||||
pm_ids: set[int] = set()
|
||||
for m in pms:
|
||||
@@ -47,89 +55,221 @@ def _project_pm_employee_ids(session: Session, project_id: int) -> set[int]:
|
||||
return pm_ids
|
||||
|
||||
|
||||
def resolve_recipients(session: Session, ctx: NotifyContext) -> set[int]:
|
||||
t = ctx.task
|
||||
def resolve_recipients(
|
||||
session: Session, ctx: NotifyContext, task: Task, comment: TaskComment | None
|
||||
) -> set[int]:
|
||||
recipients: set[int] = set()
|
||||
|
||||
if ctx.event == "task.created":
|
||||
# notify assignee + PMs
|
||||
if t.assignee_employee_id:
|
||||
recipients.add(t.assignee_employee_id)
|
||||
recipients |= _project_pm_employee_ids(session, t.project_id)
|
||||
if task.assignee_employee_id:
|
||||
recipients.add(task.assignee_employee_id)
|
||||
recipients |= _project_pm_employee_ids(session, task.project_id)
|
||||
|
||||
elif ctx.event == "task.assigned":
|
||||
if t.assignee_employee_id:
|
||||
recipients.add(t.assignee_employee_id)
|
||||
recipients |= _project_pm_employee_ids(session, t.project_id)
|
||||
if task.assignee_employee_id:
|
||||
recipients.add(task.assignee_employee_id)
|
||||
recipients |= _project_pm_employee_ids(session, task.project_id)
|
||||
|
||||
elif ctx.event == "comment.created":
|
||||
# notify assignee + reviewer + PMs, excluding author
|
||||
if t.assignee_employee_id:
|
||||
recipients.add(t.assignee_employee_id)
|
||||
if t.reviewer_employee_id:
|
||||
recipients.add(t.reviewer_employee_id)
|
||||
recipients |= _project_pm_employee_ids(session, t.project_id)
|
||||
if ctx.comment and ctx.comment.author_employee_id:
|
||||
recipients.discard(ctx.comment.author_employee_id)
|
||||
if task.assignee_employee_id:
|
||||
recipients.add(task.assignee_employee_id)
|
||||
if task.reviewer_employee_id:
|
||||
recipients.add(task.reviewer_employee_id)
|
||||
recipients |= _project_pm_employee_ids(session, task.project_id)
|
||||
if comment and comment.author_employee_id:
|
||||
recipients.discard(comment.author_employee_id)
|
||||
|
||||
elif ctx.event == "status.changed":
|
||||
new_status = (getattr(t, "status", None) or "").lower()
|
||||
if new_status in {"review", "ready_for_review"} and t.reviewer_employee_id:
|
||||
recipients.add(t.reviewer_employee_id)
|
||||
recipients |= _project_pm_employee_ids(session, t.project_id)
|
||||
new_status = (getattr(task, "status", None) or "").lower()
|
||||
if new_status in {"review", "ready_for_review"} and task.reviewer_employee_id:
|
||||
recipients.add(task.reviewer_employee_id)
|
||||
recipients |= _project_pm_employee_ids(session, task.project_id)
|
||||
|
||||
elif ctx.event == "task.updated":
|
||||
# conservative: PMs only
|
||||
recipients |= _project_pm_employee_ids(session, t.project_id)
|
||||
recipients |= _project_pm_employee_ids(session, task.project_id)
|
||||
|
||||
recipients.discard(ctx.actor_employee_id)
|
||||
return recipients
|
||||
|
||||
|
||||
def build_message(ctx: NotifyContext) -> str:
|
||||
t = ctx.task
|
||||
base = f"Task #{t.id}: {t.title}" if t.id is not None else f"Task: {t.title}"
|
||||
def ensure_employee_provisioned(session: Session, employee_id: int) -> None:
|
||||
"""Best-effort provisioning of a reviewer/manager so notifications can be delivered."""
|
||||
|
||||
if ctx.event == "task.assigned":
|
||||
return f"Assigned: {base}.\nWork ONE task only; update Mission Control with a comment when you make progress."
|
||||
emp = session.get(Employee, employee_id)
|
||||
if emp is None:
|
||||
return
|
||||
if not getattr(emp, "notify_enabled", True):
|
||||
return
|
||||
if getattr(emp, "openclaw_session_key", None):
|
||||
return
|
||||
|
||||
client = OpenClawClient.from_env()
|
||||
if client is None:
|
||||
logger.warning(
|
||||
"ensure_employee_provisioned: missing OpenClaw env", extra={"employee_id": employee_id}
|
||||
)
|
||||
return
|
||||
|
||||
prompt = (
|
||||
f"You are {emp.name} (employee_id={emp.id}).\n"
|
||||
"You are a reviewer/manager in Mission Control.\n"
|
||||
"When you get a review request, open Mission Control and approve or request changes.\n"
|
||||
)
|
||||
|
||||
try:
|
||||
res = client.tools_invoke(
|
||||
"sessions_spawn",
|
||||
{"task": prompt, "label": f"employee:{emp.id}:{emp.name}"},
|
||||
timeout_s=20.0,
|
||||
)
|
||||
details = (res.get("result") or {}).get("details") or {}
|
||||
sk = details.get("childSessionKey") or details.get("sessionKey")
|
||||
if sk:
|
||||
emp.openclaw_session_key = sk
|
||||
session.add(emp)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"ensure_employee_provisioned: provisioned",
|
||||
extra={"employee_id": emp.id, "session_key": sk},
|
||||
)
|
||||
except Exception:
|
||||
session.rollback()
|
||||
logger.exception("ensure_employee_provisioned: failed", extra={"employee_id": employee_id})
|
||||
|
||||
|
||||
def build_message(
|
||||
*,
|
||||
ctx: NotifyContext,
|
||||
task: Task,
|
||||
comment: TaskComment | None,
|
||||
recipient: Employee,
|
||||
base_url: str,
|
||||
) -> str:
|
||||
base = f"Task #{task.id}: {task.title}" if task.id is not None else f"Task: {task.title}"
|
||||
|
||||
if ctx.event in {"task.created", "task.assigned"} and recipient.employee_type == "agent":
|
||||
desc = (task.description or "").strip()
|
||||
if len(desc) > 500:
|
||||
desc = desc[:497] + "..."
|
||||
desc_block = f"\n\nDescription:\n{desc}" if desc else ""
|
||||
|
||||
return (
|
||||
f"{base}\n\n"
|
||||
f"Set BASE={base_url}\n\n"
|
||||
"You are the assignee. Start NOW (use the exec tool to run these curl commands):\n"
|
||||
f"1) curl -sS -X PATCH $BASE/tasks/{task.id} -H 'X-Actor-Employee-Id: {recipient.id}' -H 'Content-Type: application/json' -d '{{\"status\":\"in_progress\"}}'\n"
|
||||
f"2) curl -sS -X POST $BASE/task-comments -H 'X-Actor-Employee-Id: {recipient.id}' -H 'Content-Type: application/json' -d '{{\"task_id\":{task.id},\"body\":\"Plan: ... Next: ...\"}}'\n"
|
||||
"3) Do the work\n"
|
||||
"4) Post progress updates via POST $BASE/task-comments (same headers)\n"
|
||||
f"5) When complete: set status=review (assignee cannot set done) and wait for manager approval\n"
|
||||
f"{desc_block}"
|
||||
)
|
||||
|
||||
if ctx.event == "comment.created":
|
||||
snippet = ""
|
||||
if ctx.comment and ctx.comment.body:
|
||||
snippet = ctx.comment.body.strip().replace("\n", " ")
|
||||
if comment and comment.body:
|
||||
snippet = comment.body.strip().replace("\n", " ")
|
||||
if len(snippet) > 180:
|
||||
snippet = snippet[:177] + "..."
|
||||
snippet = f"\nComment: {snippet}"
|
||||
return f"New comment on {base}.{snippet}\nWork ONE task only; reply/update in Mission Control."
|
||||
return f"New comment on {base}.{snippet}\nPlease review and respond in Mission Control."
|
||||
|
||||
if ctx.event == "status.changed":
|
||||
return f"Status changed on {base} → {t.status}.\nWork ONE task only; update Mission Control with next step."
|
||||
new_status = (getattr(task, "status", None) or "").lower()
|
||||
if new_status in {"review", "ready_for_review"}:
|
||||
return (
|
||||
f"Review requested for {base}.\n"
|
||||
"As the reviewer/manager, you must:\n"
|
||||
"1) Read the task + latest assignee comments\n"
|
||||
"2) Decide: approve or request changes\n"
|
||||
"3) Leave an audit comment explaining your decision (required)\n"
|
||||
f"4) Submit decision via POST /tasks/{task.id}/review (decision=approve|changes)\n"
|
||||
"Approve → task becomes done. Changes → task returns to in_progress and assignee is notified."
|
||||
)
|
||||
return (
|
||||
f"Status changed on {base} → {task.status}.\n"
|
||||
"Please review and respond in Mission Control."
|
||||
)
|
||||
|
||||
if ctx.event == "task.created":
|
||||
return f"New task created: {base}.\nWork ONE task only; add acceptance criteria / next step in Mission Control."
|
||||
return f"New task created: {base}.\nPlease review and respond in Mission Control."
|
||||
|
||||
return f"Update on {base}.\nWork ONE task only; update Mission Control."
|
||||
if ctx.event == "task.assigned":
|
||||
return f"Assigned: {base}.\nPlease review and respond in Mission Control."
|
||||
|
||||
return f"Update on {base}.\nPlease review and respond in Mission Control."
|
||||
|
||||
|
||||
def notify_openclaw(session: Session, ctx: NotifyContext) -> None:
|
||||
def notify_openclaw(ctx: NotifyContext) -> None:
|
||||
"""Send OpenClaw notifications.
|
||||
|
||||
Runs in BackgroundTasks; opens its own DB session for safety.
|
||||
"""
|
||||
|
||||
client = OpenClawClient.from_env()
|
||||
logger.info(
|
||||
"notify_openclaw: start",
|
||||
extra={"event": ctx.event, "task_id": ctx.task_id, "actor": ctx.actor_employee_id},
|
||||
)
|
||||
if client is None:
|
||||
logger.warning("notify_openclaw: skipped (missing OpenClaw env)")
|
||||
return
|
||||
|
||||
recipient_ids = resolve_recipients(session, ctx)
|
||||
session_keys = _employee_session_keys(session, recipient_ids)
|
||||
if not session_keys:
|
||||
return
|
||||
with Session(engine) as session:
|
||||
task = session.get(Task, ctx.task_id)
|
||||
if task is None:
|
||||
logger.warning("notify_openclaw: task not found", extra={"task_id": ctx.task_id})
|
||||
return
|
||||
|
||||
message = build_message(ctx)
|
||||
comment = session.get(TaskComment, ctx.comment_id) if ctx.comment_id else None
|
||||
|
||||
for sk in session_keys:
|
||||
try:
|
||||
client.tools_invoke(
|
||||
"sessions_send",
|
||||
{"sessionKey": sk, "message": message},
|
||||
timeout_s=3.0,
|
||||
if ctx.event == "status.changed":
|
||||
new_status = (getattr(task, "status", None) or "").lower()
|
||||
if new_status in {"review", "ready_for_review"} and task.reviewer_employee_id:
|
||||
ensure_employee_provisioned(session, int(task.reviewer_employee_id))
|
||||
|
||||
recipient_ids = resolve_recipients(session, ctx, task, comment)
|
||||
logger.info(
|
||||
"notify_openclaw: recipients resolved", extra={"recipient_ids": sorted(recipient_ids)}
|
||||
)
|
||||
recipients = _employees_with_session_keys(session, recipient_ids)
|
||||
if not recipients:
|
||||
logger.info("notify_openclaw: no recipients with session keys")
|
||||
return
|
||||
|
||||
# base URL used in agent messages
|
||||
base_url = __import__(
|
||||
"app.core.urls", fromlist=["public_api_base_url"]
|
||||
).public_api_base_url()
|
||||
|
||||
for e in recipients:
|
||||
sk = getattr(e, "openclaw_session_key", None)
|
||||
if not sk:
|
||||
continue
|
||||
|
||||
message = build_message(
|
||||
ctx=ctx,
|
||||
task=task,
|
||||
comment=comment,
|
||||
recipient=e,
|
||||
base_url=base_url,
|
||||
)
|
||||
except Exception:
|
||||
# best-effort; never break Mission Control writes
|
||||
continue
|
||||
|
||||
try:
|
||||
client.tools_invoke(
|
||||
"sessions_send",
|
||||
{"sessionKey": sk, "message": message},
|
||||
timeout_s=30.0,
|
||||
)
|
||||
except Exception:
|
||||
# keep the log, but avoid giant stack spam unless debugging
|
||||
logger.warning(
|
||||
"notify_openclaw: sessions_send failed",
|
||||
extra={
|
||||
"event": ctx.event,
|
||||
"task_id": ctx.task_id,
|
||||
"to_employee_id": getattr(e, "id", None),
|
||||
"session_key": sk,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from requests.exceptions import ReadTimeout, RequestException
|
||||
|
||||
logger = logging.getLogger("app.openclaw")
|
||||
|
||||
|
||||
class OpenClawClient:
|
||||
@@ -13,22 +18,71 @@ class OpenClawClient:
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "OpenClawClient | None":
|
||||
# Ensure .env is loaded into os.environ (pydantic Settings reads env_file but
|
||||
# does not automatically populate os.environ).
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(override=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
url = os.environ.get("OPENCLAW_GATEWAY_URL")
|
||||
token = os.environ.get("OPENCLAW_GATEWAY_TOKEN")
|
||||
if not url or not token:
|
||||
return None
|
||||
return cls(url, token)
|
||||
|
||||
def tools_invoke(self, tool: str, args: dict[str, Any], *, session_key: str | None = None, timeout_s: float = 5.0) -> dict[str, Any]:
|
||||
def tools_invoke(
|
||||
self,
|
||||
tool: str,
|
||||
args: dict[str, Any],
|
||||
*,
|
||||
session_key: str | None = None,
|
||||
timeout_s: float = 10.0,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"tool": tool, "args": args}
|
||||
logger.info(
|
||||
"openclaw.tools_invoke",
|
||||
extra={"tool": tool, "has_session_key": bool(session_key), "timeout_s": timeout_s},
|
||||
)
|
||||
if session_key is not None:
|
||||
payload["sessionKey"] = session_key
|
||||
|
||||
r = requests.post(
|
||||
f"{self.base_url}/tools/invoke",
|
||||
headers={"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"},
|
||||
json=payload,
|
||||
timeout=timeout_s,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
last_err: Exception | None = None
|
||||
# Retry a few times; the gateway can be busy and respond slowly.
|
||||
for attempt in range(4):
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{self.base_url}/tools/invoke",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
# connect timeout, read timeout
|
||||
timeout=(2.0, timeout_s),
|
||||
)
|
||||
r.raise_for_status()
|
||||
logger.info(
|
||||
"openclaw.tools_invoke: ok",
|
||||
extra={"tool": tool, "status": r.status_code, "attempt": attempt + 1},
|
||||
)
|
||||
return r.json()
|
||||
except ReadTimeout as e:
|
||||
last_err = e
|
||||
logger.warning(
|
||||
"openclaw.tools_invoke: timeout",
|
||||
extra={"tool": tool, "attempt": attempt + 1, "timeout_s": timeout_s},
|
||||
)
|
||||
time.sleep(0.5 * (2**attempt))
|
||||
except RequestException as e:
|
||||
last_err = e
|
||||
logger.warning(
|
||||
"openclaw.tools_invoke: request error",
|
||||
extra={"tool": tool, "attempt": attempt + 1, "error": str(e)},
|
||||
)
|
||||
time.sleep(0.5 * (2**attempt))
|
||||
|
||||
assert last_err is not None
|
||||
raise last_err
|
||||
|
||||
@@ -8,8 +8,11 @@ from app.api.org import router as org_router
|
||||
from app.api.projects import router as projects_router
|
||||
from app.api.work import router as work_router
|
||||
from app.core.config import settings
|
||||
from app.core.logging import configure_logging
|
||||
from app.db.session import init_db
|
||||
|
||||
configure_logging()
|
||||
|
||||
app = FastAPI(title="OpenClaw Agency API", version="0.3.0")
|
||||
|
||||
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from app.models.activity import Activity
|
||||
from app.models.org import Department, Team, Employee
|
||||
from app.models.org import Department, Employee, Team
|
||||
from app.models.projects import Project, ProjectMember
|
||||
from app.models.work import Task, TaskComment
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
|
||||
@@ -26,3 +26,8 @@ class TaskCommentCreate(SQLModel):
|
||||
author_employee_id: int | None = None
|
||||
reply_to_comment_id: int | None = None
|
||||
body: str
|
||||
|
||||
|
||||
class TaskReviewDecision(SQLModel):
|
||||
decision: str # approve | changes
|
||||
comment_body: str
|
||||
|
||||
9
backend/pyproject.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ["py312"]
|
||||
extend-exclude = '(\.venv|alembic/versions)'
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
line_length = 100
|
||||
skip = [".venv", "alembic/versions"]
|
||||
4
backend/requirements-dev.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
black==24.10.0
|
||||
isort==5.13.2
|
||||
flake8==7.1.1
|
||||
pre-commit==4.1.0
|
||||
@@ -2,7 +2,7 @@ fastapi
|
||||
uvicorn[standard]
|
||||
sqlmodel
|
||||
alembic
|
||||
psycopg2-binary
|
||||
psycopg[binary]
|
||||
python-dotenv
|
||||
pydantic-settings
|
||||
requests
|
||||
|
||||
37
backend/scripts/README_seed.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# DB reset + seed (dev-machine)
|
||||
|
||||
This repo uses Alembic migrations as schema source-of-truth.
|
||||
|
||||
## Reset to the current seed
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
./scripts/reset_db.sh
|
||||
```
|
||||
|
||||
Environment variables (optional):
|
||||
|
||||
- `DB_NAME` (default `openclaw_agency`)
|
||||
- `DB_USER` (default `postgres`)
|
||||
- `DB_HOST` (default `127.0.0.1`)
|
||||
- `DB_PORT` (default `5432`)
|
||||
- `DB_PASSWORD` (default `postgres`)
|
||||
|
||||
## Updating the seed
|
||||
|
||||
The seed is a **data-only** dump (not schema). Regenerate it from the current DB state:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
PGPASSWORD=postgres pg_dump \
|
||||
--data-only \
|
||||
--column-inserts \
|
||||
--disable-triggers \
|
||||
--no-owner \
|
||||
--no-privileges \
|
||||
-U postgres -h 127.0.0.1 -d openclaw_agency \
|
||||
> scripts/seed_data.sql
|
||||
|
||||
# IMPORTANT: do not include alembic_version in the seed (migrations already set it)
|
||||
# (our committed seed already has this removed)
|
||||
```
|
||||
9
backend/scripts/lint.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
. .venv/bin/activate
|
||||
|
||||
python -m black .
|
||||
python -m isort .
|
||||
python -m flake8 .
|
||||
55
backend/scripts/reset_db.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DB_NAME=${DB_NAME:-openclaw_agency}
|
||||
DB_USER=${DB_USER:-postgres}
|
||||
DB_HOST=${DB_HOST:-127.0.0.1}
|
||||
DB_PORT=${DB_PORT:-5432}
|
||||
|
||||
# Never hardcode passwords in git. Prefer:
|
||||
# - DB_PASSWORD env var, or
|
||||
# - infer from backend/.env DATABASE_URL
|
||||
DB_PASSWORD=${DB_PASSWORD:-}
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if [[ -z "${DB_PASSWORD}" ]] && [[ -f .env ]]; then
|
||||
DB_PASSWORD=$(python3 - <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
def parse_database_url(url: str) -> str:
|
||||
# supports postgresql+psycopg://user:pass@host:port/db
|
||||
u = urlparse(url)
|
||||
return u.password or ""
|
||||
|
||||
for line in Path('.env').read_text().splitlines():
|
||||
if line.startswith('DATABASE_URL='):
|
||||
print(parse_database_url(line.split('=',1)[1].strip()))
|
||||
break
|
||||
PY
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ -z "${DB_PASSWORD}" ]]; then
|
||||
echo "ERROR: DB_PASSWORD not set and could not infer it from backend/.env DATABASE_URL" >&2
|
||||
echo "Set DB_PASSWORD=... or create backend/.env with DATABASE_URL" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
export PGPASSWORD="$DB_PASSWORD"
|
||||
|
||||
# 1) wipe schema
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 \
|
||||
-c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
|
||||
|
||||
# 2) migrate
|
||||
. .venv/bin/activate
|
||||
alembic upgrade head
|
||||
|
||||
# 3) seed
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 \
|
||||
-f scripts/seed_data.sql
|
||||
|
||||
echo "Reset complete: $DB_USER@$DB_HOST:$DB_PORT/$DB_NAME"
|
||||
50
backend/scripts/seed_data.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
-- Mission Control seed data (minimal)
|
||||
-- Keep this data-only seed small and deterministic.
|
||||
-- NOTE: Do NOT include alembic_version here; migrations manage it.
|
||||
|
||||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
-- Disable triggers to avoid FK ordering issues during seed.
|
||||
ALTER TABLE public.employees DISABLE TRIGGER ALL;
|
||||
ALTER TABLE public.departments DISABLE TRIGGER ALL;
|
||||
ALTER TABLE public.teams DISABLE TRIGGER ALL;
|
||||
ALTER TABLE public.projects DISABLE TRIGGER ALL;
|
||||
ALTER TABLE public.tasks DISABLE TRIGGER ALL;
|
||||
ALTER TABLE public.task_comments DISABLE TRIGGER ALL;
|
||||
ALTER TABLE public.project_members DISABLE TRIGGER ALL;
|
||||
ALTER TABLE public.activities DISABLE TRIGGER ALL;
|
||||
|
||||
-- Employees (keep only Abhimanyu)
|
||||
INSERT INTO public.employees (id, name, employee_type, department_id, manager_id, title, status, openclaw_session_key, notify_enabled, team_id)
|
||||
VALUES
|
||||
(2, 'Abhimanyu', 'human', NULL, NULL, 'CEO', 'active', NULL, false, NULL)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
employee_type = EXCLUDED.employee_type,
|
||||
department_id = EXCLUDED.department_id,
|
||||
manager_id = EXCLUDED.manager_id,
|
||||
title = EXCLUDED.title,
|
||||
status = EXCLUDED.status,
|
||||
openclaw_session_key = EXCLUDED.openclaw_session_key,
|
||||
notify_enabled = EXCLUDED.notify_enabled,
|
||||
team_id = EXCLUDED.team_id;
|
||||
|
||||
-- Fix sequences (avoid PK reuse after explicit ids)
|
||||
SELECT setval('employees_id_seq', (SELECT COALESCE(max(id), 1) FROM public.employees));
|
||||
SELECT setval('departments_id_seq', (SELECT COALESCE(max(id), 1) FROM public.departments));
|
||||
SELECT setval('teams_id_seq', (SELECT COALESCE(max(id), 1) FROM public.teams));
|
||||
SELECT setval('projects_id_seq', (SELECT COALESCE(max(id), 1) FROM public.projects));
|
||||
SELECT setval('tasks_id_seq', (SELECT COALESCE(max(id), 1) FROM public.tasks));
|
||||
SELECT setval('task_comments_id_seq', (SELECT COALESCE(max(id), 1) FROM public.task_comments));
|
||||
SELECT setval('project_members_id_seq', (SELECT COALESCE(max(id), 1) FROM public.project_members));
|
||||
SELECT setval('activities_id_seq', (SELECT COALESCE(max(id), 1) FROM public.activities));
|
||||
|
||||
ALTER TABLE public.employees ENABLE TRIGGER ALL;
|
||||
ALTER TABLE public.departments ENABLE TRIGGER ALL;
|
||||
ALTER TABLE public.teams ENABLE TRIGGER ALL;
|
||||
ALTER TABLE public.projects ENABLE TRIGGER ALL;
|
||||
ALTER TABLE public.tasks ENABLE TRIGGER ALL;
|
||||
ALTER TABLE public.task_comments ENABLE TRIGGER ALL;
|
||||
ALTER TABLE public.project_members ENABLE TRIGGER ALL;
|
||||
ALTER TABLE public.activities ENABLE TRIGGER ALL;
|
||||
@@ -1 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 392 B |
@@ -1 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -1 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 128 B After Width: | Height: | Size: 129 B |
@@ -1 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 386 B |
@@ -10,6 +10,7 @@ export interface Employee {
|
||||
name: string;
|
||||
employee_type: string;
|
||||
department_id?: number | null;
|
||||
team_id?: number | null;
|
||||
manager_id?: number | null;
|
||||
title?: string | null;
|
||||
status?: string;
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface EmployeeCreate {
|
||||
name: string;
|
||||
employee_type: string;
|
||||
department_id?: number | null;
|
||||
team_id?: number | null;
|
||||
manager_id?: number | null;
|
||||
title?: string | null;
|
||||
status?: string;
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface EmployeeUpdate {
|
||||
name?: string | null;
|
||||
employee_type?: string | null;
|
||||
department_id?: number | null;
|
||||
team_id?: number | null;
|
||||
manager_id?: number | null;
|
||||
title?: string | null;
|
||||
status?: string | null;
|
||||
|
||||
@@ -23,6 +23,7 @@ export * from "./hTTPValidationError";
|
||||
export * from "./listActivitiesActivitiesGetParams";
|
||||
export * from "./listTaskCommentsTaskCommentsGetParams";
|
||||
export * from "./listTasksTasksGetParams";
|
||||
export * from "./listTeamsTeamsGetParams";
|
||||
export * from "./project";
|
||||
export * from "./projectCreate";
|
||||
export * from "./projectMember";
|
||||
@@ -31,5 +32,9 @@ export * from "./task";
|
||||
export * from "./taskComment";
|
||||
export * from "./taskCommentCreate";
|
||||
export * from "./taskCreate";
|
||||
export * from "./taskReviewDecision";
|
||||
export * from "./taskUpdate";
|
||||
export * from "./team";
|
||||
export * from "./teamCreate";
|
||||
export * from "./teamUpdate";
|
||||
export * from "./validationError";
|
||||
|
||||
10
frontend/src/api/generated/model/listTeamsTeamsGetParams.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Generated by orval v8.2.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenClaw Agency API
|
||||
* OpenAPI spec version: 0.3.0
|
||||
*/
|
||||
|
||||
export type ListTeamsTeamsGetParams = {
|
||||
department_id?: number | null;
|
||||
};
|
||||
@@ -9,4 +9,5 @@ export interface Project {
|
||||
id?: number | null;
|
||||
name: string;
|
||||
status?: string;
|
||||
team_id?: number | null;
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@
|
||||
export interface ProjectCreate {
|
||||
name: string;
|
||||
status?: string;
|
||||
team_id?: number | null;
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@
|
||||
export interface ProjectUpdate {
|
||||
name?: string | null;
|
||||
status?: string | null;
|
||||
team_id?: number | null;
|
||||
}
|
||||
|
||||
11
frontend/src/api/generated/model/taskReviewDecision.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Generated by orval v8.2.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenClaw Agency API
|
||||
* OpenAPI spec version: 0.3.0
|
||||
*/
|
||||
|
||||
export interface TaskReviewDecision {
|
||||
decision: string;
|
||||
comment_body: string;
|
||||
}
|
||||
13
frontend/src/api/generated/model/team.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval v8.2.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenClaw Agency API
|
||||
* OpenAPI spec version: 0.3.0
|
||||
*/
|
||||
|
||||
export interface Team {
|
||||
id?: number | null;
|
||||
name: string;
|
||||
department_id: number;
|
||||
lead_employee_id?: number | null;
|
||||
}
|
||||
12
frontend/src/api/generated/model/teamCreate.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.2.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenClaw Agency API
|
||||
* OpenAPI spec version: 0.3.0
|
||||
*/
|
||||
|
||||
export interface TeamCreate {
|
||||
name: string;
|
||||
department_id: number;
|
||||
lead_employee_id?: number | null;
|
||||
}
|
||||
12
frontend/src/api/generated/model/teamUpdate.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.2.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenClaw Agency API
|
||||
* OpenAPI spec version: 0.3.0
|
||||
*/
|
||||
|
||||
export interface TeamUpdate {
|
||||
name?: string | null;
|
||||
department_id?: number | null;
|
||||
lead_employee_id?: number | null;
|
||||
}
|
||||
@@ -28,6 +28,10 @@ import type {
|
||||
EmployeeCreate,
|
||||
EmployeeUpdate,
|
||||
HTTPValidationError,
|
||||
ListTeamsTeamsGetParams,
|
||||
Team,
|
||||
TeamCreate,
|
||||
TeamUpdate,
|
||||
} from ".././model";
|
||||
|
||||
import { customFetch } from "../../mutator";
|
||||
@@ -327,6 +331,440 @@ export const useCreateDepartmentDepartmentsPost = <
|
||||
queryClient,
|
||||
);
|
||||
};
|
||||
/**
|
||||
* @summary List Teams
|
||||
*/
|
||||
export type listTeamsTeamsGetResponse200 = {
|
||||
data: Team[];
|
||||
status: 200;
|
||||
};
|
||||
|
||||
export type listTeamsTeamsGetResponse422 = {
|
||||
data: HTTPValidationError;
|
||||
status: 422;
|
||||
};
|
||||
|
||||
export type listTeamsTeamsGetResponseSuccess = listTeamsTeamsGetResponse200 & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type listTeamsTeamsGetResponseError = listTeamsTeamsGetResponse422 & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type listTeamsTeamsGetResponse =
|
||||
| listTeamsTeamsGetResponseSuccess
|
||||
| listTeamsTeamsGetResponseError;
|
||||
|
||||
export const getListTeamsTeamsGetUrl = (params?: ListTeamsTeamsGetParams) => {
|
||||
const normalizedParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
normalizedParams.append(key, value === null ? "null" : value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const stringifiedParams = normalizedParams.toString();
|
||||
|
||||
return stringifiedParams.length > 0
|
||||
? `/teams?${stringifiedParams}`
|
||||
: `/teams`;
|
||||
};
|
||||
|
||||
export const listTeamsTeamsGet = async (
|
||||
params?: ListTeamsTeamsGetParams,
|
||||
options?: RequestInit,
|
||||
): Promise<listTeamsTeamsGetResponse> => {
|
||||
return customFetch<listTeamsTeamsGetResponse>(
|
||||
getListTeamsTeamsGetUrl(params),
|
||||
{
|
||||
...options,
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getListTeamsTeamsGetQueryKey = (
|
||||
params?: ListTeamsTeamsGetParams,
|
||||
) => {
|
||||
return [`/teams`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListTeamsTeamsGetQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listTeamsTeamsGet>>,
|
||||
TError = HTTPValidationError,
|
||||
>(
|
||||
params?: ListTeamsTeamsGetParams,
|
||||
options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListTeamsTeamsGetQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listTeamsTeamsGet>>
|
||||
> = ({ signal }) => listTeamsTeamsGet(params, { signal, ...requestOptions });
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
};
|
||||
|
||||
export type ListTeamsTeamsGetQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listTeamsTeamsGet>>
|
||||
>;
|
||||
export type ListTeamsTeamsGetQueryError = HTTPValidationError;
|
||||
|
||||
export function useListTeamsTeamsGet<
|
||||
TData = Awaited<ReturnType<typeof listTeamsTeamsGet>>,
|
||||
TError = HTTPValidationError,
|
||||
>(
|
||||
params: undefined | ListTeamsTeamsGetParams,
|
||||
options: {
|
||||
query: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
> &
|
||||
Pick<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof listTeamsTeamsGet>>
|
||||
>,
|
||||
"initialData"
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): DefinedUseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
};
|
||||
export function useListTeamsTeamsGet<
|
||||
TData = Awaited<ReturnType<typeof listTeamsTeamsGet>>,
|
||||
TError = HTTPValidationError,
|
||||
>(
|
||||
params?: ListTeamsTeamsGetParams,
|
||||
options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
> &
|
||||
Pick<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof listTeamsTeamsGet>>
|
||||
>,
|
||||
"initialData"
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
};
|
||||
export function useListTeamsTeamsGet<
|
||||
TData = Awaited<ReturnType<typeof listTeamsTeamsGet>>,
|
||||
TError = HTTPValidationError,
|
||||
>(
|
||||
params?: ListTeamsTeamsGetParams,
|
||||
options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
};
|
||||
/**
|
||||
* @summary List Teams
|
||||
*/
|
||||
|
||||
export function useListTeamsTeamsGet<
|
||||
TData = Awaited<ReturnType<typeof listTeamsTeamsGet>>,
|
||||
TError = HTTPValidationError,
|
||||
>(
|
||||
params?: ListTeamsTeamsGetParams,
|
||||
options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listTeamsTeamsGet>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
} {
|
||||
const queryOptions = getListTeamsTeamsGetQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
|
||||
TData,
|
||||
TError
|
||||
> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Create Team
|
||||
*/
|
||||
export type createTeamTeamsPostResponse200 = {
|
||||
data: Team;
|
||||
status: 200;
|
||||
};
|
||||
|
||||
export type createTeamTeamsPostResponse422 = {
|
||||
data: HTTPValidationError;
|
||||
status: 422;
|
||||
};
|
||||
|
||||
export type createTeamTeamsPostResponseSuccess =
|
||||
createTeamTeamsPostResponse200 & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type createTeamTeamsPostResponseError =
|
||||
createTeamTeamsPostResponse422 & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type createTeamTeamsPostResponse =
|
||||
| createTeamTeamsPostResponseSuccess
|
||||
| createTeamTeamsPostResponseError;
|
||||
|
||||
export const getCreateTeamTeamsPostUrl = () => {
|
||||
return `/teams`;
|
||||
};
|
||||
|
||||
export const createTeamTeamsPost = async (
|
||||
teamCreate: TeamCreate,
|
||||
options?: RequestInit,
|
||||
): Promise<createTeamTeamsPostResponse> => {
|
||||
return customFetch<createTeamTeamsPostResponse>(getCreateTeamTeamsPostUrl(), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(teamCreate),
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateTeamTeamsPostMutationOptions = <
|
||||
TError = HTTPValidationError,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createTeamTeamsPost>>,
|
||||
TError,
|
||||
{ data: TeamCreate },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createTeamTeamsPost>>,
|
||||
TError,
|
||||
{ data: TeamCreate },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["createTeamTeamsPost"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createTeamTeamsPost>>,
|
||||
{ data: TeamCreate }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createTeamTeamsPost(data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateTeamTeamsPostMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createTeamTeamsPost>>
|
||||
>;
|
||||
export type CreateTeamTeamsPostMutationBody = TeamCreate;
|
||||
export type CreateTeamTeamsPostMutationError = HTTPValidationError;
|
||||
|
||||
/**
|
||||
* @summary Create Team
|
||||
*/
|
||||
export const useCreateTeamTeamsPost = <
|
||||
TError = HTTPValidationError,
|
||||
TContext = unknown,
|
||||
>(
|
||||
options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createTeamTeamsPost>>,
|
||||
TError,
|
||||
{ data: TeamCreate },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createTeamTeamsPost>>,
|
||||
TError,
|
||||
{ data: TeamCreate },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getCreateTeamTeamsPostMutationOptions(options),
|
||||
queryClient,
|
||||
);
|
||||
};
|
||||
/**
|
||||
* @summary Update Team
|
||||
*/
|
||||
export type updateTeamTeamsTeamIdPatchResponse200 = {
|
||||
data: Team;
|
||||
status: 200;
|
||||
};
|
||||
|
||||
export type updateTeamTeamsTeamIdPatchResponse422 = {
|
||||
data: HTTPValidationError;
|
||||
status: 422;
|
||||
};
|
||||
|
||||
export type updateTeamTeamsTeamIdPatchResponseSuccess =
|
||||
updateTeamTeamsTeamIdPatchResponse200 & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type updateTeamTeamsTeamIdPatchResponseError =
|
||||
updateTeamTeamsTeamIdPatchResponse422 & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type updateTeamTeamsTeamIdPatchResponse =
|
||||
| updateTeamTeamsTeamIdPatchResponseSuccess
|
||||
| updateTeamTeamsTeamIdPatchResponseError;
|
||||
|
||||
export const getUpdateTeamTeamsTeamIdPatchUrl = (teamId: number) => {
|
||||
return `/teams/${teamId}`;
|
||||
};
|
||||
|
||||
export const updateTeamTeamsTeamIdPatch = async (
|
||||
teamId: number,
|
||||
teamUpdate: TeamUpdate,
|
||||
options?: RequestInit,
|
||||
): Promise<updateTeamTeamsTeamIdPatchResponse> => {
|
||||
return customFetch<updateTeamTeamsTeamIdPatchResponse>(
|
||||
getUpdateTeamTeamsTeamIdPatchUrl(teamId),
|
||||
{
|
||||
...options,
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(teamUpdate),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getUpdateTeamTeamsTeamIdPatchMutationOptions = <
|
||||
TError = HTTPValidationError,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateTeamTeamsTeamIdPatch>>,
|
||||
TError,
|
||||
{ teamId: number; data: TeamUpdate },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateTeamTeamsTeamIdPatch>>,
|
||||
TError,
|
||||
{ teamId: number; data: TeamUpdate },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["updateTeamTeamsTeamIdPatch"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateTeamTeamsTeamIdPatch>>,
|
||||
{ teamId: number; data: TeamUpdate }
|
||||
> = (props) => {
|
||||
const { teamId, data } = props ?? {};
|
||||
|
||||
return updateTeamTeamsTeamIdPatch(teamId, data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateTeamTeamsTeamIdPatchMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateTeamTeamsTeamIdPatch>>
|
||||
>;
|
||||
export type UpdateTeamTeamsTeamIdPatchMutationBody = TeamUpdate;
|
||||
export type UpdateTeamTeamsTeamIdPatchMutationError = HTTPValidationError;
|
||||
|
||||
/**
|
||||
* @summary Update Team
|
||||
*/
|
||||
export const useUpdateTeamTeamsTeamIdPatch = <
|
||||
TError = HTTPValidationError,
|
||||
TContext = unknown,
|
||||
>(
|
||||
options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateTeamTeamsTeamIdPatch>>,
|
||||
TError,
|
||||
{ teamId: number; data: TeamUpdate },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateTeamTeamsTeamIdPatch>>,
|
||||
TError,
|
||||
{ teamId: number; data: TeamUpdate },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getUpdateTeamTeamsTeamIdPatchMutationOptions(options),
|
||||
queryClient,
|
||||
);
|
||||
};
|
||||
/**
|
||||
* @summary Update Department
|
||||
*/
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
TaskComment,
|
||||
TaskCommentCreate,
|
||||
TaskCreate,
|
||||
TaskReviewDecision,
|
||||
TaskUpdate,
|
||||
} from ".././model";
|
||||
|
||||
@@ -351,6 +352,246 @@ export const useCreateTaskTasksPost = <
|
||||
queryClient,
|
||||
);
|
||||
};
|
||||
/**
|
||||
* @summary Dispatch Task
|
||||
*/
|
||||
export type dispatchTaskTasksTaskIdDispatchPostResponse200 = {
|
||||
data: unknown;
|
||||
status: 200;
|
||||
};
|
||||
|
||||
export type dispatchTaskTasksTaskIdDispatchPostResponse422 = {
|
||||
data: HTTPValidationError;
|
||||
status: 422;
|
||||
};
|
||||
|
||||
export type dispatchTaskTasksTaskIdDispatchPostResponseSuccess =
|
||||
dispatchTaskTasksTaskIdDispatchPostResponse200 & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type dispatchTaskTasksTaskIdDispatchPostResponseError =
|
||||
dispatchTaskTasksTaskIdDispatchPostResponse422 & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type dispatchTaskTasksTaskIdDispatchPostResponse =
|
||||
| dispatchTaskTasksTaskIdDispatchPostResponseSuccess
|
||||
| dispatchTaskTasksTaskIdDispatchPostResponseError;
|
||||
|
||||
export const getDispatchTaskTasksTaskIdDispatchPostUrl = (taskId: number) => {
|
||||
return `/tasks/${taskId}/dispatch`;
|
||||
};
|
||||
|
||||
export const dispatchTaskTasksTaskIdDispatchPost = async (
|
||||
taskId: number,
|
||||
options?: RequestInit,
|
||||
): Promise<dispatchTaskTasksTaskIdDispatchPostResponse> => {
|
||||
return customFetch<dispatchTaskTasksTaskIdDispatchPostResponse>(
|
||||
getDispatchTaskTasksTaskIdDispatchPostUrl(taskId),
|
||||
{
|
||||
...options,
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getDispatchTaskTasksTaskIdDispatchPostMutationOptions = <
|
||||
TError = HTTPValidationError,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>,
|
||||
TError,
|
||||
{ taskId: number },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>,
|
||||
TError,
|
||||
{ taskId: number },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["dispatchTaskTasksTaskIdDispatchPost"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>,
|
||||
{ taskId: number }
|
||||
> = (props) => {
|
||||
const { taskId } = props ?? {};
|
||||
|
||||
return dispatchTaskTasksTaskIdDispatchPost(taskId, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DispatchTaskTasksTaskIdDispatchPostMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>
|
||||
>;
|
||||
|
||||
export type DispatchTaskTasksTaskIdDispatchPostMutationError =
|
||||
HTTPValidationError;
|
||||
|
||||
/**
|
||||
* @summary Dispatch Task
|
||||
*/
|
||||
export const useDispatchTaskTasksTaskIdDispatchPost = <
|
||||
TError = HTTPValidationError,
|
||||
TContext = unknown,
|
||||
>(
|
||||
options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>,
|
||||
TError,
|
||||
{ taskId: number },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseMutationResult<
|
||||
Awaited<ReturnType<typeof dispatchTaskTasksTaskIdDispatchPost>>,
|
||||
TError,
|
||||
{ taskId: number },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getDispatchTaskTasksTaskIdDispatchPostMutationOptions(options),
|
||||
queryClient,
|
||||
);
|
||||
};
|
||||
/**
|
||||
* Reviewer approves or requests changes.
|
||||
|
||||
- Approve => status=done
|
||||
- Changes => status=in_progress
|
||||
|
||||
Always writes a TaskComment by the reviewer for audit.
|
||||
* @summary Review Task
|
||||
*/
|
||||
export type reviewTaskTasksTaskIdReviewPostResponse200 = {
|
||||
data: Task;
|
||||
status: 200;
|
||||
};
|
||||
|
||||
export type reviewTaskTasksTaskIdReviewPostResponse422 = {
|
||||
data: HTTPValidationError;
|
||||
status: 422;
|
||||
};
|
||||
|
||||
export type reviewTaskTasksTaskIdReviewPostResponseSuccess =
|
||||
reviewTaskTasksTaskIdReviewPostResponse200 & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type reviewTaskTasksTaskIdReviewPostResponseError =
|
||||
reviewTaskTasksTaskIdReviewPostResponse422 & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type reviewTaskTasksTaskIdReviewPostResponse =
|
||||
| reviewTaskTasksTaskIdReviewPostResponseSuccess
|
||||
| reviewTaskTasksTaskIdReviewPostResponseError;
|
||||
|
||||
export const getReviewTaskTasksTaskIdReviewPostUrl = (taskId: number) => {
|
||||
return `/tasks/${taskId}/review`;
|
||||
};
|
||||
|
||||
export const reviewTaskTasksTaskIdReviewPost = async (
|
||||
taskId: number,
|
||||
taskReviewDecision: TaskReviewDecision,
|
||||
options?: RequestInit,
|
||||
): Promise<reviewTaskTasksTaskIdReviewPostResponse> => {
|
||||
return customFetch<reviewTaskTasksTaskIdReviewPostResponse>(
|
||||
getReviewTaskTasksTaskIdReviewPostUrl(taskId),
|
||||
{
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(taskReviewDecision),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getReviewTaskTasksTaskIdReviewPostMutationOptions = <
|
||||
TError = HTTPValidationError,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
|
||||
TError,
|
||||
{ taskId: number; data: TaskReviewDecision },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
|
||||
TError,
|
||||
{ taskId: number; data: TaskReviewDecision },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["reviewTaskTasksTaskIdReviewPost"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
|
||||
{ taskId: number; data: TaskReviewDecision }
|
||||
> = (props) => {
|
||||
const { taskId, data } = props ?? {};
|
||||
|
||||
return reviewTaskTasksTaskIdReviewPost(taskId, data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ReviewTaskTasksTaskIdReviewPostMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>
|
||||
>;
|
||||
export type ReviewTaskTasksTaskIdReviewPostMutationBody = TaskReviewDecision;
|
||||
export type ReviewTaskTasksTaskIdReviewPostMutationError = HTTPValidationError;
|
||||
|
||||
/**
|
||||
* @summary Review Task
|
||||
*/
|
||||
export const useReviewTaskTasksTaskIdReviewPost = <
|
||||
TError = HTTPValidationError,
|
||||
TContext = unknown,
|
||||
>(
|
||||
options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
|
||||
TError,
|
||||
{ taskId: number; data: TaskReviewDecision },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseMutationResult<
|
||||
Awaited<ReturnType<typeof reviewTaskTasksTaskIdReviewPost>>,
|
||||
TError,
|
||||
{ taskId: number; data: TaskReviewDecision },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getReviewTaskTasksTaskIdReviewPostMutationOptions(options),
|
||||
queryClient,
|
||||
);
|
||||
};
|
||||
/**
|
||||
* @summary Update Task
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,7 @@ const NAV = [
|
||||
{ href: "/projects", label: "Projects" },
|
||||
{ href: "/kanban", label: "Kanban" },
|
||||
{ href: "/departments", label: "Departments" },
|
||||
{ href: "/teams", label: "Teams" },
|
||||
{ href: "/people", label: "People" },
|
||||
];
|
||||
|
||||
|
||||
@@ -13,28 +13,58 @@ import {
|
||||
useCreateEmployeeEmployeesPost,
|
||||
useListDepartmentsDepartmentsGet,
|
||||
useListEmployeesEmployeesGet,
|
||||
useListTeamsTeamsGet,
|
||||
useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost,
|
||||
useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost,
|
||||
} from "@/api/generated/org/org";
|
||||
|
||||
export default function PeoplePage() {
|
||||
const [actorId] = useState(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
try {
|
||||
return window.localStorage.getItem("actor_employee_id") ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
const [name, setName] = useState("");
|
||||
const [employeeType, setEmployeeType] = useState<"human" | "agent">("human");
|
||||
const [title, setTitle] = useState("");
|
||||
const [departmentId, setDepartmentId] = useState<string>("");
|
||||
const [teamId, setTeamId] = useState<string>("");
|
||||
const [managerId, setManagerId] = useState<string>("");
|
||||
|
||||
const employees = useListEmployeesEmployeesGet();
|
||||
const departments = useListDepartmentsDepartmentsGet();
|
||||
const teams = useListTeamsTeamsGet({ department_id: undefined });
|
||||
const departmentList = useMemo(() => (departments.data?.status === 200 ? departments.data.data : []), [departments.data]);
|
||||
const employeeList = useMemo(() => (employees.data?.status === 200 ? employees.data.data : []), [employees.data]);
|
||||
const teamList = useMemo(() => (teams.data?.status === 200 ? teams.data.data : []), [teams.data]);
|
||||
|
||||
const provisionEmployee = useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost();
|
||||
const deprovisionEmployee = useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost();
|
||||
|
||||
const createEmployee = useCreateEmployeeEmployeesPost({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
onSuccess: async (res) => {
|
||||
setName("");
|
||||
setTitle("");
|
||||
setDepartmentId("");
|
||||
setTeamId("");
|
||||
setManagerId("");
|
||||
|
||||
// If an agent was created but not yet provisioned, provision immediately so it can receive tasks.
|
||||
try {
|
||||
const e = (res as any)?.data?.data ?? (res as any)?.data ?? null;
|
||||
if (e?.employee_type === "agent" && !e.openclaw_session_key) {
|
||||
await provisionEmployee.mutateAsync({ employeeId: e.id! });
|
||||
}
|
||||
} catch {
|
||||
// ignore; UI will show unprovisioned state
|
||||
}
|
||||
|
||||
employees.refetch();
|
||||
teams.refetch();
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -47,6 +77,14 @@ export default function PeoplePage() {
|
||||
return m;
|
||||
}, [departmentList]);
|
||||
|
||||
const teamNameById = useMemo(() => {
|
||||
const m = new Map<number, string>();
|
||||
for (const t of teamList) {
|
||||
if (t.id != null) m.set(t.id, t.name);
|
||||
}
|
||||
return m;
|
||||
}, [teamList]);
|
||||
|
||||
const empNameById = useMemo(() => {
|
||||
const m = new Map<number, string>();
|
||||
for (const e of employeeList) {
|
||||
@@ -88,6 +126,14 @@ export default function PeoplePage() {
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select value={teamId} onChange={(e) => setTeamId(e.target.value)}>
|
||||
<option value="">(no team)</option>
|
||||
{teamList.map((t) => (
|
||||
<option key={t.id ?? t.name} value={t.id ?? ""}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select value={managerId} onChange={(e) => setManagerId(e.target.value)}>
|
||||
<option value="">(no manager)</option>
|
||||
{employeeList.map((e) => (
|
||||
@@ -104,6 +150,7 @@ export default function PeoplePage() {
|
||||
employee_type: employeeType,
|
||||
title: title.trim() ? title : null,
|
||||
department_id: departmentId ? Number(departmentId) : null,
|
||||
team_id: teamId ? Number(teamId) : null,
|
||||
manager_id: managerId ? Number(managerId) : null,
|
||||
status: "active",
|
||||
},
|
||||
@@ -142,6 +189,7 @@ export default function PeoplePage() {
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{e.title ? <span>{e.title} · </span> : null}
|
||||
{e.department_id ? <span>{deptNameById.get(e.department_id) ?? `Dept#${e.department_id}`} · </span> : null}
|
||||
{e.team_id ? <span>Team: {teamNameById.get(e.team_id) ?? `Team#${e.team_id}`} · </span> : null}
|
||||
{e.manager_id ? <span>Mgr: {empNameById.get(e.manager_id) ?? `Emp#${e.manager_id}`}</span> : <span>No manager</span>}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -4,7 +4,13 @@ import { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { Select } from "@/components/ui/select";
|
||||
@@ -14,15 +20,16 @@ import { useListProjectsProjectsGet } from "@/api/generated/projects/projects";
|
||||
import { useListEmployeesEmployeesGet } from "@/api/generated/org/org";
|
||||
import {
|
||||
useCreateTaskTasksPost,
|
||||
useDeleteTaskTasksTaskIdDelete,
|
||||
useDispatchTaskTasksTaskIdDispatchPost,
|
||||
useListTaskCommentsTaskCommentsGet,
|
||||
useListTasksTasksGet,
|
||||
useUpdateTaskTasksTaskIdPatch,
|
||||
useDeleteTaskTasksTaskIdDelete,
|
||||
useCreateTaskCommentTaskCommentsPost,
|
||||
useListTaskCommentsTaskCommentsGet,
|
||||
} from "@/api/generated/work/work";
|
||||
import {
|
||||
useListProjectMembersProjectsProjectIdMembersGet,
|
||||
useAddProjectMemberProjectsProjectIdMembersPost,
|
||||
useListProjectMembersProjectsProjectIdMembersGet,
|
||||
useRemoveProjectMemberProjectsProjectIdMembersMemberIdDelete,
|
||||
useUpdateProjectMemberProjectsProjectIdMembersMemberIdPatch,
|
||||
} from "@/api/generated/projects/projects";
|
||||
@@ -52,6 +59,10 @@ export default function ProjectDetailPage() {
|
||||
const employees = useListEmployeesEmployeesGet();
|
||||
const employeeList = employees.data?.status === 200 ? employees.data.data : [];
|
||||
|
||||
const eligibleAssignees = employeeList.filter(
|
||||
(e) => e.employee_type !== "agent" || !!e.openclaw_session_key,
|
||||
);
|
||||
|
||||
const members = useListProjectMembersProjectsProjectIdMembersGet(projectId);
|
||||
const memberList = members.data?.status === 200 ? members.data.data : [];
|
||||
const addMember = useAddProjectMemberProjectsProjectIdMembersPost({
|
||||
@@ -75,6 +86,11 @@ export default function ProjectDetailPage() {
|
||||
const deleteTask = useDeleteTaskTasksTaskIdDelete({
|
||||
mutation: { onSuccess: () => tasks.refetch() },
|
||||
});
|
||||
const dispatchTask = useDispatchTaskTasksTaskIdDispatchPost({
|
||||
mutation: {
|
||||
onSuccess: () => tasks.refetch(),
|
||||
},
|
||||
});
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
@@ -109,6 +125,11 @@ export default function ProjectDetailPage() {
|
||||
return map;
|
||||
})();
|
||||
|
||||
const employeeById = new Map<number, (typeof employeeList)[number]>();
|
||||
for (const e of employeeList) {
|
||||
if (e.id != null) employeeById.set(Number(e.id), e);
|
||||
}
|
||||
|
||||
const employeeName = (id: number | null | undefined) =>
|
||||
employeeList.find((e) => e.id === id)?.name ?? "—";
|
||||
|
||||
@@ -127,16 +148,40 @@ export default function ProjectDetailPage() {
|
||||
{projects.isLoading || employees.isLoading || members.isLoading || tasks.isLoading ? (
|
||||
<div className="mb-4 text-sm text-muted-foreground">Loading…</div>
|
||||
) : null}
|
||||
{projects.error ? <div className="mb-4 text-sm text-destructive">{(projects.error as Error).message}</div> : null}
|
||||
{employees.error ? <div className="mb-4 text-sm text-destructive">{(employees.error as Error).message}</div> : null}
|
||||
{members.error ? <div className="mb-4 text-sm text-destructive">{(members.error as Error).message}</div> : null}
|
||||
{tasks.error ? <div className="mb-4 text-sm text-destructive">{(tasks.error as Error).message}</div> : null}
|
||||
{projects.error ? (
|
||||
<div className="mb-4 text-sm text-destructive">
|
||||
{(projects.error as Error).message}
|
||||
</div>
|
||||
) : null}
|
||||
{employees.error ? (
|
||||
<div className="mb-4 text-sm text-destructive">
|
||||
{(employees.error as Error).message}
|
||||
</div>
|
||||
) : null}
|
||||
{members.error ? (
|
||||
<div className="mb-4 text-sm text-destructive">{(members.error as Error).message}</div>
|
||||
) : null}
|
||||
{tasks.error ? (
|
||||
<div className="mb-4 text-sm text-destructive">{(tasks.error as Error).message}</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{project?.name ?? `Project #${projectId}`}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Project detail: staffing + tasks.</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{project?.name ?? `Project #${projectId}`}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Project detail: staffing + tasks.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => { tasks.refetch(); members.refetch(); }} disabled={tasks.isFetching || members.isFetching}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
tasks.refetch();
|
||||
members.refetch();
|
||||
}}
|
||||
disabled={tasks.isFetching || members.isFetching}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
@@ -148,14 +193,31 @@ export default function ProjectDetailPage() {
|
||||
<CardDescription>Project-scoped tasks</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{createTask.error ? <div className="text-sm text-destructive">{(createTask.error as Error).message}</div> : null}
|
||||
<Input placeholder="Title" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
<Textarea placeholder="Description" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
{createTask.error ? (
|
||||
<div className="text-sm text-destructive">
|
||||
{(createTask.error as Error).message}
|
||||
</div>
|
||||
) : null}
|
||||
<Input
|
||||
placeholder="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<Select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)}>
|
||||
<Select
|
||||
value={assigneeId}
|
||||
onChange={(e) => setAssigneeId(e.target.value)}
|
||||
>
|
||||
<option value="">Assignee</option>
|
||||
{employeeList.map((e) => (
|
||||
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
|
||||
{eligibleAssignees.map((e) => (
|
||||
<option key={e.id ?? e.name} value={e.id ?? ""}>
|
||||
{e.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
@@ -184,28 +246,43 @@ export default function ProjectDetailPage() {
|
||||
<CardDescription>Project members</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Select onChange={(e) => {
|
||||
const empId = e.target.value;
|
||||
if (!empId) return;
|
||||
addMember.mutate({ projectId, data: { project_id: projectId, employee_id: Number(empId), role: "member" } });
|
||||
e.currentTarget.value = "";
|
||||
}}>
|
||||
<Select
|
||||
onChange={(e) => {
|
||||
const empId = e.target.value;
|
||||
if (!empId) return;
|
||||
addMember.mutate({
|
||||
projectId,
|
||||
data: { project_id: projectId, employee_id: Number(empId), role: "member" },
|
||||
});
|
||||
e.currentTarget.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="">Add member…</option>
|
||||
{employeeList.map((e) => (
|
||||
<option key={e.id ?? e.name} value={e.id ?? ""}>{e.name}</option>
|
||||
{eligibleAssignees.map((e) => (
|
||||
<option key={e.id ?? e.name} value={e.id ?? ""}>
|
||||
{e.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{addMember.error ? (
|
||||
<div className="text-xs text-destructive">{(addMember.error as Error).message}</div>
|
||||
<div className="text-xs text-destructive">
|
||||
{(addMember.error as Error).message}
|
||||
</div>
|
||||
) : null}
|
||||
<ul className="space-y-2">
|
||||
{projectMembers.map((m) => (
|
||||
<li key={m.id ?? `${m.project_id}-${m.employee_id}`} className="rounded-md border p-2 text-sm">
|
||||
<li
|
||||
key={m.id ?? `${m.project_id}-${m.employee_id}`}
|
||||
className="rounded-md border p-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>{employeeName(m.employee_id)}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { if (m.id == null) return; removeMember.mutate({ projectId, memberId: Number(m.id) }); }}
|
||||
onClick={() => {
|
||||
if (m.id == null) return;
|
||||
removeMember.mutate({ projectId, memberId: Number(m.id) });
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
@@ -215,17 +292,25 @@ export default function ProjectDetailPage() {
|
||||
placeholder="Role (e.g., PM, QA, Dev)"
|
||||
defaultValue={m.role ?? ""}
|
||||
onBlur={(e) =>
|
||||
m.id == null ? undefined : updateMember.mutate({
|
||||
projectId,
|
||||
memberId: Number(m.id),
|
||||
data: { project_id: projectId, employee_id: m.employee_id, role: e.currentTarget.value || null },
|
||||
})
|
||||
m.id == null
|
||||
? undefined
|
||||
: updateMember.mutate({
|
||||
projectId,
|
||||
memberId: Number(m.id),
|
||||
data: {
|
||||
project_id: projectId,
|
||||
employee_id: m.employee_id,
|
||||
role: e.currentTarget.value || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{projectMembers.length === 0 ? <li className="text-sm text-muted-foreground">No members yet.</li> : null}
|
||||
{projectMembers.length === 0 ? (
|
||||
<li className="text-sm text-muted-foreground">No members yet.</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -236,36 +321,125 @@ export default function ProjectDetailPage() {
|
||||
{STATUSES.map((s) => (
|
||||
<Card key={s}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm uppercase tracking-wide">{s.replace("_", " ")}</CardTitle>
|
||||
<CardTitle className="text-sm uppercase tracking-wide">
|
||||
{s.replace("_", " ")}
|
||||
</CardTitle>
|
||||
<CardDescription>{tasksByStatus.get(s)?.length ?? 0} tasks</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{(tasksByStatus.get(s) ?? []).map((t) => (
|
||||
<div key={t.id ?? t.title} className="rounded-md border p-2 text-sm">
|
||||
<div className="font-medium">{t.title}</div>
|
||||
<div className="text-xs text-muted-foreground">Assignee: {employeeName(t.assignee_employee_id)}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{STATUSES.filter((x) => x !== s).map((x) => (
|
||||
{(tasksByStatus.get(s) ?? []).map((t) => {
|
||||
const assignee =
|
||||
t.assignee_employee_id != null
|
||||
? employeeById.get(Number(t.assignee_employee_id))
|
||||
: undefined;
|
||||
|
||||
const canTrigger = Boolean(
|
||||
t.id != null &&
|
||||
assignee &&
|
||||
assignee.employee_type === "agent" &&
|
||||
assignee.openclaw_session_key,
|
||||
);
|
||||
|
||||
const actorId = getActorEmployeeId();
|
||||
const isReviewer = Boolean(actorId && t.reviewer_employee_id && Number(t.reviewer_employee_id) === actorId);
|
||||
const canReviewActions = Boolean(t.id != null && isReviewer && (t.status ?? "") === "review");
|
||||
|
||||
return (
|
||||
<div key={t.id ?? t.title} className="rounded-md border p-2 text-sm">
|
||||
<div className="font-medium">{t.title}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Assignee: {employeeName(t.assignee_employee_id)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{STATUSES.filter((x) => x !== s).map((x) => (
|
||||
<Button
|
||||
key={x}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateTask.mutate({
|
||||
taskId: Number(t.id),
|
||||
data: { status: x },
|
||||
})
|
||||
}
|
||||
>
|
||||
{x}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button
|
||||
key={x}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => updateTask.mutate({ taskId: Number(t.id), data: { status: x } })}
|
||||
onClick={() => {
|
||||
setCommentTaskId(Number(t.id));
|
||||
setReplyToCommentId(null);
|
||||
}}
|
||||
>
|
||||
{x}
|
||||
Comments
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => dispatchTask.mutate({ taskId: Number(t.id) })}
|
||||
disabled={!canTrigger || dispatchTask.isPending}
|
||||
title={
|
||||
canTrigger
|
||||
? "Send a dispatch message to the assigned agent"
|
||||
: "Only available when the assignee is a provisioned agent"
|
||||
}
|
||||
>
|
||||
Trigger
|
||||
</Button>
|
||||
|
||||
{canReviewActions ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateTask.mutate({
|
||||
taskId: Number(t.id),
|
||||
data: { status: "done" },
|
||||
})
|
||||
}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCommentTaskId(Number(t.id));
|
||||
setReplyToCommentId(null);
|
||||
}}
|
||||
title="Leave a comment asking for changes, then move status back to in_progress"
|
||||
>
|
||||
Request changes
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteTask.mutate({ taskId: Number(t.id) })}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{dispatchTask.error ? (
|
||||
<div className="mt-2 text-xs text-destructive">
|
||||
{(dispatchTask.error as Error).message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => { setCommentTaskId(Number(t.id)); setReplyToCommentId(null); }}>
|
||||
Comments
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => deleteTask.mutate({ taskId: Number(t.id) })}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{(tasksByStatus.get(s) ?? []).length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground">No tasks</div>
|
||||
) : null}
|
||||
@@ -282,12 +456,20 @@ export default function ProjectDetailPage() {
|
||||
<CardDescription>{commentTaskId ? `Task #${commentTaskId}` : "Select a task"}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{addComment.error ? <div className="text-sm text-destructive">{(addComment.error as Error).message}</div> : null}
|
||||
{addComment.error ? (
|
||||
<div className="text-sm text-destructive">{(addComment.error as Error).message}</div>
|
||||
) : null}
|
||||
{replyToCommentId ? (
|
||||
<div className="rounded-md border bg-muted/40 p-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-xs text-muted-foreground">Replying to comment #{replyToCommentId}</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setReplyToCommentId(null)}>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Replying to comment #{replyToCommentId}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setReplyToCommentId(null)}
|
||||
>
|
||||
Cancel reply
|
||||
</Button>
|
||||
</div>
|
||||
@@ -323,17 +505,25 @@ export default function ProjectDetailPage() {
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="font-medium">{employeeName(c.author_employee_id)}</div>
|
||||
<div className="text-xs text-muted-foreground">{(c.created_at ? new Date(c.created_at).toLocaleString() : "—")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{c.created_at ? new Date(c.created_at).toLocaleString() : "—"}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setReplyToCommentId(Number(c.id))}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setReplyToCommentId(Number(c.id))}
|
||||
>
|
||||
Reply
|
||||
</Button>
|
||||
</div>
|
||||
{(c.reply_to_comment_id ? (
|
||||
{c.reply_to_comment_id ? (
|
||||
<div className="mt-2 rounded-md border bg-muted/40 p-2 text-xs">
|
||||
<div className="text-muted-foreground">Replying to #{c.reply_to_comment_id}: {commentById.get(Number(c.reply_to_comment_id))?.body ?? "—"}</div>
|
||||
<div className="text-muted-foreground">
|
||||
Replying to #{c.reply_to_comment_id}: {commentById.get(Number(c.reply_to_comment_id))?.body ?? "—"}
|
||||
</div>
|
||||
</div>
|
||||
) : null)}
|
||||
) : null}
|
||||
<div className="mt-2">{c.body}</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -13,15 +13,21 @@ import {
|
||||
useListProjectsProjectsGet,
|
||||
} from "@/api/generated/projects/projects";
|
||||
|
||||
import { useListTeamsTeamsGet } from "@/api/generated/org/org";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [teamId, setTeamId] = useState<string>("");
|
||||
|
||||
const projects = useListProjectsProjectsGet();
|
||||
const teams = useListTeamsTeamsGet({ department_id: undefined });
|
||||
const projectList = projects.data?.status === 200 ? projects.data.data : [];
|
||||
const teamList = teams.data?.status === 200 ? teams.data.data : [];
|
||||
const createProject = useCreateProjectProjectsPost({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setTeamId("");
|
||||
projects.refetch();
|
||||
},
|
||||
},
|
||||
@@ -48,8 +54,17 @@ export default function ProjectsPage() {
|
||||
{projects.error ? <div className={styles.mono}>{(projects.error as Error).message}</div> : null}
|
||||
<div className={styles.list}>
|
||||
<Input placeholder="Project name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 12, opacity: 0.8 }}>Owning team</span>
|
||||
<select value={teamId} onChange={(e) => setTeamId(e.target.value)} style={{ flex: 1, padding: '6px 8px', borderRadius: 6, border: '1px solid #333', background: 'transparent' }}>
|
||||
<option value="">(none)</option>
|
||||
{teamList.map((t) => (
|
||||
<option key={t.id ?? t.name} value={t.id ?? ''}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => createProject.mutate({ data: { name, status: "active" } })}
|
||||
onClick={() => createProject.mutate({ data: { name, status: "active", team_id: teamId ? Number(teamId) : null } })}
|
||||
disabled={!name.trim() || createProject.isPending || projects.isFetching}
|
||||
>
|
||||
Create
|
||||
|
||||
150
frontend/src/app/teams/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
|
||||
import {
|
||||
useCreateTeamTeamsPost,
|
||||
useListDepartmentsDepartmentsGet,
|
||||
useListEmployeesEmployeesGet,
|
||||
useListTeamsTeamsGet,
|
||||
} from "@/api/generated/org/org";
|
||||
|
||||
export default function TeamsPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [departmentId, setDepartmentId] = useState<string>("");
|
||||
const [leadEmployeeId, setLeadEmployeeId] = useState<string>("");
|
||||
|
||||
const departments = useListDepartmentsDepartmentsGet();
|
||||
const employees = useListEmployeesEmployeesGet();
|
||||
const teams = useListTeamsTeamsGet({ department_id: undefined });
|
||||
|
||||
const departmentList = useMemo(
|
||||
() => (departments.data?.status === 200 ? departments.data.data : []),
|
||||
[departments.data],
|
||||
);
|
||||
const employeeList = useMemo(
|
||||
() => (employees.data?.status === 200 ? employees.data.data : []),
|
||||
[employees.data],
|
||||
);
|
||||
const teamList = useMemo(() => (teams.data?.status === 200 ? teams.data.data : []), [teams.data]);
|
||||
|
||||
const deptNameById = useMemo(() => {
|
||||
const m = new Map<number, string>();
|
||||
for (const d of departmentList) {
|
||||
if (d.id != null) m.set(d.id, d.name);
|
||||
}
|
||||
return m;
|
||||
}, [departmentList]);
|
||||
|
||||
const empNameById = useMemo(() => {
|
||||
const m = new Map<number, string>();
|
||||
for (const e of employeeList) {
|
||||
if (e.id != null) m.set(e.id, e.name);
|
||||
}
|
||||
return m;
|
||||
}, [employeeList]);
|
||||
|
||||
const createTeam = useCreateTeamTeamsPost({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setDepartmentId("");
|
||||
setLeadEmployeeId("");
|
||||
teams.refetch();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sorted = teamList
|
||||
.slice()
|
||||
.sort((a, b) => `${deptNameById.get(a.department_id) ?? ""}::${a.name}`.localeCompare(`${deptNameById.get(b.department_id) ?? ""}::${b.name}`));
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Teams</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Teams live under departments. Projects are owned by teams.</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => teams.refetch()} disabled={teams.isFetching}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create team</CardTitle>
|
||||
<CardDescription>Define a team and attach it to a department.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input placeholder="Team name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Select value={departmentId} onChange={(e) => setDepartmentId(e.target.value)}>
|
||||
<option value="">(select department)</option>
|
||||
{departmentList.map((d) => (
|
||||
<option key={d.id ?? d.name} value={d.id ?? ""}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select value={leadEmployeeId} onChange={(e) => setLeadEmployeeId(e.target.value)}>
|
||||
<option value="">(no lead)</option>
|
||||
{employeeList.map((e) => (
|
||||
<option key={e.id ?? e.name} value={e.id ?? ""}>
|
||||
{e.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
onClick={() =>
|
||||
createTeam.mutate({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
department_id: Number(departmentId),
|
||||
lead_employee_id: leadEmployeeId ? Number(leadEmployeeId) : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={!name.trim() || !departmentId || createTeam.isPending}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
{createTeam.error ? <div className="text-sm text-destructive">{(createTeam.error as Error).message}</div> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All teams</CardTitle>
|
||||
<CardDescription>{sorted.length} total</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{teams.isLoading ? <div className="text-sm text-muted-foreground">Loading…</div> : null}
|
||||
{teams.error ? <div className="text-sm text-destructive">{(teams.error as Error).message}</div> : null}
|
||||
{!teams.isLoading && !teams.error ? (
|
||||
<ul className="space-y-2">
|
||||
{sorted.map((t) => (
|
||||
<li key={t.id ?? `${t.department_id}:${t.name}`} className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium">{t.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{deptNameById.get(t.department_id) ?? `Dept#${t.department_id}`}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{t.lead_employee_id ? <span>Lead: {empNameById.get(t.lead_employee_id) ?? `Emp#${t.lead_employee_id}`}</span> : <span>No lead</span>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{sorted.length === 0 ? <li className="text-sm text-muted-foreground">No teams yet.</li> : null}
|
||||
</ul>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||