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 d54bc0b4..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" },
+ ]);
+ });
+});