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]
This commit is contained in:
Arthur Bassi 2026-08-31 13:51:03 -03:00 • committed by GitHub
commit d255fb6b91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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" },
]);
});
});