mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 04:42:03 +00:00
Merge pull request #168 from Sea-Haven-Industries/feat/SH-170-wo-inline-type-correction
feat(work-orders): align inline type correction with the board AAP
This commit is contained in:
commit
793230f0c2
17 changed files with 612 additions and 13 deletions
|
|
@ -28,7 +28,7 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setAnchorEl(e.currentTarget);
|
setAnchorEl(e.currentTarget);
|
||||||
}}
|
}}
|
||||||
className="group/cell flex w-full items-center gap-1.5 rounded transition-opacity hover:opacity-80"
|
className="group/cell flex w-full min-w-0 items-center gap-1.5 rounded transition-opacity hover:opacity-80"
|
||||||
style={
|
style={
|
||||||
highlight
|
highlight
|
||||||
? {
|
? {
|
||||||
|
|
@ -43,13 +43,13 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<DispatcherPill user={selected} />
|
<DispatcherPill user={selected} />
|
||||||
) : (
|
) : (
|
||||||
<span className="flex items-center gap-1.5 text-[13px] text-[var(--color-text-muted)]">
|
<span className="flex min-w-0 items-center gap-1.5 text-[13px] text-[var(--color-text-muted)]">
|
||||||
<Users
|
<Users
|
||||||
size={13}
|
size={13}
|
||||||
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 dispatcher
|
<span className="truncate whitespace-nowrap">Select dispatcher</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<HoverChevron />
|
<HoverChevron />
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,10 @@ export function SeverityField({ value, onSelect }: SeverityFieldProps) {
|
||||||
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "4px" }}
|
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "4px" }}
|
||||||
>
|
>
|
||||||
Severity (from APM)
|
Severity (from APM)
|
||||||
|
<Text as="span" variant="label" tone="error">
|
||||||
|
{" "}
|
||||||
|
*
|
||||||
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
as="p"
|
as="p"
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,32 @@ type TypeCellProps = {
|
||||||
value: WOType | "";
|
value: WOType | "";
|
||||||
severity?: WorkOrderSeverity | null;
|
severity?: WorkOrderSeverity | null;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
viewOnly?: boolean;
|
||||||
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
|
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
|
function TypeCellValue({ value, q }: { value: WOType | ""; q?: string }) {
|
||||||
|
if (value) {
|
||||||
|
return <WoTableTypeLabel type={value} q={q} />;
|
||||||
|
}
|
||||||
|
return <span className="text-[13px] text-[var(--color-text-muted)]">Select type of WO</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }: TypeCellProps) {
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [modalSeed, setModalSeed] = useState<WOType | "">(value);
|
const [modalSeed, setModalSeed] = useState<WOType | "">(value);
|
||||||
const [resetSeverity, setResetSeverity] = useState(false);
|
const [resetSeverity, setResetSeverity] = useState(false);
|
||||||
|
const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null);
|
||||||
|
|
||||||
|
if (viewOnly) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center gap-1">
|
||||||
|
<TypeCellValue value={value} q={q} />
|
||||||
|
{Boolean(showSeverityBadge && severity != null) && <SeverityBadge severity={severity!} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function pick(t: WOType) {
|
function pick(t: WOType) {
|
||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
|
|
@ -43,23 +61,19 @@ export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
|
||||||
onSave(t, needsSeverity ? severity : null);
|
onSave(t, needsSeverity ? severity : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={value ? `Type of WO, ${value}` : "Select type of WO"}
|
||||||
|
title={value ? undefined : "Select type of WO"}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setAnchorEl(e.currentTarget);
|
setAnchorEl(e.currentTarget);
|
||||||
}}
|
}}
|
||||||
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
|
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
{value ? (
|
<TypeCellValue value={value} q={q} />
|
||||||
<WoTableTypeLabel type={value} q={q} />
|
|
||||||
) : (
|
|
||||||
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
|
|
||||||
)}
|
|
||||||
{Boolean(showSeverityBadge && severity != null) && (
|
{Boolean(showSeverityBadge && severity != null) && (
|
||||||
<SeverityBadge
|
<SeverityBadge
|
||||||
severity={severity!}
|
severity={severity!}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
} from "@mui/material";
|
||||||
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import {
|
import {
|
||||||
|
|
@ -76,6 +84,10 @@ export function TypeSeverityModal({
|
||||||
>
|
>
|
||||||
<DialogTitle
|
<DialogTitle
|
||||||
sx={{
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingRight: 1,
|
||||||
fontFamily: "var(--font-display)",
|
fontFamily: "var(--font-display)",
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
|
@ -83,6 +95,9 @@ export function TypeSeverityModal({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Type & severity
|
Type & severity
|
||||||
|
<IconButton aria-label="Close" onClick={() => onOpenChange(false)} size="small">
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -93,6 +108,10 @@ export function TypeSeverityModal({
|
||||||
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "6px" }}
|
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "6px" }}
|
||||||
>
|
>
|
||||||
Type
|
Type
|
||||||
|
<Text as="span" variant="label" tone="error">
|
||||||
|
{" "}
|
||||||
|
*
|
||||||
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{WO_TYPES.map((t) => (
|
{WO_TYPES.map((t) => (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import type { WorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
|
||||||
|
import type {
|
||||||
|
WorkOrderTablePatch,
|
||||||
|
WorkOrderTableRow,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import {
|
||||||
|
formatSeverityEscalationToast,
|
||||||
|
shouldAnnounceSeverityEscalation,
|
||||||
|
} from "@/domain/work-orders/utils/severity-escalation";
|
||||||
|
|
||||||
|
type PersistPatchFn = (
|
||||||
|
id: string | number,
|
||||||
|
patch: WorkOrderTablePatch,
|
||||||
|
options?: { onSuccess?: () => void },
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export function saveExistingRowType(
|
||||||
|
onPatch: PersistPatchFn,
|
||||||
|
row: Pick<WorkOrderTableRow, "id" | "woNumber" | "severity">,
|
||||||
|
type: WOType,
|
||||||
|
severity: WorkOrderSeverity | null,
|
||||||
|
) {
|
||||||
|
const patch = { type, severity };
|
||||||
|
if (!shouldAnnounceSeverityEscalation(row.severity, severity)) {
|
||||||
|
onPatch(row.id, patch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPatch(row.id, patch, {
|
||||||
|
onSuccess: () => toast.warn(formatSeverityEscalationToast(row.woNumber, severity)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@ import type { CSSProperties } from "react";
|
||||||
import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { applyAssignedToTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts";
|
import { applyAssignedToTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts";
|
||||||
|
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||||
|
import { saveExistingRowType } from "./save-existing-row-type";
|
||||||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||||
import { DateCell } from "./cells/date-cell";
|
import { DateCell } from "./cells/date-cell";
|
||||||
import { DueDateCell } from "./cells/due-date-cell";
|
import { DueDateCell } from "./cells/due-date-cell";
|
||||||
|
|
@ -103,7 +105,8 @@ export function WoTableRowCells({
|
||||||
value={row.type}
|
value={row.type}
|
||||||
severity={row.severity}
|
severity={row.severity}
|
||||||
q={search}
|
q={search}
|
||||||
onSave={(t, sev) => onPatchRow({ type: t, severity: sev })}
|
viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)}
|
||||||
|
onSave={(t, sev) => saveExistingRowType(handlers.onPatch, row, t, sev)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { SEVERITY_LEVELS } from "@/domain/work-orders/types/work-order-severity";
|
||||||
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order-detail";
|
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order-detail";
|
||||||
|
|
||||||
export type AuditActionSource = Pick<
|
export type AuditActionSource = Pick<
|
||||||
|
|
@ -26,6 +27,38 @@ export type AuditActionRuleContext = {
|
||||||
|
|
||||||
export type AuditActionRule = (ctx: AuditActionRuleContext) => string | null;
|
export type AuditActionRule = (ctx: AuditActionRuleContext) => string | null;
|
||||||
|
|
||||||
|
function formatSeverityAuditLabel(raw: string): string {
|
||||||
|
const n = Number(raw.trim());
|
||||||
|
if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) {
|
||||||
|
return SEVERITY_LEVELS[n].label;
|
||||||
|
}
|
||||||
|
const sevMatch = raw.trim().match(/^sev\s*([1-5])$/i);
|
||||||
|
if (sevMatch) {
|
||||||
|
return `SEV ${sevMatch[1]}`;
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryTypeRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||||
|
const matches =
|
||||||
|
fieldKey === "type" ||
|
||||||
|
fieldKey === "workordertype" ||
|
||||||
|
fieldKey === "wotype" ||
|
||||||
|
actionKey === "typechanged" ||
|
||||||
|
actionKey.includes("workordertype");
|
||||||
|
if (!matches || !newValue.text) return null;
|
||||||
|
|
||||||
|
return `Changed Type to ${wrapAuditValue(newValue)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trySeverityRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||||
|
const matches = fieldKey.includes("severity") || actionKey.includes("severity");
|
||||||
|
if (!matches || !newValue.text) return null;
|
||||||
|
|
||||||
|
const labeled = { text: formatSeverityAuditLabel(newValue.text), isQuotedList: false };
|
||||||
|
return `Changed Severity to ${wrapAuditValue(labeled)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function tryStatusRule({
|
function tryStatusRule({
|
||||||
fieldKey,
|
fieldKey,
|
||||||
actionKey,
|
actionKey,
|
||||||
|
|
@ -127,6 +160,8 @@ function tryAssignedToRule({
|
||||||
/** 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,
|
||||||
|
tryTypeRule,
|
||||||
|
trySeverityRule,
|
||||||
tryApptRule,
|
tryApptRule,
|
||||||
tryScheduleRule,
|
tryScheduleRule,
|
||||||
tryCarriedOverRule,
|
tryCarriedOverRule,
|
||||||
|
|
|
||||||
20
src/domain/work-orders/utils/severity-escalation.ts
Normal file
20
src/domain/work-orders/utils/severity-escalation.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import {
|
||||||
|
SEVERITY_LEVELS,
|
||||||
|
type WorkOrderSeverity,
|
||||||
|
} from "@/domain/work-orders/types/work-order-severity";
|
||||||
|
|
||||||
|
function isCriticalSeverity(severity: WorkOrderSeverity | null | undefined): severity is 1 | 2 {
|
||||||
|
return severity === 1 || severity === 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AAP: toast only when severity newly enters SEV 1/2 — not on re-save of the same value. */
|
||||||
|
export function shouldAnnounceSeverityEscalation(
|
||||||
|
previous: WorkOrderSeverity | null | undefined,
|
||||||
|
next: WorkOrderSeverity | null | undefined,
|
||||||
|
): next is 1 | 2 {
|
||||||
|
return isCriticalSeverity(next) && !isCriticalSeverity(previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSeverityEscalationToast(woNumber: string, severity: 1 | 2): string {
|
||||||
|
return `WO #${woNumber} escalated to ${SEVERITY_LEVELS[severity].label}.`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const toastWarn = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("react-toastify", () => ({
|
||||||
|
toast: { warn: (...args: unknown[]) => toastWarn(...args) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { saveExistingRowType } from "@/app/(protected)/workorders/_components/list/table/save-existing-row-type";
|
||||||
|
|
||||||
|
const row = { id: 7, woNumber: "00000024965", severity: 3 as const };
|
||||||
|
|
||||||
|
describe("saveExistingRowType", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
toastWarn.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns when severity newly enters SEV 1 only after persist onSuccess", () => {
|
||||||
|
const onPatch = vi.fn();
|
||||||
|
|
||||||
|
saveExistingRowType(onPatch, row, "Emergency", 1);
|
||||||
|
|
||||||
|
expect(toastWarn).not.toHaveBeenCalled();
|
||||||
|
expect(onPatch).toHaveBeenCalledWith(
|
||||||
|
7,
|
||||||
|
{ type: "Emergency", severity: 1 },
|
||||||
|
expect.objectContaining({ onSuccess: expect.any(Function) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { onSuccess } = onPatch.mock.calls[0]![2] as { onSuccess: () => void };
|
||||||
|
onSuccess();
|
||||||
|
expect(toastWarn).toHaveBeenCalledWith("WO #00000024965 escalated to SEV 1.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn when staying on a critical severity", () => {
|
||||||
|
const onPatch = vi.fn();
|
||||||
|
|
||||||
|
saveExistingRowType(onPatch, { ...row, severity: 1 }, "Emergency", 1);
|
||||||
|
|
||||||
|
expect(toastWarn).not.toHaveBeenCalled();
|
||||||
|
expect(onPatch).toHaveBeenCalledWith(7, { type: "Emergency", severity: 1 });
|
||||||
|
expect(onPatch.mock.calls[0]![2]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn when clearing severity on a PM correction", () => {
|
||||||
|
const onPatch = vi.fn();
|
||||||
|
|
||||||
|
saveExistingRowType(onPatch, { ...row, severity: 2 }, "PM", null);
|
||||||
|
|
||||||
|
expect(toastWarn).not.toHaveBeenCalled();
|
||||||
|
expect(onPatch).toHaveBeenCalledWith(7, { type: "PM", severity: null });
|
||||||
|
expect(onPatch.mock.calls[0]![2]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
113
src/test/app/(protected)/workorders/type-cell.test.tsx
Normal file
113
src/test/app/(protected)/workorders/type-cell.test.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
|
||||||
|
|
||||||
|
describe("TypeCell", () => {
|
||||||
|
it("shows the AAP empty placeholder", () => {
|
||||||
|
render(<TypeCell value="" onSave={vi.fn()} />);
|
||||||
|
expect(screen.getByRole("button", { name: "Select type of WO" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists PM, Reactive, Emergency, and Overdue in the popover", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<TypeCell value="PM" onSave={vi.fn()} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Type of WO, PM" }));
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /^Reactive$/ })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /^Emergency$/ })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /^Overdue$/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves PM from the popover without opening the modal", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<TypeCell value="Emergency" severity={2} onSave={onSave} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Type of WO, Emergency" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: /^PM$/ }));
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith("PM", null);
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens Type & severity when changing PM to Emergency and saves after SEV", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<TypeCell value="PM" onSave={onSave} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Type of WO, PM" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: /^Emergency$/ }));
|
||||||
|
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole("dialog", { name: /Type & severity/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /SEV 1/ }));
|
||||||
|
await user.click(screen.getByRole("button", { name: /^Save$/ }));
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith("Emergency", 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not save when Cancel is clicked on the modal", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<TypeCell value="PM" onSave={onSave} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Type of WO, PM" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: /^Emergency$/ }));
|
||||||
|
await user.click(screen.getByRole("button", { name: /^Cancel$/ }));
|
||||||
|
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears previous severity when switching Emergency to Reactive", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<TypeCell value="Emergency" severity={3} onSave={onSave} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Type of WO, Emergency" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: /^Reactive$/ }));
|
||||||
|
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
const save = screen.getByRole("button", { name: /^Save$/ });
|
||||||
|
expect(save).toBeDisabled();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /SEV 4/ }));
|
||||||
|
await user.click(save);
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith("Reactive", 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open the picker or severity modal when viewOnly", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<TypeCell value="Emergency" severity={2} viewOnly onSave={onSave} />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: "Type of WO, Emergency" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Emergency")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("SEV 2")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Emergency"));
|
||||||
|
await user.click(screen.getByText("SEV 2"));
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /^Reactive$/ })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the modal from the severity badge", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<TypeCell value="Emergency" severity={2} onSave={onSave} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText("SEV 2"));
|
||||||
|
|
||||||
|
expect(screen.getByRole("dialog", { name: /Type & severity/i })).toBeInTheDocument();
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { fireEvent, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { WoTableRowCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-cells";
|
||||||
|
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({
|
||||||
|
SiteCell: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock(
|
||||||
|
"@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell",
|
||||||
|
() => ({
|
||||||
|
EditableWONumberCell: () => null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({
|
||||||
|
TypeCell: ({ onSave }: { onSave: (type: string, severity: number) => void }) => (
|
||||||
|
<button type="button" onClick={() => onSave("Emergency", 1)}>
|
||||||
|
persist-type
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({
|
||||||
|
DateCell: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/due-date-cell", () => ({
|
||||||
|
DueDateCell: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({
|
||||||
|
AssignedToCell: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const row = { id: 7, woNumber: "00000024965", severity: 3 } as WorkOrderTableRow;
|
||||||
|
|
||||||
|
describe("WoTableRowCells type persist", () => {
|
||||||
|
it("forwards type save through patch onSuccess instead of onPatchRow", () => {
|
||||||
|
const onPatch = vi.fn();
|
||||||
|
const onPatchRow = vi.fn();
|
||||||
|
const handlers = { onPatch } as unknown as WoTableRowHandlers;
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<WoTableRowCells
|
||||||
|
row={row}
|
||||||
|
allRows={[]}
|
||||||
|
users={[]}
|
||||||
|
sites={[]}
|
||||||
|
handlers={handlers}
|
||||||
|
onPatchRow={onPatchRow}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "persist-type" }));
|
||||||
|
|
||||||
|
expect(onPatchRow).not.toHaveBeenCalled();
|
||||||
|
expect(onPatch).toHaveBeenCalledWith(
|
||||||
|
7,
|
||||||
|
{ type: "Emergency", severity: 1 },
|
||||||
|
expect.objectContaining({ onSuccess: expect.any(Function) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -134,4 +134,56 @@ describe("WoTableRow completed selective lock", () => {
|
||||||
expect(screen.queryByRole("button", { name: /in progress/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /in progress/i })).not.toBeInTheDocument();
|
||||||
expect(handlers.onPatch).not.toHaveBeenCalled();
|
expect(handlers.onPatch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not expose the type picker on a Completed row", () => {
|
||||||
|
const row = makeRow();
|
||||||
|
renderWithProviders(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<WoTableRow
|
||||||
|
row={row}
|
||||||
|
allRows={[row]}
|
||||||
|
users={[]}
|
||||||
|
sites={[]}
|
||||||
|
vendors={[]}
|
||||||
|
pmTypes={["HVAC"]}
|
||||||
|
isSelected={false}
|
||||||
|
isFlashing={false}
|
||||||
|
dragViz={{ dragId: null, overId: null }}
|
||||||
|
handlers={handlers}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: "Type of WO, PM" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("PM")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose the type picker on a Canceled row", () => {
|
||||||
|
const row = makeRow({ status: "Canceled" });
|
||||||
|
renderWithProviders(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<WoTableRow
|
||||||
|
row={row}
|
||||||
|
allRows={[row]}
|
||||||
|
users={[]}
|
||||||
|
sites={[]}
|
||||||
|
vendors={[]}
|
||||||
|
pmTypes={["HVAC"]}
|
||||||
|
isSelected={false}
|
||||||
|
isFlashing={false}
|
||||||
|
dragViz={{ dragId: null, overId: null }}
|
||||||
|
handlers={handlers}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: "Type of WO, PM" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("PM")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ vi.mock("react-toastify", () => ({
|
||||||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
|
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import { BoardPatchConflictError } from "@/domain/work-orders/errors/board-patch-errors";
|
||||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
|
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
|
||||||
import {
|
import {
|
||||||
applySchedulePatch,
|
applySchedulePatch,
|
||||||
|
|
@ -692,6 +693,113 @@ describe("useWorkOrderTableMutations apptTime clear", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("useWorkOrderTableMutations type and severity", () => {
|
||||||
|
let rowState: WorkOrderTableRow;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
getById.mockReset();
|
||||||
|
update.mockReset();
|
||||||
|
patchBoardField.mockReset();
|
||||||
|
rowState = { ...BASE_ROW, type: "PM", severity: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
it("patches severity before workOrderType and merges the API row", async () => {
|
||||||
|
const fields: string[] = [];
|
||||||
|
patchBoardField.mockImplementation(
|
||||||
|
async (
|
||||||
|
_id: unknown,
|
||||||
|
payload: { field: string; value?: string | null; workOrderVersion: string },
|
||||||
|
) => {
|
||||||
|
fields.push(payload.field);
|
||||||
|
if (payload.field === "severity") {
|
||||||
|
rowState = {
|
||||||
|
...rowState,
|
||||||
|
severity: payload.value ? (Number(payload.value) as 1) : null,
|
||||||
|
rowVersion: `${payload.workOrderVersion}-sev`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (payload.field === "workOrderType") {
|
||||||
|
rowState = {
|
||||||
|
...rowState,
|
||||||
|
type: (payload.value as WorkOrderTableRow["type"]) || rowState.type,
|
||||||
|
rowVersion: `${payload.workOrderVersion}-type`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...rowState };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPatch = vi.fn();
|
||||||
|
const { result } = renderHook(
|
||||||
|
() =>
|
||||||
|
useWorkOrderTableMutations({
|
||||||
|
onPatch,
|
||||||
|
clearPatch: () => undefined,
|
||||||
|
getRow: () => rowState,
|
||||||
|
}),
|
||||||
|
{ wrapper: makeWrapper() },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.patchField(42, { type: "Emergency", severity: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(fields).toEqual(["severity", "workOrderType"]));
|
||||||
|
expect(onPatch).toHaveBeenCalledWith(
|
||||||
|
42,
|
||||||
|
expect.objectContaining({ type: "Emergency", severity: 1 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies currentState on 409 conflict instead of clearing", async () => {
|
||||||
|
const currentState = { ...BASE_ROW, type: "PM" as const, severity: null };
|
||||||
|
patchBoardField.mockRejectedValue(new BoardPatchConflictError("stale", currentState));
|
||||||
|
|
||||||
|
const onPatch = vi.fn();
|
||||||
|
const clearPatch = vi.fn();
|
||||||
|
const { result } = renderHook(
|
||||||
|
() =>
|
||||||
|
useWorkOrderTableMutations({
|
||||||
|
onPatch,
|
||||||
|
clearPatch,
|
||||||
|
getRow: () => rowState,
|
||||||
|
}),
|
||||||
|
{ wrapper: makeWrapper() },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.patchField(42, { type: "Emergency", severity: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onPatch).toHaveBeenCalledWith(42, expect.objectContaining({ type: "PM" })),
|
||||||
|
);
|
||||||
|
expect(clearPatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back with clearPatch on a generic type patch error", async () => {
|
||||||
|
patchBoardField.mockRejectedValue(new Error("network"));
|
||||||
|
|
||||||
|
const onPatch = vi.fn();
|
||||||
|
const clearPatch = vi.fn();
|
||||||
|
const { result } = renderHook(
|
||||||
|
() =>
|
||||||
|
useWorkOrderTableMutations({
|
||||||
|
onPatch,
|
||||||
|
clearPatch,
|
||||||
|
getRow: () => rowState,
|
||||||
|
}),
|
||||||
|
{ wrapper: makeWrapper() },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.patchField(42, { type: "Emergency", severity: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(clearPatch).toHaveBeenCalledWith(42));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("useWorkOrderTableMutations audit cache refresh", () => {
|
describe("useWorkOrderTableMutations audit cache refresh", () => {
|
||||||
it("invalidates work-order queries after a successful status transition", async () => {
|
it("invalidates work-order queries after a successful status transition", async () => {
|
||||||
const client = new QueryClient({
|
const client = new QueryClient({
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,28 @@ describe("formatBoardAuditAction", () => {
|
||||||
).toBe("WO marked as Past Due. Schedule On has passed without completion.");
|
).toBe("WO marked as Past Due. Schedule On has passed without completion.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("formats type correction as Changed Type to (AAP, new value only)", () => {
|
||||||
|
expect(
|
||||||
|
formatBoardAuditAction({
|
||||||
|
action: "FieldPatched",
|
||||||
|
fieldName: "WorkOrderType",
|
||||||
|
oldValue: "PM",
|
||||||
|
newValue: "Emergency",
|
||||||
|
}),
|
||||||
|
).toBe('Changed Type to "Emergency"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats severity as Changed Severity to SEV N", () => {
|
||||||
|
expect(
|
||||||
|
formatBoardAuditAction({
|
||||||
|
action: "FieldPatched",
|
||||||
|
fieldName: "Severity",
|
||||||
|
oldValue: "3",
|
||||||
|
newValue: "1",
|
||||||
|
}),
|
||||||
|
).toBe('Changed Severity to "SEV 1"');
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to from → to for unknown fields", () => {
|
it("falls back to from → to for unknown fields", () => {
|
||||||
expect(
|
expect(
|
||||||
formatBoardAuditAction({
|
formatBoardAuditAction({
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
formatSeverityEscalationToast,
|
||||||
|
shouldAnnounceSeverityEscalation,
|
||||||
|
} from "@/domain/work-orders/utils/severity-escalation";
|
||||||
|
|
||||||
|
describe("shouldAnnounceSeverityEscalation", () => {
|
||||||
|
it("announces when severity newly enters SEV 1", () => {
|
||||||
|
expect(shouldAnnounceSeverityEscalation(3, 1)).toBe(true);
|
||||||
|
expect(shouldAnnounceSeverityEscalation(null, 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not announce a re-save of the same critical severity", () => {
|
||||||
|
expect(shouldAnnounceSeverityEscalation(1, 1)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not announce a de-escalation out of SEV 1/2", () => {
|
||||||
|
expect(shouldAnnounceSeverityEscalation(2, 3)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not announce SEV 2 to SEV 1 (already critical)", () => {
|
||||||
|
expect(shouldAnnounceSeverityEscalation(2, 1)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatSeverityEscalationToast", () => {
|
||||||
|
it("matches the AAP warning copy", () => {
|
||||||
|
expect(formatSeverityEscalationToast("00000024965", 1)).toBe(
|
||||||
|
"WO #00000024965 escalated to SEV 1.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -194,6 +194,22 @@ describe("filterByBarAndSearch", () => {
|
||||||
expect(ids).toEqual(["pd"]);
|
expect(ids).toEqual(["pd"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides a row after type correction when the bar filter no longer matches", () => {
|
||||||
|
const afterCorrection = makeRow({
|
||||||
|
id: "1",
|
||||||
|
type: "Emergency",
|
||||||
|
scheduledOn: "2026-06-03",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
filterByBarAndSearch(
|
||||||
|
[afterCorrection],
|
||||||
|
bar(weekMonday, { types: new Set<WOType>(["PM"]) }),
|
||||||
|
"",
|
||||||
|
).map((row) => row.id),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not filter by type when the set is empty", () => {
|
it("does not filter by type when the set is empty", () => {
|
||||||
const pm = makeRow({ id: "pm", type: "PM", scheduledOn: "2026-06-03" });
|
const pm = makeRow({ id: "pm", type: "PM", scheduledOn: "2026-06-03" });
|
||||||
const emergency = makeRow({ id: "em", type: "Emergency", scheduledOn: "2026-06-03" });
|
const emergency = makeRow({ id: "em", type: "Emergency", scheduledOn: "2026-06-03" });
|
||||||
|
|
|
||||||
|
|
@ -45,5 +45,10 @@ describe("work-order-lock", () => {
|
||||||
expect(canApplyWorkOrderTablePatch("Completed", { site: "BK1" })).toBe(false);
|
expect(canApplyWorkOrderTablePatch("Completed", { site: "BK1" })).toBe(false);
|
||||||
expect(canApplyWorkOrderTablePatch("Scheduled", { dispatcherId: "u2" })).toBe(true);
|
expect(canApplyWorkOrderTablePatch("Scheduled", { dispatcherId: "u2" })).toBe(true);
|
||||||
expect(canApplyWorkOrderTablePatch("Canceled", { flagColor: "red" })).toBe(false);
|
expect(canApplyWorkOrderTablePatch("Canceled", { flagColor: "red" })).toBe(false);
|
||||||
|
expect(canApplyWorkOrderTablePatch("Completed", { type: "Emergency", severity: 1 })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(canApplyWorkOrderTablePatch("Canceled", { type: "PM" })).toBe(false);
|
||||||
|
expect(canApplyWorkOrderTablePatch("Scheduled", { type: "Reactive", severity: 3 })).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue