From 5a0e907067d50f3003407343da5c3b5ded1fd714 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 26 Aug 2026 18:42:56 -0300 Subject: [PATCH 01/29] feat(work-orders): add consistent save/delete toast feedback Cancel WO and vendor assignment toast after a successful board PATCH. --- .../detail/use-slide-over-edit-state.ts | 2 + .../list/table/use-wo-table-row-handlers.ts | 16 +-- .../list/table/wo-table-row-service-cells.tsx | 4 +- .../_components/list/table/wo-table-row.tsx | 11 +- .../_components/list/table/wo-table.tsx | 8 +- .../_hooks/use-work-orders-list-page.ts | 8 +- .../use-work-order-table-mutations.ts | 21 +-- .../utils/work-order-feedback-toasts.ts | 67 ++++++++++ ...e-slide-over-edit-state-completed.test.tsx | 78 ++++++++++- ...se-wo-table-row-handlers-duplicate.test.ts | 21 +++ .../use-work-orders-list-page-sh-119.test.ts | 122 ++++++++++++++++++ ...le-row-service-cells-vendor-toast.test.tsx | 82 ++++++++++++ .../use-work-order-table-mutations.test.tsx | 4 +- .../utils/work-order-feedback-toasts.test.ts | 98 ++++++++++++++ 14 files changed, 512 insertions(+), 30 deletions(-) create mode 100644 src/domain/work-orders/utils/work-order-feedback-toasts.ts create mode 100644 src/test/app/(protected)/workorders/use-work-orders-list-page-sh-119.test.ts create mode 100644 src/test/app/(protected)/workorders/wo-table-row-service-cells-vendor-toast.test.tsx create mode 100644 src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts index e8b1b8b9..4d9be588 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts @@ -7,6 +7,7 @@ import { buildSlideOverPatch, isSlideOverDraftDirty, } from "@/domain/work-orders/utils/slide-over-draft"; +import { notifyVendorAssignedIfPatched } from "@/domain/work-orders/utils/work-order-feedback-toasts"; import { isWorkOrderCoreLocked, isWorkOrderFullyLocked, @@ -116,6 +117,7 @@ export function useSlideOverEditState({ } onSave(row.id, patch, { onSuccess: () => { + notifyVendorAssignedIfPatched(patch); setBaseline(activeDraft); setEditing(false); setShowUnsaved(false); diff --git a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts index 54d56554..20efccf6 100644 --- a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts +++ b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts @@ -1,9 +1,9 @@ import { useMemo } from "react"; -import type { - WorkOrderTablePatch, - WorkOrderTableRow, -} from "@/domain/work-orders/types/work-order-table-row"; -import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { + applySchedulePatch, + type WorkOrderTablePatchFn, +} from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week"; import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify"; @@ -19,7 +19,7 @@ type UseWoTableRowHandlersOptions = { onOpenCompDoc?: (row: WorkOrderTableRow) => void; onOpenUplifts?: (row: WorkOrderTableRow) => void; onOpenMedia: (row: WorkOrderTableRow) => void; - onPatch: (id: string | number, patch: WorkOrderTablePatch) => void; + onPatch: WorkOrderTablePatchFn; onScheduleHighlight: (id: string | number | null) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; }; @@ -76,8 +76,8 @@ export function useWoTableRowHandlers({ tableData.setSelectedId(row.id); onOpenMedia(row); }, - onPatch: (id: string | number, patch: Partial) => { - onPatch(id, patch); + onPatch: (id, patch, options) => { + onPatch(id, patch, options); }, onScheduleDate: (id: string | number, date: string) => { onPatch(id, applySchedulePatch(date, false)); diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx index 3e41ccf9..bb6d8d6c 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx @@ -10,7 +10,7 @@ import { UpliftCell } from "./cells/uplift-cell"; import { VendorCell } from "./cells/vendor-cell"; import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift"; import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils"; -import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; +import { applyVendorTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts"; import type { WoTableRowHandlers } from "./wo-table-row"; type WoTableRowServiceCellsProps = { @@ -77,7 +77,7 @@ export function WoTableRowServiceCells({ vendors={vendors} q={search} viewOnly={viewOnly} - onSave={(p) => onPatchRow(toVendorTablePatch(p))} + onSave={(p) => applyVendorTableSave(handlers.onPatch, row.id, p)} /> void; onOpenUplifts?: (row: WorkOrderTableRow) => void; onOpenMedia: (row: WorkOrderTableRow) => void; - onPatch: (id: string | number, patch: Partial) => void; + onPatch: ( + id: string | number, + patch: Partial, + options?: WorkOrderTablePatchOptions, + ) => void; onScheduleDate: (id: string | number, date: string) => void; onScheduleWeek: (id: string | number, week: string) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; @@ -84,9 +89,9 @@ export function WoTableRow({ const guardedHandlers: WoTableRowHandlers = { ...handlers, - onPatch: (id, patch) => { + onPatch: (id, patch, options) => { if (!canApplyWorkOrderTablePatch(row.status, patch)) return; - handlers.onPatch(id, patch); + handlers.onPatch(id, patch, options); }, onScheduleDate: (id, date) => { if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return; diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx index 8af5b8b7..226dab4a 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -5,10 +5,8 @@ import type { VendorDropdownItem, WorkOrderUser, } from "@/domain/work-orders/types/work-order"; -import type { - WorkOrderTablePatch, - WorkOrderTableRow, -} from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrderTablePatchFn } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog"; import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton"; import { WoDayPaginationFooter } from "./wo-day-pagination-footer"; @@ -39,7 +37,7 @@ type WoTableProps = { onOpenMedia: (row: WorkOrderTableRow) => void; onClearSearch?: () => void; onOpenAdvancedFilters?: () => void; - onPatch: (id: string | number, patch: WorkOrderTablePatch) => void; + onPatch: WorkOrderTablePatchFn; }; type WoTableEmptyShellProps = { diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts index 4ddf763e..266d84e3 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts @@ -8,6 +8,10 @@ import { } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import { + patchWorkOrderAsCanceled, + patchWorkOrderAsCompleted, +} from "@/domain/work-orders/utils/work-order-feedback-toasts"; import { guardDialogClose } from "@/lib/dialog-guard"; import { useWorkOrdersListFilters } from "./use-work-orders-list-filters"; import { useWorkOrdersTableData } from "./use-work-orders-table-data"; @@ -82,14 +86,14 @@ export function useWorkOrdersListPage() { const handleConfirmCancel = () => { if (!confirmCancel) return; - patchField(confirmCancel.id, { status: "Canceled" }); + patchWorkOrderAsCanceled(patchField, confirmCancel); setConfirmCancel(null); handleCloseSlideOver(); }; const handleConfirmComplete = () => { if (!confirmComplete) return; - patchField(confirmComplete.id, { status: "Completed" }); + patchWorkOrderAsCompleted(patchField, confirmComplete.id); setConfirmComplete(null); }; diff --git a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts index a15abc76..9b27e18b 100644 --- a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts @@ -24,6 +24,17 @@ import { queryKeys } from "@/infra/query-key/query-key"; export type { WorkOrderTablePatch }; +export type WorkOrderTablePatchOptions = { + onSuccess?: () => void; + onError?: (error: Error) => void; +}; + +export type WorkOrderTablePatchFn = ( + id: string | number, + patch: WorkOrderTablePatch, + options?: WorkOrderTablePatchOptions, +) => void; + export type WorkOrderTablePatchHandlers = { onPatch: (id: string | number, patch: WorkOrderTablePatch) => void; clearPatch: (id: string | number) => void; @@ -112,6 +123,7 @@ export function useWorkOrderTableMutations( }; const patchMutation = useMutation({ + meta: { suppressErrorToast: true }, mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) => enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)), onMutate: async ({ id, patch }) => { @@ -146,14 +158,7 @@ export function useWorkOrderTableMutations( }, }); - const patchField = ( - id: string | number, - patch: WorkOrderTablePatch, - options?: { - onSuccess?: () => void; - onError?: (error: Error) => void; - }, - ) => { + const patchField: WorkOrderTablePatchFn = (id, patch, options) => { patchMutation.mutate( { id, patch }, { diff --git a/src/domain/work-orders/utils/work-order-feedback-toasts.ts b/src/domain/work-orders/utils/work-order-feedback-toasts.ts new file mode 100644 index 00000000..9477b24b --- /dev/null +++ b/src/domain/work-orders/utils/work-order-feedback-toasts.ts @@ -0,0 +1,67 @@ +import { toast } from "react-toastify"; +import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row"; +import type { VendorAssignmentFields } from "@/domain/work-orders/utils/vendor-assignment-patch"; +import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; + +type PersistPatchFn = ( + id: string | number, + patch: WorkOrderTablePatch, + options?: { + onSuccess?: () => void; + onError?: (error: Error) => void; + }, +) => void; + +export function workOrderCanceledMessage(woNumber: string) { + return `Work order #${woNumber} canceled.`; +} + +/** + * AAP Work Orders board has no vendor-assign toast (bundle audit 2026-08-26). + * SH-119 names that silence as the gap to close; this copy is WO-specific. + */ +export function vendorAssignedMessage() { + return "Vendor assigned"; +} + +export function notifyWorkOrderCanceled(woNumber: string) { + toast.warning(workOrderCanceledMessage(woNumber)); +} + +export function notifyVendorAssigned() { + toast.success(vendorAssignedMessage()); +} + +export function isVendorAssignmentPatch(patch: WorkOrderTablePatch): boolean { + return Object.prototype.hasOwnProperty.call(patch, "vendorId"); +} + +export function notifyVendorAssignedIfPatched(patch: WorkOrderTablePatch) { + if (isVendorAssignmentPatch(patch)) notifyVendorAssigned(); +} + +export function patchWorkOrderAsCanceled( + patchField: PersistPatchFn, + row: { id: string | number; woNumber: string }, +) { + patchField( + row.id, + { status: "Canceled" }, + { + onSuccess: () => notifyWorkOrderCanceled(row.woNumber), + }, + ); +} + +/** AAP completes via document generate toast, not a dedicated Complete WO toast. */ +export function patchWorkOrderAsCompleted(patchField: PersistPatchFn, id: string | number) { + patchField(id, { status: "Completed" }); +} + +export function applyVendorTableSave( + onPatch: PersistPatchFn, + id: string | number, + fields: VendorAssignmentFields, +) { + onPatch(id, toVendorTablePatch(fields), { onSuccess: notifyVendorAssigned }); +} diff --git a/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx index bde4cad9..29279245 100644 --- a/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx +++ b/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx @@ -1,7 +1,17 @@ import { act, renderHook } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "react-toastify"; import { useSlideOverEditState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-edit-state"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { vendorAssignedMessage } from "@/domain/work-orders/utils/work-order-feedback-toasts"; + +vi.mock("react-toastify", () => ({ + toast: { + warning: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }, +})); function baseRow(overrides: Partial = {}): WorkOrderTableRow { return { @@ -56,6 +66,10 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow } describe("useSlideOverEditState completed lock", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("leaves edit mode and refuses commit when the same work order becomes completed", () => { const onSave = vi.fn(); const onClose = vi.fn(); @@ -93,4 +107,66 @@ describe("useSlideOverEditState completed lock", () => { expect(onSave).not.toHaveBeenCalled(); }); + + it("toasts vendor assigned after a successful save that includes vendorId", () => { + const onSave = vi.fn(); + const open = baseRow(); + + const { result } = renderHook(() => + useSlideOverEditState({ + row: open, + editMode: true, + infoSource: open, + onSave, + onClose: vi.fn(), + closeDisabled: false, + setTab: vi.fn(), + }), + ); + + act(() => { + result.current.handleDraftChange({ vendorId: "v2", company: "New Co", tech: "Lee" }); + }); + act(() => { + result.current.commit(); + }); + + expect(onSave).toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + const options = onSave.mock.calls[0][2] as { onSuccess: () => void }; + act(() => { + options.onSuccess(); + }); + expect(toast.success).toHaveBeenCalledWith(vendorAssignedMessage()); + }); + + it("does not toast on slide-over save when vendorId did not change", () => { + const onSave = vi.fn(); + const open = baseRow(); + + const { result } = renderHook(() => + useSlideOverEditState({ + row: open, + editMode: true, + infoSource: open, + onSave, + onClose: vi.fn(), + closeDisabled: false, + setTab: vi.fn(), + }), + ); + + act(() => { + result.current.handleDraftChange({ dispatcherName: "Blair" }); + }); + act(() => { + result.current.commit(); + }); + + const options = onSave.mock.calls[0][2] as { onSuccess: () => void }; + act(() => { + options.onSuccess(); + }); + expect(toast.success).not.toHaveBeenCalled(); + }); }); diff --git a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts index daad10bd..78f3911c 100644 --- a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts +++ b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts @@ -41,4 +41,25 @@ describe("useWoTableRowHandlers onDuplicateFound", () => { expect(onDuplicateFound).toHaveBeenCalledWith(existingRow); expect(onOpenSlideOver).not.toHaveBeenCalled(); }); + + it("forwards patch options so vendor persist can toast on success", () => { + const onPatch = vi.fn(); + const onSuccess = vi.fn(); + + const { result } = renderHook(() => + useWoTableRowHandlers({ + tableData: buildTableData(), + weekMonday: "2026-07-20", + onNavigateToDay: vi.fn(), + onOpenSlideOver: vi.fn(), + onOpenMedia: vi.fn(), + onPatch, + onScheduleHighlight: vi.fn(), + }), + ); + + result.current.onPatch("wo-1", { vendorId: "9" }, { onSuccess }); + + expect(onPatch).toHaveBeenCalledWith("wo-1", { vendorId: "9" }, { onSuccess }); + }); }); diff --git a/src/test/app/(protected)/workorders/use-work-orders-list-page-sh-119.test.ts b/src/test/app/(protected)/workorders/use-work-orders-list-page-sh-119.test.ts new file mode 100644 index 00000000..08973f0c --- /dev/null +++ b/src/test/app/(protected)/workorders/use-work-orders-list-page-sh-119.test.ts @@ -0,0 +1,122 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "react-toastify"; +import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { workOrderCanceledMessage } from "@/domain/work-orders/utils/work-order-feedback-toasts"; + +const patchField = vi.hoisted(() => vi.fn()); + +vi.mock("react-toastify", () => ({ + toast: { + warning: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({ + useWorkOrdersListFilters: () => ({ + search: "", + handleSearchChange: vi.fn(), + advOpen: false, + setAdvOpen: vi.fn(), + advApplied: null, + advInitial: EMPTY_ADVANCED_FILTERS, + applyAdvancedFilters: vi.fn(), + clearAdvancedFilters: vi.fn(), + clearAdvancedFilterChip: vi.fn(), + activeFilterCount: 0, + weekMonday: "2026-07-20", + selectedDayIso: "2026-07-20", + setWeekMonday: vi.fn(), + setSelectedDayIso: vi.fn(), + navigateToDay: vi.fn(), + dispatcherIds: new Set(), + setDispatcherIds: vi.fn(), + types: new Set(), + setTypes: vi.fn(), + }), +})); + +vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({ + useWorkOrdersTableData: () => ({ + rows: [], + totalCount: 0, + scopeTotalCount: 0, + isFetching: false, + isLoading: false, + setSelectedId: vi.fn(), + patchRow: vi.fn(), + clearRowPatch: vi.fn(), + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({ + useWorkOrderTableMutations: () => ({ + patchField, + patchFieldAsync: vi.fn(), + isPending: false, + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({ + useWorkOrderUsers: () => ({ data: [], isLoading: false }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderLocations: () => ({ data: [] }), + useWorkOrderVendors: () => ({ data: [] }), +})); + +vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({ + useDropdownOptionsByCategory: () => ({ data: [] }), +})); + +import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page"; + +const row = { id: 77, woNumber: "WO-77" } as WorkOrderTableRow; + +describe("useWorkOrdersListPage SH-119 toasts", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("wires Cancel WO to warning toast only after patch onSuccess", () => { + const { result } = renderHook(() => useWorkOrdersListPage()); + + act(() => { + result.current.setConfirmCancel(row); + }); + act(() => { + result.current.handleConfirmCancel(); + }); + + expect(patchField).toHaveBeenCalledWith( + 77, + { status: "Canceled" }, + expect.objectContaining({ onSuccess: expect.any(Function) }), + ); + expect(toast.warning).not.toHaveBeenCalled(); + + const { onSuccess } = patchField.mock.calls[0][2] as { onSuccess: () => void }; + onSuccess(); + expect(toast.warning).toHaveBeenCalledWith(workOrderCanceledMessage("WO-77")); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it("completes a work order without a confirmation toast", () => { + const { result } = renderHook(() => useWorkOrdersListPage()); + + act(() => { + result.current.setConfirmComplete(row); + }); + act(() => { + result.current.handleConfirmComplete(); + }); + + expect(patchField).toHaveBeenCalledWith(77, { status: "Completed" }); + expect(toast.success).not.toHaveBeenCalled(); + expect(toast.warning).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/app/(protected)/workorders/wo-table-row-service-cells-vendor-toast.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-service-cells-vendor-toast.test.tsx new file mode 100644 index 00000000..77d5801e --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-row-service-cells-vendor-toast.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WoTableRowServiceCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-service-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 { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; +import { notifyVendorAssigned } from "@/domain/work-orders/utils/work-order-feedback-toasts"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({ + PMTypeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({ + ApptTimeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({ + StatusCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell", () => ({ + CompDocCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/uplift-cell", () => ({ + UpliftCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({ + DateCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({ + VendorCell: ({ + onSave, + }: { + onSave: (patch: { vendorId: string; company: string; tech: string; techPhone: string }) => void; + }) => ( + + ), +})); + +const row = { id: 7, upliftSummary: { hasUplift: false, pendingCount: 0 } } as WorkOrderTableRow; + +describe("WoTableRowServiceCells vendor persist (SH-119)", () => { + it("forwards vendor save through patch onSuccess instead of onPatchRow", () => { + const onPatch = vi.fn(); + const onPatchRow = vi.fn(); + const handlers = { onPatch } as unknown as WoTableRowHandlers; + + renderWithProviders( + + + + + + +
, + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: "persist-vendor" })); + + expect(onPatchRow).not.toHaveBeenCalled(); + expect(onPatch).toHaveBeenCalledWith( + 7, + toVendorTablePatch({ + vendorId: "9", + company: "Gateway", + tech: "Pat", + techPhone: "555", + }), + { onSuccess: notifyVendorAssigned }, + ); + }); +}); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx index 1f629b09..7cb81232 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -19,7 +19,9 @@ vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ }, })); -vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } })); +vi.mock("react-toastify", () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, +})); import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper"; import { diff --git a/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts b/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts new file mode 100644 index 00000000..80623aa2 --- /dev/null +++ b/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "react-toastify"; +import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; +import { + applyVendorTableSave, + isVendorAssignmentPatch, + notifyVendorAssigned, + notifyVendorAssignedIfPatched, + patchWorkOrderAsCanceled, + patchWorkOrderAsCompleted, + vendorAssignedMessage, + workOrderCanceledMessage, +} from "@/domain/work-orders/utils/work-order-feedback-toasts"; + +vi.mock("react-toastify", () => ({ + toast: { + warning: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe("work-order-feedback-toasts (SH-119)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("builds the AAP cancel copy with the work order number", () => { + expect(workOrderCanceledMessage("20260819001")).toBe("Work order #20260819001 canceled."); + }); + + it("warns on cancel only after patch onSuccess", () => { + const patchField = vi.fn(); + patchWorkOrderAsCanceled(patchField, { id: 42, woNumber: "20260819001" }); + + expect(patchField).toHaveBeenCalledWith( + 42, + { status: "Canceled" }, + expect.objectContaining({ onSuccess: expect.any(Function) }), + ); + expect(toast.warning).not.toHaveBeenCalled(); + + const { onSuccess } = patchField.mock.calls[0][2] as { onSuccess: () => void }; + onSuccess(); + expect(toast.warning).toHaveBeenCalledWith("Work order #20260819001 canceled."); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it("does not warn when the cancel patch reports onError", () => { + const patchField = vi.fn(); + patchWorkOrderAsCanceled(patchField, { id: 42, woNumber: "20260819001" }); + const options = patchField.mock.calls[0][2] as { onSuccess?: () => void; onError?: () => void }; + options.onError?.(new Error("conflict")); + expect(toast.warning).not.toHaveBeenCalled(); + }); + + it("toasts vendor assignment only after persist onSuccess", () => { + const onPatch = vi.fn(); + const fields = { + vendorId: "9", + company: "Gateway", + tech: "Pat", + techPhone: "555", + }; + applyVendorTableSave(onPatch, 7, fields); + + expect(onPatch).toHaveBeenCalledWith( + 7, + toVendorTablePatch(fields), + expect.objectContaining({ onSuccess: notifyVendorAssigned }), + ); + expect(toast.success).not.toHaveBeenCalled(); + + const { onSuccess } = onPatch.mock.calls[0][2] as { onSuccess: () => void }; + onSuccess(); + expect(toast.success).toHaveBeenCalledWith(vendorAssignedMessage()); + expect(toast.warning).not.toHaveBeenCalled(); + }); + + it("toasts vendor assignment when a slide-over patch includes vendorId", () => { + notifyVendorAssignedIfPatched({ vendorId: "9", dispatcherId: "d1" }); + expect(toast.success).toHaveBeenCalledWith(vendorAssignedMessage()); + }); + + it("does not toast vendor assignment for non-vendor slide-over patches", () => { + expect(isVendorAssignmentPatch({ dispatcherId: "d1" })).toBe(false); + notifyVendorAssignedIfPatched({ dispatcherId: "d1" }); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it("completes a work order without a success toast (AAP has no Complete WO toast)", () => { + const patchField = vi.fn(); + patchWorkOrderAsCompleted(patchField, 42); + expect(patchField).toHaveBeenCalledWith(42, { status: "Completed" }); + expect(toast.success).not.toHaveBeenCalled(); + expect(toast.warning).not.toHaveBeenCalled(); + }); +}); From 481fa29f42184c9e87b01eb58472f2370d0139e7 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:04:46 -0400 Subject: [PATCH 02/29] ci: pin github actions to immutable shas (#144) --- .github/workflows/ci.yaml | 12 ++++++------ .github/workflows/deploy.yml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f645d5f9..f54345e6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,7 +14,7 @@ jobs: build-and-test: name: Build and test # Org reusable workflow (Node 24): format check, lint, build, unit tests. - uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@main + uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8 with: node-version: "24" @@ -35,10 +35,10 @@ jobs: env: GOVERNANCE_BASE: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || github.event_name == 'push' && github.event.before || 'origin/dev' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" cache: npm @@ -50,8 +50,8 @@ jobs: runs-on: ubuntu-latest container: mcr.microsoft.com/playwright:v1.61.1-noble steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" cache: npm @@ -59,7 +59,7 @@ jobs: - run: npm run test:e2e:visual - name: Upload visual diff artifacts if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: visual-regression-diffs path: | diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4942324e..aa72c01d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -27,7 +27,7 @@ concurrency: jobs: deploy: - uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@main + uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8 with: node-version: "24" region: us-east-1 From 8d755bcb1188e06bdfba791691e8ff820b71511b Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 26 Aug 2026 19:30:29 -0300 Subject: [PATCH 03/29] fix(work-orders): address PR review on toast feedback Scope vendor and save toasts to successful assigned-vendor and slide-over saves. Bind patch success callbacks per operation instead of shared mutate observers. --- .../detail/use-slide-over-edit-state.ts | 4 +- .../use-work-order-table-mutations.ts | 9 +-- .../utils/work-order-feedback-toasts.ts | 37 +++++++--- ...e-slide-over-edit-state-completed.test.tsx | 11 ++- ...ork-orders-list-page-cancel-toast.test.ts} | 2 +- ...le-row-service-cells-vendor-toast.test.tsx | 2 +- .../use-work-order-table-mutations.test.tsx | 52 +++++++++++++ .../utils/work-order-feedback-toasts.test.ts | 73 +++++++++++++------ 8 files changed, 146 insertions(+), 44 deletions(-) rename src/test/app/(protected)/workorders/{use-work-orders-list-page-sh-119.test.ts => use-work-orders-list-page-cancel-toast.test.ts} (98%) diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts index 4d9be588..4fe4a6b1 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts @@ -7,7 +7,7 @@ import { buildSlideOverPatch, isSlideOverDraftDirty, } from "@/domain/work-orders/utils/slide-over-draft"; -import { notifyVendorAssignedIfPatched } from "@/domain/work-orders/utils/work-order-feedback-toasts"; +import { notifySlideOverSaveSuccess } from "@/domain/work-orders/utils/work-order-feedback-toasts"; import { isWorkOrderCoreLocked, isWorkOrderFullyLocked, @@ -117,7 +117,7 @@ export function useSlideOverEditState({ } onSave(row.id, patch, { onSuccess: () => { - notifyVendorAssignedIfPatched(patch); + notifySlideOverSaveSuccess(patch); setBaseline(activeDraft); setEditing(false); setShowUnsaved(false); diff --git a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts index 9b27e18b..713ccd84 100644 --- a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts @@ -159,12 +159,9 @@ export function useWorkOrderTableMutations( }); const patchField: WorkOrderTablePatchFn = (id, patch, options) => { - patchMutation.mutate( - { id, patch }, - { - onSuccess: () => options?.onSuccess?.(), - onError: (error) => options?.onError?.(error), - }, + void patchMutation.mutateAsync({ id, patch }).then( + () => options?.onSuccess?.(), + (error: Error) => options?.onError?.(error), ); }; diff --git a/src/domain/work-orders/utils/work-order-feedback-toasts.ts b/src/domain/work-orders/utils/work-order-feedback-toasts.ts index 9477b24b..95f202bc 100644 --- a/src/domain/work-orders/utils/work-order-feedback-toasts.ts +++ b/src/domain/work-orders/utils/work-order-feedback-toasts.ts @@ -16,14 +16,14 @@ export function workOrderCanceledMessage(woNumber: string) { return `Work order #${woNumber} canceled.`; } -/** - * AAP Work Orders board has no vendor-assign toast (bundle audit 2026-08-26). - * SH-119 names that silence as the gap to close; this copy is WO-specific. - */ export function vendorAssignedMessage() { return "Vendor assigned"; } +export function workOrderSavedMessage() { + return "Work order saved successfully"; +} + export function notifyWorkOrderCanceled(woNumber: string) { toast.warning(workOrderCanceledMessage(woNumber)); } @@ -32,12 +32,26 @@ export function notifyVendorAssigned() { toast.success(vendorAssignedMessage()); } -export function isVendorAssignmentPatch(patch: WorkOrderTablePatch): boolean { - return Object.prototype.hasOwnProperty.call(patch, "vendorId"); +export function notifyWorkOrderSaved() { + toast.success(workOrderSavedMessage()); } -export function notifyVendorAssignedIfPatched(patch: WorkOrderTablePatch) { - if (isVendorAssignmentPatch(patch)) notifyVendorAssigned(); +function isAssignedVendorId(vendorId: unknown): boolean { + return vendorId != null && String(vendorId).trim() !== ""; +} + +export function isVendorAssignmentPatch(patch: WorkOrderTablePatch): boolean { + return ( + Object.prototype.hasOwnProperty.call(patch, "vendorId") && isAssignedVendorId(patch.vendorId) + ); +} + +export function notifySlideOverSaveSuccess(patch: WorkOrderTablePatch) { + if (isVendorAssignmentPatch(patch)) { + notifyVendorAssigned(); + return; + } + notifyWorkOrderSaved(); } export function patchWorkOrderAsCanceled( @@ -53,7 +67,6 @@ export function patchWorkOrderAsCanceled( ); } -/** AAP completes via document generate toast, not a dedicated Complete WO toast. */ export function patchWorkOrderAsCompleted(patchField: PersistPatchFn, id: string | number) { patchField(id, { status: "Completed" }); } @@ -63,5 +76,9 @@ export function applyVendorTableSave( id: string | number, fields: VendorAssignmentFields, ) { - onPatch(id, toVendorTablePatch(fields), { onSuccess: notifyVendorAssigned }); + onPatch( + id, + toVendorTablePatch(fields), + isAssignedVendorId(fields.vendorId) ? { onSuccess: notifyVendorAssigned } : undefined, + ); } diff --git a/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx index 29279245..1245a22f 100644 --- a/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx +++ b/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx @@ -3,7 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { toast } from "react-toastify"; import { useSlideOverEditState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-edit-state"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import { vendorAssignedMessage } from "@/domain/work-orders/utils/work-order-feedback-toasts"; +import { + vendorAssignedMessage, + workOrderSavedMessage, +} from "@/domain/work-orders/utils/work-order-feedback-toasts"; vi.mock("react-toastify", () => ({ toast: { @@ -138,9 +141,10 @@ describe("useSlideOverEditState completed lock", () => { options.onSuccess(); }); expect(toast.success).toHaveBeenCalledWith(vendorAssignedMessage()); + expect(toast.success).toHaveBeenCalledTimes(1); }); - it("does not toast on slide-over save when vendorId did not change", () => { + it("toasts generic save after a successful non-vendor slide-over patch", () => { const onSave = vi.fn(); const open = baseRow(); @@ -167,6 +171,7 @@ describe("useSlideOverEditState completed lock", () => { act(() => { options.onSuccess(); }); - expect(toast.success).not.toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(workOrderSavedMessage()); + expect(toast.success).not.toHaveBeenCalledWith(vendorAssignedMessage()); }); }); diff --git a/src/test/app/(protected)/workorders/use-work-orders-list-page-sh-119.test.ts b/src/test/app/(protected)/workorders/use-work-orders-list-page-cancel-toast.test.ts similarity index 98% rename from src/test/app/(protected)/workorders/use-work-orders-list-page-sh-119.test.ts rename to src/test/app/(protected)/workorders/use-work-orders-list-page-cancel-toast.test.ts index 08973f0c..73007eea 100644 --- a/src/test/app/(protected)/workorders/use-work-orders-list-page-sh-119.test.ts +++ b/src/test/app/(protected)/workorders/use-work-orders-list-page-cancel-toast.test.ts @@ -77,7 +77,7 @@ import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-w const row = { id: 77, woNumber: "WO-77" } as WorkOrderTableRow; -describe("useWorkOrdersListPage SH-119 toasts", () => { +describe("useWorkOrdersListPage cancel toast", () => { beforeEach(() => { vi.clearAllMocks(); }); diff --git a/src/test/app/(protected)/workorders/wo-table-row-service-cells-vendor-toast.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-service-cells-vendor-toast.test.tsx index 77d5801e..7b4653cc 100644 --- a/src/test/app/(protected)/workorders/wo-table-row-service-cells-vendor-toast.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-row-service-cells-vendor-toast.test.tsx @@ -42,7 +42,7 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", const row = { id: 7, upliftSummary: { hasUplift: false, pendingCount: 0 } } as WorkOrderTableRow; -describe("WoTableRowServiceCells vendor persist (SH-119)", () => { +describe("WoTableRowServiceCells vendor persist", () => { it("forwards vendor save through patch onSuccess instead of onPatchRow", () => { const onPatch = vi.fn(); const onPatchRow = vi.fn(); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx index 7cb81232..68b694fe 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -485,3 +485,55 @@ describe("useWorkOrderTableMutations vendor assignment round-trip", () => { expect(getClosabilityGaps(tableRowToClosabilityInput(returnedRow))).toContain("Company"); }); }); + +describe("useWorkOrderTableMutations patchField success callbacks", () => { + beforeEach(() => { + getById.mockReset(); + update.mockReset(); + patchBoardField.mockReset(); + }); + + it("invokes each onSuccess when two work orders overlap", async () => { + const rows = new Map([ + ["1", { ...BASE_ROW, id: 1, rowVersion: "v1" }], + ["2", { ...BASE_ROW, id: 2, rowVersion: "v2" }], + ]); + + let releaseFirst: () => void = () => undefined; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + patchBoardField.mockImplementation(async (id: unknown) => { + if (String(id) === "1") await firstGate; + const current = rows.get(String(id)); + if (!current) throw new Error("missing row"); + const next = { ...current, rowVersion: `${current.rowVersion}-n` }; + rows.set(String(id), next); + return next; + }); + + const onSuccessFirst = vi.fn(); + const onSuccessSecond = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch: () => undefined, + clearPatch: () => undefined, + getRow: (id) => rows.get(String(id)), + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(1, { dispatcherId: "d2" }, { onSuccess: onSuccessFirst }); + result.current.patchField(2, { dispatcherId: "d3" }, { onSuccess: onSuccessSecond }); + }); + + await waitFor(() => expect(onSuccessSecond).toHaveBeenCalledTimes(1)); + expect(onSuccessFirst).not.toHaveBeenCalled(); + + releaseFirst(); + await waitFor(() => expect(onSuccessFirst).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts b/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts index 80623aa2..ea1b7a5f 100644 --- a/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts +++ b/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts @@ -4,12 +4,13 @@ import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment import { applyVendorTableSave, isVendorAssignmentPatch, + notifySlideOverSaveSuccess, notifyVendorAssigned, - notifyVendorAssignedIfPatched, patchWorkOrderAsCanceled, patchWorkOrderAsCompleted, vendorAssignedMessage, workOrderCanceledMessage, + workOrderSavedMessage, } from "@/domain/work-orders/utils/work-order-feedback-toasts"; vi.mock("react-toastify", () => ({ @@ -20,12 +21,12 @@ vi.mock("react-toastify", () => ({ }, })); -describe("work-order-feedback-toasts (SH-119)", () => { +describe("work-order-feedback-toasts", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("builds the AAP cancel copy with the work order number", () => { + it("builds the cancel copy with the work order number", () => { expect(workOrderCanceledMessage("20260819001")).toBe("Work order #20260819001 canceled."); }); @@ -40,20 +41,13 @@ describe("work-order-feedback-toasts (SH-119)", () => { ); expect(toast.warning).not.toHaveBeenCalled(); - const { onSuccess } = patchField.mock.calls[0][2] as { onSuccess: () => void }; - onSuccess(); + const options = patchField.mock.calls[0][2] as { onSuccess: () => void; onError?: unknown }; + expect(options.onError).toBeUndefined(); + options.onSuccess(); expect(toast.warning).toHaveBeenCalledWith("Work order #20260819001 canceled."); expect(toast.success).not.toHaveBeenCalled(); }); - it("does not warn when the cancel patch reports onError", () => { - const patchField = vi.fn(); - patchWorkOrderAsCanceled(patchField, { id: 42, woNumber: "20260819001" }); - const options = patchField.mock.calls[0][2] as { onSuccess?: () => void; onError?: () => void }; - options.onError?.(new Error("conflict")); - expect(toast.warning).not.toHaveBeenCalled(); - }); - it("toasts vendor assignment only after persist onSuccess", () => { const onPatch = vi.fn(); const fields = { @@ -77,18 +71,55 @@ describe("work-order-feedback-toasts (SH-119)", () => { expect(toast.warning).not.toHaveBeenCalled(); }); - it("toasts vendor assignment when a slide-over patch includes vendorId", () => { - notifyVendorAssignedIfPatched({ vendorId: "9", dispatcherId: "d1" }); - expect(toast.success).toHaveBeenCalledWith(vendorAssignedMessage()); - }); + it("persists an empty vendor without an assigned toast", () => { + const onPatch = vi.fn(); + const fields = { + vendorId: "", + company: "", + tech: "", + techPhone: "", + }; + applyVendorTableSave(onPatch, 7, fields); - it("does not toast vendor assignment for non-vendor slide-over patches", () => { - expect(isVendorAssignmentPatch({ dispatcherId: "d1" })).toBe(false); - notifyVendorAssignedIfPatched({ dispatcherId: "d1" }); + expect(onPatch).toHaveBeenCalledWith(7, toVendorTablePatch(fields), undefined); expect(toast.success).not.toHaveBeenCalled(); }); - it("completes a work order without a success toast (AAP has no Complete WO toast)", () => { + it("persists a whitespace vendorId without an assigned toast", () => { + const onPatch = vi.fn(); + const fields = { + vendorId: " ", + company: "Gateway", + tech: "Pat", + techPhone: "555", + }; + applyVendorTableSave(onPatch, 7, fields); + + expect(onPatch).toHaveBeenCalledWith(7, toVendorTablePatch(fields), undefined); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it("toasts vendor assignment when a slide-over patch includes a vendor id", () => { + notifySlideOverSaveSuccess({ vendorId: "9", dispatcherId: "d1" }); + expect(toast.success).toHaveBeenCalledTimes(1); + expect(toast.success).toHaveBeenCalledWith(vendorAssignedMessage()); + }); + + it("toasts generic save when a slide-over patch has no assigned vendor", () => { + expect(isVendorAssignmentPatch({ dispatcherId: "d1" })).toBe(false); + notifySlideOverSaveSuccess({ dispatcherId: "d1" }); + expect(toast.success).toHaveBeenCalledTimes(1); + expect(toast.success).toHaveBeenCalledWith(workOrderSavedMessage()); + }); + + it("does not treat an empty vendorId as an assignment", () => { + expect(isVendorAssignmentPatch({ vendorId: "" })).toBe(false); + notifySlideOverSaveSuccess({ vendorId: "", dispatcherId: "d1" }); + expect(toast.success).toHaveBeenCalledWith(workOrderSavedMessage()); + expect(toast.success).not.toHaveBeenCalledWith(vendorAssignedMessage()); + }); + + it("completes a work order without a success toast", () => { const patchField = vi.fn(); patchWorkOrderAsCompleted(patchField, 42); expect(patchField).toHaveBeenCalledWith(42, { status: "Completed" }); From 2b17de221279ef7e8277cd33390f89328530cef7 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 26 Aug 2026 19:47:57 -0300 Subject: [PATCH 04/29] fix(work-orders): confirm cleared vendor saves --- .../utils/work-order-feedback-toasts.ts | 8 +++--- .../utils/work-order-feedback-toasts.test.ts | 26 ++++++++++++++++--- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/domain/work-orders/utils/work-order-feedback-toasts.ts b/src/domain/work-orders/utils/work-order-feedback-toasts.ts index 95f202bc..b7c5f3ff 100644 --- a/src/domain/work-orders/utils/work-order-feedback-toasts.ts +++ b/src/domain/work-orders/utils/work-order-feedback-toasts.ts @@ -76,9 +76,7 @@ export function applyVendorTableSave( id: string | number, fields: VendorAssignmentFields, ) { - onPatch( - id, - toVendorTablePatch(fields), - isAssignedVendorId(fields.vendorId) ? { onSuccess: notifyVendorAssigned } : undefined, - ); + onPatch(id, toVendorTablePatch(fields), { + onSuccess: isAssignedVendorId(fields.vendorId) ? notifyVendorAssigned : notifyWorkOrderSaved, + }); } diff --git a/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts b/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts index ea1b7a5f..643a02dc 100644 --- a/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts +++ b/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts @@ -71,7 +71,7 @@ describe("work-order-feedback-toasts", () => { expect(toast.warning).not.toHaveBeenCalled(); }); - it("persists an empty vendor without an assigned toast", () => { + it("shows generic save success for an empty vendor after persistence", () => { const onPatch = vi.fn(); const fields = { vendorId: "", @@ -81,11 +81,20 @@ describe("work-order-feedback-toasts", () => { }; applyVendorTableSave(onPatch, 7, fields); - expect(onPatch).toHaveBeenCalledWith(7, toVendorTablePatch(fields), undefined); + expect(onPatch).toHaveBeenCalledWith( + 7, + toVendorTablePatch(fields), + expect.objectContaining({ onSuccess: expect.any(Function) }), + ); expect(toast.success).not.toHaveBeenCalled(); + + const { onSuccess } = onPatch.mock.calls[0][2] as { onSuccess: () => void }; + onSuccess(); + expect(toast.success).toHaveBeenCalledWith(workOrderSavedMessage()); + expect(toast.success).not.toHaveBeenCalledWith(vendorAssignedMessage()); }); - it("persists a whitespace vendorId without an assigned toast", () => { + it("shows generic save success for a whitespace vendor id after persistence", () => { const onPatch = vi.fn(); const fields = { vendorId: " ", @@ -95,8 +104,17 @@ describe("work-order-feedback-toasts", () => { }; applyVendorTableSave(onPatch, 7, fields); - expect(onPatch).toHaveBeenCalledWith(7, toVendorTablePatch(fields), undefined); + expect(onPatch).toHaveBeenCalledWith( + 7, + toVendorTablePatch(fields), + expect.objectContaining({ onSuccess: expect.any(Function) }), + ); expect(toast.success).not.toHaveBeenCalled(); + + const { onSuccess } = onPatch.mock.calls[0][2] as { onSuccess: () => void }; + onSuccess(); + expect(toast.success).toHaveBeenCalledWith(workOrderSavedMessage()); + expect(toast.success).not.toHaveBeenCalledWith(vendorAssignedMessage()); }); it("toasts vendor assignment when a slide-over patch includes a vendor id", () => { From ba2e3e37c7f27cc4f6cb388386f15e1c670d09f4 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 27 Aug 2026 11:35:54 -0300 Subject: [PATCH 05/29] feat(uplifts): align Reject/Revoke Cancel and required asterisk [SH-120] --- .../_components/format-uplift-money.ts | 3 + .../uplifts/_components/reject-dialog.tsx | 100 +++++++++++++ .../uplifts/_components/revoke-dialog.tsx | 110 ++++++++++++++ .../_components/uplift-reason-field.tsx | 44 ++++++ src/app/(protected)/uplifts/index.tsx | 39 +++-- .../_components/vendor-po-sections.tsx | 10 +- .../uplift-decision-dialog.tsx | 26 ++-- .../list/table/cells/dialog-field-label.tsx | 14 +- .../uplifts/revoke-uplift-dialog.tsx | 28 ++-- src/components/ui/dialog-cancel-button.tsx | 20 +++ .../uplifts/reject-revoke-dialogs.test.tsx | 141 ++++++++++++++++++ .../uplift-queue-decision-flow.test.tsx | 81 ++++++++++ 12 files changed, 573 insertions(+), 43 deletions(-) create mode 100644 src/app/(protected)/uplifts/_components/format-uplift-money.ts create mode 100644 src/app/(protected)/uplifts/_components/reject-dialog.tsx create mode 100644 src/app/(protected)/uplifts/_components/revoke-dialog.tsx create mode 100644 src/app/(protected)/uplifts/_components/uplift-reason-field.tsx create mode 100644 src/components/ui/dialog-cancel-button.tsx create mode 100644 src/test/app/(protected)/uplifts/reject-revoke-dialogs.test.tsx create mode 100644 src/test/app/(protected)/uplifts/uplift-queue-decision-flow.test.tsx diff --git a/src/app/(protected)/uplifts/_components/format-uplift-money.ts b/src/app/(protected)/uplifts/_components/format-uplift-money.ts new file mode 100644 index 00000000..4f269493 --- /dev/null +++ b/src/app/(protected)/uplifts/_components/format-uplift-money.ts @@ -0,0 +1,3 @@ +export function formatUpliftMoney(value: number): string { + return `$${value.toLocaleString("en-US", { maximumFractionDigits: 2 })}`; +} diff --git a/src/app/(protected)/uplifts/_components/reject-dialog.tsx b/src/app/(protected)/uplifts/_components/reject-dialog.tsx new file mode 100644 index 00000000..016da452 --- /dev/null +++ b/src/app/(protected)/uplifts/_components/reject-dialog.tsx @@ -0,0 +1,100 @@ +import { useEffect, useState } from "react"; +import { Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money"; +import { UpliftReasonField } from "@/app/(protected)/uplifts/_components/uplift-reason-field"; +import { Button } from "@/components/ui/button"; +import { DialogCancelButton } from "@/components/ui/dialog-cancel-button"; +import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; +import { Text } from "@/components/ui/text"; +import { guardDialogClose } from "@/lib/dialog-guard"; + +export type RejectDialogProps = { + open: boolean; + amount?: number | null; + woNumber?: string | null; + onClose: () => void; + onConfirm: (reason: string) => void; + isPending?: boolean; +}; + +function rejectTitle(amount: number | null | undefined): string { + if (amount != null && Number.isFinite(amount)) { + return `Reject uplift of ${formatUpliftMoney(amount)}?`; + } + return "Reject uplift?"; +} + +export function RejectDialog({ + open, + amount, + woNumber, + onClose, + onConfirm, + isPending = false, +}: RejectDialogProps) { + const [reason, setReason] = useState(""); + const canConfirm = reason.trim().length > 0 && !isPending; + + useEffect(() => { + if (open) { + setReason(""); + } + }, [open]); + + const handleClose = () => { + if (isPending) return; + setReason(""); + onClose(); + }; + + return ( + + + {rejectTitle(amount)} + + + {Boolean(woNumber) && ( + + The dispatcher sees this reason on WO #{woNumber}. + + )} + + + + + + + + ); +} diff --git a/src/app/(protected)/uplifts/_components/revoke-dialog.tsx b/src/app/(protected)/uplifts/_components/revoke-dialog.tsx new file mode 100644 index 00000000..38bf8024 --- /dev/null +++ b/src/app/(protected)/uplifts/_components/revoke-dialog.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from "react"; +import { Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money"; +import { UpliftReasonField } from "@/app/(protected)/uplifts/_components/uplift-reason-field"; +import { Button } from "@/components/ui/button"; +import { DialogCancelButton } from "@/components/ui/dialog-cancel-button"; +import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; +import { Text } from "@/components/ui/text"; +import { guardDialogClose } from "@/lib/dialog-guard"; + +export type RevokeDialogProps = { + open: boolean; + amount?: number | null; + woNumber?: string | null; + onClose: () => void; + onConfirm: (reason: string) => void; + isPending?: boolean; +}; + +function revokeAmountLabel(amount: number | null | undefined): string { + if (amount != null && Number.isFinite(amount)) { + return formatUpliftMoney(amount); + } + return "this uplift"; +} + +function revokeConfirmLabel(amount: number | null | undefined): string { + if (amount != null && Number.isFinite(amount)) { + return `Revoke ${formatUpliftMoney(amount)}`; + } + return "Revoke"; +} + +export function RevokeDialog({ + open, + amount, + woNumber, + onClose, + onConfirm, + isPending = false, +}: RevokeDialogProps) { + const [reason, setReason] = useState(""); + const canConfirm = reason.trim().length > 0 && !isPending; + + useEffect(() => { + if (open) { + setReason(""); + } + }, [open]); + + const handleClose = () => { + if (isPending) return; + setReason(""); + onClose(); + }; + + const amountLabel = revokeAmountLabel(amount); + const woSuffix = woNumber ? ` on WO ${woNumber}` : ""; + + return ( + + + Revoke this approval? + + + + The approved uplift of {amountLabel} + {woSuffix} will be withdrawn. This does not recover money already spent — it records that + the authorization was a mistake. + + + + + + + + + ); +} diff --git a/src/app/(protected)/uplifts/_components/uplift-reason-field.tsx b/src/app/(protected)/uplifts/_components/uplift-reason-field.tsx new file mode 100644 index 00000000..37e3899d --- /dev/null +++ b/src/app/(protected)/uplifts/_components/uplift-reason-field.tsx @@ -0,0 +1,44 @@ +import { + DLG_INPUT_CLS, + DLG_INPUT_STYLE, +} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles"; +import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label"; + +type UpliftReasonFieldProps = { + id: string; + label: string; + value: string; + placeholder: string; + required?: boolean; + disabled?: boolean; + onChange: (value: string) => void; +}; + +export function UpliftReasonField({ + id, + label, + value, + placeholder, + required = false, + disabled = false, + onChange, +}: UpliftReasonFieldProps) { + return ( +
+ + {label} + +