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 (
{
- e.stopPropagation();
+ aria-label={label}
+ title={title}
+ onClick={(event) => {
+ event.stopPropagation();
onOpen();
}}
- title={label ? "View uplifts" : "Manage uplifts"}
- className="flex w-full items-center justify-start transition-opacity hover:opacity-80"
+ className="flex w-full items-center justify-start gap-1.5 text-left text-[11px] font-semibold whitespace-nowrap"
+ style={{ color: "var(--color-text-primary)" }}
>
- {label && pillStyle ? (
-
- {label}
-
- ) : (
- Manage
- )}
+ {children}
);
}
+
+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.
+
+
+
+
+ Keep request
+
+
+ Cancel request
+
+
+
+ );
+}
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)}
/>
-
+
+
Revoke
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.
+
+
inputRef.current?.click()}
+ onDragOver={(event) => event.preventDefault()}
+ onDrop={(event) => {
+ event.preventDefault();
+ stageFile(event.dataTransfer.files);
+ }}
+ className="flex w-full items-center justify-center gap-2 transition-colors hover:bg-(--color-bg-muted)"
+ style={{
+ minHeight: 64,
+ border: "1px dashed var(--color-border)",
+ borderRadius: "var(--radius)",
+ }}
+ >
+
+
+ Drag a file here or click to browse
+
+
+
{
+ 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)}
/>
-
+
+
+
+
+
{
void handleSubmit();
}}
>
- Create uplift
+
+ Request uplift
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 && (
-
- Cancel pending
-
- )}
- {showRevoke && (
-
- Revoke
-
+ {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 && (
+
+ Cancel request
+
+ )}
+ {showRevoke && (
+
+
+
+ Revoke
+
+
+
+ )}
+
+ );
+}
+
+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}
- />
-
-
- Close
-
-
-
- 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",
+ });
+ });
+});