mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
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.
This commit is contained in:
commit
55a6ff03b1
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 |
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = "",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
18
src/domain/work-orders/utils/merge-work-order-users.ts
Normal file
18
src/domain/work-orders/utils/merge-work-order-users.ts
Normal 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()];
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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("", "");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue