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 { 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 type { WorkOrderAuditEntry, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { mergeWorkOrderUsers } from "@/domain/work-orders/utils/merge-work-order-users";
|
||||||
|
|
||||||
type SlideOverAuditTabProps = {
|
type SlideOverAuditTabProps = {
|
||||||
entries: WorkOrderAuditEntry[];
|
entries: WorkOrderAuditEntry[];
|
||||||
users?: WorkOrderUser[];
|
users?: WorkOrderUser[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SlideOverAuditTab({ entries, users }: SlideOverAuditTabProps) {
|
export function SlideOverAuditTab({ entries, users = [] }: SlideOverAuditTabProps) {
|
||||||
return <SlideOverAuditList entries={entries} users={users} />;
|
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 })}
|
onSave={(dueDate) => onDraftChange({ dueDate })}
|
||||||
/>
|
/>
|
||||||
</SlideOverInfoRow>
|
</SlideOverInfoRow>
|
||||||
<SlideOverInfoRow label="Appt Time">
|
<SlideOverInfoRow label="Appt Time (optional)">
|
||||||
<ApptTimeCell
|
<ApptTimeCell
|
||||||
apptTime={draft.apptTime}
|
apptTime={draft.apptTime}
|
||||||
onSave={(apptTime) => onDraftChange({ apptTime })}
|
onSave={(apptTime) => onDraftChange({ apptTime })}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@ import { Clock } from "lucide-react";
|
||||||
import { Button, Popover } from "@mui/material";
|
import { Button, Popover } from "@mui/material";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import {
|
import {
|
||||||
calcDuration,
|
apptTimePopoverHint,
|
||||||
|
commitApptTimeSelection,
|
||||||
|
} from "@/domain/work-orders/utils/appt-time-selection";
|
||||||
|
import {
|
||||||
fmt12,
|
fmt12,
|
||||||
formatApptRange,
|
formatApptRange,
|
||||||
formatApptTimeValue,
|
|
||||||
parseApptTime,
|
parseApptTime,
|
||||||
TIME_SLOTS,
|
TIME_SLOTS,
|
||||||
} from "@/domain/work-orders/utils/wizard-date-utils";
|
} from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
@ -17,6 +19,62 @@ type ApptTimeCellProps = {
|
||||||
onSave: (value: string) => void;
|
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) {
|
export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) {
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
const [start, setStart] = useState(() => parseApptTime(apptTime).start);
|
const [start, setStart] = useState(() => parseApptTime(apptTime).start);
|
||||||
|
|
@ -30,16 +88,13 @@ export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) {
|
||||||
|
|
||||||
const parsed = parseApptTime(apptTime);
|
const parsed = parseApptTime(apptTime);
|
||||||
const display = parsed.start ? formatApptRange(parsed.start, parsed.end) : 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 => ({
|
const close = () => setAnchorEl(null);
|
||||||
background: active ? "var(--color-primary-tint)" : "transparent",
|
|
||||||
color: active ? "var(--color-primary)" : "var(--color-text-primary)",
|
|
||||||
fontWeight: active ? 600 : 400,
|
|
||||||
});
|
|
||||||
|
|
||||||
const done = () => {
|
const done = () => {
|
||||||
onSave(formatApptTimeValue(start, end));
|
commitApptTimeSelection(start, end, apptTime, onSave);
|
||||||
setAnchorEl(null);
|
close();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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"
|
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)]" />
|
<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)]">
|
<span className="whitespace-nowrap text-[13px] text-[var(--color-text-secondary)]">
|
||||||
{display}
|
{display}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
|
|
||||||
)}
|
)}
|
||||||
<HoverChevron />
|
<HoverChevron />
|
||||||
</button>
|
</button>
|
||||||
<Popover
|
<Popover
|
||||||
open={Boolean(anchorEl)}
|
open={Boolean(anchorEl)}
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
onClose={() => setAnchorEl(null)}
|
onClose={close}
|
||||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
slotProps={{ paper: { className: "w-[264px] rounded-lg border border-border p-0" } }}
|
slotProps={{ paper: { className: "w-[264px] rounded-lg border border-border p-0" } }}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 border-b border-border">
|
<div className="grid grid-cols-2 border-b border-border">
|
||||||
<div className="border-r border-border">
|
<div className="border-r border-border">
|
||||||
<Text
|
<Text as="p" className="px-3 pb-1 pt-2" style={SLOT_HEADER_SX}>
|
||||||
as="p"
|
|
||||||
className="px-3 pb-1 pt-2"
|
|
||||||
style={{
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 600,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.05em",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Start
|
Start
|
||||||
</Text>
|
</Text>
|
||||||
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
|
<ApptTimeSlotList
|
||||||
{TIME_SLOTS.map((t) => (
|
slots={TIME_SLOTS}
|
||||||
<button
|
active={start}
|
||||||
key={t}
|
onPick={(t) => {
|
||||||
type="button"
|
const next = nextStartSelection(start, end, t);
|
||||||
onClick={() => {
|
setStart(next.start);
|
||||||
setStart(t);
|
setEnd(next.end);
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Text
|
<Text as="p" className="px-3 pb-1 pt-2" style={SLOT_HEADER_SX}>
|
||||||
as="p"
|
|
||||||
className="px-3 pb-1 pt-2"
|
|
||||||
style={{
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 600,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.05em",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
End <span className="font-normal normal-case">(optional)</span>
|
End <span className="font-normal normal-case">(optional)</span>
|
||||||
</Text>
|
</Text>
|
||||||
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
|
<ApptTimeSlotList
|
||||||
{TIME_SLOTS.filter((t) => !start || t > start).map((t) => (
|
slots={endSlots}
|
||||||
<button
|
active={end}
|
||||||
key={t}
|
onPick={(t) => {
|
||||||
type="button"
|
setEnd(t === end ? "" : t);
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between px-3 py-2">
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">
|
<span className="text-xs text-[var(--color-text-muted)]">
|
||||||
{start && end ? calcDuration(start, end) : start ? fmt12(start) : "—"}
|
{apptTimePopoverHint(start, end)}
|
||||||
</span>
|
</span>
|
||||||
<Button size="small" variant="contained" onClick={done} disabled={!start}>
|
<Button size="small" variant="contained" onClick={done}>
|
||||||
Done
|
Done
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
|
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
|
||||||
/>
|
/>
|
||||||
Select …
|
Select dispatcher
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<HoverChevron />
|
<HoverChevron />
|
||||||
|
|
|
||||||
|
|
@ -298,7 +298,7 @@ export function WizardStepTypeSchedule({
|
||||||
<WizLabel optional>Assigned To</WizLabel>
|
<WizLabel optional>Assigned To</WizLabel>
|
||||||
<WizardFieldSelect
|
<WizardFieldSelect
|
||||||
value={draft.assigneeId}
|
value={draft.assigneeId}
|
||||||
placeholder="Select…"
|
placeholder="Select dispatcher"
|
||||||
icon={
|
icon={
|
||||||
selectedUser ? (
|
selectedUser ? (
|
||||||
<ColorDot color={getDispatcherAvatarColor(selectedUser)} />
|
<ColorDot color={getDispatcherAvatarColor(selectedUser)} />
|
||||||
|
|
|
||||||
|
|
@ -110,18 +110,34 @@ function toUrlSearchParams(
|
||||||
return sp;
|
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 {
|
function mapUser(raw: unknown): WorkOrderUser {
|
||||||
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||||
const id = item.id ?? item.Id;
|
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 {
|
return {
|
||||||
id: typeof id === "string" || typeof id === "number" ? id : "",
|
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 type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import { formatApptTimeValue } from "@/domain/work-orders/utils/wizard-date-utils";
|
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(
|
export function assignWizardVendorApptFields(
|
||||||
payload: WorkOrderBoardCreatePayload,
|
payload: WorkOrderBoardCreatePayload,
|
||||||
draft: WorkOrderWizardDraft,
|
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(
|
export function useWorkOrdersByLocation(
|
||||||
locationId: string | number | undefined,
|
locationId: string | number | undefined,
|
||||||
search = "",
|
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.";
|
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. */
|
/** Applied in order; the first non-null result wins. */
|
||||||
export const BOARD_AUDIT_ACTION_RULES: AuditActionRule[] = [
|
export const BOARD_AUDIT_ACTION_RULES: AuditActionRule[] = [
|
||||||
tryStatusRule,
|
tryStatusRule,
|
||||||
|
|
@ -105,5 +123,6 @@ export const BOARD_AUDIT_ACTION_RULES: AuditActionRule[] = [
|
||||||
tryScheduleRule,
|
tryScheduleRule,
|
||||||
tryCarriedOverRule,
|
tryCarriedOverRule,
|
||||||
tryPastDueRule,
|
tryPastDueRule,
|
||||||
|
tryAssignedToRule,
|
||||||
tryCreatedRule,
|
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: "" };
|
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: "" };
|
return { start: "", end: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export const queryKeys = {
|
||||||
dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const,
|
dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const,
|
||||||
taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const,
|
taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const,
|
||||||
dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const,
|
dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const,
|
||||||
|
directory: () => [...queryKeys.workOrders.all, "directory"] as const,
|
||||||
uplifts: (workOrderId: string | number) =>
|
uplifts: (workOrderId: string | number) =>
|
||||||
[...queryKeys.workOrders.all, "uplifts", workOrderId] as const,
|
[...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.targetWeek).toBe("2026-06-22");
|
||||||
expect(payload.scheduledDate).toBeUndefined();
|
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 },
|
{ withAuth: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Appt Time (optional)")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: /save site 1/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /save site 1/i })).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /save site 1/i }));
|
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 { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
|
||||||
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
|
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", () => {
|
describe("WizardStepVendorTime closability gate", () => {
|
||||||
it("disables Completed when closability requirements are missing", () => {
|
it("disables Completed when closability requirements are missing", () => {
|
||||||
const onUpdate = vi.fn();
|
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", () => {
|
describe("workOrdersApi.getBoard", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiGet.mockReset();
|
apiGet.mockReset();
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,22 @@ describe("wizard step validation", () => {
|
||||||
expect(isWizardStepValid(2, draft)).toBe(true);
|
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", () => {
|
it("allows provisional SH-prefixed woNumber", () => {
|
||||||
const draft = {
|
const draft = {
|
||||||
...EMPTY_WIZARD_DRAFT,
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,22 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
||||||
expect(payload.scheduledDate).toBeUndefined();
|
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", () => {
|
it("omits appointment fields when vendor is missing but still sends techPhone and vendorNotes", () => {
|
||||||
const payload = mapWizardDraftToBoardCreatePayload({
|
const payload = mapWizardDraftToBoardCreatePayload({
|
||||||
...EMPTY_WIZARD_DRAFT,
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,9 @@ describe("expandTablePatchToBoardFieldOps", () => {
|
||||||
expect(expandTablePatchToBoardFieldOps({ severity: null })).toEqual([
|
expect(expandTablePatchToBoardFieldOps({ severity: null })).toEqual([
|
||||||
{ field: "severity", value: "" },
|
{ field: "severity", value: "" },
|
||||||
]);
|
]);
|
||||||
|
expect(expandTablePatchToBoardFieldOps({ apptTime: "" })).toEqual([
|
||||||
|
{ field: "apptTime", value: "" },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits service notes modal ops in pm → extraServices → serviceNotes order", () => {
|
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));
|
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"');
|
).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", () => {
|
it("falls back to Updated work order when empty", () => {
|
||||||
expect(
|
expect(
|
||||||
formatBoardAuditAction({
|
formatBoardAuditAction({
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,25 @@ import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
draftHasScheduleDate,
|
draftHasScheduleDate,
|
||||||
getInlineCreateMissing,
|
getInlineCreateMissing,
|
||||||
|
INLINE_CREATE_REQ_LABELS,
|
||||||
inlineCreateSaveHint,
|
inlineCreateSaveHint,
|
||||||
inlineCreateSiteError,
|
inlineCreateSiteError,
|
||||||
} from "@/domain/work-orders/utils/get-inline-create-missing";
|
} from "@/domain/work-orders/utils/get-inline-create-missing";
|
||||||
|
|
||||||
describe("getInlineCreateMissing", () => {
|
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", () => {
|
it("returns empty when Site, WO#, and POC are filled even without schedule", () => {
|
||||||
expect(
|
expect(
|
||||||
getInlineCreateMissing({
|
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,
|
fmtDateRange,
|
||||||
fmtShortDate,
|
fmtShortDate,
|
||||||
pad2,
|
pad2,
|
||||||
|
parseApptTime,
|
||||||
rangeDayCount,
|
rangeDayCount,
|
||||||
todayIso,
|
todayIso,
|
||||||
} from "@/domain/work-orders/utils/wizard-date-utils";
|
} 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", () => {
|
describe("fmtShortDate", () => {
|
||||||
it("formats ISO dates as Mon D", () => {
|
it("formats ISO dates as Mon D", () => {
|
||||||
expect(fmtShortDate("2026-06-08")).toBe("Jun 8");
|
expect(fmtShortDate("2026-06-08")).toBe("Jun 8");
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,12 @@ describe("getClosabilityGaps", () => {
|
||||||
expect(getClosabilityGaps(makeInput())).toEqual([]);
|
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", () => {
|
it("allows company without technician", () => {
|
||||||
const gaps = getClosabilityGaps(makeInput({ tech: "" }));
|
const gaps = getClosabilityGaps(makeInput({ tech: "" }));
|
||||||
expect(gaps).toEqual([]);
|
expect(gaps).toEqual([]);
|
||||||
|
|
|
||||||