diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png index f5bf551a..f3a5e057 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png index 1a5be82a..0cd62c6d 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png index 53ad9890..80fb28ce 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png index 888665b5..6487de7d 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png index f453475d..36602c27 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png index bc5a0bc2..9f45bb9b 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png index 9c1201a3..80bcdb86 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png index 9ccc65ca..f724fe2d 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png index 50f03dbf..fb262589 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png index ffa12f37..fe49083c 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png index b21a8ded..3fcea96e 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png index 907f118a..ca5acc44 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png index 97a77e79..c1d2bd63 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png index d2e41781..8fb56c7f 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png index d812c670..c046b9c5 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png index 13e7ffe3..35e6b135 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png differ diff --git a/e2e/work-orders/work-orders.visual.spec.ts b/e2e/work-orders/work-orders.visual.spec.ts index 547723a6..17f8d49b 100644 --- a/e2e/work-orders/work-orders.visual.spec.ts +++ b/e2e/work-orders/work-orders.visual.spec.ts @@ -204,6 +204,12 @@ async function openWorkOrderPage(page: Page, mode: "default" | "empty" | "error" }); } +async function expectWorkOrderPageReady(page: Page) { + await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({ + timeout: 90_000, + }); +} + async function expectStableScreenshot(page: Page, name: string) { await page.waitForTimeout(250); await page.evaluate( @@ -217,15 +223,18 @@ async function expectStableScreenshot(page: Page, name: string) { } test.describe("Work Orders deterministic pixel regression", () => { + test.setTimeout(120_000); + test("list", async ({ page }) => { await openWorkOrderPage(page); - await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible(); + await expectWorkOrderPageReady(page); await expect(page.getByText("WO-501").first()).toBeVisible(); await expectStableScreenshot(page, "wo-list.png"); }); test("filters", async ({ page }) => { await openWorkOrderPage(page); + await expectWorkOrderPageReady(page); await page.getByRole("button", { name: "Advanced Filters" }).click(); await expect(page.getByRole("dialog", { name: "Advanced Filters" })).toBeVisible(); await expectStableScreenshot(page, "wo-filters.png"); @@ -233,6 +242,7 @@ test.describe("Work Orders deterministic pixel regression", () => { test("new", async ({ page }) => { await openWorkOrderPage(page); + await expectWorkOrderPageReady(page); await page.getByRole("button", { name: "New WO" }).click(); await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible(); await expectStableScreenshot(page, "wo-new.png"); @@ -240,6 +250,7 @@ test.describe("Work Orders deterministic pixel regression", () => { test("detail", async ({ page }) => { await openWorkOrderPage(page); + await expectWorkOrderPageReady(page); const row = page.locator("#wo-row-1"); await row.hover(); await row.getByRole("button", { name: "View details" }).click(); @@ -249,7 +260,7 @@ test.describe("Work Orders deterministic pixel regression", () => { test("empty", async ({ page }) => { await openWorkOrderPage(page, "empty"); - await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible(); + await expectWorkOrderPageReady(page); await page.getByLabel("Search work orders").fill("zz"); await expect(page.getByText("No work orders match your search")).toBeVisible(); await expectStableScreenshot(page, "wo-empty.png"); @@ -257,7 +268,7 @@ test.describe("Work Orders deterministic pixel regression", () => { test("error", async ({ page }) => { await openWorkOrderPage(page, "error"); - await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible(); + await expectWorkOrderPageReady(page); const alert = page.getByRole("main").getByRole("alert"); await expect(alert).toBeVisible(); await expect(alert).toContainText(/server error/i); @@ -267,6 +278,7 @@ test.describe("Work Orders deterministic pixel regression", () => { test("mobile", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await openWorkOrderPage(page); + await expectWorkOrderPageReady(page); await expect(page.getByText("WO-501").first()).toBeVisible(); await expectStableScreenshot(page, "wo-mobile.png"); diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 0b1eb129..9b9e3e33 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..125fa434 --- /dev/null +++ b/src/app/(protected)/team-members/_components/team-members-table.tsx @@ -0,0 +1,110 @@ +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, + hasError, + tab, + onOpen, +}: { + rows: TeamMemberListItem[]; + isLoading: boolean; + hasError: boolean; + tab: "active" | "inactive"; + onOpen: (member: TeamMemberListItem) => void; +}) { + return ( + + + + + {HEADINGS.map((heading) => ( + + {heading} + + ))} + + Actions + + + + + {isLoading ? ( + + + + + + ) : hasError ? ( + + + Unable to load team members + + Check your connection and try again. + + + + ) : 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..8ce620a3 --- /dev/null +++ b/src/app/(protected)/team-members/index.tsx @@ -0,0 +1,193 @@ +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/domain/work-orders/errors/board-create-errors.ts b/src/domain/work-orders/errors/board-create-errors.ts index 446eaf5f..60437ef2 100644 --- a/src/domain/work-orders/errors/board-create-errors.ts +++ b/src/domain/work-orders/errors/board-create-errors.ts @@ -1,12 +1,12 @@ import { isHTTPError, isNetworkError, isTimeoutError } from "ky"; import { mapHttpStatusToMessage } from "@/api/api-error"; -import { DISPATCH_REQUIRED_MESSAGE } from "@/domain/work-orders/errors/board-patch-errors"; +import { + DISPATCH_REQUIRED_MESSAGE, + DUPLICATE_WO_NUMBER_MESSAGE, +} from "@/domain/work-orders/errors/board-patch-errors"; export const BOARD_CREATE_FAILED_MESSAGE = "Unable to create the work order. Please try again."; -const DUPLICATE_WO_NUMBER_MESSAGE = - "This work order number is already assigned to another work order."; - /** Statuses whose mapped copy is fixed product text, never the response body. */ const FIXED_COPY_STATUSES = new Set([401, 403, 404, 500]); diff --git a/src/domain/work-orders/errors/board-patch-errors.ts b/src/domain/work-orders/errors/board-patch-errors.ts index 00779894..98481f87 100644 --- a/src/domain/work-orders/errors/board-patch-errors.ts +++ b/src/domain/work-orders/errors/board-patch-errors.ts @@ -1,3 +1,5 @@ +import { isNetworkError, isTimeoutError } from "ky"; +import { ApiError, mapHttpStatusToMessage } from "@/api/api-error"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; export class BoardPatchConflictError extends Error { @@ -24,10 +26,64 @@ export class BoardPatchValidationError extends Error { export const DISPATCH_REQUIRED_MESSAGE = "Select a vendor company before saving."; -/** why: backend field names must never be exposed through user-facing board toasts. */ +export const BOARD_PATCH_FAILED_MESSAGE = "Unable to save the work order. Please try again."; + +export const NO_PERSISTABLE_FIELDS_MESSAGE = + "These changes cannot be saved on the board. Refresh and edit a supported field."; + +export const DUPLICATE_WO_NUMBER_MESSAGE = + "This work order number is already assigned to another work order."; + +const KNOWN_VALIDATION_COPY: ReadonlyMap = new Map([ + ["DispatchRequired", DISPATCH_REQUIRED_MESSAGE], + ["NoPersistableFields", NO_PERSISTABLE_FIELDS_MESSAGE], + // why: editing a WO number to one that already exists returns 422 DuplicateWoNumber; + // show the same product copy as create, not the generic save-failed fallback. + ["DuplicateWoNumber", DUPLICATE_WO_NUMBER_MESSAGE], +]); + +/** + * why: board PATCH validation text ("vendorId is required…", object ids, backend codes) + * must never reach the screen. Unknown codes fall back to generic copy, not `error.message`. + */ export function boardPatchValidationMessage(error: BoardPatchValidationError): string { - if (error.code === "DispatchRequired") { - return DISPATCH_REQUIRED_MESSAGE; - } - return error.message; + return KNOWN_VALIDATION_COPY.get(error.code) ?? BOARD_PATCH_FAILED_MESSAGE; +} + +export const BOARD_PATCH_CONFLICT_MESSAGE = + "This work order was changed by someone else. Refresh to see the latest before saving again."; + +export const PARTIAL_SAVE_PREFIX = "Some changes were saved, but the rest were not."; + +/** Statuses whose mapped copy is fixed product text, never the response body. */ +const FIXED_COPY_STATUSES = new Set([401, 403, 404, 500]); + +/** why: any board edit failure toast must use product copy, never backend text or field keys. */ +export function boardPatchErrorMessage(error: unknown): string { + if (error instanceof BoardPatchConflictError) { + return BOARD_PATCH_CONFLICT_MESSAGE; + } + if (error instanceof BoardPatchValidationError) { + return boardPatchValidationMessage(error); + } + if (error instanceof ApiError && FIXED_COPY_STATUSES.has(error.status)) { + return mapHttpStatusToMessage(error.status); + } + if (isNetworkError(error) || isTimeoutError(error)) { + return error.message; + } + return BOARD_PATCH_FAILED_MESSAGE; +} + +/** Thrown when a multi-field board edit fails after some fields already persisted. */ +export class BoardPatchPartialSaveError extends Error { + readonly partialRow: WorkOrderTableRow; + readonly failure: unknown; + + constructor(partialRow: WorkOrderTableRow, failure: unknown) { + super(`${PARTIAL_SAVE_PREFIX} ${boardPatchErrorMessage(failure)}`); + this.name = "BoardPatchPartialSaveError"; + this.partialRow = partialRow; + this.failure = failure; + } } diff --git a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts index a02950d3..c0fbbe65 100644 --- a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts @@ -3,9 +3,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "react-toastify"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import { - boardPatchValidationMessage, + boardPatchErrorMessage, BoardPatchConflictError, + BoardPatchPartialSaveError, BoardPatchValidationError, + NO_PERSISTABLE_FIELDS_MESSAGE, } from "@/domain/work-orders/errors/board-patch-errors"; import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper"; import { @@ -58,10 +60,7 @@ async function applyBoardFieldOps( ): Promise { const ops = expandTablePatchToBoardFieldOps(patch); if (ops.length === 0) { - throw new BoardPatchValidationError( - "NoPersistableFields", - "These changes cannot be saved on the board. Refresh and edit a supported field.", - ); + throw new BoardPatchValidationError("NoPersistableFields", NO_PERSISTABLE_FIELDS_MESSAGE); } const current = getRow?.(id); @@ -77,14 +76,7 @@ async function applyBoardFieldOps( completedOps += 1; } catch (error) { if (completedOps > 0) { - const field = op.field; - const partial = new Error( - `Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${ - error instanceof Error ? error.message : "Unknown error" - }`, - ); - (partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest; - throw partial; + throw new BoardPatchPartialSaveError(latest, error); } throw error; } @@ -155,9 +147,8 @@ export function useWorkOrderTableMutations( invalidate(); }, onError: (error: Error, { id }) => { - const partialRow = (error as Error & { partialRow?: WorkOrderTableRow }).partialRow; - if (partialRow) { - onPatch(id, partialRow); + if (error instanceof BoardPatchPartialSaveError) { + onPatch(id, error.partialRow); toast.error(error.message); invalidate(); return; @@ -165,13 +156,10 @@ export function useWorkOrderTableMutations( if (error instanceof BoardPatchConflictError && error.currentState) { onPatch(id, error.currentState); - toast.error(error.message || "Conflict — refreshed with latest data"); - } else if (error instanceof BoardPatchValidationError) { - clearPatch(id); - toast.error(boardPatchValidationMessage(error)); + toast.error(boardPatchErrorMessage(error)); } else { clearPatch(id); - toast.error(error.message || "Failed to update work order"); + toast.error(boardPatchErrorMessage(error)); } invalidate(); }, diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index df63c881..e094cf8b 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, + }); + }); +}); diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index c4599641..e295ed9d 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -634,6 +634,28 @@ describe("workOrdersApi.patchBoardField", () => { }), ).rejects.toBeInstanceOf(BoardPatchConflictError); }); + + it("throws BoardPatchValidationError with DuplicateWoNumber code on 422", async () => { + apiPatchFn.mockResolvedValue({ + ok: false, + status: 422, + json: async () => ({ + code: "DuplicateWoNumber", + message: "WO number already exists.", + }), + }); + + await expect( + workOrdersApi.patchBoardField(10, { + field: "woNumber", + value: "12345678901", + workOrderVersion: "AAAAAAA=", + }), + ).rejects.toMatchObject({ + name: "BoardPatchValidationError", + code: "DuplicateWoNumber", + }); + }); }); describe("workOrdersApi.createComment", () => { diff --git a/src/test/domain/work-orders/errors/board-patch-errors.test.ts b/src/test/domain/work-orders/errors/board-patch-errors.test.ts new file mode 100644 index 00000000..d38217cd --- /dev/null +++ b/src/test/domain/work-orders/errors/board-patch-errors.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + BOARD_PATCH_CONFLICT_MESSAGE, + BOARD_PATCH_FAILED_MESSAGE, + BoardPatchConflictError, + BoardPatchValidationError, + DUPLICATE_WO_NUMBER_MESSAGE, + boardPatchErrorMessage, +} from "@/domain/work-orders/errors/board-patch-errors"; + +describe("boardPatchErrorMessage", () => { + it("maps a DuplicateWoNumber validation error to the duplicate-WO product copy", () => { + const error = new BoardPatchValidationError("DuplicateWoNumber", "WO number already exists."); + + expect(boardPatchErrorMessage(error)).toBe(DUPLICATE_WO_NUMBER_MESSAGE); + }); + + it("never leaks backend validation text for unknown codes", () => { + const error = new BoardPatchValidationError("SomethingElse", "vendorId is required."); + + expect(boardPatchErrorMessage(error)).toBe(BOARD_PATCH_FAILED_MESSAGE); + }); + + it("maps a conflict error to the concurrent-edit copy", () => { + const error = new BoardPatchConflictError("stale", null); + + expect(boardPatchErrorMessage(error)).toBe(BOARD_PATCH_CONFLICT_MESSAGE); + }); +}); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-errors.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-errors.test.tsx new file mode 100644 index 00000000..d4105ff0 --- /dev/null +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-errors.test.tsx @@ -0,0 +1,171 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { toast } from "react-toastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + BOARD_PATCH_CONFLICT_MESSAGE, + BOARD_PATCH_FAILED_MESSAGE, + DISPATCH_REQUIRED_MESSAGE, + PARTIAL_SAVE_PREFIX, +} from "@/domain/work-orders/errors/board-patch-errors"; +import type { + WorkOrderTablePatch, + WorkOrderTableRow, +} from "@/domain/work-orders/types/work-order-table-row"; +import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import { createTestQueryClient } from "@/test/test-utils"; + +vi.mock("react-toastify", () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, +})); + +const ROW = { id: 7, rowVersion: "v1" } as WorkOrderTableRow; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +type FakeResponse = { status: number; body: Record }; + +/** Board PATCH goes through the real ky client; only the network responses are faked. */ +function respondWith(...responses: FakeResponse[]) { + const queue = [...responses]; + globalThis.fetch = vi.fn(async () => { + const next = queue.shift() ?? responses[responses.length - 1]; + return new Response(JSON.stringify(next.body), { + status: next.status, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; +} + +async function patchAndGetToast( + patch: WorkOrderTablePatch = { woNumber: "WO-100" }, + expectedCalls = 1, + onPatch: (id: string | number, patch: WorkOrderTablePatch) => void = () => undefined, +): Promise { + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch: () => undefined, + getRow: () => ROW, + }), + { wrapper }, + ); + act(() => { + result.current.patchField(7, patch); + }); + await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1)); + expect(globalThis.fetch).toHaveBeenCalledTimes(expectedCalls); + return vi.mocked(toast.error).mock.calls[0]?.[0]; +} + +describe("useWorkOrderTableMutations edit error copy", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.mocked(toast.error).mockClear(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("shows generic copy instead of backend text for an unknown 422 code", async () => { + respondWith({ + status: 422, + body: { + code: "InvalidLifecycleStatus", + message: "lifecycleStatus cannot move from Completed for workOrderId 7.", + }, + }); + + const message = await patchAndGetToast(); + + expect(message).toBe(BOARD_PATCH_FAILED_MESSAGE); + expect(message).not.toMatch(/lifecycleStatus|workOrderId|InvalidLifecycleStatus/); + }); + + it("keeps specific copy for DispatchRequired", async () => { + respondWith({ + status: 422, + body: { + code: "DispatchRequired", + message: "vendorId is required when setting appointment fields.", + }, + }); + + const message = await patchAndGetToast(); + + expect(message).toBe(DISPATCH_REQUIRED_MESSAGE); + }); + + it("shows generic copy instead of backend text for a non-422 failure", async () => { + respondWith({ status: 400, body: { message: "siteCode is required for accountId 42." } }); + + const message = await patchAndGetToast(); + + expect(message).toBe(BOARD_PATCH_FAILED_MESSAGE); + }); + + it("reports a partial save in plain language without backend field keys", async () => { + const onPatch = vi.fn(); + respondWith( + { status: 200, body: { id: 7, workOrderNumber: "WO-100", rowVersion: "v2" } }, + { + status: 422, + body: { + code: "SeverityInvalid", + message: "severity 3 is not allowed for workOrderType Emergency.", + }, + }, + ); + + const message = await patchAndGetToast({ woNumber: "WO-100", severity: 3 }, 2, onPatch); + + expect(message).toBe(`${PARTIAL_SAVE_PREFIX} ${BOARD_PATCH_FAILED_MESSAGE}`); + expect(message).not.toMatch(/severity|woNumber|workOrderType|SeverityInvalid|\d\/\d/); + expect(onPatch).toHaveBeenCalledWith(7, expect.objectContaining({ rowVersion: "v2" })); + }); + + it("keeps DispatchRequired copy inside a partial save", async () => { + respondWith( + { status: 200, body: { id: 7, workOrderNumber: "WO-100", rowVersion: "v2" } }, + { + status: 422, + body: { code: "DispatchRequired", message: "vendorId is required." }, + }, + ); + + const message = await patchAndGetToast({ woNumber: "WO-100", severity: 2 }, 2); + + expect(message).toBe(`${PARTIAL_SAVE_PREFIX} ${DISPATCH_REQUIRED_MESSAGE}`); + }); + + it("shows fixed conflict copy and still applies the latest row on a 409", async () => { + const onPatch = vi.fn(); + respondWith({ + status: 409, + body: { + message: "RowVersion mismatch for workOrderId 7 (expected v1, found v3).", + currentState: { id: 7, workOrderNumber: "WO-099", rowVersion: "v3" }, + }, + }); + + const message = await patchAndGetToast({ woNumber: "WO-100" }, 1, onPatch); + + expect(message).toBe(BOARD_PATCH_CONFLICT_MESSAGE); + expect(message).not.toMatch(/RowVersion|workOrderId|v3/); + expect(onPatch).toHaveBeenLastCalledWith(7, expect.objectContaining({ rowVersion: "v3" })); + }); + + it("shows fixed conflict copy when a 409 carries no latest row", async () => { + respondWith({ status: 409, body: { message: "RowVersion mismatch for workOrderId 7." } }); + + const message = await patchAndGetToast(); + + expect(message).toBe(BOARD_PATCH_CONFLICT_MESSAGE); + }); +});