diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 9b9e3e33..d7b17c18 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -79,6 +79,7 @@ export const API_PATHS = { }, teamMember: { getList: "User", + create: "team-members", }, contact: { getList: "Contact/GetContactList", diff --git a/src/app/(protected)/team-members/_components/add-team-member-dialog.tsx b/src/app/(protected)/team-members/_components/add-team-member-dialog.tsx new file mode 100644 index 00000000..8ee4ac73 --- /dev/null +++ b/src/app/(protected)/team-members/_components/add-team-member-dialog.tsx @@ -0,0 +1,473 @@ +import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Alert, + Box, + Button, + Checkbox, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + FormHelperText, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import { AREA_ZONES } from "@/data/areas"; +import { useCreateTeamMember } from "@/domain/team-members/use-cases/use-create-team-member"; +import type { + CreateTeamMemberInput, + TeamMemberRole, +} from "@/domain/team-members/types/team-member"; + +const ROLES: TeamMemberRole[] = ["Dispatcher", "Scheduler", "Admin"]; +const COLORS = [ + "#F59E0B", + "#0D9488", + "#6366F1", + "#F43F5E", + "#7C3AED", + "#0EA5E9", + "#16A34A", + "#EA580C", + "#0891B2", + "#C026D3", +] as const; + +const PERMISSION_GROUPS = [ + { + label: "Team Members", + keys: ["manageTeamMembers", "changeTeamMemberRole"], + }, + { + label: "Vendors", + keys: ["createVendors", "editVendors", "deactivateVendors"], + }, + { + label: "Sites", + keys: ["createSites", "editSites", "deleteSites"], + }, + { + label: "Services", + keys: ["createServices", "editServices", "deactivateServices"], + }, + { + label: "Completion Document Templates", + keys: [ + "createCompletionDocTemplates", + "editCompletionDocTemplates", + "deleteCompletionDocTemplates", + ], + }, + { + label: "Work Orders", + keys: ["createWorkOrders", "editOthersWorkOrders", "cancelWorkOrders", "deleteWorkOrders"], + }, + { + label: "Uplifts", + keys: ["requestUplifts", "autoApproveUplifts", "reviewUplifts"], + }, + { + label: "Dashboard", + keys: ["viewAllDispatchersOnDashboard"], + }, +] as const; + +const PERMISSION_LABELS: Record = { + manageTeamMembers: "Manage team members", + changeTeamMemberRole: "Change team member role", + createVendors: "Create vendors", + editVendors: "Edit vendors", + deactivateVendors: "Deactivate vendors", + createSites: "Create sites", + editSites: "Edit sites", + deleteSites: "Delete sites", + createServices: "Create services", + editServices: "Edit services", + deactivateServices: "Deactivate services", + createCompletionDocTemplates: "Create completion document templates", + editCompletionDocTemplates: "Edit completion document templates", + deleteCompletionDocTemplates: "Delete completion document templates", + createWorkOrders: "Create work orders", + editOthersWorkOrders: "Edit other users' work orders", + cancelWorkOrders: "Cancel work orders", + deleteWorkOrders: "Delete work orders", + requestUplifts: "Request uplifts", + autoApproveUplifts: "Automatically approve uplifts", + reviewUplifts: "Review uplifts", + viewAllDispatchersOnDashboard: "View all Dispatchers on dashboard", +}; + +const ALL_PERMISSION_KEYS = PERMISSION_GROUPS.flatMap((group) => group.keys); +const DISPATCHER_DEFAULTS = new Set([ + "createVendors", + "editVendors", + "deactivateVendors", + "createSites", + "editSites", + "createWorkOrders", + "editOthersWorkOrders", + "cancelWorkOrders", + "requestUplifts", + "autoApproveUplifts", +]); +const SCHEDULER_DEFAULTS = new Set([ + ...Array.from(DISPATCHER_DEFAULTS).filter( + (key) => key !== "requestUplifts" && key !== "autoApproveUplifts", + ), + "deleteSites", + "createServices", + "editServices", + "createCompletionDocTemplates", + "editCompletionDocTemplates", + "viewAllDispatchersOnDashboard", +]); + +function defaultsForRole(role: TeamMemberRole): Set { + if (role === "Admin") return new Set(ALL_PERMISSION_KEYS); + return role === "Scheduler" ? new Set(SCHEDULER_DEFAULTS) : new Set(DISPATCHER_DEFAULTS); +} + +function emptyForm() { + return { + name: "", + role: "Dispatcher" as TeamMemberRole, + color: "", + email: "", + phone: "", + serviceAreas: [] as string[], + permissions: defaultsForRole("Dispatcher"), + }; +} + +function permissionOverrides(role: TeamMemberRole, selected: Set) { + const defaults = defaultsForRole(role); + return Object.fromEntries( + ALL_PERMISSION_KEYS.filter((key) => defaults.has(key) !== selected.has(key)).map((key) => [ + key, + selected.has(key) ? 1 : 2, + ]), + ) as Record; +} + +type TeamMemberForm = ReturnType; +type FormErrors = ReturnType; + +function getFormErrors(form: TeamMemberForm, submitted: boolean, emailIsValid: boolean) { + return { + name: submitted && !form.name.trim() ? "Name is required." : "", + role: submitted && !form.role ? "Role is required." : "", + color: submitted && !form.color ? "Color is required." : "", + email: + submitted && !form.email.trim() + ? "Email is required." + : submitted && !emailIsValid + ? "Enter a valid email address." + : "", + serviceAreas: + submitted && form.role === "Dispatcher" && form.serviceAreas.length === 0 + ? "Select at least one service area." + : "", + }; +} + +function PermissionsSection({ + form, + togglePermission, +}: { + form: TeamMemberForm; + togglePermission: (key: string) => void; +}) { + return ( + + + Permissions + + + Permissions start with the selected role's defaults. Customize them before saving. + + {PERMISSION_GROUPS.map((group) => ( + + }> + {group.label} + + + + {group.keys.map((key) => ( + togglePermission(key)} + /> + } + label={PERMISSION_LABELS[key]} + /> + ))} + + + + ))} + + ); +} + +function TeamMemberFormFields({ + form, + setForm, + errors, + updateRole, + toggleArea, + togglePermission, +}: { + form: TeamMemberForm; + setForm: Dispatch>; + errors: FormErrors; + updateRole: (role: TeamMemberRole) => void; + toggleArea: (area: string) => void; + togglePermission: (key: string) => void; +}) { + return ( + + setForm((current) => ({ ...current, name: event.target.value }))} + error={Boolean(errors.name)} + helperText={errors.name} + required + fullWidth + /> + + Role + + {Boolean(errors.role) && {errors.role}} + + + + + Color + + + {COLORS.map((color) => ( + + + + + ); +} diff --git a/src/app/(protected)/team-members/_components/team-members-table.tsx b/src/app/(protected)/team-members/_components/team-members-table.tsx index 125fa434..777aeea4 100644 --- a/src/app/(protected)/team-members/_components/team-members-table.tsx +++ b/src/app/(protected)/team-members/_components/team-members-table.tsx @@ -15,8 +15,8 @@ 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"; +function memberStatusLabel(member: TeamMemberListItem) { + return member.pendingRegistration ? "Pending registration" : member.status; } export function TeamMembersTable({ @@ -88,7 +88,7 @@ export function TeamMembersTable({ - {memberStatusLabel(member.status)} + {memberStatusLabel(member)} - - + setAddMemberOpen(false)} /> ); } diff --git a/src/domain/team-members/api/team-members-api.ts b/src/domain/team-members/api/team-members-api.ts index 9b58b486..b9cb1455 100644 --- a/src/domain/team-members/api/team-members-api.ts +++ b/src/domain/team-members/api/team-members-api.ts @@ -1,8 +1,12 @@ -import { apiGet } from "@/api/api"; +import { apiGet, apiPost } 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"; +import type { + CreateTeamMemberInput, + TeamMemberCreated, + TeamMemberListResult, +} from "@/domain/team-members/types/team-member"; export const teamMembersApi = { getList: async (): Promise => { @@ -10,4 +14,9 @@ export const teamMembersApi = { const items = handleApiResponse(data); return { items: mapTeamMemberListItems(Array.isArray(items) ? items : []) }; }, + + create: async (input: CreateTeamMemberInput): Promise => { + const data = await apiPost(API_PATHS.teamMember.create, input); + return handleApiResponse(data); + }, }; diff --git a/src/domain/team-members/mappers/team-member-mapper.ts b/src/domain/team-members/mappers/team-member-mapper.ts index ada426ce..0d7d43e6 100644 --- a/src/domain/team-members/mappers/team-member-mapper.ts +++ b/src/domain/team-members/mappers/team-member-mapper.ts @@ -37,6 +37,7 @@ export function mapTeamMemberListItem(raw: unknown): TeamMemberListItem { color: readString(record, "color", "Color"), status, isAccountOwner: readBoolean(record, "isAccountOwner", "IsAccountOwner"), + pendingRegistration: readBoolean(record, "pendingRegistration", "PendingRegistration"), }; } diff --git a/src/domain/team-members/types/team-member.ts b/src/domain/team-members/types/team-member.ts index 9aafdb97..30bc05e6 100644 --- a/src/domain/team-members/types/team-member.ts +++ b/src/domain/team-members/types/team-member.ts @@ -8,6 +8,30 @@ export interface TeamMemberListItem { color: string; status: "Active" | "Inactive"; isAccountOwner: boolean; + pendingRegistration: boolean; +} + +export type TeamMemberRole = "Dispatcher" | "Scheduler" | "Admin"; + +export interface CreateTeamMemberInput { + name: string; + role: TeamMemberRole; + color: string; + email: string; + phone?: string; + serviceAreas: string[]; + permissionOverrides: Record; +} + +export interface TeamMemberCreated { + id: string; + name: string; + role: TeamMemberRole; + color: string; + email: string; + phone: string; + serviceAreas: string[]; + pendingRegistration: boolean; } export interface TeamMemberListResult { diff --git a/src/domain/team-members/use-cases/use-create-team-member.ts b/src/domain/team-members/use-cases/use-create-team-member.ts new file mode 100644 index 00000000..bf025222 --- /dev/null +++ b/src/domain/team-members/use-cases/use-create-team-member.ts @@ -0,0 +1,27 @@ +import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import { teamMembersApi } from "@/domain/team-members/api/team-members-api"; +import type { + CreateTeamMemberInput, + TeamMemberCreated, +} from "@/domain/team-members/types/team-member"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useCreateTeamMember(): UseMutationResult< + TeamMemberCreated, + Error, + CreateTeamMemberInput +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: teamMembersApi.create, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.teamMembers.all }); + toast.success("Team member created with pending registration."); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to create team member"); + }, + }); +} diff --git a/src/test/app/(protected)/team-members/add-team-member-dialog.test.tsx b/src/test/app/(protected)/team-members/add-team-member-dialog.test.tsx new file mode 100644 index 00000000..8bd47668 --- /dev/null +++ b/src/test/app/(protected)/team-members/add-team-member-dialog.test.tsx @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/test/test-utils"; +import { AddTeamMemberDialog } from "@/app/(protected)/team-members/_components/add-team-member-dialog"; +import { useCreateTeamMember } from "@/domain/team-members/use-cases/use-create-team-member"; + +vi.mock("@/domain/team-members/use-cases/use-create-team-member", () => ({ + useCreateTeamMember: vi.fn(), +})); + +describe("AddTeamMemberDialog", () => { + const mutate = vi.fn(); + const reset = vi.fn(); + + beforeEach(() => { + mutate.mockReset(); + reset.mockReset(); + vi.mocked(useCreateTeamMember).mockReturnValue({ + mutate, + reset, + isPending: false, + error: null, + } as unknown as ReturnType); + }); + + it("requires the required fields and a Dispatcher service area", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + await user.click(screen.getByRole("button", { name: "Add Member" })); + + expect(screen.getByText("Name is required.")).toBeInTheDocument(); + expect(screen.getByText("Color is required.")).toBeInTheDocument(); + expect(screen.getByText("Email is required.")).toBeInTheDocument(); + expect(screen.getByText("Select at least one service area.")).toBeInTheDocument(); + expect(mutate).not.toHaveBeenCalled(); + }); + + it("resets role defaults and submits customized permissions", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + await user.type(screen.getByRole("textbox", { name: "Name" }), "Taylor Dispatcher"); + await user.type(screen.getByRole("textbox", { name: "Email" }), "taylor@example.com"); + await user.click(screen.getByRole("button", { name: "Select color #F59E0B" })); + await user.click(screen.getByRole("combobox", { name: "Role" })); + await user.click(screen.getByRole("option", { name: "Scheduler" })); + + expect(screen.queryByLabelText("East")).not.toBeInTheDocument(); + const createSites = screen.getByLabelText("Create sites"); + expect(createSites).toBeChecked(); + await user.click(createSites); + await user.click(screen.getByRole("button", { name: "Add Member" })); + + expect(mutate).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Taylor Dispatcher", + role: "Scheduler", + color: "#F59E0B", + email: "taylor@example.com", + serviceAreas: [], + permissionOverrides: { createSites: 2 }, + }), + expect.any(Object), + ); + }, 15000); + + it("does not close while a member is being created", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + vi.mocked(useCreateTeamMember).mockReturnValue({ + mutate, + reset, + isPending: true, + error: null, + } as unknown as ReturnType); + renderWithProviders(, { withAuth: false }); + + await user.keyboard("{Escape}"); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it("clears a previous mutation error when reopened", () => { + const mutation = { + mutate, + reset, + isPending: false, + error: new Error("Email is already in use."), + } as unknown as ReturnType; + vi.mocked(useCreateTeamMember).mockReturnValue(mutation); + const { rerender } = renderWithProviders(, { + withAuth: false, + }); + + expect(screen.getByText("Email is already in use.")).toBeInTheDocument(); + mutation.error = null; + rerender(); + rerender(); + + expect(screen.queryByText("Email is already in use.")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/domain/team-members/api/team-members-api.test.ts b/src/test/domain/team-members/api/team-members-api.test.ts new file mode 100644 index 00000000..158917f9 --- /dev/null +++ b/src/test/domain/team-members/api/team-members-api.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { API_PATHS } from "@/api/api-paths"; + +const apiGet = vi.fn(); +const apiPost = vi.fn(); + +vi.mock("@/api/api", () => ({ + apiGet: (...args: unknown[]) => apiGet(...args), + apiPost: (...args: unknown[]) => apiPost(...args), +})); + +import { teamMembersApi } from "@/domain/team-members/api/team-members-api"; + +describe("teamMembersApi", () => { + beforeEach(() => { + apiGet.mockReset(); + apiPost.mockReset(); + }); + + it("posts the pending team member contract without invitation fields", async () => { + apiPost.mockResolvedValueOnce({ + data: { + id: "u-1", + name: "Taylor Dispatcher", + role: "Dispatcher", + pendingRegistration: true, + }, + }); + + const input = { + name: "Taylor Dispatcher", + role: "Dispatcher" as const, + color: "#F59E0B", + email: "taylor@example.com", + phone: "555-0100", + serviceAreas: ["East"], + permissionOverrides: { deleteSites: 1 as const }, + }; + + await expect(teamMembersApi.create(input)).resolves.toMatchObject({ + id: "u-1", + pendingRegistration: true, + }); + expect(apiPost).toHaveBeenCalledWith(API_PATHS.teamMember.create, input); + expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("invite"); + expect(apiPost.mock.calls[0]?.[1]).not.toHaveProperty("password"); + }); +}); diff --git a/src/test/domain/team-members/team-member-mapper.test.ts b/src/test/domain/team-members/team-member-mapper.test.ts index 7bfb208d..8ef293a1 100644 --- a/src/test/domain/team-members/team-member-mapper.test.ts +++ b/src/test/domain/team-members/team-member-mapper.test.ts @@ -23,6 +23,7 @@ describe("mapTeamMemberListItem", () => { color: "", status: "Inactive", isAccountOwner: true, + pendingRegistration: false, }); }); });