Compare commits

...

11 commits

Author SHA1 Message Date
Arthur Bassi
a90acad331 test(work-orders): refresh New WO Linux visual baseline after merge
CI failed on 73 pixels in the board behind the wizard. Use the Linux actual so visual regression matches the merged Assigned To board.
2026-08-31 15:21:24 -03:00
Arthur Bassi
55a6ff03b1 merge(dev): keep Linux CI New WO visual baseline
The only conflict was wo-new.png (Type and schedule). Appt Time is on a later step, so origin/dev's Assigned To screenshot is the correct combined UI.
2026-08-31 15:11:43 -03:00
Arthur Bassi
d255fb6b91
Merge pull request #152 from Sea-Haven-Industries/feature/sh-127-assigned-to-ownership
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
feat(work-orders): keep Assigned To open and name audit actors [SH-127]
2026-08-31 13:51:03 -03:00
Arthur Bassi
8031357dfa fix(work-orders): assert GET User full display name 2026-08-31 10:22:27 -03:00
Arthur Bassi
e9e17dadec fix(work-orders): compose full user names from User list 2026-08-31 09:23:10 -03:00
Arthur Bassi
223a734b11
Merge branch 'dev' into feature/sh-127-assigned-to-ownership 2026-08-31 09:17:03 -03:00
Arthur Bassi
f2cd68c57c
Merge branch 'dev' into feature/sh-127-assigned-to-ownership 2026-08-28 12:31:06 -03:00
Arthur Bassi
38571d3386 test(work-orders): use Linux CI baseline for New WO screenshot 2026-08-28 10:08:23 -03:00
Arthur Bassi
7954153e9c test(work-orders): refresh New WO visual baseline for Assigned To copy 2026-08-28 09:45:38 -03:00
Arthur Bassi
0d883f625c chore: merge origin/dev into feature/sh-127-assigned-to-ownership 2026-08-28 09:27:18 -03:00
Arthur Bassi
a299b8980c feat(work-orders): keep Assigned To open and name audit actors [SH-127]
Keep assignment ungated. Match AAP unassigned copy and name audit actors.
2026-08-28 09:19:43 -03:00
17 changed files with 385 additions and 10 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 185 KiB

View file

@ -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 <SlideOverAuditList entries={entries} users={users} />;
export function SlideOverAuditTab({ entries, users = [] }: SlideOverAuditTabProps) {
const { data: directory = [] } = useWorkOrderDirectory();
const actors = useMemo(() => mergeWorkOrderUsers(users, directory), [users, directory]);
return <SlideOverAuditList entries={entries} users={actors} />;
}

View file

@ -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
</span>
)}
<HoverChevron />

View file

@ -298,7 +298,7 @@ export function WizardStepTypeSchedule({
<WizLabel optional>Assigned To</WizLabel>
<WizardFieldSelect
value={draft.assigneeId}
placeholder="Select…"
placeholder="Select dispatcher"
icon={
selectedUser ? (
<ColorDot color={getDispatcherAvatarColor(selectedUser)} />

View file

@ -110,18 +110,34 @@ function toUrlSearchParams(
return sp;
}
function readOptionalString(item: Record<string, unknown>, ...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, unknown>): 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<string, unknown>;
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),
};
}

View file

@ -54,6 +54,15 @@ export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
});
}
/** Full user directory for audit actor names (Schedulers are not on the dispatcher lookup). */
export function useWorkOrderDirectory(): UseQueryResult<WorkOrderUser[], Error> {
return useQuery({
queryKey: queryKeys.workOrders.directory(),
queryFn: () => workOrdersApi.fetchUsers(),
staleTime: 5 * 60 * 1000,
});
}
export function useWorkOrdersByLocation(
locationId: string | number | undefined,
search = "",

View file

@ -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,
];

View file

@ -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<string, WorkOrderUser>();
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()];
}

View file

@ -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,
},

View file

@ -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(<AssignedToCell dispatcherId="" users={USERS} highlight onSave={onSave} />);
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(<AssignedToCell dispatcherId="sarah" users={USERS} onSave={onSave} />);
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(<AssignedToCell dispatcherId="" users={[]} onSave={onSave} />);
await user.click(screen.getByRole("button", { name: /select dispatcher/i }));
await user.click(await screen.findByRole("button", { name: "Unassigned" }));
expect(onSave).toHaveBeenCalledWith("", "");
});
});

View file

@ -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");
});
});

View file

@ -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> = {}): 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(<SlideOverAuditList entries={[assignmentEntry()]} users={[ACTOR]} />);
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(
<SlideOverAuditList
entries={[assignmentEntry({ dispatcherId: "missing", userName: "Pat Scheduler" })]}
users={[ACTOR]}
/>,
);
expect(screen.getByText("Pat Scheduler")).toBeInTheDocument();
expect(screen.getByText('Changed Assigned To to "Morgan Blake"')).toBeInTheDocument();
});
});

View file

@ -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(<SlideOverAuditTab entries={[entry]} users={[DISPATCHER]} />, {
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();
});
});

View file

@ -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(
<WizardStepTypeSchedule
draft={EMPTY_WIZARD_DRAFT}
users={USERS}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
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(
<WizardStepTypeSchedule
draft={EMPTY_WIZARD_DRAFT}
users={[]}
onUpdate={vi.fn()}
onPatch={vi.fn()}
/>,
);
const block = assignedToBlock();
expect(block).not.toBeNull();
expect(within(block!).getByRole("button", { name: /select dispatcher/i })).toBeEnabled();
});
});

View file

@ -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();

View file

@ -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({

View file

@ -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" },
]);
});
});