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 32d4d1fd..606249a5 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;
@@ -18,67 +47,202 @@ type WorkOrderUpliftListItemProps = {
onRevoke?: () => void;
};
-export function WorkOrderUpliftListItem({
- uplift,
- readOnly,
- currentUserId,
- currentUserIsAdmin,
- 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, currentUserIsAdmin) &&
- 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,
+ currentUserIsAdmin,
+ pendingAction,
+ onCancel,
+ onRevoke,
+}: WorkOrderUpliftListItemProps) {
+ const nowMs = useUpliftClock();
+ const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel);
+ const showRevoke =
+ canRevokeWorkOrderUplift(uplift, currentUserId, currentUserIsAdmin) && 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 d8159230..00000000
--- a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content.tsx
+++ /dev/null
@@ -1,104 +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;
- currentUserIsAdmin: boolean;
- 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,
- currentUserIsAdmin,
- 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 c7a60390..00000000
--- a/src/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog.tsx
+++ /dev/null
@@ -1,124 +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 { isAdminUser } from "@/lib/auth/user-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 87987653..aa83a355 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
@@ -74,7 +74,6 @@ export function useWorkOrdersListPage() {
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);
@@ -119,10 +118,9 @@ 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 handleOpenUplifts = (row: WorkOrderTableRow) => {
+ handleOpenSlideOver(row, false, "uplifts");
+ };
const handleCloseSlideOver = () => {
closeSlideOverState();
@@ -185,7 +183,6 @@ export function useWorkOrdersListPage() {
setDocRow,
mediaRow,
setMediaRow,
- setUpliftsRow,
completionDocs,
filters,
tableData,
@@ -202,8 +199,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 c0756c2a..ce7d2c83 100644
--- a/src/app/(protected)/workorders/index.tsx
+++ b/src/app/(protected)/workorders/index.tsx
@@ -30,7 +30,7 @@ export default function WorkOrdersListPage() {
const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters);
useWorkOrderDeepLink({
onOpenSlideOver: page.handleOpenSlideOver,
- onOpenUplifts: page.setUpliftsRow,
+ onOpenUplifts: page.handleOpenUplifts,
});
return (
@@ -121,7 +121,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 368a6767..bb018d1d 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
@@ -9,6 +9,7 @@ import { isHTTPError, isNetworkError, isTimeoutError } from "ky";
import { toast } from "react-toastify";
import { mapHttpStatusToMessage, REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE } from "@/api/api-error";
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,
@@ -63,7 +64,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 7b271420..883e4e99 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,13 +85,60 @@ export function isWorkOrderUpliftsReadOnly(status: WorkOrderTableRow["status"]):
return status === "Completed" || status === "Canceled";
}
+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",
+ };
+ }
+ 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(
uplift: Pick,
currentUserId: string | number | null | undefined,
currentUserIsAdmin: boolean,
): 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;
}
if (currentUserIsAdmin) {
@@ -123,3 +181,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/app/(protected)/workorders/wo-table-row-service-cells-uplift-open.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-service-cells-uplift-open.test.tsx
index 87f2bda4..37116300 100644
--- a/src/test/app/(protected)/workorders/wo-table-row-service-cells-uplift-open.test.tsx
+++ b/src/test/app/(protected)/workorders/wo-table-row-service-cells-uplift-open.test.tsx
@@ -46,8 +46,6 @@ function renderCells(row: WorkOrderTableRow, onOpenUplifts = vi.fn()) {
}
describe("WoTableRowServiceCells uplift column", () => {
- // A closed work order whose uplifts were all cancelled or revoked has no live uplift,
- // but its read-only uplift history must stay one click away.
it.each(["Completed", "Canceled"] as const)(
"opens the read-only uplift history from a dash on a %s work order with no live uplift",
(status) => {
@@ -59,14 +57,14 @@ describe("WoTableRowServiceCells uplift column", () => {
const onOpenUplifts = renderCells(row);
- const trigger = screen.getByRole("button", { name: "—" });
- expect(screen.queryByText("Manage")).not.toBeInTheDocument();
+ const trigger = screen.getByRole("button", { name: /view uplifts/i });
+ expect(screen.queryByRole("button", { name: /add uplift/i })).not.toBeInTheDocument();
fireEvent.click(trigger);
expect(onOpenUplifts).toHaveBeenCalledWith(row);
},
);
- it("offers Manage on an open work order with no live uplift", () => {
+ it("offers Add on an open work order with no live uplift", () => {
const row = {
id: 8,
status: "Scheduled",
@@ -75,7 +73,7 @@ describe("WoTableRowServiceCells uplift column", () => {
const onOpenUplifts = renderCells(row);
- fireEvent.click(screen.getByRole("button", { name: "Manage" }));
+ fireEvent.click(screen.getByRole("button", { name: /add uplift/i }));
expect(onOpenUplifts).toHaveBeenCalledWith(row);
});
});
diff --git a/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx b/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx
index 41c11c17..f6e70cda 100644
--- a/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx
+++ b/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx
@@ -2,7 +2,7 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
import { toast } from "react-toastify";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog";
+import { SlideOverUpliftsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab";
import { REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE } from "@/api/api-error";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { renderWithProviders } from "@/test/test-utils";
@@ -45,13 +45,14 @@ function respondToCreate(status: number, message: string) {
async function submitInternalCreate(status: number, serverMessage: string) {
const fetchMock = respondToCreate(status, serverMessage);
- renderWithProviders( , {
- withAuth: true,
- });
+ renderWithProviders(
+ ,
+ { withAuth: true },
+ );
- fireEvent.change(await screen.findByLabelText("Amount"), { target: { value: "125" } });
- fireEvent.change(screen.getByLabelText("Notes"), { target: { value: "Additional work" } });
- fireEvent.click(screen.getByRole("button", { name: "Create uplift" }));
+ fireEvent.change(await screen.findByLabelText(/amount/i), { target: { value: "125" } });
+ fireEvent.change(screen.getByLabelText(/^notes/i), { target: { value: "Additional work" } });
+ fireEvent.click(screen.getByRole("button", { name: /request uplift/i }));
await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalled());
expect(
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 624c6ac3..00121814 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(),
authUser: { id: "u1", userRoles: "Dispatcher" },
}));
@@ -87,12 +90,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(
@@ -104,6 +107,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,
@@ -114,53 +141,115 @@ 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 files 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));
});
it("shows no Revoke to an admin on the auto-approved uplift they requested", () => {
mockState.authUser = { id: "admin-1", userRoles: "Admin" };
mockState.uplifts = [{ ...pendingUplift, status: "auto_approved", requestedById: "admin-1" }];
- renderWithProviders( , {
- withAuth: true,
- });
+ renderWithProviders(
+ ,
+ { withAuth: true },
+ );
- expect(screen.getByText("Auto")).toBeInTheDocument();
+ expect(screen.getByText("Auto-approved")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /revoke/i })).not.toBeInTheDocument();
});
@@ -170,9 +259,14 @@ describe("WorkOrderUpliftsDialog affordances", () => {
{ ...pendingUplift, status: "auto_approved", requestedById: "dispatcher-1" },
];
- renderWithProviders( , {
- withAuth: true,
- });
+ renderWithProviders(
+ ,
+ { withAuth: true },
+ );
expect(screen.getByRole("button", { name: /revoke/i })).toBeInTheDocument();
});
@@ -208,4 +302,87 @@ 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 a9c0bc65..aaef5cc8 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";
@@ -95,3 +99,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",
+ });
+ });
+});