Add Teams (DB + API + UI)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Add teams and team ownership
|
||||
|
||||
Revision ID: 3f2c1b9c8e12
|
||||
Revises: bacd5e6a253d
|
||||
Create Date: 2026-02-02
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "3f2c1b9c8e12"
|
||||
down_revision = "bacd5e6a253d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1) Teams
|
||||
op.create_table(
|
||||
"teams",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("department_id", sa.Integer(), nullable=False),
|
||||
sa.Column("lead_employee_id", sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["department_id"], ["departments.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["lead_employee_id"], ["employees.id"], ondelete="SET NULL"),
|
||||
sa.UniqueConstraint("department_id", "name", name="uq_teams_department_id_name"),
|
||||
)
|
||||
op.create_index("ix_teams_name", "teams", ["name"], unique=False)
|
||||
op.create_index("ix_teams_department_id", "teams", ["department_id"], unique=False)
|
||||
|
||||
# 2) Employees belong to one (optional) team
|
||||
op.add_column("employees", sa.Column("team_id", sa.Integer(), nullable=True))
|
||||
op.create_index("ix_employees_team_id", "employees", ["team_id"], unique=False)
|
||||
op.create_foreign_key(
|
||||
"fk_employees_team_id_teams",
|
||||
"employees",
|
||||
"teams",
|
||||
["team_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
# 3) Projects are owned by teams (not departments)
|
||||
op.add_column("projects", sa.Column("team_id", sa.Integer(), nullable=True))
|
||||
op.create_index("ix_projects_team_id", "projects", ["team_id"], unique=False)
|
||||
op.create_foreign_key(
|
||||
"fk_projects_team_id_teams",
|
||||
"projects",
|
||||
"teams",
|
||||
["team_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_projects_team_id_teams", "projects", type_="foreignkey")
|
||||
op.drop_index("ix_projects_team_id", table_name="projects")
|
||||
op.drop_column("projects", "team_id")
|
||||
|
||||
op.drop_constraint("fk_employees_team_id_teams", "employees", type_="foreignkey")
|
||||
op.drop_index("ix_employees_team_id", table_name="employees")
|
||||
op.drop_column("employees", "team_id")
|
||||
|
||||
op.drop_index("ix_teams_department_id", table_name="teams")
|
||||
op.drop_index("ix_teams_name", table_name="teams")
|
||||
op.drop_table("teams")
|
||||
@@ -7,8 +7,15 @@ from sqlmodel import Session, select
|
||||
from app.api.utils import get_actor_employee_id, log_activity
|
||||
from app.db.session import get_session
|
||||
from app.integrations.openclaw import OpenClawClient
|
||||
from app.models.org import Department, Employee
|
||||
from app.schemas.org import DepartmentCreate, DepartmentUpdate, EmployeeCreate, EmployeeUpdate
|
||||
from app.models.org import Department, Team, Employee
|
||||
from app.schemas.org import (
|
||||
DepartmentCreate,
|
||||
DepartmentUpdate,
|
||||
TeamCreate,
|
||||
TeamUpdate,
|
||||
EmployeeCreate,
|
||||
EmployeeUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["org"])
|
||||
|
||||
@@ -127,6 +134,71 @@ def list_departments(session: Session = Depends(get_session)):
|
||||
return session.exec(select(Department).order_by(Department.name.asc())).all()
|
||||
|
||||
|
||||
@router.get("/teams", response_model=list[Team])
|
||||
def list_teams(department_id: int | None = None, session: Session = Depends(get_session)):
|
||||
q = select(Team)
|
||||
if department_id is not None:
|
||||
q = q.where(Team.department_id == department_id)
|
||||
return session.exec(q.order_by(Team.name.asc())).all()
|
||||
|
||||
|
||||
@router.post("/teams", response_model=Team)
|
||||
def create_team(
|
||||
payload: TeamCreate,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
team = Team(**payload.model_dump())
|
||||
session.add(team)
|
||||
|
||||
try:
|
||||
session.flush()
|
||||
log_activity(
|
||||
session,
|
||||
actor_employee_id=actor_employee_id,
|
||||
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},
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail="Team already exists or violates constraints")
|
||||
|
||||
session.refresh(team)
|
||||
return team
|
||||
|
||||
|
||||
@router.patch("/teams/{team_id}", response_model=Team)
|
||||
def update_team(
|
||||
team_id: int,
|
||||
payload: TeamUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
actor_employee_id: int = Depends(get_actor_employee_id),
|
||||
):
|
||||
team = session.get(Team, team_id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=404, detail="Team not found")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for k, v in data.items():
|
||||
setattr(team, k, v)
|
||||
|
||||
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)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail="Team update violates constraints")
|
||||
|
||||
session.refresh(team)
|
||||
return team
|
||||
|
||||
|
||||
|
||||
@router.post("/departments", response_model=Department)
|
||||
def create_department(
|
||||
payload: DepartmentCreate,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,11 +1,12 @@
|
||||
from app.models.activity import Activity
|
||||
from app.models.org import Department, Employee
|
||||
from app.models.org import Department, Team, Employee
|
||||
from app.models.projects import Project, ProjectMember
|
||||
from app.models.work import Task, TaskComment
|
||||
|
||||
__all__ = [
|
||||
"Department",
|
||||
"Employee",
|
||||
"Team",
|
||||
"Project",
|
||||
"ProjectMember",
|
||||
"Task",
|
||||
|
||||
@@ -13,6 +13,16 @@ class Department(SQLModel, table=True):
|
||||
head_employee_id: int | None = Field(default=None, foreign_key="employees.id")
|
||||
|
||||
|
||||
class Team(SQLModel, table=True):
|
||||
__tablename__ = "teams"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
name: str = Field(index=True)
|
||||
|
||||
department_id: int = Field(foreign_key="departments.id")
|
||||
lead_employee_id: int | None = Field(default=None, foreign_key="employees.id")
|
||||
|
||||
|
||||
class Employee(SQLModel, table=True):
|
||||
__tablename__ = "employees"
|
||||
|
||||
@@ -21,6 +31,7 @@ class Employee(SQLModel, table=True):
|
||||
employee_type: str # human | agent
|
||||
|
||||
department_id: int | None = Field(default=None, foreign_key="departments.id")
|
||||
team_id: int | None = Field(default=None, foreign_key="teams.id")
|
||||
manager_id: int | None = Field(default=None, foreign_key="employees.id")
|
||||
|
||||
title: str | None = None
|
||||
|
||||
@@ -10,6 +10,9 @@ class Project(SQLModel, table=True):
|
||||
name: str = Field(index=True, unique=True)
|
||||
status: str = Field(default="active")
|
||||
|
||||
# Project ownership: projects are assigned to teams (not departments)
|
||||
team_id: int | None = Field(default=None, foreign_key="teams.id")
|
||||
|
||||
|
||||
class ProjectMember(SQLModel, table=True):
|
||||
__tablename__ = "project_members"
|
||||
|
||||
@@ -13,10 +13,23 @@ class DepartmentUpdate(SQLModel):
|
||||
head_employee_id: int | None = None
|
||||
|
||||
|
||||
class TeamCreate(SQLModel):
|
||||
name: str
|
||||
department_id: int
|
||||
lead_employee_id: int | None = None
|
||||
|
||||
|
||||
class TeamUpdate(SQLModel):
|
||||
name: str | None = None
|
||||
department_id: int | None = None
|
||||
lead_employee_id: int | None = None
|
||||
|
||||
|
||||
class EmployeeCreate(SQLModel):
|
||||
name: str
|
||||
employee_type: str
|
||||
department_id: int | None = None
|
||||
team_id: int | None = None
|
||||
manager_id: int | None = None
|
||||
title: str | None = None
|
||||
status: str = "active"
|
||||
@@ -30,6 +43,7 @@ class EmployeeUpdate(SQLModel):
|
||||
name: str | None = None
|
||||
employee_type: str | None = None
|
||||
department_id: int | None = None
|
||||
team_id: int | None = None
|
||||
manager_id: int | None = None
|
||||
title: str | None = None
|
||||
status: str | None = None
|
||||
|
||||
@@ -6,8 +6,10 @@ from sqlmodel import SQLModel
|
||||
class ProjectCreate(SQLModel):
|
||||
name: str
|
||||
status: str = "active"
|
||||
team_id: int | None = None
|
||||
|
||||
|
||||
class ProjectUpdate(SQLModel):
|
||||
name: str | None = None
|
||||
status: str | None = None
|
||||
team_id: int | None = None
|
||||
|
||||
@@ -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";
|
||||
@@ -32,4 +33,7 @@ export * from "./taskComment";
|
||||
export * from "./taskCommentCreate";
|
||||
export * from "./taskCreate";
|
||||
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
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;
|
||||
}
|
||||
|
||||
13
frontend/src/api/generated/model/team.ts
Normal file
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
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
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
|
||||
*/
|
||||
|
||||
@@ -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,6 +13,7 @@ import {
|
||||
useCreateEmployeeEmployeesPost,
|
||||
useListDepartmentsDepartmentsGet,
|
||||
useListEmployeesEmployeesGet,
|
||||
useListTeamsTeamsGet,
|
||||
} from "@/api/generated/org/org";
|
||||
|
||||
export default function PeoplePage() {
|
||||
@@ -20,12 +21,15 @@ export default function PeoplePage() {
|
||||
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 createEmployee = useCreateEmployeeEmployeesPost({
|
||||
mutation: {
|
||||
@@ -33,6 +37,7 @@ export default function PeoplePage() {
|
||||
setName("");
|
||||
setTitle("");
|
||||
setDepartmentId("");
|
||||
setTeamId("");
|
||||
setManagerId("");
|
||||
employees.refetch();
|
||||
},
|
||||
@@ -47,6 +52,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 +101,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 +125,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 +164,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>
|
||||
|
||||
@@ -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
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user