diff --git a/src/app/(protected)/completion-docs/_components/completion-templates-table.tsx b/src/app/(protected)/completion-docs/_components/completion-templates-table.tsx index 0a8bd5d7..5756ad64 100644 --- a/src/app/(protected)/completion-docs/_components/completion-templates-table.tsx +++ b/src/app/(protected)/completion-docs/_components/completion-templates-table.tsx @@ -23,6 +23,8 @@ const BODY_CELL_SX = { fontSize: 13, padding: "8px 12px" } as const; type CompletionTemplatesTableProps = { templates: CompletionTemplate[]; isLoading: boolean; + /** The load failed: the page shows the error, so there is no search result to explain. */ + hasError: boolean; canEdit: boolean; onView: (template: CompletionTemplate) => void; onEdit: (template: CompletionTemplate) => void; @@ -79,7 +81,7 @@ function TemplateRow({ canEdit, onView, onEdit, -}: Omit & { +}: Omit & { template: CompletionTemplate; }) { return ( @@ -120,6 +122,7 @@ function TemplateRow({ export function CompletionTemplatesTable({ templates, isLoading, + hasError, canEdit, onView, onEdit, @@ -143,7 +146,7 @@ export function CompletionTemplatesTable({ {isLoading && } - {!isLoading && templates.length === 0 && } + {!isLoading && !hasError && templates.length === 0 && } {templates.map((template) => ( setDialog({ template, mode: "view" })} onEdit={(template) => setDialog({ template, mode: "edit" })} diff --git a/src/app/(protected)/uplifts/_components/uplift-approval-tabs.ts b/src/app/(protected)/uplifts/_components/uplift-approval-tabs.ts new file mode 100644 index 00000000..eefc3bf1 --- /dev/null +++ b/src/app/(protected)/uplifts/_components/uplift-approval-tabs.ts @@ -0,0 +1,52 @@ +export const UPLIFT_APPROVAL_TABS = ["pending", "approved", "rejected"] as const; +export type UpliftApprovalTab = (typeof UPLIFT_APPROVAL_TABS)[number]; + +export function isUpliftApprovalTab(value: string): value is UpliftApprovalTab { + return (UPLIFT_APPROVAL_TABS as readonly string[]).includes(value); +} + +const PENDING_HEADER_CELLS = [ + "Work Order", + "Service / Vendor", + "Amount", + "Approved on WO", + "Notes", + "Attachments", + "Requested By", + "Waiting", + "Actions", +] as const; + +const APPROVED_HEADER_CELLS = [ + "Work Order", + "Service / Vendor", + "Amount", + "Approved on WO", + "Notes", + "Attachments", + "Requested By", + "Approved By", + "Approved At", + "Actions", +] as const; + +// A rejection is terminal, so the archive ends with the decision and its reason +// instead of an Actions column. +const REJECTED_HEADER_CELLS = [ + "Work Order", + "Service / Vendor", + "Amount", + "Approved on WO", + "Notes", + "Attachments", + "Requested By", + "Rejected By", + "Rejected At", + "Rejection Reason", +] as const; + +export const UPLIFT_APPROVAL_HEADER_CELLS: Record = { + pending: PENDING_HEADER_CELLS, + approved: APPROVED_HEADER_CELLS, + rejected: REJECTED_HEADER_CELLS, +}; diff --git a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx index 3f52d36b..9819d2cd 100644 --- a/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx +++ b/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx @@ -16,14 +16,20 @@ import { formatUpliftMoney, isGrantedAmount, } from "@/app/(protected)/uplifts/_components/format-uplift-money"; +import { + UPLIFT_APPROVAL_HEADER_CELLS, + type UpliftApprovalTab, +} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs"; +import { + RejectedEmptyState, + RejectedRowCells, +} from "@/app/(protected)/uplifts/_components/uplift-rejected-cells"; import { Text } from "@/components/ui/text"; import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve"; import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount"; import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils"; -export type UpliftApprovalTab = "pending" | "approved"; - const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked."; const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts"; @@ -236,65 +242,84 @@ function UpliftApprovalRow({ {row.requestedByVendorName || "—"} - {tab === "pending" ? ( - - - - ) : ( + {tab === "pending" && ( + <> + + + + + + + + )} + {tab === "approved" && ( <> {row.decidedByName || "—"} {row.decidedAt ? formatDateTime(row.decidedAt) : "—"} + + + )} - - {tab === "pending" ? ( - - ) : ( - - )} + {tab === "rejected" && } + + ); +} + +function UpliftApprovalsEmptyState({ tab }: { tab: UpliftApprovalTab }) { + if (tab === "rejected") return ; + if (tab === "approved") { + return ( + + No approved uplifts yet + + Uplifts you approve are archived here for reference. + + + ); + } + return ( + + No uplifts waiting for approval. + + ); +} + +// A failed queue is not an empty one: the page's error alert explains it, so the +// empty-state copy must not also claim the list has nothing in it. +function UpliftApprovalsEmptyRow({ + tab, + colSpan, + hasError, +}: { + tab: UpliftApprovalTab; + colSpan: number; + hasError: boolean; +}) { + if (hasError) return null; + return ( + + + ); } -const PENDING_HEADER_CELLS = [ - "Work Order", - "Service / Vendor", - "Amount", - "Approved on WO", - "Notes", - "Attachments", - "Requested By", - "Waiting", - "Actions", -] as const; - -const APPROVED_HEADER_CELLS = [ - "Work Order", - "Service / Vendor", - "Amount", - "Approved on WO", - "Notes", - "Attachments", - "Requested By", - "Approved By", - "Approved At", - "Actions", -] as const; - export function UpliftApprovalsTable({ tab, isLoading, + hasError, rows, canRevoke, onOpenDetail, @@ -306,6 +331,7 @@ export function UpliftApprovalsTable({ }: { tab: UpliftApprovalTab; isLoading: boolean; + hasError: boolean; rows: UpliftQueueItem[]; canRevoke: boolean; onOpenDetail: (row: UpliftQueueItem) => void; @@ -315,7 +341,7 @@ export function UpliftApprovalsTable({ onOpenAttachment: (row: UpliftQueueItem) => void; isDecisionPending: boolean; }) { - const headerCells = tab === "pending" ? PENDING_HEADER_CELLS : APPROVED_HEADER_CELLS; + const headerCells = UPLIFT_APPROVAL_HEADER_CELLS[tab]; return ( @@ -336,22 +362,7 @@ export function UpliftApprovalsTable({ ) : rows.length === 0 ? ( - - - {tab === "pending" ? ( - - No uplifts waiting for approval. - - ) : ( - - No approved uplifts yet - - Uplifts you approve are archived here for reference. - - - )} - - + ) : ( rows.map((row) => ( + {row.decidedByName || "—"} + {row.decidedAt ? formatDateTime(row.decidedAt) : "—"} + + + {row.decisionNote || "—"} + + + + ); +} + +export function RejectedEmptyState() { + return ( + + + No rejected uplifts. + + ); +} diff --git a/src/app/(protected)/uplifts/index.tsx b/src/app/(protected)/uplifts/index.tsx index db538443..2aaf0005 100644 --- a/src/app/(protected)/uplifts/index.tsx +++ b/src/app/(protected)/uplifts/index.tsx @@ -1,9 +1,11 @@ import { useState } from "react"; import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material"; import { - UpliftApprovalsTable, + UPLIFT_APPROVAL_TABS, + isUpliftApprovalTab, type UpliftApprovalTab, -} from "@/app/(protected)/uplifts/_components/uplift-approvals-table"; +} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs"; +import { UpliftApprovalsTable } from "@/app/(protected)/uplifts/_components/uplift-approvals-table"; import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs"; import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal"; import { @@ -16,6 +18,7 @@ import { useAuthContext } from "@/providers/auth-context"; import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller"; import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue"; import { Text } from "@/components/ui/text"; +import type { UpliftsQueueResult } from "@/domain/uplifts/types/uplift"; const PAGE_SIZE = 25; @@ -39,58 +42,71 @@ function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal: ); } +const TAB_LABELS: Record = { + pending: "Pending", + approved: "Approved", + rejected: "Rejected", +}; + +// A tab's total is only known once its queue has loaded at least once; until then the +// pill and pager must not claim a count (a loading or failed queue is not an empty one). +// A failed refetch keeps the last loaded page, so its total stays usable. +function knownTotal(query: { data?: UpliftsQueueResult | undefined }): number | null { + return query.data ? query.data.totalCount : null; +} + +function TabCountPill({ tab, count }: { tab: UpliftApprovalTab; count: number | null }) { + return ( + + ); +} + function UpliftApprovalsTabs({ tab, - pendingCount, + counts, onChange, }: { tab: UpliftApprovalTab; - pendingCount: number; + counts: Record; onChange: (_event: unknown, value: string) => void; }) { return ( - - Pending - - - } - /> - + {UPLIFT_APPROVAL_TABS.map((value) => ( + + {TAB_LABELS[value]} + + + } + /> + ))} ); } function UpliftApprovalsPagination({ - tab, count, - pendingPage, - approvedPage, - onPendingPageChange, - onApprovedPageChange, + page, + onPageChange, }: { - tab: UpliftApprovalTab; count: number; - pendingPage: number; - approvedPage: number; - onPendingPageChange: (page: number) => void; - onApprovedPageChange: (page: number) => void; + page: number; + onPageChange: (page: number) => void; }) { return ( - tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage) - } + page={page} + onPageChange={(_event, nextPage) => onPageChange(nextPage)} rowsPerPage={PAGE_SIZE} rowsPerPageOptions={[PAGE_SIZE]} /> @@ -108,22 +124,31 @@ function UpliftQueueError({ error }: { error: unknown }) { export default function UpliftQueuePage() { const [tab, setTab] = useState("pending"); - const [pendingPage, setPendingPage] = useState(0); - const [approvedPage, setApprovedPage] = useState(0); + const [pages, setPages] = useState>({ + pending: 0, + approved: 0, + rejected: 0, + }); const { user } = useAuthContext(); const canRevokeUplifts = isAdminUser(user?.userRoles); const pendingQuery = useUpliftsQueue({ status: "Pending", - page: pendingPage + 1, + page: pages.pending + 1, pageSize: PAGE_SIZE, }); const approvedQuery = useUpliftsQueue({ status: "Approved", - page: approvedPage + 1, + page: pages.approved + 1, pageSize: PAGE_SIZE, }); + const rejectedQuery = useUpliftsQueue({ + status: "Rejected", + page: pages.rejected + 1, + pageSize: PAGE_SIZE, + }); + const queries = { pending: pendingQuery, approved: approvedQuery, rejected: rejectedQuery }; const controller = useUpliftApprovalController(); const { @@ -138,13 +163,18 @@ export default function UpliftQueuePage() { openDetail, } = controller; - const activeQuery = tab === "pending" ? pendingQuery : approvedQuery; + const activeQuery = queries[tab]; const rows = activeQuery.data?.items ?? []; - const pendingCount = pendingQuery.data?.totalCount ?? 0; + const counts = { + pending: knownTotal(pendingQuery), + approved: knownTotal(approvedQuery), + rejected: knownTotal(rejectedQuery), + }; + const activeTotal = counts[tab]; const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null; const handleTabChange = (_event: unknown, value: string) => { - if (value === "pending" || value === "approved") { + if (isUpliftApprovalTab(value)) { setTab(value); } }; @@ -152,11 +182,12 @@ export default function UpliftQueuePage() { return ( - + - + {activeTotal != null && ( + setPages((current) => ({ ...current, [tab]: page }))} + /> + )} {detailItem != null && ( - + {selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
{ALL_WIZARD_STATUSES.map((status) => ( @@ -122,6 +126,14 @@ export function AdvancedSearchFilterSections({ onClick={() => toggleIn("statuses", status)} /> ))} + setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))} + />
diff --git a/src/domain/services/api/services-api.ts b/src/domain/services/api/services-api.ts index d725d828..df781742 100644 --- a/src/domain/services/api/services-api.ts +++ b/src/domain/services/api/services-api.ts @@ -20,10 +20,13 @@ const SERVICE_WORK_ORDER_TYPE_API = { Reactive: 6, } as const; -const TYPE_TO_API: Record, number> = { +// The registry is keyed on PM, Reactive and Emergency; Overdue is PM work the +// client never confirmed, so it lists the PM catalog. +const TYPE_TO_API: Record = { PM: WORK_ORDER_TYPE_API.PM, Reactive: WORK_ORDER_TYPE_API.Reactive, Emergency: WORK_ORDER_TYPE_API.Emergency, + Overdue: WORK_ORDER_TYPE_API.PM, }; function toApiInput(input: ServiceInput) { @@ -51,7 +54,6 @@ export const servicesApi = { }, getActiveForWorkOrderType: async (workOrderType: WOType): Promise => { - if (workOrderType === "Overdue") return []; const data = await apiGet(API_PATHS.services.list, { searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] }, }); diff --git a/src/domain/services/use-cases/use-services-for-work-order-type.ts b/src/domain/services/use-cases/use-services-for-work-order-type.ts index 6dd8d4ac..f712051e 100644 --- a/src/domain/services/use-cases/use-services-for-work-order-type.ts +++ b/src/domain/services/use-cases/use-services-for-work-order-type.ts @@ -11,7 +11,6 @@ export function useServicesForWorkOrderType( return useQuery({ queryKey: queryKeys.services.byWorkOrderType(effectiveType), queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType), - enabled: effectiveType !== "Overdue", staleTime: 60 * 1000, }); } diff --git a/src/domain/uplifts/use-cases/use-uplifts-queue.ts b/src/domain/uplifts/use-cases/use-uplifts-queue.ts index 9ebcf4bd..256250ad 100644 --- a/src/domain/uplifts/use-cases/use-uplifts-queue.ts +++ b/src/domain/uplifts/use-cases/use-uplifts-queue.ts @@ -10,5 +10,8 @@ export function useUpliftsQueue( queryKey: queryKeys.uplifts.queue(params as Record), queryFn: () => upliftsApi.list(params), placeholderData: keepPreviousData, + // The Uplift Approvals page reports a failed queue inline, so the global toast + // would announce the same failure twice. + meta: { suppressErrorToast: true }, }); } diff --git a/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts index e45381e7..b4940692 100644 --- a/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts +++ b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts @@ -1,4 +1,5 @@ import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board"; +import { typeNeedsSeverity } from "@/domain/work-orders/types/work-order-severity"; import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper"; @@ -64,7 +65,7 @@ export function assignWizardServicePocFields( payload.vendorNotes = vendorNotes; } - if (draft.severity != null) { + if (draft.severity != null && typeNeedsSeverity(draft.type)) { payload.severity = draft.severity; } diff --git a/src/domain/work-orders/mappers/work-order-board-mapper.ts b/src/domain/work-orders/mappers/work-order-board-mapper.ts index af00a55d..f1f046f9 100644 --- a/src/domain/work-orders/mappers/work-order-board-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-mapper.ts @@ -28,7 +28,7 @@ const WO_TYPE_TO_API: Record = { Reactive: WORK_ORDER_TYPE_API.Reactive, - Overdue: WORK_ORDER_TYPE_API.Other, + Overdue: WORK_ORDER_TYPE_API.Overdue, }; /** Leading token of a location label, e.g. `BK5` from `BK5 · Dallas` or `CHI-1042` from `CHI-1042 — Chicago`. */ diff --git a/src/domain/work-orders/types/work-order-advanced-filters.ts b/src/domain/work-orders/types/work-order-advanced-filters.ts index e440da76..6b8ba659 100644 --- a/src/domain/work-orders/types/work-order-advanced-filters.ts +++ b/src/domain/work-orders/types/work-order-advanced-filters.ts @@ -35,6 +35,8 @@ export interface WorkOrderAdvancedFilters { dispatchers: Set; regions: Set; statuses: Set; + /** Past Due overlay (any type); narrows the other filters, independent of the Overdue type. */ + pastDue: boolean; rescheduled: boolean; carriedOver: boolean; addOn: boolean; @@ -61,6 +63,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { dispatchers: new Set(), regions: new Set(), statuses: new Set(), + pastDue: false, rescheduled: false, carriedOver: false, addOn: false, diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 978ef988..6bfb0e28 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -17,6 +17,7 @@ export const WORK_ORDER_TYPE_API = { Inspection: 5, Reactive: 6, AddOn: 7, + Overdue: 8, Other: 99, } as const; @@ -64,6 +65,7 @@ export const WORK_ORDER_TYPE_API_TO_FE: Record = { [WORK_ORDER_TYPE_API.PM]: "PM", [WORK_ORDER_TYPE_API.Reactive]: "Reactive", [WORK_ORDER_TYPE_API.Emergency]: "Emergency", + [WORK_ORDER_TYPE_API.Overdue]: "Overdue", }; export const LIFECYCLE_STATUS_API_TO_FE: Record = { diff --git a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts index f1c43846..1c4e789c 100644 --- a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts +++ b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts @@ -192,6 +192,15 @@ export function appendStatusPmVendorDocChips( clear: (current) => ({ ...current, statuses: new Set() }), }); + if (filters.pastDue) { + chips.push({ + key: "pastdue", + group: "Status", + value: "Past Due", + clear: (current) => ({ ...current, pastDue: false }), + }); + } + pushSetChip({ chips, key: "pm", diff --git a/src/domain/work-orders/utils/advanced-filter-predicates.ts b/src/domain/work-orders/utils/advanced-filter-predicates.ts index 45fb611e..530bbd70 100644 --- a/src/domain/work-orders/utils/advanced-filter-predicates.ts +++ b/src/domain/work-orders/utils/advanced-filter-predicates.ts @@ -45,12 +45,10 @@ const matchesDateRange: AdvancedFilterPredicate = (row, filters) => { const matchesSite: AdvancedFilterPredicate = (row, filters) => !filters.sites.size || filters.sites.has(row.site); -const matchesType: AdvancedFilterPredicate = (row, filters) => { - if (!filters.types.size) return true; - const overdueMatch = filters.types.has("Overdue") && row.isPastDue; - const typeMatch = filters.types.has(row.type as never); - return overdueMatch || typeMatch; -}; +const matchesType: AdvancedFilterPredicate = (row, filters) => + !filters.types.size || filters.types.has(row.type as never); + +const matchesPastDue: AdvancedFilterPredicate = (row, filters) => !filters.pastDue || row.isPastDue; const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => { if (!filters.dispatchers.size) return true; @@ -95,6 +93,7 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [ matchesType, matchesDispatcher, matchesStatus, + matchesPastDue, matchesPmType, matchesVendorTech, matchesDoc, diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts index fc36045b..e60a9435 100644 --- a/src/domain/work-orders/utils/advanced-filters-utils.ts +++ b/src/domain/work-orders/utils/advanced-filters-utils.ts @@ -36,6 +36,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte upliftStatuses: new Set(filters.upliftStatuses ?? []), workOrderIds: new Set(filters.workOrderIds ?? []), unscheduledOnly: Boolean(filters.unscheduledOnly), + pastDue: Boolean(filters.pastDue), }; } @@ -47,6 +48,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool Boolean(filters.dispatchers?.size), Boolean(filters.regions?.size), Boolean(filters.statuses?.size), + Boolean(filters.pastDue), filters.rescheduled, filters.carriedOver, filters.addOn, diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index c2e34373..a027fd1e 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -42,30 +42,19 @@ function mapDispatchers(ids: Iterable): string[] | undefined { return list.length > 0 ? list : undefined; } -/** Maps UI type filters to API `types` + separate `overdue` flag. Overdue is never sent as types=99. */ -function mapWoTypeFilters(types: Iterable): { - types?: number[]; - overdue?: boolean; -} { +/** Maps UI type filters to API `types`. The Overdue type is sent as its own enum; past due uses `overdue`. */ +function mapWoTypeFilters(types: Iterable): number[] | undefined { const list: number[] = []; - let overdue = false; for (const type of types) { if (type === "All") { continue; } - if (type === "Overdue") { - overdue = true; - continue; - } const api = mapWizardTypeToApi(type as WOType); if (api != null) { list.push(api); } } - return { - types: list.length > 0 ? list : undefined, - overdue: overdue || undefined, - }; + return list.length > 0 ? list : undefined; } function mapStatuses(statuses: Iterable): number[] | undefined { @@ -147,14 +136,11 @@ export function toBoardQueryParams( search: string, ): WorkOrderBoardQueryParams { const [, weekTo] = weekRange(bar.weekMonday); - const typeFilters = bar.types.size > 0 ? mapWoTypeFilters(bar.types) : {}; - return { weekStart: bar.weekMonday, weekEnd: weekTo, dispatchers: mapDispatchers(bar.dispatcherIds), - types: typeFilters.types, - overdue: typeFilters.overdue, + types: mapWoTypeFilters(bar.types), search: search.trim().length >= 2 ? search.trim() : undefined, }; } @@ -211,7 +197,6 @@ export function toBoardSearchQueryParams(input: { BOARD_SEARCH_MAX_PAGE_SIZE, ); - const typeFilters = mapWoTypeFilters(filters.types); const trimmedSearch = input.search?.trim(); const search = trimmedSearch && trimmedSearch.length >= 2 ? trimmedSearch : undefined; @@ -224,8 +209,8 @@ export function toBoardSearchQueryParams(input: { dateTo: date.dateTo, includeDateless: date.includeDateless, sites: filters.sites.size > 0 ? [...filters.sites] : undefined, - types: typeFilters.types, - overdue: typeFilters.overdue, + types: mapWoTypeFilters(filters.types), + overdue: filters.pastDue || undefined, dispatchers: mapDispatchers(filters.dispatchers), regions: filters.regions.size > 0 ? [...filters.regions] : undefined, statuses: mapStatuses(filters.statuses), @@ -261,10 +246,6 @@ export function buildBoardSearchParamsRecord( return out; } -export function woTypeFilterIsOverdue(type: string): boolean { - return type === "Overdue"; -} - /** Week-independent GET /board/search params for the pinned Unassigned queue. */ export function toUnassignedQueueQueryParams( page = 0, diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index 3d8eb132..2300335f 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -65,12 +65,7 @@ function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean { } function rowMatchesBarTypes(row: WorkOrderTableRow, types: Set): boolean { - if (types.size === 0) { - return true; - } - const overdueMatch = types.has("Overdue") && row.isPastDue; - const typeMatch = types.has(row.type as WOType); - return overdueMatch || typeMatch; + return types.size === 0 || types.has(row.type as WOType); } export function filterByBarAndSearch( diff --git a/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx b/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx index 524135cb..25580ff9 100644 --- a/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx +++ b/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx @@ -109,6 +109,21 @@ describe("Completion Docs page", { timeout: 15_000 }, () => { expect(screen.queryByRole("row", { name: /Backflow Test Report/ })).not.toBeInTheDocument(); }); + it("reports a failed load without the empty-search message, including after a failed Retry", async () => { + api.apiGet.mockRejectedValue(new Error("Templates are unavailable")); + renderPage(); + + const retry = await screen.findByRole("button", { name: "Retry" }); + expect(screen.getByText("Templates are unavailable")).toBeInTheDocument(); + expect(screen.queryByText("No templates found")).not.toBeInTheDocument(); + + const callsBeforeRetry = api.apiGet.mock.calls.length; + await user.click(retry); + await waitFor(() => expect(api.apiGet.mock.calls.length).toBeGreaterThan(callsBeforeRetry)); + expect(await screen.findByText("Templates are unavailable")).toBeInTheDocument(); + expect(screen.queryByText("No templates found")).not.toBeInTheDocument(); + }); + it("opens a read-only detail panel from a row with every part of the template", async () => { renderPage(); const dialog = await openTemplate("Backflow Test Report"); diff --git a/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx b/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx index a8d88128..8d45ebcf 100644 --- a/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx +++ b/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx @@ -235,12 +235,23 @@ describe("Edit template", { timeout: 15_000 }, () => { ).toBeInTheDocument(); }); - it("stays closed when the user closes it while a save is still in flight", async () => { - let finishSave: (value: unknown) => void = () => undefined; - api.apiPut.mockImplementationOnce( - () => + it.each([ + [ + "Escape", + async () => { + // The disabled Save button drops focus; Escape is pressed from inside the form. + screen.getByRole("textbox", { name: /Document Title/ }).focus(); + await user.keyboard("{Escape}"); + }, + ], + ["the Close button", () => user.click(screen.getByRole("button", { name: "Close" }))], + ])("stays closed when dismissed with %s while a save is in flight", async (_, dismiss) => { + let finishSave: () => void = () => {}; + const respond = api.apiPut.getMockImplementation()!; + api.apiPut.mockImplementation( + (...args: unknown[]) => new Promise((resolve) => { - finishSave = resolve; + finishSave = () => resolve(respond(...args)); }), ); const dialog = await openTemplate("Backflow Test Report"); @@ -249,9 +260,9 @@ describe("Edit template", { timeout: 15_000 }, () => { await user.click(within(dialog).getByRole("button", { name: /Save changes/ })); await waitFor(() => expect(api.apiPut).toHaveBeenCalled()); - await user.click(within(dialog).getByRole("button", { name: "Close" })); + await dismiss(); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); - finishSave({ ...BACKFLOW, name: "Backflow Test Report v2" }); + finishSave(); await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template updated")); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx index 3bbf9dc1..1816bb04 100644 --- a/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx +++ b/src/test/app/(protected)/uplifts/uplift-approvals-approved-tab.test.tsx @@ -100,7 +100,7 @@ describe("Uplift Approvals approved tab", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); expect(screen.getByRole("columnheader", { name: "Approved By" })).toBeInTheDocument(); expect(screen.getByRole("columnheader", { name: "Approved At" })).toBeInTheDocument(); expect(screen.queryByRole("columnheader", { name: "Waiting" })).not.toBeInTheDocument(); @@ -125,7 +125,7 @@ describe("Uplift Approvals approved tab", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); const revoke = await screen.findByRole("button", { name: "Revoke" }); expect(revoke).toBeEnabled(); fireEvent.click(revoke); @@ -149,7 +149,7 @@ describe("Uplift Approvals approved tab", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); const revoke = await screen.findByRole("button", { name: "Revoke" }); expect(revoke).toBeDisabled(); fireEvent.mouseOver(revoke); @@ -172,7 +172,7 @@ describe("Uplift Approvals approved tab", () => { const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); renderWithProviders(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); expect(await screen.findByText("No approved uplifts yet")).toBeInTheDocument(); expect( screen.getByText("Uplifts you approve are archived here for reference."), diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx index 30a13a51..51c52285 100644 --- a/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx +++ b/src/test/app/(protected)/uplifts/uplift-approvals-one-amount.test.tsx @@ -167,7 +167,7 @@ describe("Uplift Approvals shows one amount per request", () => { queueState.data = queueOf(approvedItem); await renderQueue(); - fireEvent.click(screen.getByRole("tab", { name: "Approved" })); + fireEvent.click(screen.getByRole("tab", { name: /^Approved/ })); expect(amountCell()).toHaveTextContent(UPLIFT); fireEvent.click(screen.getByRole("button", { name: "Revoke" })); diff --git a/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx b/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx new file mode 100644 index 00000000..23323d0a --- /dev/null +++ b/src/test/app/(protected)/uplifts/uplift-approvals-rejected-tab.test.tsx @@ -0,0 +1,237 @@ +import { fireEvent, screen, within } from "@testing-library/react"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + UpliftQueueItem, + UpliftsQueueParams, + UpliftsQueueResult, +} from "@/domain/uplifts/types/uplift"; +import { formatDateTime } from "@/lib/time-utils"; +import { renderWithProviders } from "@/test/test-utils"; + +const queueState = vi.hoisted(() => ({ + byStatus: {} as Record, + errorByStatus: {} as Record, + requestedStatuses: [] as Array, +})); + +const baseItem: UpliftQueueItem = { + id: 1, + status: "Pending", + currentNTE: 500, + requestedNTE: 750, + delta: 250, + vendorReason: "Second hydro-jetting pass on the adjoining line", + requestedAt: "2026-06-21T10:05:00Z", + requestedByVendorName: "Alyssa Dispatcher", + decidedAt: "", + decidedByName: "", + decisionNote: "", + requiredTier: 1, + canDecide: true, + expiresAt: "", + notificationStatus: "", + notificationError: "", + evidenceDocumentId: "doc-9", + evidenceFileName: "quote.pdf", + evidenceContentType: "application/pdf", + evidenceSizeBytes: 1024, + dispatchNumber: "DSP-1", + poNumber: "PO-1", + vendorCompanyName: "Drain Cleaning Bros", + workOrderId: 10, + dispatchId: 20, + woNumber: "WO-25603", + site: "CHI2", + serviceName: "Drains", + technicianName: "", + workOrderDispatcherName: "", + workOrderScheduledDate: "", + attachmentCount: 1, + approvedOnWoAuto: null, + approvedOnWoAdmin: null, + approvedOnWoTotal: null, + workOrderClosed: false, +}; + +const newestRejection: UpliftQueueItem = { + ...baseItem, + id: 31, + status: "Rejected", + woNumber: "WO-31", + decidedAt: "2026-06-21T16:40:00Z", + decidedByName: "Adam Moussa", + decisionNote: "Outside this work order's scope — raise a separate reactive WO.", + canDecide: false, +}; + +const olderRejection: UpliftQueueItem = { + ...baseItem, + id: 30, + status: "Rejected", + woNumber: "WO-30", + requestedByVendorName: "Derek Dispatcher", + decidedAt: "2026-05-26T15:05:00Z", + decidedByName: "Grace Admin", + decisionNote: "Quote is above market for this scope.", + canDecide: false, +}; + +const pendingItem: UpliftQueueItem = { ...baseItem, id: 40, woNumber: "WO-PENDING" }; +const approvedItem: UpliftQueueItem = { + ...baseItem, + id: 50, + status: "Approved", + woNumber: "WO-APPROVED", + decidedAt: "2026-06-01T09:00:00Z", + decidedByName: "Adam Moussa", +}; + +function page(items: UpliftQueueItem[], totalCount = items.length): UpliftsQueueResult { + return { items, totalCount, page: 1, pageSize: 25, pendingExposureTotal: null }; +} + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({ + useUpliftsQueue: (params: UpliftsQueueParams) => { + queueState.requestedStatuses.push(params.status); + const data = queueState.byStatus[params.status ?? ""]; + const error = queueState.errorByStatus[params.status ?? ""] ?? null; + return { + data, + isLoading: data === undefined && error === null, + isFetching: false, + error, + refetch: vi.fn(), + }; + }, +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({ + useApproveUplift: () => ({ isPending: false, mutate: vi.fn() }), + useRejectUplift: () => ({ isPending: false, mutate: vi.fn() }), + useRevokeUplift: () => ({ isPending: false, mutate: vi.fn() }), +})); + +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: { userRoles: "Admin" } }), +})); + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({ + useUpliftsCanApprove: () => ({ data: true }), +})); + +async function openRejectedTab() { + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + fireEvent.click(screen.getByRole("tab", { name: /^Rejected/ })); + return screen.getByRole("table", { name: "rejected uplift requests" }); +} + +describe("Uplift Approvals rejected tab", () => { + // Load the page module once up front so the first test's budget is not spent on a + // cold import of the whole route. + beforeAll(async () => { + await import("@/app/(protected)/uplifts/index"); + }, 60_000); + + beforeEach(() => { + queueState.requestedStatuses = []; + queueState.errorByStatus = {}; + queueState.byStatus = { + Pending: page([pendingItem]), + Approved: page([approvedItem]), + Rejected: page([newestRejection, olderRejection], 12), + }; + }); + + it("shows the rejected total in the tab count pill next to the other tabs' pills", async () => { + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + const rejectedTab = screen.getByRole("tab", { name: /^Rejected/ }); + expect(within(rejectedTab).getByLabelText("12 rejected uplifts")).toHaveTextContent("12"); + expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument(); + expect(screen.getByLabelText("1 approved uplifts")).toBeInTheDocument(); + expect(queueState.requestedStatuses).toContain("Rejected"); + }); + + it("never claims a count for a queue that is still loading or failed to load", async () => { + queueState.byStatus.Rejected = undefined; + queueState.errorByStatus.Rejected = new Error("Forbidden"); + queueState.byStatus.Approved = undefined; + const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index"); + renderWithProviders(); + + const rejectedPill = screen.getByLabelText("rejected uplift count unavailable"); + expect(rejectedPill).toHaveTextContent("—"); + expect(screen.getByLabelText("approved uplift count unavailable")).toHaveTextContent("—"); + expect(screen.queryByLabelText("0 rejected uplifts")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("0 approved uplifts")).not.toBeInTheDocument(); + expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument(); + }); + + it("keeps the count and pager for kept rows when a rejected refetch fails", async () => { + queueState.errorByStatus.Rejected = new Error("Failed to load uplift queue"); + const table = await openRejectedTab(); + + expect(screen.getByRole("alert")).toHaveTextContent("Failed to load uplift queue"); + expect(within(table).getAllByRole("row")).toHaveLength(3); + expect(screen.getByLabelText("12 rejected uplifts")).toHaveTextContent("12"); + expect(screen.getByText(/of 12/)).toBeInTheDocument(); + }); + + it("shows the load error instead of an empty archive when the rejected queue fails", async () => { + queueState.byStatus.Rejected = undefined; + queueState.errorByStatus.Rejected = new Error("Failed to load uplift queue"); + const table = await openRejectedTab(); + + expect(screen.getByRole("alert")).toHaveTextContent("Failed to load uplift queue"); + expect(within(table).queryByText("No rejected uplifts.")).not.toBeInTheDocument(); + expect(within(table).queryByTestId("HighlightOffOutlinedIcon")).not.toBeInTheDocument(); + expect(screen.queryByText(/of 0/)).not.toBeInTheDocument(); + }); + + it("lists only rejected uplifts, in the server's most-recently-rejected order", async () => { + const table = await openRejectedTab(); + + const dataRows = within(table).getAllByRole("row").slice(1); + expect(dataRows).toHaveLength(2); + expect(dataRows[0]).toHaveTextContent("WO-31"); + expect(dataRows[1]).toHaveTextContent("WO-30"); + expect(within(table).queryByText("WO-PENDING")).not.toBeInTheDocument(); + expect(within(table).queryByText("WO-APPROVED")).not.toBeInTheDocument(); + }); + + it("shows who requested, who rejected, when, and the rejection reason inline", async () => { + const table = await openRejectedTab(); + + for (const header of ["Requested By", "Rejected By", "Rejected At", "Rejection Reason"]) { + expect(within(table).getByRole("columnheader", { name: header })).toBeInTheDocument(); + } + const [, firstRow] = within(table).getAllByRole("row"); + expect(firstRow).toHaveTextContent("Alyssa Dispatcher"); + expect(firstRow).toHaveTextContent("Adam Moussa"); + expect(firstRow).toHaveTextContent(formatDateTime(newestRejection.decidedAt)); + expect( + within(firstRow).getByText("Outside this work order's scope — raise a separate reactive WO."), + ).toBeVisible(); + }); + + it("offers no action on a rejected uplift", async () => { + const table = await openRejectedTab(); + + expect(within(table).queryByRole("columnheader", { name: "Actions" })).not.toBeInTheDocument(); + for (const name of [/^Approve/, /^Reject$/, /^Revoke/]) { + expect(within(table).queryByRole("button", { name })).not.toBeInTheDocument(); + } + }); + + it("shows an icon and the empty copy when nothing has been rejected", async () => { + queueState.byStatus.Rejected = page([]); + const table = await openRejectedTab(); + + expect(within(table).getByText("No rejected uplifts.")).toBeInTheDocument(); + expect(within(table).getByTestId("HighlightOffOutlinedIcon")).toBeInTheDocument(); + expect(screen.getByLabelText("0 rejected uplifts")).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/overdue-work-order-type.test.tsx b/src/test/app/(protected)/workorders/overdue-work-order-type.test.tsx new file mode 100644 index 00000000..5c429789 --- /dev/null +++ b/src/test/app/(protected)/workorders/overdue-work-order-type.test.tsx @@ -0,0 +1,154 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab"; +import { AdvancedSearchFilterSections } from "@/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections"; +import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell"; +import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule"; +import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper"; +import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper"; +import { + EMPTY_ADVANCED_FILTERS, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { + canContinueWizardStep1, + EMPTY_WIZARD_DRAFT, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; +import { buildAdvancedFilterChips } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { getClosabilityGaps } from "@/domain/work-orders/utils/wo-closability"; + +const USERS = [{ id: "sarah", name: "Sarah Chen" }]; + +describe("Overdue work order type", () => { + it("offers Overdue in the Create WO wizard and never asks for a severity", () => { + const onPatch = vi.fn(); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /No client response — due date passed/ })); + expect(onPatch).toHaveBeenCalledWith( + expect.objectContaining({ type: "Overdue", severity: null }), + ); + + const overdueDraft: WorkOrderWizardDraft = { ...EMPTY_WIZARD_DRAFT, type: "Overdue" }; + rerender( + , + ); + expect(screen.queryByText(/Severity \(from APM\)/)).not.toBeInTheDocument(); + expect(canContinueWizardStep1(overdueDraft)).toBe(true); + }); + + it("creates the work order with the Overdue enum and no severity", () => { + const payload = mapWizardDraftToBoardCreatePayload({ + ...EMPTY_WIZARD_DRAFT, + type: "Overdue", + severity: 2, + locationId: "1", + siteCode: "BK5", + }); + + expect(payload.workOrderType).toBe(WORK_ORDER_TYPE_API.Overdue); + expect(payload.severity ?? null).toBeNull(); + }); + + it("corrects a type inline to Overdue without opening the severity dialog", async () => { + const onSave = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Type of WO, Reactive" })); + await user.click(screen.getByRole("button", { name: /^Overdue$/ })); + + expect(onSave).toHaveBeenCalledWith("Overdue", null); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("reads the Overdue enum back from the board as the Overdue type", () => { + const row = mapWorkOrderTableRow({ id: 1, workOrderType: WORK_ORDER_TYPE_API.Overdue }); + expect(row.type).toBe("Overdue"); + }); + + it("closes with the completion document, not photos", () => { + const base = { + id: "1", + dispatcherId: "sarah", + dispatcherName: "Sarah Chen", + company: "Gateway Plumbing", + vendorId: "", + tech: "", + completedDate: "2026-09-24", + woNumber: "24971", + pm: "Backflow Preventers", + mediaCount: 0, + }; + expect( + getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "No" as const }), + ).toEqual(["Completion document"]); + expect( + getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "Yes" as const }), + ).toEqual([]); + + const row = { + id: "1", + type: "Overdue", + pm: "Backflow Preventers", + docStatus: "No", + rowVersion: "1", + }; + render( + , + ); + expect(screen.getByText("Completion Document")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /open completion document/i })).toBeEnabled(); + }); + + it("keeps a separate Past Due status toggle in the advanced filters", () => { + let draft: WorkOrderAdvancedFilters = { ...EMPTY_ADVANCED_FILTERS }; + const setDraft = vi.fn( + (update: (prev: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters) => { + draft = update(draft); + }, + ); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /^Status/ })); + fireEvent.click(screen.getByRole("button", { name: /Past Due/ })); + + expect(draft.pastDue).toBe(true); + expect(draft.types.size).toBe(0); + expect(buildAdvancedFilterChips(draft)).toContainEqual( + expect.objectContaining({ group: "Status", value: "Past Due" }), + ); + }); +}); diff --git a/src/test/domain/services/api/services-api.test.ts b/src/test/domain/services/api/services-api.test.ts index be46e93b..7dc0b1b0 100644 --- a/src/test/domain/services/api/services-api.test.ts +++ b/src/test/domain/services/api/services-api.test.ts @@ -78,11 +78,14 @@ describe("servicesApi", () => { }); }); - it("does not request services for the unsupported Overdue type", async () => { - const services = await servicesApi.getActiveForWorkOrderType("Overdue"); + it("lists the PM catalog for the Overdue type", async () => { + vi.mocked(apiGet).mockResolvedValue([]); - expect(apiGet).not.toHaveBeenCalled(); - expect(services).toEqual([]); + await servicesApi.getActiveForWorkOrderType("Overdue"); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.services.list, { + searchParams: { isActive: true, workOrderType: 2 }, + }); }); it("creates, updates, and deactivates through the registry routes", async () => { diff --git a/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx b/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx new file mode 100644 index 00000000..f71eb8d4 --- /dev/null +++ b/src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx @@ -0,0 +1,40 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; +import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue"; +import { createAppQueryClient } from "@/lib/query/query-client"; + +const toastMocks = vi.hoisted(() => ({ error: vi.fn() })); + +vi.mock("react-toastify", () => ({ + toast: { error: toastMocks.error, success: vi.fn() }, +})); + +afterEach(() => { + vi.restoreAllMocks(); + toastMocks.error.mockReset(); +}); + +describe("useUpliftsQueue", () => { + it("does not show the global error toast, since the approvals page reports the failure inline", async () => { + vi.spyOn(upliftsApi, "list").mockRejectedValue(new Error("Failed to load uplift queue")); + // The real app query client runs the QueryCache's global onError handler, which + // toasts unless the query opts out. + const queryClient = createAppQueryClient(); + queryClient.setDefaultOptions({ queries: { retry: false } }); + + const { result } = renderHook( + () => useUpliftsQueue({ status: "Rejected", page: 1, pageSize: 25 }), + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); + expect(toastMocks.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts index e5d0cd0e..b82d970c 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts @@ -36,7 +36,9 @@ describe("mapWizardTypeToApi", () => { expect(mapWizardTypeToApi("Reactive")).toBe(WORK_ORDER_TYPE_API.Reactive); - expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Other); + expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Overdue); + + expect(WORK_ORDER_TYPE_API.Overdue).toBe(8); expect(mapWizardTypeToApi("")).toBeNull(); }); diff --git a/src/test/domain/work-orders/utils/board-query-params.test.ts b/src/test/domain/work-orders/utils/board-query-params.test.ts index 866de6f6..39fa0275 100644 --- a/src/test/domain/work-orders/utils/board-query-params.test.ts +++ b/src/test/domain/work-orders/utils/board-query-params.test.ts @@ -60,16 +60,16 @@ describe("toBoardQueryParams", () => { expect(params.overdue).toBeUndefined(); }); - it("maps Overdue bar filter to overdue=true without types", () => { + it("maps the Overdue bar filter to the Overdue type, not the past-due flag", () => { const params = toBoardQueryParams({ ...baseBar, types: new Set(["Overdue"]) }, ""); - expect(params.overdue).toBe(true); - expect(params.types).toBeUndefined(); + expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]); + expect(params.overdue).toBeUndefined(); }); - it("maps Overdue with real types as OR (types + overdue)", () => { + it("maps Overdue with other types as a plain type union", () => { const params = toBoardQueryParams({ ...baseBar, types: new Set(["PM", "Overdue"]) }, ""); - expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]); - expect(params.overdue).toBe(true); + expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM, WORK_ORDER_TYPE_API.Overdue]); + expect(params.overdue).toBeUndefined(); }); it("omits types when the set is empty", () => { @@ -222,22 +222,23 @@ describe("toBoardSearchQueryParams", () => { expect(buildBoardSearchParamsRecord(params)).not.toHaveProperty("unscheduledOnly"); }); - it("maps Overdue-only advanced filter to overdue=true without types", () => { + it("maps the Overdue type advanced filter to types without the past-due flag", () => { const params = toBoardSearchQueryParams({ filters: { ...EMPTY_ADVANCED_FILTERS, types: new Set(["Overdue"]), }, }); - expect(params.overdue).toBe(true); - expect(params.types).toBeUndefined(); + expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]); + expect(params.overdue).toBeUndefined(); }); - it("maps Overdue with real types as OR (types + overdue)", () => { + it("maps the Past Due status toggle to overdue=true alongside types", () => { const params = toBoardSearchQueryParams({ filters: { ...EMPTY_ADVANCED_FILTERS, - types: new Set(["PM", "Overdue"]), + types: new Set(["PM"]), + pastDue: true, }, }); expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]); diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index cb7b10e5..9798afb8 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -172,26 +172,26 @@ describe("filterByBarAndSearch", () => { expect(ids).toEqual(["em", "rx"]); }); - it("matches Overdue via isPastDue even when row.type is not Overdue", () => { + it("matches the Overdue type only, never a past-due row of another type", () => { const pastDuePm = makeRow({ id: "pd", type: "PM", isPastDue: true, scheduledOn: "2026-06-03", }); - const currentPm = makeRow({ - id: "now", - type: "PM", + const overdue = makeRow({ + id: "ov", + type: "Overdue", isPastDue: false, scheduledOn: "2026-06-03", }); const ids = filterByBarAndSearch( - [pastDuePm, currentPm], + [pastDuePm, overdue], bar(weekMonday, { types: new Set(["Overdue"]) }), "", ).map((row) => row.id); - expect(ids).toEqual(["pd"]); + expect(ids).toEqual(["ov"]); }); it("hides a row after type correction when the bar filter no longer matches", () => { @@ -395,13 +395,8 @@ describe("matchesAdvancedFilters", () => { expect(matchesAdvancedFilters(row, noMatch)).toBe(false); }); - it("matches Overdue when isPastDue is true", () => { + it("matches the Overdue type filter by type, independent of isPastDue", () => { const weekMonday = mondayOf(todayIso()); - const pastDue = makeRow({ - scheduledOn: weekMonday, - ...deriveDayFields(weekMonday), - isPastDue: true, - }); const filters = { ...EMPTY_ADVANCED_FILTERS, dateRange: "custom" as const, @@ -409,9 +404,39 @@ describe("matchesAdvancedFilters", () => { customTo: "9999-12-31", types: new Set(["Overdue" as const]), }; - expect(matchesAdvancedFilters(pastDue, filters)).toBe(true); + const overdue = makeRow({ type: "Overdue", isPastDue: false, scheduledOn: weekMonday }); + const pastDuePm = makeRow({ type: "PM", isPastDue: true, scheduledOn: weekMonday }); + expect(matchesAdvancedFilters(overdue, filters)).toBe(true); + expect(matchesAdvancedFilters(pastDuePm, filters)).toBe(false); + }); + + it("narrows to past-due rows with the Past Due status toggle", () => { + const weekMonday = mondayOf(todayIso()); + const filters = { + ...EMPTY_ADVANCED_FILTERS, + dateRange: "custom" as const, + customFrom: "0000-01-01", + customTo: "9999-12-31", + types: new Set(["PM" as const]), + pastDue: true, + }; expect( - matchesAdvancedFilters(makeRow({ isPastDue: false, scheduledOn: weekMonday }), filters), + matchesAdvancedFilters( + makeRow({ type: "PM", isPastDue: true, scheduledOn: weekMonday }), + filters, + ), + ).toBe(true); + expect( + matchesAdvancedFilters( + makeRow({ type: "PM", isPastDue: false, scheduledOn: weekMonday }), + filters, + ), + ).toBe(false); + expect( + matchesAdvancedFilters( + makeRow({ type: "Overdue", isPastDue: true, scheduledOn: weekMonday }), + filters, + ), ).toBe(false); });