From de79daefb1dbd601d2a1afc45f3b19f8264152ad Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 18:54:33 -0300 Subject: [PATCH] feat(team-members): add searchable team member registry (SH-324) --- src/api/api-paths.ts | 3 + .../_components/team-members-table.tsx | 99 ++++++++++ src/app/(protected)/team-members/index.tsx | 187 ++++++++++++++++++ src/config/menu.ts | 5 + .../team-members/api/team-members-api.ts | 13 ++ .../mappers/team-member-mapper.ts | 45 +++++ src/domain/team-members/types/team-member.ts | 15 ++ .../use-cases/use-team-members-list.ts | 11 ++ src/infra/query-key/query-key.ts | 4 + .../team-members/team-member-mapper.test.ts | 28 +++ 10 files changed, 410 insertions(+) create mode 100644 src/app/(protected)/team-members/_components/team-members-table.tsx create mode 100644 src/app/(protected)/team-members/index.tsx create mode 100644 src/domain/team-members/api/team-members-api.ts create mode 100644 src/domain/team-members/mappers/team-member-mapper.ts create mode 100644 src/domain/team-members/types/team-member.ts create mode 100644 src/domain/team-members/use-cases/use-team-members-list.ts create mode 100644 src/test/domain/team-members/team-member-mapper.test.ts diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 33db519b..477b74ee 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -77,6 +77,9 @@ export const API_PATHS = { deleteEmployees: "Employee/DeleteEmployees", dropdown: "Employee/Dropdown", }, + teamMember: { + getList: "User", + }, contact: { getList: "Contact/GetContactList", getContacts: "Contact/GetContacts", diff --git a/src/app/(protected)/team-members/_components/team-members-table.tsx b/src/app/(protected)/team-members/_components/team-members-table.tsx new file mode 100644 index 00000000..a70cef65 --- /dev/null +++ b/src/app/(protected)/team-members/_components/team-members-table.tsx @@ -0,0 +1,99 @@ +import { + Avatar, + Button, + CircularProgress, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import type { TeamMemberListItem } from "@/domain/team-members/types/team-member"; + +const HEADINGS = ["Team Member", "Role", "Service Area", "Phone", "Email", "Color", "Status"]; + +function memberStatusLabel(status: TeamMemberListItem["status"]) { + return status === "Active" ? "Active" : "Inactive"; +} + +export function TeamMembersTable({ + rows, + isLoading, + tab, + onOpen, +}: { + rows: TeamMemberListItem[]; + isLoading: boolean; + tab: "active" | "inactive"; + onOpen: (member: TeamMemberListItem) => void; +}) { + return ( + + + + + {HEADINGS.map((heading) => ( + + {heading} + + ))} + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + No {tab} team members found + + Try a different search, or add a new team member. + + + + ) : ( + rows.map((member) => ( + onOpen(member)} + sx={{ cursor: "pointer" }} + > + {member.name || "—"} + {member.role || "—"} + {member.serviceArea || "—"} + {member.phone || "—"} + {member.email || "—"} + + + + {memberStatusLabel(member.status)} + + + + + )) + )} + +
+
+ ); +} diff --git a/src/app/(protected)/team-members/index.tsx b/src/app/(protected)/team-members/index.tsx new file mode 100644 index 00000000..d88c94d5 --- /dev/null +++ b/src/app/(protected)/team-members/index.tsx @@ -0,0 +1,187 @@ +import { useState } from "react"; +import { useDebounce } from "@/hooks/use-debounce"; +import { useTeamMembersList } from "@/domain/team-members/use-cases/use-team-members-list"; +import type { TeamMemberListItem } from "@/domain/team-members/types/team-member"; +import { + Alert, + Avatar, + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Drawer, + IconButton, + Stack, + Tab, + Tabs, + TextField, + Typography, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import { TeamMembersTable } from "./_components/team-members-table"; + +type StatusTab = "active" | "inactive"; + +const STATUS_LABELS: Record = { + active: "Active", + inactive: "Inactive", +}; + +function MemberStatus({ status }: { status: TeamMemberListItem["status"] }) { + return ; +} + +function TeamMemberDrawer({ + member, + onClose, +}: { + member: TeamMemberListItem | null; + onClose: () => void; +}) { + return ( + + + + Team Member + + + + + {member !== null && ( + + + + {member.name.slice(0, 1).toUpperCase() || "?"} + + + {member.name || "—"} + {member.role || "—"} + + + + + Service Area: {member.serviceArea || "—"} + + + Phone: {member.phone || "—"} + + + Email: {member.email || "—"} + + + )} + + + ); +} + +export default function TeamMembersListPage() { + const [search, setSearch] = useState(""); + const [tab, setTab] = useState("active"); + const [selectedMember, setSelectedMember] = useState(null); + const [addMemberOpen, setAddMemberOpen] = useState(false); + const debouncedSearch = useDebounce(search, 300).trim().toLowerCase(); + const { data, isLoading, error } = useTeamMembersList(); + const allMembers = data?.items ?? []; + + const activeCount = allMembers.filter((member) => member.status === "Active").length; + const inactiveCount = allMembers.length - activeCount; + const rows = allMembers + .filter((member) => member.status === STATUS_LABELS[tab]) + .filter((member) => !debouncedSearch || member.name.toLowerCase().includes(debouncedSearch)); + + return ( + + + + + Team Members + + + {allMembers.length} team member{allMembers.length === 1 ? "" : "s"} · {activeCount}{" "} + active + + + + setSearch(event.target.value)} + placeholder="Search team member…" + aria-label="Search team member" + sx={{ width: 224 }} + /> + + + + + setTab(value)} + aria-label="Team member status" + > + + + + + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load team members"} + + )} + + + + + + + + {rows.length} {tab} team member{rows.length === 1 ? "" : "s"} + {debouncedSearch ? " · filtered" : ""} + + + {activeCount} active · {inactiveCount} inactive + + + + setSelectedMember(null)} /> + setAddMemberOpen(false)}> + Add Member + Member details will be added here. + + + + + + ); +} diff --git a/src/config/menu.ts b/src/config/menu.ts index 3974c0dc..c5b6fce4 100644 --- a/src/config/menu.ts +++ b/src/config/menu.ts @@ -10,6 +10,7 @@ import { Bell, BarChart3, Star, + Users, } from "lucide-react"; export type NavItemDef = { @@ -58,6 +59,10 @@ export const navGroups: NavGroup[] = [ { label: "Sites", icon: MapPin, path: "/locations" }, ], }, + { + title: "TEAM", + items: [{ label: "Team Members", icon: Users, path: "/team-members" }], + }, // { // title: "INTERNAL", // items: [{ label: "Documents", icon: FileText, path: "/documents" }], diff --git a/src/domain/team-members/api/team-members-api.ts b/src/domain/team-members/api/team-members-api.ts new file mode 100644 index 00000000..9b58b486 --- /dev/null +++ b/src/domain/team-members/api/team-members-api.ts @@ -0,0 +1,13 @@ +import { apiGet } from "@/api/api"; +import { API_PATHS } from "@/api/api-paths"; +import { handleApiResponse } from "@/api/handle-api-response"; +import { mapTeamMemberListItems } from "@/domain/team-members/mappers/team-member-mapper"; +import type { TeamMemberListResult } from "@/domain/team-members/types/team-member"; + +export const teamMembersApi = { + getList: async (): Promise => { + const data = await apiGet(API_PATHS.teamMember.getList); + const items = handleApiResponse(data); + return { items: mapTeamMemberListItems(Array.isArray(items) ? items : []) }; + }, +}; diff --git a/src/domain/team-members/mappers/team-member-mapper.ts b/src/domain/team-members/mappers/team-member-mapper.ts new file mode 100644 index 00000000..ada426ce --- /dev/null +++ b/src/domain/team-members/mappers/team-member-mapper.ts @@ -0,0 +1,45 @@ +import type { TeamMemberListItem } from "@/domain/team-members/types/team-member"; + +function readString(record: Record, ...keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + } + return ""; +} + +function readBoolean(record: Record, ...keys: string[]): boolean { + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") return value; + if (typeof value === "string") return value.toLowerCase() === "true"; + } + return false; +} + +export function mapTeamMemberListItem(raw: unknown): TeamMemberListItem { + const record = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + const id = record.id ?? record.Id; + const status = + readString(record, "status", "Status").toLowerCase() === "inactive" || + readBoolean(record, "isDeleted", "IsDeleted") + ? "Inactive" + : "Active"; + + return { + id: typeof id === "string" || typeof id === "number" ? id : "", + name: readString(record, "name", "Name"), + role: readString(record, "role", "Role", "roleName", "RoleName"), + serviceArea: readString(record, "serviceArea", "ServiceArea", "locations", "Locations"), + phone: readString(record, "phone", "Phone"), + email: readString(record, "email", "Email"), + color: readString(record, "color", "Color"), + status, + isAccountOwner: readBoolean(record, "isAccountOwner", "IsAccountOwner"), + }; +} + +export function mapTeamMemberListItems(rawItems: unknown[]): TeamMemberListItem[] { + return rawItems.map(mapTeamMemberListItem); +} diff --git a/src/domain/team-members/types/team-member.ts b/src/domain/team-members/types/team-member.ts new file mode 100644 index 00000000..9aafdb97 --- /dev/null +++ b/src/domain/team-members/types/team-member.ts @@ -0,0 +1,15 @@ +export interface TeamMemberListItem { + id: string | number; + name: string; + role: string; + serviceArea: string; + phone: string; + email: string; + color: string; + status: "Active" | "Inactive"; + isAccountOwner: boolean; +} + +export interface TeamMemberListResult { + items: TeamMemberListItem[]; +} diff --git a/src/domain/team-members/use-cases/use-team-members-list.ts b/src/domain/team-members/use-cases/use-team-members-list.ts new file mode 100644 index 00000000..b22f95e0 --- /dev/null +++ b/src/domain/team-members/use-cases/use-team-members-list.ts @@ -0,0 +1,11 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { teamMembersApi } from "@/domain/team-members/api/team-members-api"; +import type { TeamMemberListResult } from "@/domain/team-members/types/team-member"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useTeamMembersList(): UseQueryResult { + return useQuery({ + queryKey: queryKeys.teamMembers.list(), + queryFn: teamMembersApi.getList, + }); +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index c4a9e284..d78d8b26 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -47,6 +47,10 @@ export const queryKeys = { [...queryKeys.employees.all, "list", params ?? {}] as const, detail: (id: string | number) => [...queryKeys.employees.all, "detail", id] as const, }, + teamMembers: { + all: ["teamMembers"] as const, + list: () => [...queryKeys.teamMembers.all, "list"] as const, + }, dashboard: { all: ["dashboard"] as const, stats: () => [...queryKeys.dashboard.all, "stats"] as const, diff --git a/src/test/domain/team-members/team-member-mapper.test.ts b/src/test/domain/team-members/team-member-mapper.test.ts new file mode 100644 index 00000000..7bfb208d --- /dev/null +++ b/src/test/domain/team-members/team-member-mapper.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { mapTeamMemberListItem } from "@/domain/team-members/mappers/team-member-mapper"; + +describe("mapTeamMemberListItem", () => { + it("maps the existing User list response and derives inactive state", () => { + expect( + mapTeamMemberListItem({ + Id: "u1", + Name: "Alex Smith", + RoleName: "Dispatcher", + Locations: "Central", + Email: "alex@example.com", + Status: "Inactive", + IsAccountOwner: true, + }), + ).toEqual({ + id: "u1", + name: "Alex Smith", + role: "Dispatcher", + serviceArea: "Central", + phone: "", + email: "alex@example.com", + color: "", + status: "Inactive", + isAccountOwner: true, + }); + }); +});