Compare commits

...

21 commits

Author SHA1 Message Date
Arthur Bassi
a3b889c15e
Merge branch 'dev' into feature/sh-166-accessible-row-flags 2026-08-31 16:04:27 -03:00
Arthur Bassi
88c3d08afa
Merge pull request #153 from Sea-Haven-Industries/feat/sh-131-appt-time
feat(work-orders): make appointment time optional in create flows [SH-131]
2026-08-31 15:51:12 -03:00
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
07c03e99b9 test(work-orders): assert empty apptTime board PATCH and refetch
EOF
2026-08-31 10:54:28 -03:00
Arthur Bassi
8031357dfa fix(work-orders): assert GET User full display name 2026-08-31 10:22:27 -03:00
Arthur Bassi
8b745608dc Merge branch 'feat/sh-131-appt-time' of https://github.com/Sea-Haven-Industries/shoc-frontend-new into feat/sh-131-appt-time 2026-08-31 09:36:35 -03:00
Arthur Bassi
ac9f4ff6e7
Merge branch 'dev' into feat/sh-131-appt-time 2026-08-31 09:36:18 -03:00
Arthur Bassi
15459e8d40 fix(work-orders): let picker toggle clear appointment start and end
EOF
2026-08-31 09:35:49 -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
740048508c
Merge branch 'dev' into feat/sh-131-appt-time 2026-08-28 12:31:03 -03:00
Arthur Bassi
650543abb3 test(e2e): refresh wo-mobile-navigation visual baseline 2026-08-28 11:55:29 -03:00
Arthur Bassi
fb54414ade fix(work-orders): parse start-only appointment times and refresh visual baselines 2026-08-28 11:46:54 -03:00
Arthur Bassi
8bb126b1a3 feat(work-orders): make appointment time optional in create flows
Allow empty Appt Time in wizard, table, and slide-over without blocking
create or complete. Omit appointment fields without vendorId so create
does not hit DispatchRequired.
2026-08-28 11:05:43 -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
37 changed files with 802 additions and 81 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

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

@ -121,7 +121,7 @@ export function SlideOverInfoTabEditView({
onSave={(dueDate) => onDraftChange({ dueDate })}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Appt Time">
<SlideOverInfoRow label="Appt Time (optional)">
<ApptTimeCell
apptTime={draft.apptTime}
onSave={(apptTime) => onDraftChange({ apptTime })}

View file

@ -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 (
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
{slots.map((t) => (
<button
key={t}
type="button"
onClick={() => 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)}
</button>
))}
</div>
);
}
export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(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"
>
<Clock size={11} className="shrink-0 text-[var(--color-text-muted)]" />
{display && display !== "—" ? (
{showPlaceholder ? (
<span className="text-[13px] text-[var(--color-text-muted)]">Select appt time …</span>
) : (
<span className="whitespace-nowrap text-[13px] text-[var(--color-text-secondary)]">
{display}
</span>
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
)}
<HoverChevron />
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => 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" } }}
>
<div className="grid grid-cols-2 border-b border-border">
<div className="border-r border-border">
<Text
as="p"
className="px-3 pb-1 pt-2"
style={{
fontSize: 10,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--color-text-muted)",
}}
>
<Text as="p" className="px-3 pb-1 pt-2" style={SLOT_HEADER_SX}>
Start
</Text>
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
{TIME_SLOTS.map((t) => (
<button
key={t}
type="button"
onClick={() => {
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)}
</button>
))}
</div>
<ApptTimeSlotList
slots={TIME_SLOTS}
active={start}
onPick={(t) => {
const next = nextStartSelection(start, end, t);
setStart(next.start);
setEnd(next.end);
}}
/>
</div>
<div>
<Text
as="p"
className="px-3 pb-1 pt-2"
style={{
fontSize: 10,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--color-text-muted)",
}}
>
<Text as="p" className="px-3 pb-1 pt-2" style={SLOT_HEADER_SX}>
End <span className="font-normal normal-case">(optional)</span>
</Text>
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
{TIME_SLOTS.filter((t) => !start || t > start).map((t) => (
<button
key={t}
type="button"
onClick={() => 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)}
</button>
))}
</div>
<ApptTimeSlotList
slots={endSlots}
active={end}
onPick={(t) => {
setEnd(t === end ? "" : t);
}}
/>
</div>
</div>
<div className="flex items-center justify-between px-3 py-2">
<span className="text-xs text-[var(--color-text-muted)]">
{start && end ? calcDuration(start, end) : start ? fmt12(start) : "—"}
{apptTimePopoverHint(start, end)}
</span>
<Button size="small" variant="contained" onClick={done} disabled={!start}>
<Button size="small" variant="contained" onClick={done}>
Done
</Button>
</div>

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

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

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

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

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

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

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,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(<ApptTimeCell apptTime="" onSave={vi.fn()} />);
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(<ApptTimeCell apptTime="" onSave={onSave} />);
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(<ApptTimeCell apptTime="" onSave={onSave} />);
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(<ApptTimeCell apptTime="AM before 2pm" onSave={onSave} />);
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(<ApptTimeCell apptTime="09:00 – 11:00" onSave={onSave} />);
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(<ApptTimeCell apptTime="09:00 – 11:00" onSave={onSave} />);
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(<ApptTimeCell apptTime="09:00" onSave={onSave} />);
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");
});
});

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

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

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

@ -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(
<WizardStepVendorTime
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete" }}
vendors={[]}
onUpdate={vi.fn()}
onPatch={vi.fn()}
/>,
);
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();

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

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

View file

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

View file

@ -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", () => {

View file

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

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

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

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

View file

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

View file

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