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 ( + + {title} + + + {description} + + setReason(event.target.value)} + /> + + + + + + + ); +} 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 ( + <> + + Uplifts — WO {row?.woNumber ?? ""} + + void refetch()} + onCreate={async (input) => { + await createUplift.mutateAsync(input); + }} + onCancelUplift={(upliftId) => cancelUplift.mutate(upliftId)} + onRevokeUplift={handleRevokeUplift} + /> + + + + + + + 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); + }); +});