Merge branch 'dev' into feature/sh-166-accessible-row-flags
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = "",
|
||||
|
|
|
|||
33
src/domain/work-orders/utils/appt-time-selection.ts
Normal 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("");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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()];
|
||||
}
|
||||
|
|
@ -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: "" };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
140
src/test/app/(protected)/workorders/appt-time-cell.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }));
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||