diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts
index f43d1e07..d175e6c5 100644
--- a/src/api/api-paths.ts
+++ b/src/api/api-paths.ts
@@ -38,6 +38,11 @@ export const API_PATHS = {
mediaItem: (id: string | number, mediaId: string | number) =>
`workorders/${id}/media/${mediaId}`,
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
+ uplifts: (id: string | number) => `workorders/${id}/uplifts`,
+ upliftCancel: (workOrderId: string | number, upliftId: string | number) =>
+ `workorders/${workOrderId}/uplifts/${upliftId}/cancel`,
+ upliftRevoke: (workOrderId: string | number, upliftId: string | number) =>
+ `workorders/${workOrderId}/uplifts/${upliftId}/revoke`,
lookupsDispatchers: "workorders/lookups/dispatchers",
},
pmSchedule: {
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/uplift-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/uplift-cell.tsx
new file mode 100644
index 00000000..6f4c4558
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/cells/uplift-cell.tsx
@@ -0,0 +1,48 @@
+import type { UpliftSummary } from "@/domain/work-orders/types/work-order-uplift";
+import {
+ getUpliftCellLabel,
+ getUpliftStatusPillStyle,
+} from "@/domain/work-orders/utils/uplift-display-utils";
+
+type UpliftCellProps = {
+ summary: UpliftSummary;
+ onOpen?: () => void;
+};
+
+export function UpliftCell({ summary, onOpen }: UpliftCellProps) {
+ const label = getUpliftCellLabel(summary);
+
+ if (!onOpen) {
+ return label ? (
+ {label}
+ ) : (
+ —
+ );
+ }
+
+ const pillStatus = summary.pendingCount > 0 ? "pending" : summary.primaryStatus;
+ const pillStyle = pillStatus ? getUpliftStatusPillStyle(pillStatus) : null;
+
+ return (
+
+ );
+}
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 c9626b3e..1a6b51c5 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
@@ -16,6 +16,7 @@ type UseWoTableRowHandlersOptions = {
onWeekChange: (mondayIso: string) => void;
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
+ onOpenUplifts?: (row: WorkOrderTableRow) => void;
onOpenMedia: (row: WorkOrderTableRow) => void;
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
onScheduleHighlight: (id: string | number | null) => void;
@@ -28,6 +29,7 @@ export function useWoTableRowHandlers({
onWeekChange,
onOpenSlideOver,
onOpenCompDoc,
+ onOpenUplifts,
onOpenMedia,
onPatch,
onScheduleHighlight,
@@ -62,6 +64,12 @@ export function useWoTableRowHandlers({
onOpenCompDoc(row);
}
: undefined,
+ onOpenUplifts: onOpenUplifts
+ ? (row: WorkOrderTableRow) => {
+ tableData.setSelectedId(row.id);
+ onOpenUplifts(row);
+ }
+ : undefined,
onOpenMedia: (row: WorkOrderTableRow) => {
tableData.setSelectedId(row.id);
onOpenMedia(row);
@@ -136,6 +144,7 @@ export function useWoTableRowHandlers({
onWeekChange,
onOpenSlideOver,
onOpenCompDoc,
+ onOpenUplifts,
onOpenMedia,
onPatch,
onScheduleHighlight,
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts
index e9c4f1ee..12194252 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-cols.ts
@@ -12,9 +12,10 @@ export const WO_TABLE_COLS = [
{ key: "appt", label: "APPT TIME", w: 128 },
{ key: "status", label: "STATUS", w: 148 },
{ key: "doc", label: "COMP DOC", w: 84 },
+ { key: "uplift", label: "UPLIFT", w: 108 },
{ key: "completed", label: "COMPLETED DATE", w: 130 },
{ key: "actions", label: "", w: 100 },
] as const;
-export const WO_TABLE_MIN_WIDTH = 1634;
+export const WO_TABLE_MIN_WIDTH = 1742;
export const WO_TABLE_ROW_HEIGHT = 52;
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
index bcf6e105..f50d464d 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx
@@ -224,6 +224,7 @@ export function WoTableInlineRowCells({
+
>
);
}
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 7974f536..7c7c2d50 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
@@ -6,7 +6,10 @@ import { CompDocCell } from "./cells/comp-doc-cell";
import { DateCell } from "./cells/date-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
import { StatusCell } from "./cells/status-cell";
+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 type { WoTableRowHandlers } from "./wo-table-row";
type WoTableRowServiceCellsProps = {
@@ -103,6 +106,17 @@ export function WoTableRowServiceCells({
onOpenMedia={() => handlers.onOpenMedia(row)}
/>
+
e.stopPropagation()}>
+ handlers.onOpenUplifts!(row)
+ : undefined
+ }
+ />
+ |
void;
onEdit: (row: WorkOrderTableRow) => void;
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
+ onOpenUplifts?: (row: WorkOrderTableRow) => void;
onOpenMedia: (row: WorkOrderTableRow) => void;
onPatch: (id: string | number, patch: Partial) => void;
onScheduleDate: (id: string | number, date: string) => void;
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 c733ce89..d783a944 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx
@@ -33,6 +33,7 @@ type WoTableProps = {
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
/** Omit until completion-doc slice mounts CompDocDialog. */
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
+ onOpenUplifts?: (row: WorkOrderTableRow) => void;
onOpenMedia: (row: WorkOrderTableRow) => void;
onClearSearch?: () => void;
onOpenAdvancedFilters?: () => void;
@@ -49,6 +50,7 @@ export function WoTable({
onWeekChange,
onOpenSlideOver,
onOpenCompDoc,
+ onOpenUplifts,
onOpenMedia,
onClearSearch,
onOpenAdvancedFilters,
@@ -71,6 +73,7 @@ export function WoTable({
onWeekChange,
onOpenSlideOver,
onOpenCompDoc,
+ onOpenUplifts,
onOpenMedia,
onPatch,
onScheduleHighlight: setDueDateHighlightId,
diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx
index a38542d6..6bb73f82 100644
--- a/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx
+++ b/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx
@@ -1,88 +1,22 @@
-import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
-import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
-import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
-import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
-import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
-import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
-import type { LocationOption } from "@/domain/work-orders/types/work-order-common";
-import type { WorkOrderUser } from "@/domain/work-orders/types/work-order-list";
+import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type WorkOrdersListOverlaysProps = {
- wizardOpen: boolean;
- onWizardOpenChange: (open: boolean) => void;
- users: WorkOrderUser[];
- sites: LocationOption[];
- pmTypes: string[];
- vendorTechs: string[];
- advOpen: boolean;
- onAdvOpenChange: (open: boolean) => void;
- advInitial: WorkOrderAdvancedFilters;
- onApplyAdvanced: (next: WorkOrderAdvancedFilters) => void;
- onClearAdvanced: () => void;
- activeDocRow: WorkOrderTableRow | null;
- onDocOpenChange: (open: boolean) => void;
- completionDocs: Record;
- onSaveCompDoc: (data: CompDocData, status: CompDocSaveStatus) => void | Promise;
- onDateCompletedChange: (date: string) => void | Promise;
- activeMediaRow: WorkOrderTableRow | null;
- onMediaOpenChange: (open: boolean) => void;
+ activeUpliftsRow: WorkOrderTableRow | null;
+ onCloseUplifts: () => void;
};
export function WorkOrdersListOverlays({
- wizardOpen,
- onWizardOpenChange,
- users,
- sites,
- pmTypes,
- vendorTechs,
- advOpen,
- onAdvOpenChange,
- advInitial,
- onApplyAdvanced,
- onClearAdvanced,
- activeDocRow,
- onDocOpenChange,
- completionDocs,
- onSaveCompDoc,
- onDateCompletedChange,
- activeMediaRow,
- onMediaOpenChange,
+ activeUpliftsRow,
+ onCloseUplifts,
}: WorkOrdersListOverlaysProps) {
return (
<>
-
-
-
-
- {activeDocRow != null && (
-
- )}
-
- {activeMediaRow != null && (
-
)}
>
diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx
new file mode 100644
index 00000000..9fd5723c
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx
@@ -0,0 +1,95 @@
+import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
+import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
+import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
+import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
+import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
+import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
+import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
+import type { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
+
+type WorkOrdersListPagePanelsProps = {
+ page: ReturnType;
+};
+
+export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps) {
+ const { filters, tableData, activeDocRow, users, locations, vendors, pmTypes, vendorTechs } =
+ page;
+
+ return (
+ <>
+ page.handleOpenSlideOver(row, false)}
+ />
+
+
+
+ page.patchField(id, patch, options)}
+ onOpenCompletionDoc={page.setDocRow}
+ onCancelWO={page.setConfirmCancel}
+ onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
+ saving={page.isPatchPending}
+ onClose={page.handleCloseSlideOver}
+ />
+
+ page.setConfirmCancel(null)}
+ onConfirm={page.handleConfirmCancel}
+ />
+
+ {page.activeMediaRow != null && (
+ {
+ if (!open) page.setMediaRow(null);
+ }}
+ />
+ )}
+
+ {activeDocRow != null && (
+ {
+ if (!open) page.setDocRow(null);
+ }}
+ data={page.completionDocs[String(activeDocRow.id)]}
+ onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
+ onDateCompletedChange={async (date) => {
+ await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
+ }}
+ />
+ )}
+
+ page.setUpliftsRow(null)}
+ />
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx b/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx
new file mode 100644
index 00000000..f5cee496
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx
@@ -0,0 +1,80 @@
+import { useEffect, useState } from "react";
+import {
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ TextField,
+} from "@mui/material";
+import { Text } from "@/components/ui/text";
+
+type RevokeUpliftDialogProps = {
+ open: boolean;
+ onClose: () => void;
+ onConfirm: (reason: string) => void;
+ pending?: boolean;
+ requireReason?: boolean;
+ title?: string;
+ description?: string;
+};
+
+export function RevokeUpliftDialog({
+ open,
+ onClose,
+ onConfirm,
+ pending = false,
+ requireReason = false,
+ title = "Revoke auto-approved uplift?",
+ description = "This will mark the uplift as revoked. Optional reason is stored with the decision.",
+}: RevokeUpliftDialogProps) {
+ const [reason, setReason] = useState("");
+
+ // Clear only after the dialog closes (parent closes on mutation success).
+ useEffect(() => {
+ if (!open) {
+ setReason("");
+ }
+ }, [open]);
+
+ const handleClose = () => {
+ onClose();
+ };
+
+ const handleConfirm = () => {
+ onConfirm(reason.trim());
+ };
+
+ const reasonMissing = requireReason && reason.trim() === "";
+
+ return (
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx
new file mode 100644
index 00000000..8e0902e6
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form.tsx
@@ -0,0 +1,85 @@
+import { useState } from "react";
+import { Button, TextField } from "@mui/material";
+import { Text } from "@/components/ui/text";
+import { formatUpliftMoney } from "@/domain/work-orders/utils/uplift-display-utils";
+
+type WorkOrderUpliftCreateFormProps = {
+ pending?: boolean;
+ remainingAllowance: number;
+ onSubmit: (input: { amount: number; notes: string }) => void | Promise;
+};
+
+function amountHelperText(parsedAmount: number, remainingAllowance: number): string {
+ const remaining = `Remaining auto-approval: ${formatUpliftMoney(remainingAllowance)}.`;
+ if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
+ return remaining;
+ }
+ if (parsedAmount <= remainingAllowance) {
+ return `${remaining} This request will auto-approve.`;
+ }
+ return `${remaining} This request will go to the admin approval queue.`;
+}
+
+export function WorkOrderUpliftCreateForm({
+ pending = false,
+ remainingAllowance,
+ onSubmit,
+}: WorkOrderUpliftCreateFormProps) {
+ const [amount, setAmount] = useState("");
+ const [notes, setNotes] = useState("");
+
+ const parsedAmount = Number(amount);
+ const canSubmit = Number.isFinite(parsedAmount) && parsedAmount > 0 && notes.trim().length > 0;
+
+ const handleSubmit = async () => {
+ try {
+ await onSubmit({ amount: parsedAmount, notes: notes.trim() });
+ setAmount("");
+ setNotes("");
+ } catch {
+ // Mutation onError already toasts; keep form values after failure.
+ }
+ };
+
+ return (
+
+
+ Request uplift
+
+
+ setAmount(event.target.value)}
+ helperText={amountHelperText(parsedAmount, remainingAllowance)}
+ slotProps={{ htmlInput: { min: 0, step: "0.01" } }}
+ />
+ setNotes(event.target.value)}
+ />
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx
new file mode 100644
index 00000000..0578302c
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item.tsx
@@ -0,0 +1,80 @@
+import { Button } from "@mui/material";
+import { Text } from "@/components/ui/text";
+import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift";
+import {
+ canRevokeWorkOrderUplift,
+ formatUpliftMoney,
+ formatUpliftStatusLabel,
+ getUpliftStatusPillStyle,
+} from "@/domain/work-orders/utils/uplift-display-utils";
+
+type WorkOrderUpliftListItemProps = {
+ uplift: WorkOrderUplift;
+ readOnly: boolean;
+ currentUserId: string | number | null | undefined;
+ pendingAction?: boolean;
+ onCancel?: () => void;
+ onRevoke?: () => void;
+};
+
+export function WorkOrderUpliftListItem({
+ uplift,
+ readOnly,
+ currentUserId,
+ pendingAction = false,
+ onCancel,
+ onRevoke,
+}: WorkOrderUpliftListItemProps) {
+ const pillStyle = getUpliftStatusPillStyle(uplift.status);
+ const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel);
+ const showRevoke =
+ !readOnly && canRevokeWorkOrderUplift(uplift, currentUserId) && Boolean(onRevoke);
+
+ return (
+
+
+
+ {formatUpliftStatusLabel(uplift.status)}
+
+
+ {formatUpliftMoney(uplift.amount)}
+
+
+ {Boolean(uplift.notes) && (
+
+ {uplift.notes}
+
+ )}
+ {Boolean(uplift.requestedByName) && (
+
+ Requested by {uplift.requestedByName}
+
+ )}
+ {showCancel && (
+
+ )}
+ {showRevoke && (
+
+ )}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx
new file mode 100644
index 00000000..b0c0c6a0
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx
@@ -0,0 +1,101 @@
+import { CircularProgress } from "@mui/material";
+import { QueryErrorPanel } from "@/components/ui/query-error-panel";
+import { Text } from "@/components/ui/text";
+import { WorkOrderUpliftCreateForm } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form";
+import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item";
+import type {
+ CreateWorkOrderUpliftInput,
+ WorkOrderUplift,
+} from "@/domain/work-orders/types/work-order-uplift";
+import { hasOpenWorkOrderUplift } from "@/domain/work-orders/utils/uplift-display-utils";
+
+type WorkOrderUpliftsDialogContentProps = {
+ readOnly: boolean;
+ currentUserId: string | number | null | undefined;
+ readOnlyStatusLabel?: string;
+ isLoading: boolean;
+ error: Error | null;
+ uplifts: WorkOrderUplift[];
+ remainingAllowance: number;
+ actionPending: boolean;
+ createPending: boolean;
+ onRetry: () => void;
+ onCreate: (input: CreateWorkOrderUpliftInput) => void | Promise;
+ onCancelUplift: (upliftId: string | number) => void;
+ onRevokeUplift: (upliftId: string | number) => void;
+};
+
+export function WorkOrderUpliftsDialogContent({
+ readOnly,
+ currentUserId,
+ readOnlyStatusLabel,
+ isLoading,
+ error,
+ uplifts,
+ remainingAllowance,
+ actionPending,
+ createPending,
+ onRetry,
+ onCreate,
+ onCancelUplift,
+ onRevokeUplift,
+}: WorkOrderUpliftsDialogContentProps) {
+ const hasOpenRequest = hasOpenWorkOrderUplift(uplifts);
+
+ return (
+ <>
+ {readOnly && (
+
+ This work order is {readOnlyStatusLabel?.toLowerCase()}. Uplifts are read-only.
+
+ )}
+
+ {Boolean(error) && (
+
+ )}
+
+ {isLoading && (
+
+
+
+ )}
+
+ {!isLoading && !error && uplifts.length === 0 && (
+
+ No uplift requests yet.
+
+ )}
+
+ {!isLoading &&
+ uplifts.map((uplift) => (
+ onCancelUplift(uplift.id)}
+ onRevoke={readOnly ? undefined : () => onRevokeUplift(uplift.id)}
+ />
+ ))}
+
+ {!readOnly && !isLoading && hasOpenRequest && (
+
+ Cancel the pending request to file a new one.
+
+ )}
+
+ {!readOnly && !isLoading && !hasOpenRequest && (
+
+ )}
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx
new file mode 100644
index 00000000..7e6cbc27
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx
@@ -0,0 +1,122 @@
+import { useMemo, useState } from "react";
+import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
+import { RevokeUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog";
+import { WorkOrderUpliftsDialogContent } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { WorkOrderUpliftStatus } from "@/domain/work-orders/types/work-order-uplift";
+import {
+ useCancelWorkOrderUplift,
+ useCreateWorkOrderUplift,
+ useRevokeWorkOrderUplift,
+ useWorkOrderUplifts,
+} from "@/domain/work-orders/use-cases/use-work-order-uplifts";
+import {
+ getRemainingUpliftAllowance,
+ isWorkOrderUpliftsReadOnly,
+ upliftRevokeRequiresReason,
+} from "@/domain/work-orders/utils/uplift-display-utils";
+import { useAuthContext } from "@/providers/auth-context";
+
+type RevokeTarget = {
+ id: string | number;
+ status: WorkOrderUpliftStatus;
+};
+
+type WorkOrderUpliftsDialogProps = {
+ row: WorkOrderTableRow | null;
+ open: boolean;
+ onClose: () => void;
+};
+
+export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsDialogProps) {
+ const { user } = useAuthContext();
+ const workOrderId = row?.id ?? null;
+ const readOnly = row ? isWorkOrderUpliftsReadOnly(row.status) : true;
+ const {
+ data: uplifts = [],
+ isLoading,
+ error,
+ refetch,
+ } = useWorkOrderUplifts(open ? workOrderId : null);
+ const createUplift = useCreateWorkOrderUplift(workOrderId ?? "");
+ const cancelUplift = useCancelWorkOrderUplift(workOrderId ?? "");
+ const revokeUplift = useRevokeWorkOrderUplift(workOrderId ?? "");
+ const [revokeTarget, setRevokeTarget] = useState(null);
+
+ const actionPending = createUplift.isPending || cancelUplift.isPending || revokeUplift.isPending;
+
+ const revokeDialogCopy = useMemo(() => {
+ if (revokeTarget?.status === "approved") {
+ return {
+ title: "Revoke approved uplift?",
+ description:
+ "Admin revoke requires a reason. This will mark the uplift as revoked and store your note with the decision.",
+ };
+ }
+ return {
+ title: "Revoke auto-approved uplift?",
+ description:
+ "This will mark the uplift as revoked. Optional reason is stored with the decision.",
+ };
+ }, [revokeTarget?.status]);
+
+ const handleRevokeUplift = (upliftId: string | number) => {
+ const uplift = uplifts.find((item) => String(item.id) === String(upliftId));
+ if (!uplift) {
+ return;
+ }
+ setRevokeTarget({ id: upliftId, status: uplift.status });
+ };
+
+ const remainingAllowance = getRemainingUpliftAllowance(row?.type ?? "", uplifts);
+
+ return (
+ <>
+
+
+ setRevokeTarget(null)}
+ onConfirm={(reason) => {
+ if (revokeTarget == null) {
+ return;
+ }
+ revokeUplift.mutate(
+ { upliftId: revokeTarget.id, reason },
+ { onSuccess: () => setRevokeTarget(null) },
+ );
+ }}
+ />
+ >
+ );
+}
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 b2963c11..cfcbe7b6 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
@@ -19,6 +19,7 @@ export function useWorkOrdersListPage() {
const [confirmCancel, setConfirmCancel] = useState(null);
const [docRow, setDocRow] = useState(null);
const [mediaRow, setMediaRow] = useState(null);
+ const [upliftsRow, setUpliftsRow] = useState(null);
const [completionDocs, setCompletionDocs] = useState>({});
const filters = useWorkOrdersListFilters();
const tableData = useWorkOrdersTableData(filters);
@@ -62,6 +63,11 @@ export function useWorkOrdersListPage() {
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
}, [mediaRow, tableData.rows]);
+ const activeUpliftsRow = useMemo(() => {
+ if (!upliftsRow) return null;
+ return tableData.rows.find((row) => row.id === upliftsRow.id) ?? upliftsRow;
+ }, [upliftsRow, tableData.rows]);
+
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
setSlideOverRow(row);
setSlideOverEdit(edit);
@@ -107,6 +113,7 @@ export function useWorkOrdersListPage() {
setDocRow,
mediaRow,
setMediaRow,
+ setUpliftsRow,
completionDocs,
filters,
tableData,
@@ -122,6 +129,7 @@ export function useWorkOrdersListPage() {
activeSlideOverRow,
activeDocRow,
activeMediaRow,
+ activeUpliftsRow,
handleOpenSlideOver,
handleCloseSlideOver,
handleConfirmCancel,
diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx
index 9036de10..6f9d46ef 100644
--- a/src/app/(protected)/workorders/index.tsx
+++ b/src/app/(protected)/workorders/index.tsx
@@ -1,15 +1,10 @@
import { Box } from "@mui/material";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
-import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
-import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
-import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
-import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
-import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
-import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
+import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import { useAuthContext } from "@/providers/auth-context";
@@ -17,7 +12,7 @@ import { useAuthContext } from "@/providers/auth-context";
export default function WorkOrdersListPage() {
const { user } = useAuthContext();
const page = useWorkOrdersListPage();
- const { filters, tableData, activeDocRow } = page;
+ const { filters, tableData } = page;
return (
@@ -86,79 +81,13 @@ export default function WorkOrdersListPage() {
onOpenSlideOver={page.handleOpenSlideOver}
onOpenCompDoc={page.setDocRow}
onOpenMedia={page.setMediaRow}
+ onOpenUplifts={page.setUpliftsRow}
onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
onPatch={page.patchField}
/>
- page.handleOpenSlideOver(row, false)}
- />
-
-
-
- page.patchField(id, patch, options)}
- onOpenCompletionDoc={page.setDocRow}
- onCancelWO={page.setConfirmCancel}
- onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
- saving={page.isPatchPending}
- onClose={page.handleCloseSlideOver}
- />
-
- page.setConfirmCancel(null)}
- onConfirm={page.handleConfirmCancel}
- />
-
- {page.activeMediaRow != null && (
- {
- if (!open) page.setMediaRow(null);
- }}
- />
- )}
-
- {activeDocRow != null && (
- {
- if (!open) page.setDocRow(null);
- }}
- data={page.completionDocs[String(activeDocRow.id)]}
- onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
- onDateCompletedChange={async (date) => {
- await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
- }}
- />
- )}
+
);
}
diff --git a/src/domain/work-orders/api/work-order-uplifts-api.ts b/src/domain/work-orders/api/work-order-uplifts-api.ts
new file mode 100644
index 00000000..866ff164
--- /dev/null
+++ b/src/domain/work-orders/api/work-order-uplifts-api.ts
@@ -0,0 +1,61 @@
+import { API_PATHS } from "@/api/api-paths";
+import { apiGet, apiPost } from "@/api/api";
+import { handleApiResponse } from "@/api/handle-api-response";
+import {
+ mapWorkOrderUplift,
+ mapWorkOrderUplifts,
+} from "@/domain/work-orders/mappers/work-order-uplift-mapper";
+import type {
+ CreateWorkOrderUpliftInput,
+ WorkOrderUplift,
+} from "@/domain/work-orders/types/work-order-uplift";
+
+function extractUpliftItems(data: unknown): unknown[] {
+ if (Array.isArray(data)) {
+ return data;
+ }
+ const record = typeof data === "object" && data != null ? (data as Record) : {};
+ const items = record.items ?? record.Items ?? record.uplifts ?? record.Uplifts;
+ return Array.isArray(items) ? items : [];
+}
+
+export const workOrderUpliftsApi = {
+ list: async (workOrderId: string | number): Promise => {
+ const data = await apiGet(API_PATHS.workOrder.uplifts(workOrderId));
+ const unwrapped = handleApiResponse(data);
+ return mapWorkOrderUplifts(extractUpliftItems(unwrapped));
+ },
+
+ create: async (
+ workOrderId: string | number,
+ input: CreateWorkOrderUpliftInput,
+ ): Promise => {
+ const data = await apiPost(API_PATHS.workOrder.uplifts(workOrderId), {
+ amount: input.amount,
+ notes: input.notes,
+ });
+ return mapWorkOrderUplift(handleApiResponse(data));
+ },
+
+ cancel: async (
+ workOrderId: string | number,
+ upliftId: string | number,
+ ): Promise => {
+ const data = await apiPost(
+ API_PATHS.workOrder.upliftCancel(workOrderId, upliftId),
+ {},
+ );
+ return mapWorkOrderUplift(handleApiResponse(data));
+ },
+
+ revoke: async (
+ workOrderId: string | number,
+ upliftId: string | number,
+ reason?: string,
+ ): Promise => {
+ const data = await apiPost(API_PATHS.workOrder.upliftRevoke(workOrderId, upliftId), {
+ reason,
+ });
+ return mapWorkOrderUplift(handleApiResponse(data));
+ },
+};
diff --git a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
index c253ee39..8dfe70e0 100644
--- a/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
+++ b/src/domain/work-orders/mappers/work-order-table-row-mapper.ts
@@ -1,5 +1,6 @@
import { readAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
+import { mapUpliftSummary } from "@/domain/work-orders/mappers/work-order-uplift-mapper";
import {
DOC_STATUS_API,
LIFECYCLE_STATUS_API_TO_FE,
@@ -320,8 +321,9 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
const locationIdRaw = item.locationId ?? item.LocationId;
const vendorIdNum = readNumber(item, "vendorId", "VendorId");
const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId");
- const upliftSummary = readUpliftSummary(item);
- const hasPendingUplift = readHasPendingUplift(item, upliftSummary);
+ const boardUpliftSummary = readUpliftSummary(item);
+ const hasPendingUplift = readHasPendingUplift(item, boardUpliftSummary);
+ const upliftSummary = boardUpliftSummary == null ? undefined : mapUpliftSummary(item);
const row: WorkOrderTableRow = {
id: readId(item),
diff --git a/src/domain/work-orders/mappers/work-order-uplift-mapper.ts b/src/domain/work-orders/mappers/work-order-uplift-mapper.ts
new file mode 100644
index 00000000..ec712467
--- /dev/null
+++ b/src/domain/work-orders/mappers/work-order-uplift-mapper.ts
@@ -0,0 +1,158 @@
+import type {
+ UpliftSummary,
+ WorkOrderUplift,
+ WorkOrderUpliftStatus,
+} from "@/domain/work-orders/types/work-order-uplift";
+import { WORK_ORDER_UPLIFT_STATUSES } from "@/domain/work-orders/types/work-order-uplift";
+
+function asRecord(raw: unknown): Record {
+ return typeof raw === "object" && raw !== null ? (raw as Record) : {};
+}
+
+function readString(record: Record, ...keys: string[]): string {
+ for (const key of keys) {
+ const value = record[key];
+ if (typeof value === "string") {
+ return value;
+ }
+ if (typeof value === "number") {
+ return String(value);
+ }
+ }
+ return "";
+}
+
+function readNumber(record: Record, ...keys: string[]): number | null {
+ for (const key of keys) {
+ const value = record[key];
+ if (typeof value === "number") {
+ return value;
+ }
+ if (typeof value === "string" && value !== "") {
+ const parsed = Number(value);
+ if (!Number.isNaN(parsed)) {
+ return parsed;
+ }
+ }
+ }
+ return null;
+}
+
+function readBool(record: Record, ...keys: string[]): boolean {
+ for (const key of keys) {
+ const value = record[key];
+ if (typeof value === "boolean") {
+ return value;
+ }
+ }
+ return false;
+}
+
+function readId(record: Record): string | number {
+ const value = record.id ?? record.Id;
+ if (typeof value === "string" || typeof value === "number") {
+ return value;
+ }
+ return "";
+}
+
+function readOptionalId(
+ record: Record,
+ ...keys: string[]
+): string | number | null {
+ for (const key of keys) {
+ const value = record[key];
+ if (typeof value === "string" || typeof value === "number") {
+ return value;
+ }
+ }
+ return null;
+}
+
+const STATUS_SET = new Set(WORK_ORDER_UPLIFT_STATUSES);
+
+function normalizeUpliftStatus(raw: string): WorkOrderUpliftStatus {
+ const normalized = raw.trim().toLowerCase().replace(/\s+/g, "_");
+ if (STATUS_SET.has(normalized)) {
+ return normalized as WorkOrderUpliftStatus;
+ }
+ if (normalized === "canceled") {
+ return "cancelled";
+ }
+ if (normalized === "autoapproved") {
+ return "auto_approved";
+ }
+ return "pending";
+}
+
+export function mapUpliftSummary(raw: unknown): UpliftSummary {
+ const item = asRecord(raw);
+ const nested = asRecord(item.upliftSummary ?? item.UpliftSummary);
+ const hasNested = Object.keys(nested).length > 0;
+ const source = hasNested ? nested : item;
+
+ const pendingCount =
+ readNumber(source, "pendingCount", "PendingCount") ??
+ readNumber(item, "pendingUpliftCount", "PendingUpliftCount") ??
+ 0;
+ const hasUplift =
+ readBool(source, "hasUplift", "HasUplift") ||
+ readBool(item, "hasUplift", "HasUplift") ||
+ pendingCount > 0;
+
+ // Never treat the work-order lifecycle status/Status as uplift primaryStatus.
+ const primaryStatusRaw = hasNested
+ ? readString(source, "primaryStatus", "PrimaryStatus", "status", "Status")
+ : readString(item, "primaryStatus", "PrimaryStatus");
+ const amount = hasNested
+ ? readNumber(source, "amount", "Amount")
+ : readNumber(item, "amount", "Amount");
+
+ const summary: UpliftSummary = {
+ hasUplift,
+ pendingCount,
+ };
+
+ if (primaryStatusRaw) {
+ summary.primaryStatus = normalizeUpliftStatus(primaryStatusRaw);
+ }
+ if (amount != null) {
+ summary.amount = amount;
+ }
+
+ return summary;
+}
+
+export function mapWorkOrderUplift(raw: unknown): WorkOrderUplift {
+ const item = asRecord(raw);
+ const statusRaw = readString(item, "status", "Status");
+
+ return {
+ id: readId(item),
+ status: statusRaw ? normalizeUpliftStatus(statusRaw) : "pending",
+ amount: readNumber(item, "amount", "Amount", "requestedNTE", "RequestedNTE") ?? 0,
+ notes: readString(item, "notes", "Notes", "vendorReason", "VendorReason"),
+ requestedAt: readString(item, "requestedAt", "RequestedAt"),
+ requestedById: readOptionalId(
+ item,
+ "requestedById",
+ "RequestedById",
+ "requesterId",
+ "RequesterId",
+ ),
+ requestedByName: readString(
+ item,
+ "requestedByName",
+ "RequestedByName",
+ "requestedByVendorName",
+ "RequestedByVendorName",
+ ),
+ decidedAt: readString(item, "decidedAt", "DecidedAt"),
+ decidedByName: readString(item, "decidedByName", "DecidedByName"),
+ decisionNote: readString(item, "decisionNote", "DecisionNote"),
+ };
+}
+
+export function mapWorkOrderUplifts(rawItems: unknown[]): WorkOrderUplift[] {
+ return rawItems.map(mapWorkOrderUplift);
+}
diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts
index 2e5ab9de..4b066382 100644
--- a/src/domain/work-orders/types/work-order-table-row.ts
+++ b/src/domain/work-orders/types/work-order-table-row.ts
@@ -1,6 +1,7 @@
import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
import type { WizardWOStatus, WOType } from "./work-order-wizard";
import type { WorkOrderSeverity } from "./work-order-severity";
+import type { UpliftSummary } from "./work-order-uplift";
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
@@ -67,7 +68,7 @@ export interface WorkOrderTableRow {
dispatchRowVersion: string;
/** Board flag color (#RRGGBB). Null/empty = no flag. Distinct from dispatcher avatar `color`. */
flagColor: string | null;
- upliftSummary?: WorkOrderUpliftSummary | null;
+ upliftSummary?: UpliftSummary | null;
hasPendingUplift?: boolean;
}
diff --git a/src/domain/work-orders/types/work-order-uplift.ts b/src/domain/work-orders/types/work-order-uplift.ts
new file mode 100644
index 00000000..aaed9c16
--- /dev/null
+++ b/src/domain/work-orders/types/work-order-uplift.ts
@@ -0,0 +1,41 @@
+export const WORK_ORDER_UPLIFT_STATUSES = [
+ "pending",
+ "approved",
+ "auto_approved",
+ "rejected",
+ "cancelled",
+ "revoked",
+] as const;
+
+export type WorkOrderUpliftStatus = (typeof WORK_ORDER_UPLIFT_STATUSES)[number];
+
+export interface UpliftSummary {
+ hasUplift: boolean;
+ pendingCount: number;
+ primaryStatus?: WorkOrderUpliftStatus;
+ amount?: number;
+}
+
+export interface WorkOrderUplift {
+ id: string | number;
+ status: WorkOrderUpliftStatus;
+ amount: number;
+ notes: string;
+ requestedAt: string;
+ requestedById: string | number | null;
+ requestedByName: string;
+ decidedAt: string;
+ decidedByName: string;
+ decisionNote: string;
+}
+
+export interface CreateWorkOrderUpliftInput {
+ amount: number;
+ notes: string;
+ attachments?: File[];
+}
+
+export const EMPTY_UPLIFT_SUMMARY: UpliftSummary = {
+ hasUplift: false,
+ pendingCount: 0,
+};
diff --git a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts
new file mode 100644
index 00000000..ec9cd3cc
--- /dev/null
+++ b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts
@@ -0,0 +1,87 @@
+import {
+ useMutation,
+ useQuery,
+ useQueryClient,
+ type UseMutationResult,
+ type UseQueryResult,
+} from "@tanstack/react-query";
+import { toast } from "react-toastify";
+import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api";
+import type {
+ CreateWorkOrderUpliftInput,
+ WorkOrderUplift,
+} from "@/domain/work-orders/types/work-order-uplift";
+import { queryKeys } from "@/infra/query-key/query-key";
+import { requireQueryParam } from "@/lib/query/require-query-param";
+
+function invalidateUpliftQueries(
+ queryClient: ReturnType,
+ workOrderId: string | number,
+): void {
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.uplifts(workOrderId) });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
+}
+
+export function useWorkOrderUplifts(
+ workOrderId: string | number | null | undefined,
+): UseQueryResult {
+ return useQuery({
+ queryKey: queryKeys.workOrders.uplifts(workOrderId ?? ""),
+ queryFn: () =>
+ workOrderUpliftsApi.list(requireQueryParam(workOrderId ?? undefined, "Work order id")),
+ enabled: workOrderId != null && workOrderId !== "",
+ });
+}
+
+export function useCreateWorkOrderUplift(
+ workOrderId: string | number,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (input: CreateWorkOrderUpliftInput) =>
+ workOrderUpliftsApi.create(workOrderId, input),
+ onSuccess: () => {
+ invalidateUpliftQueries(queryClient, workOrderId);
+ toast.success("Uplift request created");
+ },
+ onError: (error) => {
+ toast.error(error.message || "Failed to create uplift");
+ },
+ });
+}
+
+export function useCancelWorkOrderUplift(
+ workOrderId: string | number,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (upliftId: string | number) => workOrderUpliftsApi.cancel(workOrderId, upliftId),
+ onSuccess: () => {
+ invalidateUpliftQueries(queryClient, workOrderId);
+ toast.success("Uplift cancelled");
+ },
+ onError: (error) => {
+ toast.error(error.message || "Failed to cancel uplift");
+ },
+ });
+}
+
+export function useRevokeWorkOrderUplift(
+ workOrderId: string | number,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ upliftId, reason }: { upliftId: string | number; reason?: string }) =>
+ workOrderUpliftsApi.revoke(workOrderId, upliftId, reason),
+ onSuccess: () => {
+ invalidateUpliftQueries(queryClient, workOrderId);
+ toast.success("Uplift revoked");
+ },
+ onError: (error) => {
+ toast.error(error.message || "Failed to revoke uplift");
+ },
+ });
+}
diff --git a/src/domain/work-orders/utils/uplift-display-utils.ts b/src/domain/work-orders/utils/uplift-display-utils.ts
new file mode 100644
index 00000000..26dd14c2
--- /dev/null
+++ b/src/domain/work-orders/utils/uplift-display-utils.ts
@@ -0,0 +1,130 @@
+import type {
+ WorkOrderUplift,
+ UpliftSummary,
+ WorkOrderUpliftStatus,
+} from "@/domain/work-orders/types/work-order-uplift";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+export function formatUpliftMoney(value: number | null | undefined): string {
+ if (value == null || Number.isNaN(value)) {
+ return "—";
+ }
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ maximumFractionDigits: 0,
+ }).format(value);
+}
+
+const STATUS_LABELS: Record = {
+ pending: "Pending",
+ approved: "Approved",
+ auto_approved: "Auto",
+ rejected: "Rejected",
+ cancelled: "Cancelled",
+ revoked: "Revoked",
+};
+
+export function formatUpliftStatusLabel(status: WorkOrderUpliftStatus): string {
+ return STATUS_LABELS[status];
+}
+
+export function getUpliftCellLabel(summary: UpliftSummary): string | null {
+ if (!summary.hasUplift && summary.pendingCount <= 0) {
+ return null;
+ }
+ if (summary.pendingCount > 0) {
+ return summary.pendingCount === 1 ? "1 Pending" : `${summary.pendingCount} Pending`;
+ }
+ if (summary.primaryStatus) {
+ const label = formatUpliftStatusLabel(summary.primaryStatus);
+ if (summary.amount != null) {
+ return `${label} ${formatUpliftMoney(summary.amount)}`;
+ }
+ return label;
+ }
+ if (summary.hasUplift) {
+ return "Uplift";
+ }
+ return null;
+}
+
+export function getUpliftStatusPillStyle(status: WorkOrderUpliftStatus): {
+ background: string;
+ color: string;
+} {
+ switch (status) {
+ case "pending":
+ return { background: "var(--status-pending-bg)", color: "var(--status-pending-text)" };
+ case "approved":
+ return { background: "var(--status-success-bg, #DCFCE7)", color: "var(--color-success)" };
+ case "auto_approved":
+ return { background: "var(--color-primary-tint)", color: "var(--color-primary)" };
+ case "rejected":
+ return { background: "#FEE2E2", color: "#B91C1C" };
+ case "cancelled":
+ case "revoked":
+ return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" };
+ default:
+ return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" };
+ }
+}
+
+export function isWorkOrderUpliftsReadOnly(status: WorkOrderTableRow["status"]): boolean {
+ return status === "Completed" || status === "Canceled";
+}
+
+export function canOpenUpliftsDialog(
+ summary: UpliftSummary,
+ status: WorkOrderTableRow["status"],
+): boolean {
+ if (summary.hasUplift || summary.pendingCount > 0) {
+ return true;
+ }
+ return !isWorkOrderUpliftsReadOnly(status);
+}
+
+export function canRevokeWorkOrderUplift(
+ uplift: Pick,
+ currentUserId: string | number | null | undefined,
+): boolean {
+ if (uplift.status !== "auto_approved") {
+ // SH-214/SH-212: admin-approved revoke lives on Uplift Approvals, not the WO dialog.
+ return false;
+ }
+ return (
+ currentUserId != null &&
+ currentUserId !== "" &&
+ uplift.requestedById != null &&
+ String(uplift.requestedById) === String(currentUserId)
+ );
+}
+
+export function upliftRevokeRequiresReason(status: WorkOrderUpliftStatus): boolean {
+ return status === "approved";
+}
+
+export const UPLIFT_STANDARD_ALLOWANCE_USD = 500;
+export const UPLIFT_EMERGENCY_ALLOWANCE_USD = 5000;
+
+export function getUpliftAllowanceCap(workOrderType: string): number {
+ return workOrderType === "Emergency"
+ ? UPLIFT_EMERGENCY_ALLOWANCE_USD
+ : UPLIFT_STANDARD_ALLOWANCE_USD;
+}
+
+export function hasOpenWorkOrderUplift(
+ uplifts: ReadonlyArray>,
+): boolean {
+ return uplifts.some((uplift) => uplift.status === "pending");
+}
+
+export function getRemainingUpliftAllowance(
+ workOrderType: string,
+ uplifts: ReadonlyArray>,
+): number {
+ const consumed = uplifts
+ .filter((uplift) => uplift.status === "auto_approved")
+ .reduce((sum, uplift) => sum + uplift.amount, 0);
+ return Math.max(0, getUpliftAllowanceCap(workOrderType) - consumed);
+}
diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts
index 87eeda4e..3b135667 100644
--- a/src/infra/query-key/query-key.ts
+++ b/src/infra/query-key/query-key.ts
@@ -26,6 +26,8 @@ export const queryKeys = {
dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const,
taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const,
dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const,
+ uplifts: (workOrderId: string | number) =>
+ [...queryKeys.workOrders.all, "uplifts", workOrderId] as const,
},
pmSchedules: {
all: ["pmSchedules"] as const,
diff --git a/src/lib/auth/user-utils.ts b/src/lib/auth/user-utils.ts
index 98716ee7..55ca7411 100644
--- a/src/lib/auth/user-utils.ts
+++ b/src/lib/auth/user-utils.ts
@@ -4,3 +4,13 @@ export function getPrimaryUserRole(
): string {
return userRoles?.split(",")[0]?.trim() || fallback;
}
+
+export function isAdminUser(userRoles: string | null | undefined): boolean {
+ if (!userRoles) {
+ return false;
+ }
+ return userRoles
+ .split(",")
+ .map((role) => role.trim().toLowerCase())
+ .includes("admin");
+}
diff --git a/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
index 3a8fb16b..f36cc442 100644
--- a/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
+++ b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
@@ -74,6 +74,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
+ upliftSummary: { hasUplift: false, pendingCount: 0 },
};
return {
...defaults,
diff --git a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
index f5c987b6..fd628353 100644
--- a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
+++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
@@ -86,6 +86,7 @@ function baseRow(): WorkOrderTableRow {
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
+ upliftSummary: { hasUplift: false, pendingCount: 0 },
};
}
diff --git a/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx b/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx
index 6c0b4e8e..80a9b011 100644
--- a/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx
+++ b/src/test/app/(protected)/workorders/use-comp-doc-dialog-address-seed.test.tsx
@@ -61,6 +61,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
+ upliftSummary: { hasUplift: false, pendingCount: 0 },
};
return {
...defaults,
diff --git a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx
index 06acd120..e4d7d670 100644
--- a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx
+++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx
@@ -51,6 +51,7 @@ function baseInfo(overrides: Partial = {}): WorkOrderTableRow
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
+ upliftSummary: { hasUplift: false, pendingCount: 0 },
...overrides,
};
}
diff --git a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx
index 7bd7ec75..a8c2924a 100644
--- a/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx
+++ b/src/test/app/(protected)/workorders/wo-table-unscheduled-highlight.test.tsx
@@ -55,6 +55,7 @@ const unscheduledRow: WorkOrderTableRow = {
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
+ upliftSummary: { hasUplift: false, pendingCount: 0 },
};
const handlers: WoTableRowHandlers = {
diff --git a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx
index 28f00d9b..9597efee 100644
--- a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx
+++ b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx
@@ -94,6 +94,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
+ upliftSummary: { hasUplift: false, pendingCount: 0 },
...overrides,
};
}
diff --git a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx
new file mode 100644
index 00000000..4230eb74
--- /dev/null
+++ b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx
@@ -0,0 +1,177 @@
+import { fireEvent, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { UpliftCell } from "@/app/(protected)/workorders/_components/list/table/cells/uplift-cell";
+import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item";
+import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift";
+import { renderWithProviders } from "@/test/test-utils";
+
+const pendingUplift: WorkOrderUplift = {
+ id: 1,
+ status: "pending",
+ amount: 450,
+ notes: "Extra work",
+ requestedAt: "2026-07-20T10:00:00Z",
+ requestedById: "vendor-1",
+ requestedByName: "Vendor",
+ decidedAt: "",
+ decidedByName: "",
+ decisionNote: "",
+};
+
+const mockState = vi.hoisted(() => ({
+ uplifts: [] as WorkOrderUplift[],
+}));
+
+const baseRow: WorkOrderTableRow = {
+ id: 42,
+ woNumber: "WO-42",
+ site: "CHI-1042",
+ type: "PM",
+ severity: null,
+ dispatcherId: "u1",
+ dispatcherName: "Alex",
+ scheduledOn: "2026-07-20",
+ completedDate: "",
+ targetWeek: "2026-07-20",
+ dayGroup: "2026-07-20",
+ dayLabel: "Mon",
+ location: "Chicago Loop",
+ locationId: "loc-1",
+ pm: "HVAC",
+ serviceNotes: "",
+ extraServices: [],
+ vendorId: "1",
+ company: "Vendor Co",
+ tech: "Sam",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Scheduled",
+ docStatus: "No",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ isAddOn: false,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ title: "",
+ priority: "",
+ primaryDispatchId: null,
+ rowVersion: "v1",
+ dispatchRowVersion: "",
+ flagColor: null,
+ upliftSummary: { hasUplift: true, pendingCount: 1, primaryStatus: "pending", amount: 450 },
+};
+
+vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({
+ useWorkOrderUplifts: () => ({
+ data: mockState.uplifts,
+ isLoading: false,
+ error: null,
+ refetch: vi.fn(),
+ }),
+ useCreateWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }),
+ useCancelWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }),
+ useRevokeWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }),
+}));
+
+describe("UpliftCell affordances", () => {
+ it("renders pending pill and opens handler when onOpen is provided", () => {
+ const onOpen = vi.fn();
+
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /1 pending/i }));
+ expect(onOpen).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows read-only dash when onOpen is omitted", () => {
+ renderWithProviders(, {
+ withAuth: false,
+ });
+
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ expect(screen.getByText("—")).toBeInTheDocument();
+ });
+});
+
+describe("WorkOrderUpliftsDialog affordances", () => {
+ it("hides create form when a pending request already exists", () => {
+ mockState.uplifts = [pendingUplift];
+
+ renderWithProviders(, {
+ withAuth: true,
+ });
+
+ expect(screen.getByRole("heading", { name: /uplifts — wo wo-42/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /cancel pending/i })).toBeInTheDocument();
+ expect(screen.getByText(/cancel the pending request to file a new one/i)).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /create uplift/i })).not.toBeInTheDocument();
+ });
+
+ it("shows create form with remaining allowance when no pending request exists", () => {
+ mockState.uplifts = [];
+
+ renderWithProviders(, {
+ withAuth: true,
+ });
+
+ expect(screen.getByRole("button", { name: /create uplift/i })).toBeInTheDocument();
+ expect(screen.getByText(/remaining auto-approval: \$500/i)).toBeInTheDocument();
+ });
+
+ it("hides create and cancel actions when work order is completed", () => {
+ mockState.uplifts = [pendingUplift];
+
+ renderWithProviders(
+ ,
+ { withAuth: true },
+ );
+
+ expect(screen.getByText(/uplifts are read-only/i)).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /create uplift/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /cancel pending/i })).not.toBeInTheDocument();
+ });
+});
+
+describe("WorkOrderUpliftListItem revoke affordances", () => {
+ it("hides Revoke for admin-approved uplifts on the Work Order surface", () => {
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ expect(screen.queryByRole("button", { name: /revoke/i })).not.toBeInTheDocument();
+ });
+
+ it("shows Revoke for the requesting dispatcher on auto-approved uplifts", () => {
+ renderWithProviders(
+ ,
+ { withAuth: false },
+ );
+
+ expect(screen.getByRole("button", { name: /revoke/i })).toBeInTheDocument();
+ });
+});
diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
index 06fe1751..c9b46873 100644
--- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
+++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
@@ -113,6 +113,7 @@ const canceledRow: WorkOrderTableRow = {
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
+ upliftSummary: { hasUplift: false, pendingCount: 0 },
};
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({
diff --git a/src/test/domain/work-orders/api/work-order-uplifts-api.test.ts b/src/test/domain/work-orders/api/work-order-uplifts-api.test.ts
new file mode 100644
index 00000000..599ae34e
--- /dev/null
+++ b/src/test/domain/work-orders/api/work-order-uplifts-api.test.ts
@@ -0,0 +1,25 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const apiGet = vi.fn();
+const apiPost = vi.fn();
+
+vi.mock("@/api/api", () => ({
+ apiGet: (...args: unknown[]) => apiGet(...args),
+ apiPost: (...args: unknown[]) => apiPost(...args),
+}));
+
+import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api";
+
+describe("workOrderUpliftsApi", () => {
+ beforeEach(() => {
+ apiGet.mockReset();
+ apiPost.mockReset();
+ });
+
+ it("propagates a real API list failure instead of returning mock uplifts", async () => {
+ const error = new Error("Unauthorized");
+ apiGet.mockRejectedValueOnce(error);
+
+ await expect(workOrderUpliftsApi.list(42)).rejects.toBe(error);
+ });
+});
diff --git a/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts
new file mode 100644
index 00000000..d765c4d5
--- /dev/null
+++ b/src/test/domain/work-orders/mappers/work-order-uplift-mapper.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it } from "vitest";
+import {
+ mapUpliftSummary,
+ mapWorkOrderUplift,
+} from "@/domain/work-orders/mappers/work-order-uplift-mapper";
+import {
+ getUpliftCellLabel,
+ getUpliftStatusPillStyle,
+} from "@/domain/work-orders/utils/uplift-display-utils";
+
+describe("mapUpliftSummary", () => {
+ it("maps nested PascalCase upliftSummary from board row payload", () => {
+ const result = mapUpliftSummary({
+ id: 10,
+ UpliftSummary: {
+ HasUplift: true,
+ PendingCount: 2,
+ PrimaryStatus: "Pending",
+ Amount: 500,
+ },
+ });
+
+ expect(result).toEqual({
+ hasUplift: true,
+ pendingCount: 2,
+ primaryStatus: "pending",
+ amount: 500,
+ });
+ });
+
+ it("derives hasUplift from pendingUpliftCount when nested summary is absent", () => {
+ const result = mapUpliftSummary({
+ pendingUpliftCount: 1,
+ });
+
+ expect(result.hasUplift).toBe(true);
+ expect(result.pendingCount).toBe(1);
+ });
+
+ it("does not treat work-order status as uplift primaryStatus when nested summary is absent", () => {
+ const result = mapUpliftSummary({
+ status: "Cancelled",
+ Status: "Cancelled",
+ hasUplift: false,
+ });
+
+ expect(result.hasUplift).toBe(false);
+ expect(result.primaryStatus).toBeUndefined();
+ expect(getUpliftCellLabel(result)).toBeNull();
+ });
+
+ it("normalizes auto-approved and canceled status aliases", () => {
+ expect(mapUpliftSummary({ primaryStatus: "AutoApproved" }).primaryStatus).toBe("auto_approved");
+ expect(mapWorkOrderUplift({ status: "Canceled" }).status).toBe("cancelled");
+ });
+
+ it("maps requester identity for revoke ownership checks", () => {
+ expect(mapWorkOrderUplift({ requestedById: "dispatcher-1" }).requestedById).toBe(
+ "dispatcher-1",
+ );
+ });
+});
+
+describe("uplift display utils", () => {
+ it("builds pending pill label and style", () => {
+ const summary = { hasUplift: true, pendingCount: 1, primaryStatus: "pending" as const };
+ expect(getUpliftCellLabel(summary)).toBe("1 Pending");
+ expect(getUpliftStatusPillStyle("pending")).toEqual({
+ background: "var(--status-pending-bg)",
+ color: "var(--status-pending-text)",
+ });
+ });
+
+ it("includes amount in non-pending primary status label", () => {
+ expect(
+ getUpliftCellLabel({
+ hasUplift: true,
+ pendingCount: 0,
+ primaryStatus: "approved",
+ amount: 1200,
+ }),
+ ).toBe("Approved $1,200");
+ });
+
+ it("hides Pending/Cancelled when hasUplift is false", () => {
+ expect(
+ getUpliftCellLabel({
+ hasUplift: false,
+ pendingCount: 0,
+ primaryStatus: "pending",
+ }),
+ ).toBeNull();
+ expect(
+ getUpliftCellLabel({
+ hasUplift: false,
+ pendingCount: 0,
+ primaryStatus: "cancelled",
+ }),
+ ).toBeNull();
+ });
+});
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 9a071310..73729279 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
@@ -99,7 +99,8 @@ const BASE_ROW: WorkOrderTableRow = {
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
-};
+ upliftSummary: { hasUplift: false, pendingCount: 0 },
+} as WorkOrderTableRow;
describe("useWorkOrderTableMutations completion-doc persistence", () => {
let serverState: ServerState;
diff --git a/src/test/domain/work-orders/utils/slide-over-draft.test.ts b/src/test/domain/work-orders/utils/slide-over-draft.test.ts
index c58d8d04..885b7780 100644
--- a/src/test/domain/work-orders/utils/slide-over-draft.test.ts
+++ b/src/test/domain/work-orders/utils/slide-over-draft.test.ts
@@ -4,6 +4,7 @@ import {
isSlideOverDraftDirty,
SLIDE_OVER_EDIT_KEYS,
} from "@/domain/work-orders/utils/slide-over-draft";
+import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
function baseRow(overrides: Partial = {}): WorkOrderTableRow {
@@ -53,6 +54,7 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow
rowVersion: "1",
dispatchRowVersion: "",
flagColor: null,
+ upliftSummary: EMPTY_UPLIFT_SUMMARY,
...overrides,
};
}
diff --git a/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts b/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts
index 0b5ec1f6..a85a291a 100644
--- a/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts
+++ b/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts
@@ -75,6 +75,7 @@ function row(overrides: Partial = {}): WorkOrderTableRow {
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
+ upliftSummary: { hasUplift: false, pendingCount: 0 },
...overrides,
};
}
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 95a3012f..6f6eb105 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
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
@@ -70,6 +71,7 @@ function makeRow(overrides: Partial = {}): WorkOrderTableRow
rowVersion: "AQAAAAAAAAA=",
dispatchRowVersion: "",
flagColor: null,
+ upliftSummary: EMPTY_UPLIFT_SUMMARY,
...overrides,
};
}
diff --git a/src/test/domain/work-orders/utils/uplift-display-utils.test.ts b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts
new file mode 100644
index 00000000..af91feeb
--- /dev/null
+++ b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest";
+import {
+ canRevokeWorkOrderUplift,
+ getRemainingUpliftAllowance,
+ hasOpenWorkOrderUplift,
+ upliftRevokeRequiresReason,
+} from "@/domain/work-orders/utils/uplift-display-utils";
+
+describe("canRevokeWorkOrderUplift", () => {
+ it("allows a dispatcher to revoke their own auto-approved uplift", () => {
+ expect(
+ canRevokeWorkOrderUplift(
+ { status: "auto_approved", requestedById: "dispatcher-1" },
+ "dispatcher-1",
+ ),
+ ).toBe(true);
+ });
+
+ it("rejects a dispatcher revoking another user's auto-approved uplift", () => {
+ expect(
+ canRevokeWorkOrderUplift(
+ { status: "auto_approved", requestedById: "other-dispatcher" },
+ "dispatcher-1",
+ ),
+ ).toBe(false);
+ });
+
+ it("rejects admin revoking auto-approved uplift they did not request", () => {
+ expect(
+ canRevokeWorkOrderUplift(
+ { status: "auto_approved", requestedById: "dispatcher-1" },
+ "admin-user",
+ ),
+ ).toBe(false);
+ });
+
+ it("allows admin to revoke their own auto-approved uplift as the requester", () => {
+ expect(
+ canRevokeWorkOrderUplift(
+ { status: "auto_approved", requestedById: "admin-user" },
+ "admin-user",
+ ),
+ ).toBe(true);
+ });
+
+ it("rejects approved revoke on the Work Order surface (SH-214)", () => {
+ const uplift = { status: "approved" as const, requestedById: "dispatcher-1" };
+ expect(canRevokeWorkOrderUplift(uplift, "dispatcher-1")).toBe(false);
+ expect(canRevokeWorkOrderUplift(uplift, "admin-user")).toBe(false);
+ });
+
+ it("rejects revoke for other statuses", () => {
+ expect(canRevokeWorkOrderUplift({ status: "pending", requestedById: "u1" }, "u1")).toBe(false);
+ expect(canRevokeWorkOrderUplift({ status: "rejected", requestedById: "u1" }, "u1")).toBe(false);
+ });
+});
+
+describe("upliftRevokeRequiresReason", () => {
+ it("requires reason only for approved uplifts", () => {
+ expect(upliftRevokeRequiresReason("approved")).toBe(true);
+ expect(upliftRevokeRequiresReason("auto_approved")).toBe(false);
+ });
+});
+
+describe("SH-196 allowance helpers", () => {
+ it("uses a $500 cap for non-emergency work orders", () => {
+ expect(getRemainingUpliftAllowance("PM", [])).toBe(500);
+ expect(getRemainingUpliftAllowance("PM", [{ status: "auto_approved", amount: 400 }])).toBe(100);
+ });
+
+ it("uses a $5,000 cap for emergency work orders", () => {
+ expect(getRemainingUpliftAllowance("Emergency", [])).toBe(5000);
+ });
+
+ it("ignores revoked and pending amounts when computing remaining allowance", () => {
+ expect(
+ getRemainingUpliftAllowance("PM", [
+ { status: "auto_approved", amount: 200 },
+ { status: "revoked", amount: 300 },
+ { status: "pending", amount: 400 },
+ ]),
+ ).toBe(300);
+ });
+
+ it("detects an open pending request", () => {
+ expect(hasOpenWorkOrderUplift([{ status: "pending" }])).toBe(true);
+ expect(hasOpenWorkOrderUplift([{ status: "auto_approved" }])).toBe(false);
+ });
+});
diff --git a/src/test/lib/auth/user-utils.test.ts b/src/test/lib/auth/user-utils.test.ts
new file mode 100644
index 00000000..eddb88c5
--- /dev/null
+++ b/src/test/lib/auth/user-utils.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from "vitest";
+import { isAdminUser } from "@/lib/auth/user-utils";
+
+describe("isAdminUser", () => {
+ it("returns true when Admin appears in roles", () => {
+ expect(isAdminUser("Admin")).toBe(true);
+ expect(isAdminUser("Dispatcher,Admin")).toBe(true);
+ expect(isAdminUser(" admin ")).toBe(true);
+ });
+
+ it("returns false for non-admin roles", () => {
+ expect(isAdminUser("Dispatcher")).toBe(false);
+ expect(isAdminUser(undefined)).toBe(false);
+ });
+});
|