Add Teams (DB + API + UI)

This commit is contained in:
Abhimanyu Saharan
2026-02-02 18:59:54 +05:30
parent dc8750353d
commit ef2676fa1c
26 changed files with 865 additions and 5 deletions

View File

@@ -14,7 +14,7 @@ No auth (yet). The goal is simple visibility: everyone can see what exists and w
Uses local Postgres: Uses local Postgres:
- user: `postgres` - user: `postgres`
- password: `netbox` - password: `REDACTED`
- db: `openclaw_agency` - db: `openclaw_agency`
## Environment ## Environment

View File

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

View File

@@ -7,8 +7,15 @@ from sqlmodel import Session, select
from app.api.utils import get_actor_employee_id, log_activity from app.api.utils import get_actor_employee_id, log_activity
from app.db.session import get_session from app.db.session import get_session
from app.integrations.openclaw import OpenClawClient from app.integrations.openclaw import OpenClawClient
from app.models.org import Department, Employee from app.models.org import Department, Team, Employee
from app.schemas.org import DepartmentCreate, DepartmentUpdate, EmployeeCreate, EmployeeUpdate from app.schemas.org import (
DepartmentCreate,
DepartmentUpdate,
TeamCreate,
TeamUpdate,
EmployeeCreate,
EmployeeUpdate,
)
router = APIRouter(tags=["org"]) 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() 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) @router.post("/departments", response_model=Department)
def create_department( def create_department(
payload: DepartmentCreate, payload: DepartmentCreate,

View File

@@ -1,11 +1,12 @@
from app.models.activity import Activity 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.projects import Project, ProjectMember
from app.models.work import Task, TaskComment from app.models.work import Task, TaskComment
__all__ = [ __all__ = [
"Department", "Department",
"Employee", "Employee",
"Team",
"Project", "Project",
"ProjectMember", "ProjectMember",
"Task", "Task",

View File

@@ -13,6 +13,16 @@ class Department(SQLModel, table=True):
head_employee_id: int | None = Field(default=None, foreign_key="employees.id") 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): class Employee(SQLModel, table=True):
__tablename__ = "employees" __tablename__ = "employees"
@@ -21,6 +31,7 @@ class Employee(SQLModel, table=True):
employee_type: str # human | agent employee_type: str # human | agent
department_id: int | None = Field(default=None, foreign_key="departments.id") 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") manager_id: int | None = Field(default=None, foreign_key="employees.id")
title: str | None = None title: str | None = None

View File

@@ -10,6 +10,9 @@ class Project(SQLModel, table=True):
name: str = Field(index=True, unique=True) name: str = Field(index=True, unique=True)
status: str = Field(default="active") 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): class ProjectMember(SQLModel, table=True):
__tablename__ = "project_members" __tablename__ = "project_members"

View File

@@ -13,10 +13,23 @@ class DepartmentUpdate(SQLModel):
head_employee_id: int | None = None 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): class EmployeeCreate(SQLModel):
name: str name: str
employee_type: str employee_type: str
department_id: int | None = None department_id: int | None = None
team_id: int | None = None
manager_id: int | None = None manager_id: int | None = None
title: str | None = None title: str | None = None
status: str = "active" status: str = "active"
@@ -30,6 +43,7 @@ class EmployeeUpdate(SQLModel):
name: str | None = None name: str | None = None
employee_type: str | None = None employee_type: str | None = None
department_id: int | None = None department_id: int | None = None
team_id: int | None = None
manager_id: int | None = None manager_id: int | None = None
title: str | None = None title: str | None = None
status: str | None = None status: str | None = None

View File

@@ -6,8 +6,10 @@ from sqlmodel import SQLModel
class ProjectCreate(SQLModel): class ProjectCreate(SQLModel):
name: str name: str
status: str = "active" status: str = "active"
team_id: int | None = None
class ProjectUpdate(SQLModel): class ProjectUpdate(SQLModel):
name: str | None = None name: str | None = None
status: str | None = None status: str | None = None
team_id: int | None = None

View File

@@ -10,6 +10,7 @@ export interface Employee {
name: string; name: string;
employee_type: string; employee_type: string;
department_id?: number | null; department_id?: number | null;
team_id?: number | null;
manager_id?: number | null; manager_id?: number | null;
title?: string | null; title?: string | null;
status?: string; status?: string;

View File

@@ -9,6 +9,7 @@ export interface EmployeeCreate {
name: string; name: string;
employee_type: string; employee_type: string;
department_id?: number | null; department_id?: number | null;
team_id?: number | null;
manager_id?: number | null; manager_id?: number | null;
title?: string | null; title?: string | null;
status?: string; status?: string;

View File

@@ -9,6 +9,7 @@ export interface EmployeeUpdate {
name?: string | null; name?: string | null;
employee_type?: string | null; employee_type?: string | null;
department_id?: number | null; department_id?: number | null;
team_id?: number | null;
manager_id?: number | null; manager_id?: number | null;
title?: string | null; title?: string | null;
status?: string | null; status?: string | null;

View File

@@ -23,6 +23,7 @@ export * from "./hTTPValidationError";
export * from "./listActivitiesActivitiesGetParams"; export * from "./listActivitiesActivitiesGetParams";
export * from "./listTaskCommentsTaskCommentsGetParams"; export * from "./listTaskCommentsTaskCommentsGetParams";
export * from "./listTasksTasksGetParams"; export * from "./listTasksTasksGetParams";
export * from "./listTeamsTeamsGetParams";
export * from "./project"; export * from "./project";
export * from "./projectCreate"; export * from "./projectCreate";
export * from "./projectMember"; export * from "./projectMember";
@@ -32,4 +33,7 @@ export * from "./taskComment";
export * from "./taskCommentCreate"; export * from "./taskCommentCreate";
export * from "./taskCreate"; export * from "./taskCreate";
export * from "./taskUpdate"; export * from "./taskUpdate";
export * from "./team";
export * from "./teamCreate";
export * from "./teamUpdate";
export * from "./validationError"; export * from "./validationError";

View 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;
};

View File

@@ -9,4 +9,5 @@ export interface Project {
id?: number | null; id?: number | null;
name: string; name: string;
status?: string; status?: string;
team_id?: number | null;
} }

View File

@@ -8,4 +8,5 @@
export interface ProjectCreate { export interface ProjectCreate {
name: string; name: string;
status?: string; status?: string;
team_id?: number | null;
} }

View File

@@ -8,4 +8,5 @@
export interface ProjectUpdate { export interface ProjectUpdate {
name?: string | null; name?: string | null;
status?: string | null; status?: string | null;
team_id?: number | null;
} }

View 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;
}

View 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;
}

View 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;
}

View File

@@ -28,6 +28,10 @@ import type {
EmployeeCreate, EmployeeCreate,
EmployeeUpdate, EmployeeUpdate,
HTTPValidationError, HTTPValidationError,
ListTeamsTeamsGetParams,
Team,
TeamCreate,
TeamUpdate,
} from ".././model"; } from ".././model";
import { customFetch } from "../../mutator"; import { customFetch } from "../../mutator";
@@ -327,6 +331,440 @@ export const useCreateDepartmentDepartmentsPost = <
queryClient, 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 * @summary Update Department
*/ */

View File

@@ -10,6 +10,7 @@ const NAV = [
{ href: "/projects", label: "Projects" }, { href: "/projects", label: "Projects" },
{ href: "/kanban", label: "Kanban" }, { href: "/kanban", label: "Kanban" },
{ href: "/departments", label: "Departments" }, { href: "/departments", label: "Departments" },
{ href: "/teams", label: "Teams" },
{ href: "/people", label: "People" }, { href: "/people", label: "People" },
]; ];

View File

@@ -13,6 +13,7 @@ import {
useCreateEmployeeEmployeesPost, useCreateEmployeeEmployeesPost,
useListDepartmentsDepartmentsGet, useListDepartmentsDepartmentsGet,
useListEmployeesEmployeesGet, useListEmployeesEmployeesGet,
useListTeamsTeamsGet,
} from "@/api/generated/org/org"; } from "@/api/generated/org/org";
export default function PeoplePage() { export default function PeoplePage() {
@@ -20,12 +21,15 @@ export default function PeoplePage() {
const [employeeType, setEmployeeType] = useState<"human" | "agent">("human"); const [employeeType, setEmployeeType] = useState<"human" | "agent">("human");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [departmentId, setDepartmentId] = useState<string>(""); const [departmentId, setDepartmentId] = useState<string>("");
const [teamId, setTeamId] = useState<string>("");
const [managerId, setManagerId] = useState<string>(""); const [managerId, setManagerId] = useState<string>("");
const employees = useListEmployeesEmployeesGet(); const employees = useListEmployeesEmployeesGet();
const departments = useListDepartmentsDepartmentsGet(); const departments = useListDepartmentsDepartmentsGet();
const teams = useListTeamsTeamsGet({ department_id: undefined });
const departmentList = useMemo(() => (departments.data?.status === 200 ? departments.data.data : []), [departments.data]); 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 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({ const createEmployee = useCreateEmployeeEmployeesPost({
mutation: { mutation: {
@@ -33,6 +37,7 @@ export default function PeoplePage() {
setName(""); setName("");
setTitle(""); setTitle("");
setDepartmentId(""); setDepartmentId("");
setTeamId("");
setManagerId(""); setManagerId("");
employees.refetch(); employees.refetch();
}, },
@@ -47,6 +52,14 @@ export default function PeoplePage() {
return m; return m;
}, [departmentList]); }, [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 empNameById = useMemo(() => {
const m = new Map<number, string>(); const m = new Map<number, string>();
for (const e of employeeList) { for (const e of employeeList) {
@@ -88,6 +101,14 @@ export default function PeoplePage() {
</option> </option>
))} ))}
</Select> </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)}> <Select value={managerId} onChange={(e) => setManagerId(e.target.value)}>
<option value="">(no manager)</option> <option value="">(no manager)</option>
{employeeList.map((e) => ( {employeeList.map((e) => (
@@ -104,6 +125,7 @@ export default function PeoplePage() {
employee_type: employeeType, employee_type: employeeType,
title: title.trim() ? title : null, title: title.trim() ? title : null,
department_id: departmentId ? Number(departmentId) : null, department_id: departmentId ? Number(departmentId) : null,
team_id: teamId ? Number(teamId) : null,
manager_id: managerId ? Number(managerId) : null, manager_id: managerId ? Number(managerId) : null,
status: "active", status: "active",
}, },
@@ -142,6 +164,7 @@ export default function PeoplePage() {
<div className="mt-2 text-sm text-muted-foreground"> <div className="mt-2 text-sm text-muted-foreground">
{e.title ? <span>{e.title} · </span> : null} {e.title ? <span>{e.title} · </span> : null}
{e.department_id ? <span>{deptNameById.get(e.department_id) ?? `Dept#${e.department_id}`} · </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>} {e.manager_id ? <span>Mgr: {empNameById.get(e.manager_id) ?? `Emp#${e.manager_id}`}</span> : <span>No manager</span>}
</div> </div>
</li> </li>

View File

@@ -13,15 +13,21 @@ import {
useListProjectsProjectsGet, useListProjectsProjectsGet,
} from "@/api/generated/projects/projects"; } from "@/api/generated/projects/projects";
import { useListTeamsTeamsGet } from "@/api/generated/org/org";
export default function ProjectsPage() { export default function ProjectsPage() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [teamId, setTeamId] = useState<string>("");
const projects = useListProjectsProjectsGet(); const projects = useListProjectsProjectsGet();
const teams = useListTeamsTeamsGet({ department_id: undefined });
const projectList = projects.data?.status === 200 ? projects.data.data : []; const projectList = projects.data?.status === 200 ? projects.data.data : [];
const teamList = teams.data?.status === 200 ? teams.data.data : [];
const createProject = useCreateProjectProjectsPost({ const createProject = useCreateProjectProjectsPost({
mutation: { mutation: {
onSuccess: () => { onSuccess: () => {
setName(""); setName("");
setTeamId("");
projects.refetch(); projects.refetch();
}, },
}, },
@@ -48,8 +54,17 @@ export default function ProjectsPage() {
{projects.error ? <div className={styles.mono}>{(projects.error as Error).message}</div> : null} {projects.error ? <div className={styles.mono}>{(projects.error as Error).message}</div> : null}
<div className={styles.list}> <div className={styles.list}>
<Input placeholder="Project name" value={name} onChange={(e) => setName(e.target.value)} autoFocus /> <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 <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} disabled={!name.trim() || createProject.isPending || projects.isFetching}
> >
Create Create

View 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>
);
}