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

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

View File

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

View File

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

View File

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

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;
name: string;
status?: string;
team_id?: number | null;
}

View File

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

View File

@@ -8,4 +8,5 @@
export interface ProjectUpdate {
name?: 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,
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
*/

View File

@@ -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" },
];

View File

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

View File

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

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