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 9edaccd5..6fdfb70d 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/src/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab.tsx index 1b8200e0..87719588 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab.tsx @@ -1,11 +1,16 @@ +import { useMemo } from "react"; import { SlideOverAuditList } from "@/app/(protected)/workorders/_components/detail/slide-over-audit-list"; +import { useWorkOrderDirectory } from "@/domain/work-orders/use-cases/use-work-orders-list"; import type { WorkOrderAuditEntry, WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import { mergeWorkOrderUsers } from "@/domain/work-orders/utils/merge-work-order-users"; type SlideOverAuditTabProps = { entries: WorkOrderAuditEntry[]; users?: WorkOrderUser[]; }; -export function SlideOverAuditTab({ entries, users }: SlideOverAuditTabProps) { - return ; +export function SlideOverAuditTab({ entries, users = [] }: SlideOverAuditTabProps) { + const { data: directory = [] } = useWorkOrderDirectory(); + const actors = useMemo(() => mergeWorkOrderUsers(users, directory), [users, directory]); + return ; } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx index 42a1bf93..cc4cf518 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx @@ -49,7 +49,7 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig className="shrink-0" style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }} /> - Select … + Select dispatcher )} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx index 47cd6ee3..565c7de1 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx @@ -298,7 +298,7 @@ export function WizardStepTypeSchedule({ Assigned To diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts index ff5d59f0..ddebf957 100644 --- a/src/domain/work-orders/api/work-orders-api.ts +++ b/src/domain/work-orders/api/work-orders-api.ts @@ -110,18 +110,34 @@ function toUrlSearchParams( return sp; } +function readOptionalString(item: Record, ...keys: string[]): string { + for (const key of keys) { + const value = item[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return ""; +} + +/** GET User serializes `Name` as FirstName + LastName; compose those fields if present. */ +function mapUserDisplayName(item: Record): string { + const firstName = readOptionalString(item, "firstName", "FirstName"); + const lastName = readOptionalString(item, "lastName", "LastName"); + const fallback = readOptionalString(item, "name", "Name", "userName", "UserName"); + if (lastName) { + return `${firstName || fallback} ${lastName}`.trim(); + } + return firstName || fallback; +} + function mapUser(raw: unknown): WorkOrderUser { const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record; const id = item.id ?? item.Id; - const name = - (typeof item.name === "string" ? item.name : undefined) ?? - (typeof item.Name === "string" ? item.Name : undefined) ?? - (typeof item.userName === "string" ? item.userName : "") ?? - ""; return { id: typeof id === "string" || typeof id === "number" ? id : "", - name, + name: mapUserDisplayName(item), }; } diff --git a/src/domain/work-orders/use-cases/use-work-orders-list.ts b/src/domain/work-orders/use-cases/use-work-orders-list.ts index 46fd94ef..b05f3a8e 100644 --- a/src/domain/work-orders/use-cases/use-work-orders-list.ts +++ b/src/domain/work-orders/use-cases/use-work-orders-list.ts @@ -54,6 +54,15 @@ export function useWorkOrderUsers(): UseQueryResult { }); } +/** Full user directory for audit actor names (Schedulers are not on the dispatcher lookup). */ +export function useWorkOrderDirectory(): UseQueryResult { + return useQuery({ + queryKey: queryKeys.workOrders.directory(), + queryFn: () => workOrdersApi.fetchUsers(), + staleTime: 5 * 60 * 1000, + }); +} + export function useWorkOrdersByLocation( locationId: string | number | undefined, search = "", diff --git a/src/domain/work-orders/utils/board-audit-action-rules.ts b/src/domain/work-orders/utils/board-audit-action-rules.ts index d286485d..bae79b4b 100644 --- a/src/domain/work-orders/utils/board-audit-action-rules.ts +++ b/src/domain/work-orders/utils/board-audit-action-rules.ts @@ -98,6 +98,24 @@ function tryCreatedRule({ fieldKey, actionKey }: AuditActionRuleContext): string return "Work order created in the system."; } +function tryAssignedToRule({ + fieldKey, + actionKey, + newValue, +}: AuditActionRuleContext): string | null { + const matches = + fieldKey === "assignto" || + fieldKey === "assignedto" || + fieldKey === "assigneeid" || + fieldKey === "dispatcherid" || + actionKey === "assignmentchanged" || + actionKey.includes("changeassignment"); + if (!matches) return null; + + const value = newValue.text ? wrapAuditValue(newValue) : '"—"'; + return `Changed Assigned To to ${value}`; +} + /** Applied in order; the first non-null result wins. */ export const BOARD_AUDIT_ACTION_RULES: AuditActionRule[] = [ tryStatusRule, @@ -105,5 +123,6 @@ export const BOARD_AUDIT_ACTION_RULES: AuditActionRule[] = [ tryScheduleRule, tryCarriedOverRule, tryPastDueRule, + tryAssignedToRule, tryCreatedRule, ]; diff --git a/src/domain/work-orders/utils/merge-work-order-users.ts b/src/domain/work-orders/utils/merge-work-order-users.ts new file mode 100644 index 00000000..dea344fb --- /dev/null +++ b/src/domain/work-orders/utils/merge-work-order-users.ts @@ -0,0 +1,18 @@ +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; + +/** Primary list wins on id collision (dispatcher lookup before directory). */ +export function mergeWorkOrderUsers( + primary: WorkOrderUser[], + extra: WorkOrderUser[], +): WorkOrderUser[] { + const byId = new Map(); + for (const user of primary) { + const key = String(user.id); + if (key) byId.set(key, user); + } + for (const user of extra) { + const key = String(user.id); + if (key && !byId.has(key)) byId.set(key, user); + } + return [...byId.values()]; +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index 293a7518..36915f91 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -25,6 +25,7 @@ export const queryKeys = { dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const, taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const, dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const, + directory: () => [...queryKeys.workOrders.all, "directory"] as const, uplifts: (workOrderId: string | number) => [...queryKeys.workOrders.all, "uplifts", workOrderId] as const, }, diff --git a/src/test/app/(protected)/workorders/assigned-to-cell.test.tsx b/src/test/app/(protected)/workorders/assigned-to-cell.test.tsx new file mode 100644 index 00000000..14484e4d --- /dev/null +++ b/src/test/app/(protected)/workorders/assigned-to-cell.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell"; + +const USERS = [ + { id: "sarah", name: "Sarah Chen" }, + { id: "morgan", name: "Morgan Blake" }, +]; + +describe("AssignedToCell", () => { + it("lets any board user pick a dispatcher without a role/disabled prop", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + + render(); + + const trigger = screen.getByRole("button", { name: /select dispatcher/i }); + expect(trigger).toBeEnabled(); + expect(trigger).not.toHaveAttribute("aria-disabled", "true"); + + await user.click(trigger); + await user.click(await screen.findByRole("button", { name: /sarah chen/i })); + + expect(onSave).toHaveBeenCalledWith("sarah", "Sarah Chen"); + }); + + it("can clear assignment to Unassigned", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + + render(); + + await user.click(screen.getByRole("button", { name: /sarah chen/i })); + await user.click(await screen.findByRole("button", { name: "Unassigned" })); + + expect(onSave).toHaveBeenCalledWith("", ""); + }); + + it("still opens with an empty dispatcher list", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + + render(); + + await user.click(screen.getByRole("button", { name: /select dispatcher/i })); + await user.click(await screen.findByRole("button", { name: "Unassigned" })); + + expect(onSave).toHaveBeenCalledWith("", ""); + }); +}); diff --git a/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts b/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts index b8c48168..441f39e2 100644 --- a/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts +++ b/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts @@ -54,4 +54,42 @@ describe("buildInlineDraftCreateInput", () => { expect(payload.targetWeek).toBe("2026-06-22"); expect(payload.scheduledDate).toBeUndefined(); }); + + it("sends assignTo when a dispatcher is selected at inline create", () => { + const draft = { + ...EMPTY_TABLE_DRAFT, + site: "BK5", + locationId: "9", + woNumber: "25003", + pocName: "Jane", + pocPhone: "555-0100", + dispatcherId: "sarah", + }; + const users = [{ id: "sarah", name: "Sarah Chen" }]; + + const input = buildInlineDraftCreateInput(draft, users, [{ id: 9, name: "BK5 · Dallas, TX" }]); + const payload = mapWizardDraftToBoardCreatePayload(input); + + expect(input.assigneeId).toBe("sarah"); + expect(input.assigneeName).toBe("Sarah Chen"); + expect(payload.assignTo).toBe("sarah"); + }); + + it("omits assignTo when Assigned To is left unassigned", () => { + const draft = { + ...EMPTY_TABLE_DRAFT, + site: "BK5", + locationId: "9", + woNumber: "25004", + pocName: "Jane", + pocPhone: "555-0100", + dispatcherId: "", + }; + + const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]); + const payload = mapWizardDraftToBoardCreatePayload(input); + + expect(input.assigneeId).toBe(""); + expect(payload).not.toHaveProperty("assignTo"); + }); }); diff --git a/src/test/app/(protected)/workorders/slide-over-audit-list-assignment.test.tsx b/src/test/app/(protected)/workorders/slide-over-audit-list-assignment.test.tsx new file mode 100644 index 00000000..7a3ff063 --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-audit-list-assignment.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { SlideOverAuditList } from "@/app/(protected)/workorders/_components/detail/slide-over-audit-list"; +import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order"; + +const ACTOR = { id: "actor-1", name: "Alex Dispatcher" }; + +function assignmentEntry(overrides: Partial = {}): WorkOrderAuditEntry { + return { + id: "a1", + fieldName: "AssignTo", + oldValue: "Unassigned", + newValue: "Morgan Blake", + action: "AssignmentChanged", + createdAt: "2026-06-01T13:10:00.000Z", + userName: "", + type: "manual", + dispatcherId: ACTOR.id, + ...overrides, + }; +} + +describe("SlideOverAuditList assignment actor", () => { + it("names who changed Assigned To from dispatcherId on the users list", () => { + render(); + + expect(screen.getByText("Alex Dispatcher")).toBeInTheDocument(); + expect(screen.getByText('Changed Assigned To to "Morgan Blake"')).toBeInTheDocument(); + }); + + it("falls back to userName when dispatcherId is not in the users list", () => { + render( + , + ); + + expect(screen.getByText("Pat Scheduler")).toBeInTheDocument(); + expect(screen.getByText('Changed Assigned To to "Morgan Blake"')).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/slide-over-audit-tab-assignment.test.tsx b/src/test/app/(protected)/workorders/slide-over-audit-tab-assignment.test.tsx new file mode 100644 index 00000000..0549bb2a --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-audit-tab-assignment.test.tsx @@ -0,0 +1,47 @@ +import { screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SlideOverAuditTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab"; +import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order"; +import { renderWithProviders } from "@/test/test-utils"; + +const fetchUsers = vi.fn(); + +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { + fetchUsers: (...args: unknown[]) => fetchUsers(...args), + }, +})); + +const DISPATCHER = { id: "d1", name: "Alex Dispatcher" }; +const SCHEDULER = { id: "s1", name: "Pat Scheduler" }; + +const entry: WorkOrderAuditEntry = { + id: "a1", + fieldName: "AssignTo", + oldValue: "Unassigned", + newValue: "Morgan Blake", + action: "AssignmentChanged", + createdAt: "2026-06-01T13:10:00.000Z", + userName: "", + type: "manual", + dispatcherId: SCHEDULER.id, +}; + +describe("SlideOverAuditTab assignment actor", () => { + beforeEach(() => { + fetchUsers.mockReset(); + fetchUsers.mockResolvedValue([SCHEDULER]); + }); + + it("names a scheduler actor who is not on the dispatcher lookup", async () => { + renderWithProviders(, { + withAuth: false, + }); + + await waitFor(() => { + expect(screen.getByText("Pat Scheduler")).toBeInTheDocument(); + }); + expect(screen.getByText('Changed Assigned To to "Morgan Blake"')).toBeInTheDocument(); + expect(screen.queryByText("s1")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/wizard-step-type-schedule-assigned-to.test.tsx b/src/test/app/(protected)/workorders/wizard-step-type-schedule-assigned-to.test.tsx new file mode 100644 index 00000000..407ce9c6 --- /dev/null +++ b/src/test/app/(protected)/workorders/wizard-step-type-schedule-assigned-to.test.tsx @@ -0,0 +1,60 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule"; +import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard"; + +const USERS = [ + { id: "sarah", name: "Sarah Chen" }, + { id: "morgan", name: "Morgan Blake" }, +]; + +function assignedToBlock() { + return screen.getByText("Assigned To").closest("div"); +} + +describe("WizardStepTypeSchedule Assigned To", () => { + it("keeps Assigned To optional and selectable without a role gate", async () => { + const user = userEvent.setup(); + const onPatch = vi.fn(); + + render( + , + ); + + const block = assignedToBlock(); + expect(block).not.toBeNull(); + expect(within(block!).getByText("optional")).toBeInTheDocument(); + + const trigger = within(block!).getByRole("button", { name: /select dispatcher/i }); + expect(trigger).toBeEnabled(); + + await user.click(trigger); + await user.click(await screen.findByRole("button", { name: "Sarah Chen" })); + + expect(onPatch).toHaveBeenCalledWith({ + assigneeId: "sarah", + assigneeName: "Sarah Chen", + }); + }); + + it("stays interactive when the dispatcher list is empty", () => { + render( + , + ); + + const block = assignedToBlock(); + expect(block).not.toBeNull(); + expect(within(block!).getByRole("button", { name: /select dispatcher/i })).toBeEnabled(); + }); +}); 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 a24381d0..d383aefc 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 @@ -200,6 +200,30 @@ describe("workOrdersApi.fetchDispatchers", () => { }); }); +const GET_USER_SERIALIZED_DIRECTORY = { + data: [ + { + Id: "s1", + Name: "Alex Smith", + }, + ], +}; + +describe("workOrdersApi.fetchUsers", () => { + beforeEach(() => { + apiGet.mockReset(); + }); + + it("maps the serialized GET User Name as the full display name", async () => { + apiGet.mockResolvedValue(GET_USER_SERIALIZED_DIRECTORY); + + const users = await workOrdersApi.fetchUsers(); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.user.list); + expect(users).toEqual([{ id: "s1", name: "Alex Smith" }]); + }); +}); + describe("workOrdersApi.getBoard", () => { beforeEach(() => { apiGet.mockReset(); diff --git a/src/test/domain/work-orders/utils/format-board-audit-action.test.ts b/src/test/domain/work-orders/utils/format-board-audit-action.test.ts index b2a9e67d..7e8da248 100644 --- a/src/test/domain/work-orders/utils/format-board-audit-action.test.ts +++ b/src/test/domain/work-orders/utils/format-board-audit-action.test.ts @@ -90,6 +90,28 @@ describe("formatBoardAuditAction", () => { ).toBe('Changed extra services to "Other", "Installation", "Preventive Maintenance"'); }); + it("formats AssignmentChanged as AAP Assigned To copy", () => { + expect( + formatBoardAuditAction({ + action: "AssignmentChanged", + fieldName: "AssignTo", + oldValue: "Unassigned", + newValue: "Morgan Blake", + }), + ).toBe('Changed Assigned To to "Morgan Blake"'); + }); + + it("formats empty AssignTo as an em dash", () => { + expect( + formatBoardAuditAction({ + action: "AssignmentChanged", + fieldName: "AssignedTo", + oldValue: "Morgan Blake", + newValue: "", + }), + ).toBe('Changed Assigned To to "—"'); + }); + it("falls back to Updated work order when empty", () => { expect( formatBoardAuditAction({ diff --git a/src/test/domain/work-orders/utils/merge-work-order-users.test.ts b/src/test/domain/work-orders/utils/merge-work-order-users.test.ts new file mode 100644 index 00000000..493b1fc9 --- /dev/null +++ b/src/test/domain/work-orders/utils/merge-work-order-users.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { mergeWorkOrderUsers } from "@/domain/work-orders/utils/merge-work-order-users"; + +describe("mergeWorkOrderUsers", () => { + it("keeps dispatcher lookup entries and adds directory-only actors", () => { + const dispatchers = [{ id: "d1", name: "Alex Dispatcher" }]; + const directory = [ + { id: "d1", name: "Alex Dispatcher (stale)" }, + { id: "s1", name: "Pat Scheduler" }, + ]; + + expect(mergeWorkOrderUsers(dispatchers, directory)).toEqual([ + { id: "d1", name: "Alex Dispatcher" }, + { id: "s1", name: "Pat Scheduler" }, + ]); + }); + + it("skips empty ids", () => { + expect(mergeWorkOrderUsers([{ id: "", name: "Bad" }], [{ id: "s1", name: "Pat" }])).toEqual([ + { id: "s1", name: "Pat" }, + ]); + }); +});