From 32466c01d29a62e140c37a87e85072da5b7a34ac Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 24 Sep 2026 14:30:24 -0300 Subject: [PATCH] fix(work-orders): open uplifts from the slide-over and send evidence (SH-388) Dispatchers need the design surface from the column and the detail, and an optional evidence file is sent only after the scan passes. --- src/api/api-paths.ts | 3 + src/api/api.ts | 8 +- .../detail/tabs/slide-over-uplifts-tab.tsx | 187 ++++++++++++ .../detail/use-work-order-slide-over.ts | 14 +- .../detail/work-order-slide-over-tab-nav.tsx | 3 +- .../detail/work-order-slide-over-tabs.tsx | 20 ++ .../detail/work-order-slide-over.tsx | 6 +- .../list/table/cells/uplift-cell.tsx | 123 ++++++-- .../list/table/wo-table-row-service-cells.tsx | 10 +- .../list/work-orders-list-overlays.tsx | 24 -- .../list/work-orders-list-page-panels.tsx | 7 +- .../uplifts/cancel-uplift-dialog.tsx | 61 ++++ .../uplifts/revoke-uplift-dialog.tsx | 26 +- .../uplifts/uplift-attachment-picker.tsx | 100 +++++++ .../uplifts/work-order-uplift-create-form.tsx | 125 +++++--- .../uplifts/work-order-uplift-list-item.tsx | 273 ++++++++++++++---- .../work-order-uplifts-dialog-content.tsx | 101 ------- .../uplifts/work-order-uplifts-dialog.tsx | 122 -------- .../_hooks/use-work-orders-list-page.ts | 24 +- src/app/(protected)/workorders/index.tsx | 2 +- .../work-orders/api/work-order-uplifts-api.ts | 46 ++- .../mappers/work-order-uplift-mapper.ts | 51 +++- .../work-orders/types/work-order-uplift.ts | 10 +- .../use-cases/submit-work-order-uplift.ts | 73 +++++ .../use-cases/use-work-order-uplifts.ts | 8 +- .../work-orders/utils/uplift-display-utils.ts | 169 +++++++++-- .../api/api-formdata-content-type.test.ts | 10 +- .../work-order-uplifts-affordances.test.tsx | 224 ++++++++++++-- .../api/work-order-uplifts-api.test.ts | 40 +++ .../mappers/work-order-uplift-mapper.test.ts | 25 +- .../submit-work-order-uplift.test.ts | 73 +++++ .../utils/uplift-display-utils.test.ts | 102 +++++++ 32 files changed, 1604 insertions(+), 466 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab.tsx delete mode 100644 src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx create mode 100644 src/app/(protected)/workorders/_components/uplifts/cancel-uplift-dialog.tsx create mode 100644 src/app/(protected)/workorders/_components/uplifts/uplift-attachment-picker.tsx delete mode 100644 src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx delete mode 100644 src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx create mode 100644 src/domain/work-orders/use-cases/submit-work-order-uplift.ts create mode 100644 src/test/domain/work-orders/use-cases/submit-work-order-uplift.test.ts diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 37db2b85..6f84f015 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -41,6 +41,9 @@ export const API_PATHS = { `workorders/${id}/media/${mediaId}/content`, completionDoc: (id: string | number) => `workorders/${id}/completion-doc`, uplifts: (id: string | number) => `workorders/${id}/uplifts`, + upliftEvidence: (id: string | number) => `workorders/${id}/uplift-evidence`, + upliftEvidenceStatus: (workOrderId: string | number, documentId: string | number) => + `workorders/${workOrderId}/uplift-evidence/${documentId}`, upliftCancel: (workOrderId: string | number, upliftId: string | number) => `workorders/${workOrderId}/uplifts/${upliftId}/cancel`, upliftRevoke: (workOrderId: string | number, upliftId: string | number) => diff --git a/src/api/api.ts b/src/api/api.ts index 147c0571..0ba1f1f3 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -12,7 +12,7 @@ function isAuthLoginRequest(request: Request): boolean { } /** Ky's beforeRequest sees request.body as a stream; check options.body for FormData. */ -export function shouldStripJsonContentType(body: unknown): boolean { +export function shouldStripJsonContentType(body: unknown): body is FormData { return body instanceof FormData; } @@ -29,9 +29,13 @@ export const api: KyInstance = ky.create({ if (token) { request.headers.set("Authorization", token); } - // Let the runtime set multipart boundary; default JSON Content-Type breaks FormData. + // why: the instance default is application/json. Deleting that header after + // Request construction drops the multipart boundary, so the API never sees `file`. + // Rebuild from the original FormData with no Content-Type so the runtime sets + // multipart/form-data including the boundary. if (shouldStripJsonContentType(options.body)) { request.headers.delete("Content-Type"); + return new Request(request, { body: options.body }); } }, ], diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab.tsx new file mode 100644 index 00000000..62234e49 --- /dev/null +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab.tsx @@ -0,0 +1,187 @@ +import { useState, type ReactNode } from "react"; +import { CircularProgress } from "@mui/material"; +import { AlertTriangle, Ban } from "lucide-react"; +import { QueryErrorPanel } from "@/components/ui/query-error-panel"; +import { Text } from "@/components/ui/text"; +import { CancelUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/cancel-uplift-dialog"; +import { RevokeUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog"; +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 { 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 { + formatUpliftMoney, + getRemainingUpliftAllowance, + hasOpenWorkOrderUplift, + isWorkOrderUpliftsReadOnly, + upliftRevokeRequiresReason, +} from "@/domain/work-orders/utils/uplift-display-utils"; +import { useAuthContext } from "@/providers/auth-context"; + +type RevokeTarget = { + id: string | number; + status: WorkOrderUpliftStatus; + amount: number; +}; + +type SlideOverUpliftsTabProps = { + workOrderId: string | number; + workOrderType: string; + status: WorkOrderTableRow["status"]; +}; + +function revokeDialogDescription(amount: number | undefined): string { + const money = amount == null ? "" : `${formatUpliftMoney(amount)} `; + return `${money}goes back to your auto-approval allowance. This is recorded in the log.`; +} + +function UpliftNotice({ + background, + icon, + children, +}: { + background: string; + icon: ReactNode; + children: string; +}) { + return ( +
+ {icon} + + {children} + +
+ ); +} + +export function SlideOverUpliftsTab({ + workOrderId, + workOrderType, + status, +}: SlideOverUpliftsTabProps) { + const { user } = useAuthContext(); + const readOnly = isWorkOrderUpliftsReadOnly(status); + const { data: uplifts = [], isLoading, error, refetch } = useWorkOrderUplifts(workOrderId); + const createUplift = useCreateWorkOrderUplift(workOrderId); + const cancelUplift = useCancelWorkOrderUplift(workOrderId); + const revokeUplift = useRevokeWorkOrderUplift(workOrderId); + const [revokeTarget, setRevokeTarget] = useState(null); + const [cancelTargetId, setCancelTargetId] = useState(null); + const hasOpenRequest = hasOpenWorkOrderUplift(uplifts); + const showBody = !isLoading && !error; + const showEmpty = showBody && uplifts.length === 0; + const showForm = showBody && !readOnly && !hasOpenRequest; + const actionPending = createUplift.isPending || cancelUplift.isPending || revokeUplift.isPending; + + return ( +
+ {readOnly && ( + + } + > + This work order is closed. Uplifts can no longer be requested. + + )} + {showBody && !readOnly && hasOpenRequest && ( + + } + > + A request is pending admin approval. Cancel it if you need to submit a different amount. + + )} + {Boolean(error) && ( + void refetch()} + /> + )} + {isLoading && ( +
+ +
+ )} + {showForm && ( + createUplift.mutateAsync(input)} + /> + )} + {showBody && + uplifts.map((uplift) => ( + setCancelTargetId(uplift.id)} + onRevoke={() => + setRevokeTarget({ id: uplift.id, status: uplift.status, amount: uplift.amount }) + } + /> + ))} + {showEmpty && ( + + No uplifts on this work order. + + )} + setCancelTargetId(null)} + onConfirm={() => { + if (cancelTargetId == null) return; + cancelUplift.mutate(cancelTargetId, { onSuccess: () => setCancelTargetId(null) }); + }} + /> + setRevokeTarget(null)} + onConfirm={(reason) => { + if (revokeTarget == null) return; + revokeUplift.mutate( + { upliftId: revokeTarget.id, reason }, + { onSuccess: () => setRevokeTarget(null) }, + ); + }} + /> +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts index dee476ec..00b05495 100644 --- a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts +++ b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { toast } from "react-toastify"; import type { WorkOrderTablePatch, @@ -27,11 +27,12 @@ import { useSlideOverMediaState } from "./use-slide-over-media-state"; import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; import type { SlideOverSaveOptions } from "./work-order-slide-over"; -export type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras"; +export type SlideOverTab = "info" | "comments" | "completion" | "uplifts" | "extras" | "audit"; type UseWorkOrderSlideOverArgs = { row: WorkOrderTableRow | null; editMode: boolean; + initialTab?: SlideOverTab; onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void; onClose: () => void; saving?: boolean; @@ -109,6 +110,7 @@ function slideOverErrorMessage(error: unknown) { export function useWorkOrderSlideOver({ row, editMode, + initialTab = "info", onSave, onClose, saving, @@ -129,7 +131,7 @@ export function useWorkOrderSlideOver({ const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? ""); const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? ""); - const [tab, setTab] = useState("info"); + const [tab, setTab] = useState(initialTab); const { detailForRow, infoSource } = resolveSlideOverSource(detail, row); const readFlags = slideOverReadFlags(infoSource?.status); @@ -161,6 +163,12 @@ export function useWorkOrderSlideOver({ closeDisabled, setTab, }); + const openedRowId = row?.id; + // why: opening a row resets the tab to Info inside edit state; re-apply the requested tab after that. + useEffect(() => { + if (openedRowId == null) return; + setTab(initialTab); + }, [openedRowId, initialTab]); const uploadCompletionPdf = (file: File) => { uploadSlideOverCompletionPdf({ diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx index 2efcac54..ce0b6455 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav.tsx @@ -3,9 +3,10 @@ import type { SlideOverTab } from "./use-work-order-slide-over"; const SLIDE_OVER_TABS: { id: SlideOverTab; label: string }[] = [ { id: "info", label: "Info" }, { id: "comments", label: "Comments" }, - { id: "audit", label: "Audit Log" }, { id: "completion", label: "Completion Doc" }, + { id: "uplifts", label: "Uplifts" }, { id: "extras", label: "Extra Docs" }, + { id: "audit", label: "Audit Log" }, ]; type WorkOrderSlideOverTabNavProps = { diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index d43d94c1..8b4080a4 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -4,6 +4,7 @@ import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/d import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab"; import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab"; import { SlideOverInfoTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab"; +import { SlideOverUpliftsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab"; import type { LocationOption, VendorDropdownItem, @@ -16,6 +17,23 @@ import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel"; type SlideOverState = ReturnType; +function SlideOverUpliftsPanel({ + tab, + infoSource, +}: { + tab: SlideOverTab; + infoSource: WorkOrderTableRow | null | undefined; +}) { + if (tab !== "uplifts" || infoSource == null) return null; + return ( + + ); +} + type WorkOrderSlideOverTabsProps = { tab: SlideOverTab; state: SlideOverState; @@ -136,6 +154,8 @@ export function WorkOrderSlideOverTabs({ )} + + {tab === "extras" && ( void; }; -export function UpliftCell({ summary, onOpen }: UpliftCellProps) { - const label = getUpliftCellLabel(summary); +const CELL_DOT: Record<"pending" | "approved" | "rejected", string> = { + pending: "var(--warning)", + approved: "var(--success)", + rejected: "var(--destructive)", +}; - if (!onOpen) { - return label ? ( - {label} - ) : ( - — - ); - } - - const pillStatus = summary.pendingCount > 0 ? "pending" : summary.primaryStatus; - const pillStyle = pillStatus ? getUpliftStatusPillStyle(pillStatus) : null; +const CELL_STATUS_COLOR: Record<"pending" | "approved" | "rejected", string> = { + pending: "var(--color-warning)", + approved: "var(--color-success)", + rejected: "var(--color-destructive)", +}; +function UpliftCellButton({ + label, + title, + onOpen, + children, +}: { + label: string; + title: string; + onOpen: () => void; + children: ReactNode; +}) { return ( ); } + +function UpliftCellVisualBody({ visual }: { visual: UpliftCellVisual }) { + if (visual.kind === "plain") { + return {visual.label}; + } + const struck = visual.kind === "rejected"; + return ( + <> + + {visual.amount != null && ( + + {visual.amount} + + )} + + {visual.statusLabel} + + + ); +} + +export function UpliftCell({ summary, closed = false, onOpen }: UpliftCellProps) { + const label = getUpliftCellLabel(summary); + const visual = getUpliftCellVisual(summary); + + if (!onOpen) { + return {label ?? "—"}; + } + + if (!label && closed) { + return ( + + — + + ); + } + + if (!label) { + return ( + + + Add + + ); + } + + return ( + + {visual != null && } + + ); +} 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 bb6d8d6c..fe715a21 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 @@ -9,7 +9,7 @@ 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 { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-display-utils"; import { applyVendorTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts"; import type { WoTableRowHandlers } from "./wo-table-row"; @@ -114,12 +114,8 @@ export function WoTableRowServiceCells({ e.stopPropagation()}> handlers.onOpenUplifts!(row) - : undefined - } + closed={isWorkOrderUpliftsReadOnly(row.status)} + onOpen={handlers.onOpenUplifts ? () => handlers.onOpenUplifts!(row) : undefined} /> void; -}; - -export function WorkOrdersListOverlays({ - activeUpliftsRow, - onCloseUplifts, -}: WorkOrdersListOverlaysProps) { - return ( - <> - {activeUpliftsRow != 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 index 8b150532..7ac1ff40 100644 --- 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 @@ -4,7 +4,6 @@ import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media 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 { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-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"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; @@ -51,6 +50,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps )} - - page.setUpliftsRow(null)} - /> ); } diff --git a/src/app/(protected)/workorders/_components/uplifts/cancel-uplift-dialog.tsx b/src/app/(protected)/workorders/_components/uplifts/cancel-uplift-dialog.tsx new file mode 100644 index 00000000..453dfae0 --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/cancel-uplift-dialog.tsx @@ -0,0 +1,61 @@ +import { Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import { guardDialogClose } from "@/lib/dialog-guard"; + +type CancelUpliftDialogProps = { + open: boolean; + pending?: boolean; + onClose: () => void; + onConfirm: () => void; +}; + +export function CancelUpliftDialog({ + open, + pending = false, + onClose, + onConfirm, +}: CancelUpliftDialogProps) { + const handleClose = () => { + if (pending) return; + onClose(); + }; + + return ( + + + Cancel uplift request? + + + + This request will be withdrawn. You can submit a new one afterwards. + + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx b/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx index 2c7b9584..435bdfae 100644 --- a/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx +++ b/src/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material"; +import { Undo2 } from "lucide-react"; import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label"; import { Button } from "@/components/ui/button"; import { DialogCancelButton } from "@/components/ui/dialog-cancel-button"; @@ -22,8 +23,8 @@ export function RevokeUpliftDialog({ 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.", + title = "Revoke this uplift?", + description = "goes back to your auto-approval allowance. This is recorded in the log.", }: RevokeUpliftDialogProps) { const [reason, setReason] = useState(""); @@ -47,32 +48,43 @@ export function RevokeUpliftDialog({ return ( - {title} + + {title} + - + {description} - Reason + {requireReason ? "Reason" : "Reason (optional)"} setReason(event.target.value)} /> - + diff --git a/src/app/(protected)/workorders/_components/uplifts/uplift-attachment-picker.tsx b/src/app/(protected)/workorders/_components/uplifts/uplift-attachment-picker.tsx new file mode 100644 index 00000000..48bfe671 --- /dev/null +++ b/src/app/(protected)/workorders/_components/uplifts/uplift-attachment-picker.tsx @@ -0,0 +1,100 @@ +import { useRef } from "react"; +import { Upload, X } from "lucide-react"; +import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label"; +import { Text } from "@/components/ui/text"; + +const ACCEPTED_TYPES = new Set(["application/pdf", "image/jpeg", "image/jpg", "image/png"]); + +type UpliftAttachmentPickerProps = { + file: File | null; + onChange: (file: File | null) => void; +}; + +function isAllowedUpliftEvidenceFile(file: File): boolean { + if (ACCEPTED_TYPES.has(file.type)) return true; + return /\.(pdf|jpe?g|png)$/i.test(file.name); +} + +function firstAllowedFile(incoming: FileList | null): File | null { + if (!incoming || incoming.length === 0) return null; + return Array.from(incoming).find(isAllowedUpliftEvidenceFile) ?? null; +} + +function UpliftStagedFile({ file, onRemove }: { file: File; onRemove: () => void }) { + return ( +
+ + {file.name} + + +
+ ); +} + +export function UpliftAttachmentPicker({ file, onChange }: UpliftAttachmentPickerProps) { + const inputRef = useRef(null); + + const stageFile = (incoming: FileList | null) => { + const next = firstAllowedFile(incoming); + if (next) onChange(next); + }; + + return ( +
+ Attachment + + Optional — PDF, JPG, or PNG. + + + { + stageFile(event.target.files); + event.target.value = ""; + }} + /> + {file != null && ( +
+ onChange(null)} /> +
+ )} +
+ ); +} 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 index 8e0902e6..39995a86 100644 --- 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 @@ -1,23 +1,60 @@ import { useState } from "react"; import { Button, TextField } from "@mui/material"; +import { AlertTriangle, Check, Plus } from "lucide-react"; +import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label"; +import { UpliftAttachmentPicker } from "@/app/(protected)/workorders/_components/uplifts/uplift-attachment-picker"; import { Text } from "@/components/ui/text"; -import { formatUpliftMoney } from "@/domain/work-orders/utils/uplift-display-utils"; +import { + upliftAllowanceHelper, + type UpliftAllowanceHelper, + type UpliftAllowanceHelperTone, +} from "@/domain/work-orders/utils/uplift-display-utils"; type WorkOrderUpliftCreateFormProps = { pending?: boolean; remainingAllowance: number; - onSubmit: (input: { amount: number; notes: string }) => void | Promise; + onSubmit: (input: { + amount: number; + notes: string; + file: File | null; + }) => 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.`; +function canSubmitUplift(amount: number, notes: string): boolean { + return Number.isFinite(amount) && amount > 0 && notes.trim().length > 0; +} + +const ALLOWANCE_HINT_COLOR: Record = { + muted: "var(--muted-foreground)", + success: "var(--status-completed-text)", + warning: "var(--warning)", +}; + +function UpliftAllowanceHint({ helper }: { helper: UpliftAllowanceHelper }) { + return ( + + {helper.icon === "check" && } + {helper.icon === "alert" && } + {helper.text} + + ); } export function WorkOrderUpliftCreateForm({ @@ -27,57 +64,69 @@ export function WorkOrderUpliftCreateForm({ }: WorkOrderUpliftCreateFormProps) { const [amount, setAmount] = useState(""); const [notes, setNotes] = useState(""); - + const [file, setFile] = useState(null); const parsedAmount = Number(amount); - const canSubmit = Number.isFinite(parsedAmount) && parsedAmount > 0 && notes.trim().length > 0; + const helper = upliftAllowanceHelper(parsedAmount, remainingAllowance); + const canSubmit = canSubmitUplift(parsedAmount, notes); const handleSubmit = async () => { try { - await onSubmit({ amount: parsedAmount, notes: notes.trim() }); + await onSubmit({ amount: parsedAmount, notes: notes.trim(), file }); setAmount(""); setNotes(""); + setFile(null); } catch { - // Mutation onError already toasts; keep form values after failure. + // why: the mutation toasts onError, so a failed submit keeps the typed values. } }; return ( -
- - Request uplift - -
+
+ + Amount ($) + + setAmount(event.target.value)} + slotProps={{ htmlInput: { min: 0, step: "0.01" } }} + /> + + +
+ + Notes + 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 index 0578302c..b1a7b5e3 100644 --- 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 @@ -1,13 +1,42 @@ -import { Button } from "@mui/material"; +import { useSyncExternalStore } from "react"; +import { Button, Tooltip } from "@mui/material"; +import { FileText, Undo2 } from "lucide-react"; +import { toast } from "react-toastify"; import { Text } from "@/components/ui/text"; -import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift"; +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; +import type { + WorkOrderUplift, + WorkOrderUpliftAttachment, +} from "@/domain/work-orders/types/work-order-uplift"; import { canRevokeWorkOrderUplift, + formatUpliftCardMeta, formatUpliftMoney, formatUpliftStatusLabel, getUpliftStatusPillStyle, + isVoidUpliftStatus, } from "@/domain/work-orders/utils/uplift-display-utils"; +const CLOSED_REVOKE_HINT = "This work order is closed. Uplifts can no longer be revoked."; + +let upliftClock = Date.now(); + +function subscribeUpliftClock(onChange: () => void) { + const id = window.setInterval(() => { + upliftClock = Date.now(); + onChange(); + }, 60_000); + return () => window.clearInterval(id); +} + +function readUpliftClock(): number { + return upliftClock; +} + +function useUpliftClock(): number { + return useSyncExternalStore(subscribeUpliftClock, readUpliftClock, readUpliftClock); +} + type WorkOrderUpliftListItemProps = { uplift: WorkOrderUplift; readOnly: boolean; @@ -17,64 +46,200 @@ type WorkOrderUpliftListItemProps = { onRevoke?: () => void; }; -export function WorkOrderUpliftListItem({ - uplift, - readOnly, - currentUserId, - pendingAction = false, - onCancel, - onRevoke, -}: WorkOrderUpliftListItemProps) { +function UpliftStatusPill({ uplift }: { uplift: WorkOrderUplift }) { 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)} + + ); +} + +function UpliftFiledAttachment({ + upliftId, + file, +}: { + upliftId: string | number; + file: WorkOrderUpliftAttachment; +}) { + const hasUrl = file.url !== ""; + const downloadEvidence = () => { + void upliftsApi.downloadEvidence(upliftId, file.name).catch((error: unknown) => { + toast.error(error instanceof Error ? error.message : "Unable to download evidence."); + }); + }; + return ( + - {Boolean(uplift.notes) && ( - - {uplift.notes} - + {file.name} + )} - {Boolean(uplift.requestedByName) && ( - - Requested by {uplift.requestedByName} - - )} - {showCancel && ( - - )} - {showRevoke && ( - + {file.name} + )}
); } + +function UpliftDecisionCallout({ uplift }: { uplift: WorkOrderUplift }) { + const rejected = uplift.status === "rejected" && uplift.decisionNote !== ""; + const revoked = uplift.status === "revoked" && uplift.decisionNote !== ""; + if (!rejected && !revoked) return null; + return ( + + {uplift.decisionNote} + + ); +} + +function UpliftCardActions({ + uplift, + readOnly, + currentUserId, + pendingAction, + onCancel, + onRevoke, +}: WorkOrderUpliftListItemProps) { + const nowMs = useUpliftClock(); + const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel); + const showRevoke = canRevokeWorkOrderUplift(uplift, currentUserId) && Boolean(onRevoke); + const revokeBlocked = readOnly || pendingAction; + + return ( +
+ + {formatUpliftCardMeta(uplift, nowMs)} + + {showCancel && ( + + )} + {showRevoke && ( + + + + + + )} +
+ ); +} + +export function WorkOrderUpliftListItem(props: WorkOrderUpliftListItemProps) { + const { uplift } = props; + const isVoid = isVoidUpliftStatus(uplift.status); + const attachments = uplift.attachments ?? []; + const pendingBorder = uplift.status === "pending"; + + return ( +
+
+ + {formatUpliftMoney(uplift.amount)} + + +
+ {uplift.notes !== "" && ( + + {uplift.notes} + + )} + {attachments.length > 0 && ( +
+ {attachments.map((file) => ( + + ))} +
+ )} + + +
+ ); +} 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 deleted file mode 100644 index b0c0c6a0..00000000 --- a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx +++ /dev/null @@ -1,101 +0,0 @@ -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 deleted file mode 100644 index 7e6cbc27..00000000 --- a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx +++ /dev/null @@ -1,122 +0,0 @@ -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 266d84e3..32779f7b 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 @@ -12,6 +12,7 @@ import { patchWorkOrderAsCanceled, patchWorkOrderAsCompleted, } from "@/domain/work-orders/utils/work-order-feedback-toasts"; +import type { SlideOverTab } from "@/app/(protected)/workorders/_components/detail/use-work-order-slide-over"; import { guardDialogClose } from "@/lib/dialog-guard"; import { useWorkOrdersListFilters } from "./use-work-orders-list-filters"; import { useWorkOrdersTableData } from "./use-work-orders-table-data"; @@ -20,11 +21,11 @@ export function useWorkOrdersListPage() { const [wizardOpen, setWizardOpen] = useState(false); const [slideOverRow, setSlideOverRow] = useState(null); const [slideOverEdit, setSlideOverEdit] = useState(false); + const [slideOverTab, setSlideOverTab] = useState("info"); const [confirmCancel, setConfirmCancel] = useState(null); const [confirmComplete, setConfirmComplete] = 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); @@ -68,19 +69,24 @@ 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) => { + const handleOpenSlideOver = ( + row: WorkOrderTableRow, + edit = false, + tab: SlideOverTab = "info", + ) => { setSlideOverRow(row); setSlideOverEdit(edit); + setSlideOverTab(tab); + }; + + const handleOpenUplifts = (row: WorkOrderTableRow) => { + handleOpenSlideOver(row, false, "uplifts"); }; const handleCloseSlideOver = () => { setSlideOverRow(null); setSlideOverEdit(false); + setSlideOverTab("info"); tableData.setSelectedId(null); }; @@ -127,7 +133,7 @@ export function useWorkOrdersListPage() { setDocRow, mediaRow, setMediaRow, - setUpliftsRow, + slideOverTab, completionDocs, filters, tableData, @@ -143,8 +149,8 @@ export function useWorkOrdersListPage() { activeSlideOverRow, activeDocRow, activeMediaRow, - activeUpliftsRow, handleOpenSlideOver, + handleOpenUplifts, handleCloseSlideOver, handleConfirmCancel, handleConfirmComplete, diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 571251cb..dc09493b 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -100,7 +100,7 @@ export default function WorkOrdersListPage() { onRequestComplete={page.setConfirmComplete} onOpenCompDoc={(row) => openCompletionDoc(row, page.setDocRow)} onOpenMedia={page.setMediaRow} - onOpenUplifts={page.setUpliftsRow} + onOpenUplifts={page.handleOpenUplifts} onClearSearch={() => filters.handleSearchChange("")} onOpenAdvancedFilters={() => filters.setAdvOpen(true)} onPatch={page.patchField} diff --git a/src/domain/work-orders/api/work-order-uplifts-api.ts b/src/domain/work-orders/api/work-order-uplifts-api.ts index 866ff164..c37c2e36 100644 --- a/src/domain/work-orders/api/work-order-uplifts-api.ts +++ b/src/domain/work-orders/api/work-order-uplifts-api.ts @@ -1,5 +1,5 @@ import { API_PATHS } from "@/api/api-paths"; -import { apiGet, apiPost } from "@/api/api"; +import { apiGet, apiPost, apiPostForm } from "@/api/api"; import { handleApiResponse } from "@/api/handle-api-response"; import { mapWorkOrderUplift, @@ -19,6 +19,22 @@ function extractUpliftItems(data: unknown): unknown[] { return Array.isArray(items) ? items : []; } +export type UpliftEvidenceSnapshot = { + id: number; + scanStatus: string; + originalFileName: string; +}; + +function readEvidenceSnapshot(data: unknown): UpliftEvidenceSnapshot { + const record = handleApiResponse>(data); + const id = Number(record.id ?? record.Id); + return { + id, + scanStatus: String(record.scanStatus ?? record.ScanStatus ?? ""), + originalFileName: String(record.originalFileName ?? record.OriginalFileName ?? ""), + }; +} + export const workOrderUpliftsApi = { list: async (workOrderId: string | number): Promise => { const data = await apiGet(API_PATHS.workOrder.uplifts(workOrderId)); @@ -30,13 +46,37 @@ export const workOrderUpliftsApi = { workOrderId: string | number, input: CreateWorkOrderUpliftInput, ): Promise => { - const data = await apiPost(API_PATHS.workOrder.uplifts(workOrderId), { + const body: { amount: number; notes: string; evidenceDocumentId?: number } = { amount: input.amount, notes: input.notes, - }); + }; + if (input.evidenceDocumentId != null) { + body.evidenceDocumentId = input.evidenceDocumentId; + } + const data = await apiPost(API_PATHS.workOrder.uplifts(workOrderId), body); return mapWorkOrderUplift(handleApiResponse(data)); }, + uploadEvidence: async ( + workOrderId: string | number, + file: File, + ): Promise => { + const body = new FormData(); + body.append("file", file); + const data = await apiPostForm(API_PATHS.workOrder.upliftEvidence(workOrderId), body); + return readEvidenceSnapshot(data); + }, + + getEvidenceStatus: async ( + workOrderId: string | number, + documentId: number, + ): Promise => { + const data = await apiGet( + API_PATHS.workOrder.upliftEvidenceStatus(workOrderId, documentId), + ); + return readEvidenceSnapshot(data); + }, + cancel: async ( workOrderId: string | number, upliftId: string | number, diff --git a/src/domain/work-orders/mappers/work-order-uplift-mapper.ts b/src/domain/work-orders/mappers/work-order-uplift-mapper.ts index ec712467..fb9d71bf 100644 --- a/src/domain/work-orders/mappers/work-order-uplift-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-uplift-mapper.ts @@ -1,6 +1,7 @@ import type { UpliftSummary, WorkOrderUplift, + WorkOrderUpliftAttachment, WorkOrderUpliftStatus, } from "@/domain/work-orders/types/work-order-uplift"; import { WORK_ORDER_UPLIFT_STATUSES } from "@/domain/work-orders/types/work-order-uplift"; @@ -123,6 +124,53 @@ export function mapUpliftSummary(raw: unknown): UpliftSummary { return summary; } +function mapUpliftAttachment(raw: unknown, index: number): WorkOrderUpliftAttachment | null { + const record = asRecord(raw); + const name = readString(record, "name", "Name", "fileName", "FileName"); + if (name === "") return null; + const idValue = record.id ?? record.Id; + const id = + typeof idValue === "string" || typeof idValue === "number" + ? String(idValue) + : `${name}-${index}`; + return { + id, + name, + url: readString(record, "url", "Url", "fileUrl", "FileUrl"), + }; +} + +function mapUpliftAttachments(raw: unknown): WorkOrderUpliftAttachment[] { + if (!Array.isArray(raw)) return []; + return raw.flatMap((item, index) => { + const attachment = mapUpliftAttachment(item, index); + return attachment == null ? [] : [attachment]; + }); +} + +function mapEvidenceAttachment(item: Record): WorkOrderUpliftAttachment[] { + const listed = mapUpliftAttachments( + item.attachments ?? item.Attachments ?? item.documents ?? item.Documents, + ); + if (listed.length > 0) return listed; + const evidenceId = readOptionalId(item, "evidenceDocumentId", "EvidenceDocumentId"); + const name = readString( + item, + "evidenceFileName", + "EvidenceFileName", + "originalFileName", + "OriginalFileName", + ); + if (evidenceId == null && name === "") return []; + return [ + { + id: evidenceId == null ? "evidence" : String(evidenceId), + name: name === "" ? `Document #${evidenceId}` : name, + url: "", + }, + ]; +} + export function mapWorkOrderUplift(raw: unknown): WorkOrderUplift { const item = asRecord(raw); const statusRaw = readString(item, "status", "Status"); @@ -149,7 +197,8 @@ export function mapWorkOrderUplift(raw: unknown): WorkOrderUplift { ), decidedAt: readString(item, "decidedAt", "DecidedAt"), decidedByName: readString(item, "decidedByName", "DecidedByName"), - decisionNote: readString(item, "decisionNote", "DecisionNote"), + decisionNote: readString(item, "decisionNote", "DecisionNote", "revokeReason", "RevokeReason"), + attachments: mapEvidenceAttachment(item), }; } diff --git a/src/domain/work-orders/types/work-order-uplift.ts b/src/domain/work-orders/types/work-order-uplift.ts index aaed9c16..6a213db6 100644 --- a/src/domain/work-orders/types/work-order-uplift.ts +++ b/src/domain/work-orders/types/work-order-uplift.ts @@ -16,6 +16,12 @@ export interface UpliftSummary { amount?: number; } +export interface WorkOrderUpliftAttachment { + id: string; + name: string; + url: string; +} + export interface WorkOrderUplift { id: string | number; status: WorkOrderUpliftStatus; @@ -27,12 +33,14 @@ export interface WorkOrderUplift { decidedAt: string; decidedByName: string; decisionNote: string; + attachments?: WorkOrderUpliftAttachment[]; } export interface CreateWorkOrderUpliftInput { amount: number; notes: string; - attachments?: File[]; + file?: File | null; + evidenceDocumentId?: number; } export const EMPTY_UPLIFT_SUMMARY: UpliftSummary = { diff --git a/src/domain/work-orders/use-cases/submit-work-order-uplift.ts b/src/domain/work-orders/use-cases/submit-work-order-uplift.ts new file mode 100644 index 00000000..fb3a28d9 --- /dev/null +++ b/src/domain/work-orders/use-cases/submit-work-order-uplift.ts @@ -0,0 +1,73 @@ +import type { UpliftEvidenceSnapshot } from "@/domain/work-orders/api/work-order-uplifts-api"; +import type { + CreateWorkOrderUpliftInput, + WorkOrderUplift, +} from "@/domain/work-orders/types/work-order-uplift"; + +const POLL_INTERVAL_MS = 1000; +const MAX_POLL_ATTEMPTS = 30; + +type CreateUpliftBody = { + amount: number; + notes: string; + evidenceDocumentId?: number; +}; + +export type SubmitWorkOrderUpliftDeps = { + uploadEvidence: (workOrderId: string | number, file: File) => Promise; + getEvidenceStatus: ( + workOrderId: string | number, + documentId: number, + ) => Promise; + create: (workOrderId: string | number, input: CreateUpliftBody) => Promise; + sleep?: (ms: number) => Promise; +}; + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function scanFailureMessage(scanStatus: "Rejected" | "Timeout"): string { + if (scanStatus === "Rejected") { + return "The evidence file was rejected by the security scan."; + } + return "The evidence file is still being scanned. Try again in a moment."; +} + +async function waitForPassedScan( + workOrderId: string | number, + file: File, + deps: SubmitWorkOrderUpliftDeps, +): Promise { + const pause = deps.sleep ?? sleep; + const uploaded = await deps.uploadEvidence(workOrderId, file); + for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) { + const document = + attempt === 0 ? uploaded : await deps.getEvidenceStatus(workOrderId, uploaded.id); + if (document.scanStatus === "Passed") { + return uploaded.id; + } + if (document.scanStatus === "Rejected") { + throw new Error(scanFailureMessage("Rejected")); + } + await pause(POLL_INTERVAL_MS); + } + throw new Error(scanFailureMessage("Timeout")); +} + +export async function submitWorkOrderUplift( + workOrderId: string | number, + input: CreateWorkOrderUpliftInput, + deps: SubmitWorkOrderUpliftDeps, +): Promise { + const evidenceDocumentId = input.file + ? await waitForPassedScan(workOrderId, input.file, deps) + : undefined; + return deps.create(workOrderId, { + amount: input.amount, + notes: input.notes, + evidenceDocumentId, + }); +} 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 index ec9cd3cc..8060508d 100644 --- a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts +++ b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts @@ -7,6 +7,7 @@ import { } from "@tanstack/react-query"; import { toast } from "react-toastify"; import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api"; +import { submitWorkOrderUplift } from "@/domain/work-orders/use-cases/submit-work-order-uplift"; import type { CreateWorkOrderUpliftInput, WorkOrderUplift, @@ -40,7 +41,12 @@ export function useCreateWorkOrderUplift( return useMutation({ mutationFn: (input: CreateWorkOrderUpliftInput) => - workOrderUpliftsApi.create(workOrderId, input), + submitWorkOrderUplift(workOrderId, input, { + uploadEvidence: (id, file) => workOrderUpliftsApi.uploadEvidence(id, file), + getEvidenceStatus: (id, documentId) => + workOrderUpliftsApi.getEvidenceStatus(id, documentId), + create: (id, body) => workOrderUpliftsApi.create(id, body), + }), onSuccess: () => { invalidateUpliftQueries(queryClient, workOrderId); toast.success("Uplift request created"); diff --git a/src/domain/work-orders/utils/uplift-display-utils.ts b/src/domain/work-orders/utils/uplift-display-utils.ts index 26dd14c2..7a06dcad 100644 --- a/src/domain/work-orders/utils/uplift-display-utils.ts +++ b/src/domain/work-orders/utils/uplift-display-utils.ts @@ -16,17 +16,26 @@ export function formatUpliftMoney(value: number | null | undefined): string { }).format(value); } -const STATUS_LABELS: Record = { +const PILL_LABELS: Record = { + pending: "Pending approval", + approved: "Approved", + auto_approved: "Auto-approved", + rejected: "Rejected", + cancelled: "Cancelled", + revoked: "Revoked", +}; + +const CELL_STATUS_WORD: Record = { pending: "Pending", approved: "Approved", - auto_approved: "Auto", + auto_approved: "Approved", rejected: "Rejected", cancelled: "Cancelled", revoked: "Revoked", }; export function formatUpliftStatusLabel(status: WorkOrderUpliftStatus): string { - return STATUS_LABELS[status]; + return PILL_LABELS[status]; } export function getUpliftCellLabel(summary: UpliftSummary): string | null { @@ -34,12 +43,15 @@ export function getUpliftCellLabel(summary: UpliftSummary): string | null { return null; } if (summary.pendingCount > 0) { + if (summary.amount != null) { + return `${formatUpliftMoney(summary.amount)} Pending`; + } return summary.pendingCount === 1 ? "1 Pending" : `${summary.pendingCount} Pending`; } if (summary.primaryStatus) { - const label = formatUpliftStatusLabel(summary.primaryStatus); + const label = CELL_STATUS_WORD[summary.primaryStatus]; if (summary.amount != null) { - return `${label} ${formatUpliftMoney(summary.amount)}`; + return `${formatUpliftMoney(summary.amount)} ${label}`; } return label; } @@ -57,14 +69,13 @@ export function getUpliftStatusPillStyle(status: WorkOrderUpliftStatus): { 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)" }; + return { background: "var(--status-completed-bg)", color: "var(--status-completed-text)" }; case "rejected": - return { background: "#FEE2E2", color: "#B91C1C" }; + return { background: "var(--status-pastdue-bg)", color: "var(--status-pastdue-text)" }; case "cancelled": case "revoked": - return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" }; + return { background: "var(--status-canceled-bg)", color: "var(--status-canceled-text)" }; default: return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" }; } @@ -74,14 +85,51 @@ export function isWorkOrderUpliftsReadOnly(status: WorkOrderTableRow["status"]): return status === "Completed" || status === "Canceled"; } -export function canOpenUpliftsDialog( - summary: UpliftSummary, - status: WorkOrderTableRow["status"], -): boolean { - if (summary.hasUplift || summary.pendingCount > 0) { - return true; +export type UpliftAllowanceHelperTone = "muted" | "success" | "warning"; +export type UpliftAllowanceHelperIcon = "check" | "alert" | null; + +export type UpliftAllowanceHelper = { + text: string; + tone: UpliftAllowanceHelperTone; + tint: boolean; + icon: UpliftAllowanceHelperIcon; +}; + +export function upliftAllowanceHelper( + amount: number, + remainingAllowance: number, +): UpliftAllowanceHelper { + const typed = Number.isFinite(amount) && amount > 0; + if (!typed) { + if (remainingAllowance > 0) { + return { + text: `${formatUpliftMoney(remainingAllowance)} left to auto-approve`, + tone: "muted", + tint: false, + icon: null, + }; + } + return { + text: "Limit used — new uplifts need admin approval", + tone: "warning", + tint: false, + icon: "alert", + }; } - return !isWorkOrderUpliftsReadOnly(status); + if (amount <= remainingAllowance) { + return { + text: `Approves instantly · ${formatUpliftMoney(remainingAllowance - amount)} left after this`, + tone: "success", + tint: false, + icon: "check", + }; + } + return { + text: `Needs admin approval · ${formatUpliftMoney(remainingAllowance)} limit`, + tone: "warning", + tint: true, + icon: "alert", + }; } export function canRevokeWorkOrderUplift( @@ -89,7 +137,7 @@ export function canRevokeWorkOrderUplift( 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. + // why: SH-214/SH-212 keep admin-approved revoke on Uplift Approvals, not this work order panel. return false; } return ( @@ -128,3 +176,90 @@ export function getRemainingUpliftAllowance( .reduce((sum, uplift) => sum + uplift.amount, 0); return Math.max(0, getUpliftAllowanceCap(workOrderType) - consumed); } + +function displayActor(name: string): string { + return name.trim() === "" ? "—" : name; +} + +export function formatUpliftAbsoluteDate(iso: string): string { + if (iso.trim() === "") return "—"; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return "—"; + return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); +} + +export function formatUpliftRelativeTime(iso: string, nowMs: number): string { + if (iso.trim() === "") return "—"; + const timestamp = new Date(iso).getTime(); + if (Number.isNaN(timestamp)) return "—"; + const minutes = Math.floor((nowMs - timestamp) / 60000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +type UpliftMetaSource = Pick< + WorkOrderUplift, + "status" | "requestedAt" | "requestedByName" | "decidedAt" | "decidedByName" +>; + +export function formatUpliftCardMeta(uplift: UpliftMetaSource, nowMs: number): string { + const requester = displayActor(uplift.requestedByName); + const decider = displayActor(uplift.decidedByName); + if (uplift.status === "pending") { + return `${requester} · ${formatUpliftRelativeTime(uplift.requestedAt, nowMs)}`; + } + if (uplift.status === "auto_approved") { + return `Within allowance · ${requester} · ${formatUpliftAbsoluteDate(uplift.requestedAt)}`; + } + if (uplift.status === "approved") { + return `Approved by ${decider} · ${formatUpliftAbsoluteDate(uplift.decidedAt)}`; + } + if (uplift.status === "rejected") { + return `Rejected by ${decider} · ${formatUpliftAbsoluteDate(uplift.decidedAt)}`; + } + if (uplift.status === "revoked") { + return `Revoked by ${decider} · ${formatUpliftAbsoluteDate(uplift.decidedAt)}`; + } + const when = uplift.decidedAt.trim() === "" ? uplift.requestedAt : uplift.decidedAt; + return `Cancelled by ${requester} · ${formatUpliftAbsoluteDate(when)}`; +} + +export function isVoidUpliftStatus(status: WorkOrderUpliftStatus): boolean { + return status === "rejected" || status === "cancelled" || status === "revoked"; +} + +export type UpliftCellVisual = + | { kind: "pending"; amount: string | null; statusLabel: "Pending" } + | { kind: "approved"; amount: string | null; statusLabel: "Approved" } + | { kind: "rejected"; amount: string | null; statusLabel: "Rejected" } + | { kind: "plain"; label: string }; + +export function getUpliftCellVisual(summary: UpliftSummary): UpliftCellVisual | null { + const label = getUpliftCellLabel(summary); + if (label == null) return null; + if (summary.pendingCount > 0) { + return { + kind: "pending", + amount: summary.amount != null ? formatUpliftMoney(summary.amount) : null, + statusLabel: "Pending", + }; + } + if (summary.primaryStatus === "approved" || summary.primaryStatus === "auto_approved") { + return { + kind: "approved", + amount: summary.amount != null ? formatUpliftMoney(summary.amount) : null, + statusLabel: "Approved", + }; + } + if (summary.primaryStatus === "rejected") { + return { + kind: "rejected", + amount: summary.amount != null ? formatUpliftMoney(summary.amount) : null, + statusLabel: "Rejected", + }; + } + return { kind: "plain", label }; +} diff --git a/src/test/api/api-formdata-content-type.test.ts b/src/test/api/api-formdata-content-type.test.ts index b914b9f0..ec20805c 100644 --- a/src/test/api/api-formdata-content-type.test.ts +++ b/src/test/api/api-formdata-content-type.test.ts @@ -48,13 +48,7 @@ describe("api.post FormData Content-Type", () => { await api.post("workorders/9/media", { body: form, throwHttpErrors: false }); expect(capturedContentType).not.toBe("application/json"); - // Browser sets multipart/form-data; boundary=... when Content-Type was stripped. - // In jsdom/fetch mock the runtime may leave it null or set multipart - both are OK - // as long as it is not forced JSON. - // Cast after expect(): Vitest assertion narrowing collapses string|null to never. - const contentType = capturedContentType as string | null; - if (contentType !== null) { - expect(contentType.toLowerCase()).toContain("multipart/form-data"); - } + expect(capturedContentType?.toLowerCase()).toContain("multipart/form-data"); + expect(capturedContentType?.toLowerCase()).toContain("boundary="); }); }); 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 index 1afe8888..eb32a4cf 100644 --- a/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-uplifts-affordances.test.tsx @@ -1,8 +1,10 @@ -import { fireEvent, screen } from "@testing-library/react"; +import { fireEvent, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { WorkOrderSlideOverTabNav } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over-tab-nav"; +import { SlideOverUpliftsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab"; import { UpliftCell } from "@/app/(protected)/workorders/_components/list/table/cells/uplift-cell"; +import { RevokeUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog"; 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"; @@ -22,6 +24,7 @@ const pendingUplift: WorkOrderUplift = { const mockState = vi.hoisted(() => ({ uplifts: [] as WorkOrderUplift[], + cancel: vi.fn(), })); const baseRow: WorkOrderTableRow = { @@ -81,12 +84,12 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({ refetch: vi.fn(), }), useCreateWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }), - useCancelWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }), + useCancelWorkOrderUplift: () => ({ mutate: mockState.cancel, isPending: false }), useRevokeWorkOrderUplift: () => ({ mutate: vi.fn(), isPending: false }), })); describe("UpliftCell affordances", () => { - it("renders pending pill and opens handler when onOpen is provided", () => { + it("renders pending label and opens handler when onOpen is provided", () => { const onOpen = vi.fn(); renderWithProviders( @@ -98,6 +101,30 @@ describe("UpliftCell affordances", () => { expect(onOpen).toHaveBeenCalledTimes(1); }); + it("shows Add and opens the slide-over shortcut when the work order has no uplift", () => { + const onOpen = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /add uplift/i })); + expect(onOpen).toHaveBeenCalledTimes(1); + }); + + it("keeps a closed work order with no uplift clickable", () => { + const onOpen = vi.fn(); + + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /view uplifts/i })); + expect(onOpen).toHaveBeenCalledTimes(1); + }); + it("shows read-only dash when onOpen is omitted", () => { renderWithProviders(, { withAuth: false, @@ -108,42 +135,99 @@ describe("UpliftCell affordances", () => { }); }); -describe("WorkOrderUpliftsDialog affordances", () => { - it("hides create form when a pending request already exists", () => { - mockState.uplifts = [pendingUplift]; - - renderWithProviders(, { - withAuth: true, +describe("slide-over section order", () => { + it("places Uplifts before Extra Docs and keeps Audit Log last", () => { + renderWithProviders(, { + withAuth: false, }); - 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(); + expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([ + "Info", + "Comments", + "Completion Doc", + "Uplifts", + "Extra Docs", + "Audit Log", + ]); }); +}); - 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", () => { +describe("SlideOverUpliftsTab affordances", () => { + it("hides the request form when a pending request already exists", () => { 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(); + expect(screen.getByRole("button", { name: /cancel request/i })).toBeInTheDocument(); + expect(screen.getByText(/pending admin approval/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /request uplift/i })).not.toBeInTheDocument(); + }); + + it("shows the request form and optional attachments when no pending request exists", () => { + mockState.uplifts = []; + + renderWithProviders( + , + { withAuth: true }, + ); + + expect(screen.getByRole("button", { name: /request uplift/i })).toBeDisabled(); + expect( + screen.getByRole("button", { name: /drag a file here or click to browse/i }), + ).toBeEnabled(); + expect(screen.getByText(/\$500 left to auto-approve/i)).toBeInTheDocument(); + expect(screen.getByText(/no uplifts on this work order/i)).toBeInTheDocument(); + }); + + it("hides request and cancel actions when the work order is completed", () => { + mockState.uplifts = [pendingUplift]; + + renderWithProviders( + , + { withAuth: true }, + ); + + expect(screen.getByText(/uplifts can no longer be requested/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /request uplift/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /cancel request/i })).not.toBeInTheDocument(); + }); + + it("confirms before cancelling a pending request", () => { + mockState.uplifts = [pendingUplift]; + mockState.cancel.mockClear(); + + renderWithProviders( + , + { withAuth: true }, + ); + + fireEvent.click(screen.getByRole("button", { name: /cancel request/i })); + expect(mockState.cancel).not.toHaveBeenCalled(); + expect(screen.getByRole("heading", { name: "Cancel uplift request?" })).toBeInTheDocument(); + + fireEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: /cancel request/i }), + ); + expect(mockState.cancel).toHaveBeenCalledWith(pendingUplift.id, expect.any(Object)); }); }); @@ -175,4 +259,84 @@ describe("WorkOrderUpliftListItem revoke affordances", () => { expect(screen.getByRole("button", { name: /revoke/i })).toBeInTheDocument(); }); + + it("shows the within-allowance meta on an auto-approved uplift", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("Auto-approved")).toBeInTheDocument(); + expect(screen.getByText("$60")).toBeInTheDocument(); + expect(screen.getByText("Within allowance · Adam Moussa · Sep 24, 2026")).toBeInTheDocument(); + }); + + it("shows a linked evidence document on the card", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("button", { name: "quote.png" })).toBeInTheDocument(); + }); + + it("keeps revoke visible and disabled when the work order is closed", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("button", { name: /revoke/i })).toBeDisabled(); + }); +}); + +describe("RevokeUpliftDialog copy", () => { + it("explains that the amount returns to the allowance", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("heading", { name: "Revoke this uplift?" })).toBeInTheDocument(); + expect( + screen.getByText( + "$60 goes back to your auto-approval allowance. This is recorded in the log.", + ), + ).toBeInTheDocument(); + expect(screen.getByText("Reason (optional)")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Wrong amount, wrong quote...")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /revoke/i })).toBeEnabled(); + }); }); 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 index 599ae34e..941e71a6 100644 --- 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 @@ -2,10 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const apiGet = vi.fn(); const apiPost = vi.fn(); +const apiPostForm = vi.fn(); vi.mock("@/api/api", () => ({ apiGet: (...args: unknown[]) => apiGet(...args), apiPost: (...args: unknown[]) => apiPost(...args), + apiPostForm: (...args: unknown[]) => apiPostForm(...args), })); import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api"; @@ -14,6 +16,44 @@ describe("workOrderUpliftsApi", () => { beforeEach(() => { apiGet.mockReset(); apiPost.mockReset(); + apiPostForm.mockReset(); + }); + + it("creates an uplift with amount and notes when no evidence id is present", async () => { + apiPost.mockResolvedValueOnce({ id: 9, status: "pending", amount: 25, notes: "Leak" }); + + await workOrderUpliftsApi.create(7, { amount: 25, notes: "Leak" }); + + expect(apiPost).toHaveBeenCalledWith("workorders/7/uplifts", { amount: 25, notes: "Leak" }); + }); + + it("includes evidenceDocumentId when the scanned document id is present", async () => { + apiPost.mockResolvedValueOnce({ id: 9, status: "pending", amount: 25, notes: "Leak" }); + + await workOrderUpliftsApi.create(7, { amount: 25, notes: "Leak", evidenceDocumentId: 22 }); + + expect(apiPost).toHaveBeenCalledWith("workorders/7/uplifts", { + amount: 25, + notes: "Leak", + evidenceDocumentId: 22, + }); + }); + + it("uploads one evidence file and reads the pending document id", async () => { + apiPostForm.mockResolvedValueOnce({ + data: { id: 22, scanStatus: "Pending", originalFileName: "quote.pdf" }, + }); + const quote = new File(["%PDF"], "quote.pdf", { type: "application/pdf" }); + + await expect(workOrderUpliftsApi.uploadEvidence(7, quote)).resolves.toEqual({ + id: 22, + scanStatus: "Pending", + originalFileName: "quote.pdf", + }); + + expect(apiPostForm).toHaveBeenCalledWith("workorders/7/uplift-evidence", expect.any(FormData)); + const body = apiPostForm.mock.calls[0]?.[1] as FormData; + expect(body.get("file")).toBe(quote); }); it("propagates a real API list failure instead of returning mock uplifts", async () => { 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 index d765c4d5..f52c61f6 100644 --- 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 @@ -59,6 +59,29 @@ describe("mapUpliftSummary", () => { "dispatcher-1", ); }); + + it("maps filed attachments when the list payload includes them", () => { + expect( + mapWorkOrderUplift({ + attachments: [{ id: "file-1", name: "quote.png", url: "https://files.example/quote.png" }], + }).attachments, + ).toEqual([{ id: "file-1", name: "quote.png", url: "https://files.example/quote.png" }]); + }); + + it("maps the linked evidence document when the list payload has no attachment array", () => { + expect( + mapWorkOrderUplift({ + evidenceDocumentId: 22, + evidenceFileName: "d502d917_Instore_logo_white_720x180.png", + }).attachments, + ).toEqual([ + { + id: "22", + name: "d502d917_Instore_logo_white_720x180.png", + url: "", + }, + ]); + }); }); describe("uplift display utils", () => { @@ -79,7 +102,7 @@ describe("uplift display utils", () => { primaryStatus: "approved", amount: 1200, }), - ).toBe("Approved $1,200"); + ).toBe("$1,200 Approved"); }); it("hides Pending/Cancelled when hasUplift is false", () => { diff --git a/src/test/domain/work-orders/use-cases/submit-work-order-uplift.test.ts b/src/test/domain/work-orders/use-cases/submit-work-order-uplift.test.ts new file mode 100644 index 00000000..ff410055 --- /dev/null +++ b/src/test/domain/work-orders/use-cases/submit-work-order-uplift.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; +import { submitWorkOrderUplift } from "@/domain/work-orders/use-cases/submit-work-order-uplift"; +import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift"; + +const created = { id: 9, status: "pending", amount: 25, notes: "Leak" } as WorkOrderUplift; + +function deps(overrides: Partial[2]> = {}) { + return { + uploadEvidence: vi.fn(), + getEvidenceStatus: vi.fn(), + create: vi.fn().mockResolvedValue(created), + sleep: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe("submitWorkOrderUplift", () => { + it("creates without an evidence id when no file is attached", async () => { + const collaborators = deps(); + + await submitWorkOrderUplift(7, { amount: 25, notes: "Leak", file: null }, collaborators); + + expect(collaborators.uploadEvidence).not.toHaveBeenCalled(); + expect(collaborators.create).toHaveBeenCalledWith(7, { + amount: 25, + notes: "Leak", + evidenceDocumentId: undefined, + }); + }); + + it("creates with the document id after the scan passes", async () => { + const quote = new File(["%PDF"], "quote.pdf", { type: "application/pdf" }); + const collaborators = deps({ + uploadEvidence: vi.fn().mockResolvedValue({ id: 22, scanStatus: "Pending" }), + getEvidenceStatus: vi.fn().mockResolvedValue({ id: 22, scanStatus: "Passed" }), + }); + + await submitWorkOrderUplift(7, { amount: 25, notes: "Leak", file: quote }, collaborators); + + expect(collaborators.uploadEvidence).toHaveBeenCalledWith(7, quote); + expect(collaborators.create).toHaveBeenCalledWith(7, { + amount: 25, + notes: "Leak", + evidenceDocumentId: 22, + }); + }); + + it("does not create the uplift when the scan rejects the file", async () => { + const quote = new File(["%PDF"], "quote.pdf", { type: "application/pdf" }); + const collaborators = deps({ + uploadEvidence: vi.fn().mockResolvedValue({ id: 22, scanStatus: "Rejected" }), + }); + + await expect( + submitWorkOrderUplift(7, { amount: 25, notes: "Leak", file: quote }, collaborators), + ).rejects.toThrow(/rejected/i); + expect(collaborators.create).not.toHaveBeenCalled(); + }); + + it("does not create the uplift when the scan does not finish", async () => { + const quote = new File(["%PDF"], "quote.pdf", { type: "application/pdf" }); + const collaborators = deps({ + uploadEvidence: vi.fn().mockResolvedValue({ id: 22, scanStatus: "Pending" }), + getEvidenceStatus: vi.fn().mockResolvedValue({ id: 22, scanStatus: "Pending" }), + }); + + await expect( + submitWorkOrderUplift(7, { amount: 25, notes: "Leak", file: quote }, collaborators), + ).rejects.toThrow(/still being scanned/i); + expect(collaborators.create).not.toHaveBeenCalled(); + expect(collaborators.sleep).toHaveBeenCalledTimes(30); + }); +}); 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 index af91feeb..2984e96b 100644 --- a/src/test/domain/work-orders/utils/uplift-display-utils.test.ts +++ b/src/test/domain/work-orders/utils/uplift-display-utils.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vitest"; import { canRevokeWorkOrderUplift, + formatUpliftCardMeta, getRemainingUpliftAllowance, + getUpliftCellLabel, + getUpliftCellVisual, hasOpenWorkOrderUplift, + upliftAllowanceHelper, upliftRevokeRequiresReason, } from "@/domain/work-orders/utils/uplift-display-utils"; @@ -87,3 +91,101 @@ describe("SH-196 allowance helpers", () => { expect(hasOpenWorkOrderUplift([{ status: "auto_approved" }])).toBe(false); }); }); + +describe("upliftAllowanceHelper", () => { + it("describes the remaining allowance before an amount is typed", () => { + expect(upliftAllowanceHelper(0, 5000).text).toBe("$5,000 left to auto-approve"); + expect(upliftAllowanceHelper(Number.NaN, 0).text).toBe( + "Limit used — new uplifts need admin approval", + ); + }); + + it("says the request approves instantly while it stays inside the allowance", () => { + expect(upliftAllowanceHelper(100, 500).text).toBe("Approves instantly · $400 left after this"); + }); + + it("says the request needs admin approval when it exceeds the allowance", () => { + expect(upliftAllowanceHelper(600, 500).text).toBe("Needs admin approval · $500 limit"); + }); + + it("marks instant approval without a tint and over-limit requests with a warning tint", () => { + expect(upliftAllowanceHelper(12, 500)).toMatchObject({ + icon: "check", + tint: false, + tone: "success", + }); + expect(upliftAllowanceHelper(600, 500)).toMatchObject({ + icon: "alert", + tint: true, + tone: "warning", + }); + expect(upliftAllowanceHelper(0, 440)).toMatchObject({ icon: null, tint: false, tone: "muted" }); + }); +}); + +describe("uplift card meta", () => { + const requestedAt = "2026-09-24T15:00:00.000Z"; + const nowMs = new Date(requestedAt).getTime() + 20 * 60_000; + + it("uses elapsed time while a request is pending", () => { + expect( + formatUpliftCardMeta( + { + status: "pending", + requestedAt, + requestedByName: "Adam Moussa", + decidedAt: "", + decidedByName: "", + }, + nowMs, + ), + ).toBe("Adam Moussa · 20m ago"); + }); + + it("records an auto-approved uplift as within allowance", () => { + expect( + formatUpliftCardMeta( + { + status: "auto_approved", + requestedAt, + requestedByName: "Adam Moussa", + decidedAt: "", + decidedByName: "", + }, + nowMs, + ), + ).toBe("Within allowance · Adam Moussa · Sep 24, 2026"); + }); +}); + +describe("uplift cell visual", () => { + it("collapses auto-approved into the approved cell word", () => { + const summary = { + hasUplift: true, + pendingCount: 0, + primaryStatus: "auto_approved" as const, + amount: 60, + }; + expect(getUpliftCellLabel(summary)).toBe("$60 Approved"); + expect(getUpliftCellVisual(summary)).toEqual({ + kind: "approved", + amount: "$60", + statusLabel: "Approved", + }); + }); + + it("shows a pending amount separately from the status word", () => { + expect( + getUpliftCellVisual({ + hasUplift: true, + pendingCount: 1, + primaryStatus: "pending", + amount: 123123, + }), + ).toEqual({ + kind: "pending", + amount: "$123,123", + statusLabel: "Pending", + }); + }); +});