diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png index fb36ec63..52d00495 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png index 21809dbd..f9ab4659 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png index a7109395..bf2ebed4 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png index b4da848d..d812c670 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png index 6e98e3fa..5311388e 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png differ 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..0c225e74 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/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index dbb11c52..fe420159 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -121,7 +121,7 @@ export function SlideOverInfoTabEditView({ onSave={(dueDate) => onDraftChange({ dueDate })} /> - + onDraftChange({ apptTime })} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx index 1c9d160f..e43832d2 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx @@ -3,10 +3,12 @@ import { Clock } from "lucide-react"; import { Button, Popover } from "@mui/material"; import { Text } from "@/components/ui/text"; import { - calcDuration, + apptTimePopoverHint, + commitApptTimeSelection, +} from "@/domain/work-orders/utils/appt-time-selection"; +import { fmt12, formatApptRange, - formatApptTimeValue, parseApptTime, TIME_SLOTS, } from "@/domain/work-orders/utils/wizard-date-utils"; @@ -17,6 +19,62 @@ type ApptTimeCellProps = { onSave: (value: string) => void; }; +const SLOT_HEADER_SX = { + fontSize: 10, + fontWeight: 600, + textTransform: "uppercase" as const, + letterSpacing: "0.05em", + color: "var(--color-text-muted)", +}; + +function slotStyle(active: boolean): React.CSSProperties { + return { + background: active ? "var(--color-primary-tint)" : "transparent", + color: active ? "var(--color-primary)" : "var(--color-text-primary)", + fontWeight: active ? 600 : 400, + }; +} + +function nextStartSelection( + currentStart: string, + currentEnd: string, + slot: string, +): { start: string; end: string } { + if (slot === currentStart) { + return { start: "", end: "" }; + } + return { + start: slot, + end: currentEnd && currentEnd <= slot ? "" : currentEnd, + }; +} + +function ApptTimeSlotList({ + slots, + active, + onPick, +}: { + slots: string[]; + active: string; + onPick: (slot: string) => void; +}) { + return ( + + {slots.map((t) => ( + onPick(t)} + className="w-full rounded px-2 py-1 text-left text-xs transition-colors hover:bg-[#F9FAFB]" + style={slotStyle(active === t)} + > + {fmt12(t)} + + ))} + + ); +} + export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) { const [anchorEl, setAnchorEl] = useState(null); const [start, setStart] = useState(() => parseApptTime(apptTime).start); @@ -30,16 +88,13 @@ export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) { const parsed = parseApptTime(apptTime); const display = parsed.start ? formatApptRange(parsed.start, parsed.end) : apptTime; + const showPlaceholder = !display || display === "—"; + const endSlots = TIME_SLOTS.filter((t) => !start || t > start); - const slotStyle = (active: boolean): React.CSSProperties => ({ - background: active ? "var(--color-primary-tint)" : "transparent", - color: active ? "var(--color-primary)" : "var(--color-text-primary)", - fontWeight: active ? 600 : 400, - }); - + const close = () => setAnchorEl(null); const done = () => { - onSave(formatApptTimeValue(start, end)); - setAnchorEl(null); + commitApptTimeSelection(start, end, apptTime, onSave); + close(); }; return ( @@ -53,91 +108,56 @@ export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) { className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80" > - {display && display !== "—" ? ( + {showPlaceholder ? ( + Select appt time … + ) : ( {display} - ) : ( - Select … )} setAnchorEl(null)} + onClose={close} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} onClick={(e) => e.stopPropagation()} slotProps={{ paper: { className: "w-[264px] rounded-lg border border-border p-0" } }} > - + Start - - {TIME_SLOTS.map((t) => ( - { - setStart(t); - if (end && end <= t) { - setEnd(""); - } - }} - className="w-full rounded px-2 py-1 text-left text-xs transition-colors hover:bg-[#F9FAFB]" - style={slotStyle(start === t)} - > - {fmt12(t)} - - ))} - + { + const next = nextStartSelection(start, end, t); + setStart(next.start); + setEnd(next.end); + }} + /> - + End (optional) - - {TIME_SLOTS.filter((t) => !start || t > start).map((t) => ( - setEnd(t)} - className="w-full rounded px-2 py-1 text-left text-xs transition-colors hover:bg-[#F9FAFB]" - style={slotStyle(end === t)} - > - {fmt12(t)} - - ))} - + { + setEnd(t === end ? "" : t); + }} + /> - {start && end ? calcDuration(start, end) : start ? fmt12(start) : "—"} + {apptTimePopoverHint(start, end)} - + Done 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/mappers/assign-wizard-vendor-appt-fields.ts b/src/domain/work-orders/mappers/assign-wizard-vendor-appt-fields.ts index 33f8303e..43179ba9 100644 --- a/src/domain/work-orders/mappers/assign-wizard-vendor-appt-fields.ts +++ b/src/domain/work-orders/mappers/assign-wizard-vendor-appt-fields.ts @@ -2,7 +2,10 @@ import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/wor import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; import { formatApptTimeValue } from "@/domain/work-orders/utils/wizard-date-utils"; -/** Mutates `payload` with `vendorId` and, when applicable, single-day appt date/time. */ +/** + * Mutates `payload` with `vendorId` and, when applicable, single-day appt date/time. + * Board create rejects appointment fields without vendorId (`DispatchRequired`). + */ export function assignWizardVendorApptFields( payload: WorkOrderBoardCreatePayload, draft: WorkOrderWizardDraft, 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/appt-time-selection.ts b/src/domain/work-orders/utils/appt-time-selection.ts new file mode 100644 index 00000000..3ed8c19b --- /dev/null +++ b/src/domain/work-orders/utils/appt-time-selection.ts @@ -0,0 +1,33 @@ +import { + calcDuration, + formatApptTimeValue, + parseApptTime, +} from "@/domain/work-orders/utils/wizard-date-utils"; + +export function apptTimePopoverHint(start: string, end: string): string { + if (start && end) { + const duration = calcDuration(start, end); + return duration ? `Duration: ${duration}` : "—"; + } + if (start) { + return "Pick end (optional)"; + } + return "Pick a start time"; +} + +export function commitApptTimeSelection( + start: string, + end: string, + apptTime: string, + onSave: (value: string) => void, +): void { + if (start) { + onSave(formatApptTimeValue(start, end)); + return; + } + const parsed = parseApptTime(apptTime); + const isRawUnparsed = Boolean(apptTime.trim()) && !parsed.start; + if (!isRawUnparsed) { + onSave(""); + } +} 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/domain/work-orders/utils/wizard-date-utils.ts b/src/domain/work-orders/utils/wizard-date-utils.ts index 1d1f757b..48a5ddfd 100644 --- a/src/domain/work-orders/utils/wizard-date-utils.ts +++ b/src/domain/work-orders/utils/wizard-date-utils.ts @@ -151,6 +151,13 @@ export function parseApptTime(appt: string): { start: string; end: string } { } return { start: `${pad2(h)}:${single[2]}`, end: "" }; } + const startOnly = appt.trim().match(/^(\d{1,2}):(\d{2})$/); + if (startOnly) { + return { + start: `${startOnly[1].padStart(2, "0")}:${startOnly[2]}`, + end: "", + }; + } return { start: "", end: "" }; } 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/appt-time-cell.test.tsx b/src/test/app/(protected)/workorders/appt-time-cell.test.tsx new file mode 100644 index 00000000..07012d32 --- /dev/null +++ b/src/test/app/(protected)/workorders/appt-time-cell.test.tsx @@ -0,0 +1,140 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ApptTimeCell } from "@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell"; +import { + apptTimePopoverHint, + commitApptTimeSelection, +} from "@/domain/work-orders/utils/appt-time-selection"; +import { fmt12, formatApptRange } from "@/domain/work-orders/utils/wizard-date-utils"; + +describe("apptTimePopoverHint", () => { + it("asks for start when empty", () => { + expect(apptTimePopoverHint("", "")).toBe("Pick a start time"); + }); + + it("marks end as optional after start", () => { + expect(apptTimePopoverHint("08:00", "")).toBe("Pick end (optional)"); + }); + + it("prefixes duration when both ends are set", () => { + expect(apptTimePopoverHint("08:00", "10:00")).toBe("Duration: 2h"); + }); +}); + +describe("commitApptTimeSelection", () => { + it("saves an empty string when start is empty and the current value parses empty", () => { + const onSave = vi.fn(); + commitApptTimeSelection("", "", "", onSave); + expect(onSave).toHaveBeenCalledWith(""); + }); + + it("does not clear a raw unparsed appointment label", () => { + const onSave = vi.fn(); + commitApptTimeSelection("", "", "AM before 2pm", onSave); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("clears a stored start-only time when Start is empty", () => { + const onSave = vi.fn(); + commitApptTimeSelection("", "", "09:00", onSave); + expect(onSave).toHaveBeenCalledWith(""); + }); + + it("saves start only when end is omitted", () => { + const onSave = vi.fn(); + commitApptTimeSelection("09:00", "", "", onSave); + expect(onSave).toHaveBeenCalledWith("09:00"); + }); + + it("saves an en-dash range when both slots are set", () => { + const onSave = vi.fn(); + commitApptTimeSelection("09:00", "11:00", "", onSave); + expect(onSave).toHaveBeenCalledWith("09:00 – 11:00"); + }); +}); + +describe("ApptTimeCell", () => { + it("shows the AAP empty placeholder", () => { + render(); + expect(screen.getByRole("button", { name: /select appt time/i })).toBeInTheDocument(); + }); + + it("lets Done confirm an empty selection", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /select appt time/i })); + const done = await screen.findByRole("button", { name: /^done$/i }); + expect(done).toBeEnabled(); + expect(screen.getByText("Pick a start time")).toBeInTheDocument(); + await user.click(done); + + expect(onSave).toHaveBeenCalledWith(""); + }); + + it("saves start and optional end", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /select appt time/i })); + const startSlots = await screen.findAllByRole("button", { name: fmt12("09:00") }); + await user.click(startSlots[0]!); + const endSlots = screen.getAllByRole("button", { name: fmt12("11:00") }); + await user.click(endSlots[endSlots.length - 1]!); + await user.click(screen.getByRole("button", { name: /^done$/i })); + + expect(onSave).toHaveBeenCalledWith("09:00 – 11:00"); + }); + + it("keeps an unparsed label when Done is pressed without a start slot", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /am before 2pm/i })); + await user.click(await screen.findByRole("button", { name: /^done$/i })); + + expect(onSave).not.toHaveBeenCalled(); + }); + + it("clears optional end when the selected end slot is toggled off", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: formatApptRange("09:00", "11:00") })); + const endSlots = await screen.findAllByRole("button", { name: fmt12("11:00") }); + await user.click(endSlots[endSlots.length - 1]!); + await user.click(screen.getByRole("button", { name: /^done$/i })); + + expect(onSave).toHaveBeenCalledWith("09:00"); + }); + + it("clears start and end when the selected start slot is toggled off", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: formatApptRange("09:00", "11:00") })); + const startSlots = await screen.findAllByRole("button", { name: fmt12("09:00") }); + await user.click(startSlots[0]!); + await user.click(screen.getByRole("button", { name: /^done$/i })); + + expect(onSave).toHaveBeenCalledWith(""); + }); + + it("hydrates start-only stored times so Done can clear them", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: fmt12("09:00") })); + expect(await screen.findByText("Pick end (optional)")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /^done$/i })); + + expect(onSave).toHaveBeenCalledWith("09:00"); + }); +}); 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/slide-over-info-tab-edit-view-additional-contacts.test.tsx b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx index b1627674..9ebf754e 100644 --- a/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx @@ -138,6 +138,7 @@ describe("SlideOverInfoTabEditView additional contacts", () => { { withAuth: false }, ); + expect(screen.getByText("Appt Time (optional)")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /save site 1/i })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /save site 1/i })); 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/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx b/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx index 12ad3346..e2d04458 100644 --- a/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx +++ b/src/test/app/(protected)/workorders/wizard-step-vendor-time-closability.test.tsx @@ -3,6 +3,22 @@ import { describe, expect, it, vi } from "vitest"; import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time"; import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard"; +describe("WizardStepVendorTime Appt Time", () => { + it("marks Appt Time as optional", () => { + render( + , + ); + + expect(screen.getByText("Appt Time")).toBeInTheDocument(); + expect(screen.getAllByText("optional").length).toBeGreaterThan(0); + }); +}); + describe("WizardStepVendorTime closability gate", () => { it("disables Completed when closability requirements are missing", () => { const onUpdate = vi.fn(); 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/mappers/wizard-draft-mapper.test.ts b/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts index b1124749..a086e5f5 100644 --- a/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts @@ -194,6 +194,22 @@ describe("wizard step validation", () => { expect(isWizardStepValid(2, draft)).toBe(true); }); + it("does not require Appt Time for wizard step 3 or create", () => { + const draft = { + ...EMPTY_WIZARD_DRAFT, + type: "PM" as const, + woNumber: "12345678901", + locationId: "99", + siteCode: "BK5", + pocName: "Jordan", + pocPhone: "555-0100", + apptStart: "", + apptEnd: "", + }; + expect(isWizardStepValid(3, draft)).toBe(true); + expect(canCreateWorkOrderFromWizard(draft)).toBe(true); + }); + it("allows provisional SH-prefixed woNumber", () => { const draft = { ...EMPTY_WIZARD_DRAFT, diff --git a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts index 0f003070..26991511 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts @@ -299,6 +299,22 @@ describe("mapWizardDraftToBoardCreatePayload", () => { expect(payload.scheduledDate).toBeUndefined(); }); + it("omits apptTime when start and end are empty even with a vendor", () => { + const payload = mapWizardDraftToBoardCreatePayload({ + ...EMPTY_WIZARD_DRAFT, + type: "PM", + siteCode: "BK5", + vendorId: "5", + scheduledOn: "2026-06-25", + apptStart: "", + apptEnd: "", + }); + + expect(payload.vendorId).toBe(5); + expect(payload.apptDate).toBe("2026-06-25"); + expect(payload.apptTime).toBeUndefined(); + }); + it("omits appointment fields when vendor is missing but still sends techPhone and vendorNotes", () => { const payload = mapWizardDraftToBoardCreatePayload({ ...EMPTY_WIZARD_DRAFT, diff --git a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts index 3aae3dc6..25006242 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts @@ -70,6 +70,9 @@ describe("expandTablePatchToBoardFieldOps", () => { expect(expandTablePatchToBoardFieldOps({ severity: null })).toEqual([ { field: "severity", value: "" }, ]); + expect(expandTablePatchToBoardFieldOps({ apptTime: "" })).toEqual([ + { field: "apptTime", value: "" }, + ]); }); it("emits service notes modal ops in pm → extraServices → serviceNotes order", () => { diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx index 68b694fe..eed8dbce 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -537,3 +537,63 @@ describe("useWorkOrderTableMutations patchField success callbacks", () => { await waitFor(() => expect(onSuccessFirst).toHaveBeenCalledTimes(1)); }); }); + +describe("useWorkOrderTableMutations apptTime clear", () => { + it("patches empty apptTime and keeps the appointment date on refetch", async () => { + const datedRow: WorkOrderTableRow = { + ...BASE_ROW, + scheduledOn: "2026-06-25", + apptStart: "09:00", + apptEnd: "11:00", + apptTime: "09:00 – 11:00", + vendorId: "7", + primaryDispatchId: 10, + dispatchRowVersion: "disp-v1", + }; + const clearedRow: WorkOrderTableRow = { + ...datedRow, + apptStart: "", + apptEnd: "", + apptTime: "", + rowVersion: "wo-v2", + }; + patchBoardField.mockReset(); + patchBoardField.mockResolvedValue(clearedRow); + + const onPatch = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch: () => undefined, + getRow: () => datedRow, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(42, { apptTime: "" }); + }); + + await waitFor(() => + expect(onPatch).toHaveBeenCalledWith( + 42, + expect.objectContaining({ + apptTime: "", + scheduledOn: "2026-06-25", + }), + ), + ); + + expect(patchBoardField).toHaveBeenCalledWith( + 42, + expect.objectContaining({ + field: "apptTime", + value: "", + workOrderVersion: "AAAAAAA=", + dispatchVersion: "disp-v1", + primaryDispatchId: 10, + }), + ); + }); +}); 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/get-inline-create-missing.test.ts b/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts index 3ddd8dca..c5336aa1 100644 --- a/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts +++ b/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts @@ -2,11 +2,25 @@ import { describe, expect, it } from "vitest"; import { draftHasScheduleDate, getInlineCreateMissing, + INLINE_CREATE_REQ_LABELS, inlineCreateSaveHint, inlineCreateSiteError, } from "@/domain/work-orders/utils/get-inline-create-missing"; describe("getInlineCreateMissing", () => { + it("does not require Appt Time for inline create", () => { + expect( + getInlineCreateMissing({ + site: "BK5", + locationId: "12", + woNumber: "25001", + pocName: "Jane", + pocPhone: "555-0100", + }), + ).toEqual([]); + expect(Object.keys(INLINE_CREATE_REQ_LABELS)).not.toContain("apptTime"); + }); + it("returns empty when Site, WO#, and POC are filled even without schedule", () => { expect( getInlineCreateMissing({ 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" }, + ]); + }); +}); diff --git a/src/test/domain/work-orders/utils/wizard-date-utils.test.ts b/src/test/domain/work-orders/utils/wizard-date-utils.test.ts index 8f63bbbf..acf1ce97 100644 --- a/src/test/domain/work-orders/utils/wizard-date-utils.test.ts +++ b/src/test/domain/work-orders/utils/wizard-date-utils.test.ts @@ -3,10 +3,21 @@ import { fmtDateRange, fmtShortDate, pad2, + parseApptTime, rangeDayCount, todayIso, } from "@/domain/work-orders/utils/wizard-date-utils"; +describe("parseApptTime", () => { + it("parses a 24-hour start-only value", () => { + expect(parseApptTime("09:00")).toEqual({ start: "09:00", end: "" }); + }); + + it("leaves free-text labels unparsed", () => { + expect(parseApptTime("AM before 2pm")).toEqual({ start: "", end: "" }); + }); +}); + describe("fmtShortDate", () => { it("formats ISO dates as Mon D", () => { expect(fmtShortDate("2026-06-08")).toBe("Jun 8"); diff --git a/src/test/domain/work-orders/utils/wo-closability.test.ts b/src/test/domain/work-orders/utils/wo-closability.test.ts index 9d97358d..674d5d4a 100644 --- a/src/test/domain/work-orders/utils/wo-closability.test.ts +++ b/src/test/domain/work-orders/utils/wo-closability.test.ts @@ -77,6 +77,12 @@ describe("getClosabilityGaps", () => { expect(getClosabilityGaps(makeInput())).toEqual([]); }); + it("does not require appointment time to complete", () => { + const gaps = getClosabilityGaps(makeInput()); + expect(gaps).toEqual([]); + expect(gaps.join(" ")).not.toMatch(/appt/i); + }); + it("allows company without technician", () => { const gaps = getClosabilityGaps(makeInput({ tech: "" })); expect(gaps).toEqual([]);