fix(work-orders): open uplifts from the slide-over and send evidence (SH-388)

Dispatchers need the design surface from the column and the detail,
and an optional evidence file is sent only after the scan passes.
This commit is contained in:
Arthur Bassi 2026-09-24 14:30:24 -03:00
parent c30e8aa74b
commit 32466c01d2
32 changed files with 1604 additions and 466 deletions

View file

@ -41,6 +41,9 @@ export const API_PATHS = {
`workorders/${id}/media/${mediaId}/content`,
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
uplifts: (id: string | number) => `workorders/${id}/uplifts`,
upliftEvidence: (id: string | number) => `workorders/${id}/uplift-evidence`,
upliftEvidenceStatus: (workOrderId: string | number, documentId: string | number) =>
`workorders/${workOrderId}/uplift-evidence/${documentId}`,
upliftCancel: (workOrderId: string | number, upliftId: string | number) =>
`workorders/${workOrderId}/uplifts/${upliftId}/cancel`,
upliftRevoke: (workOrderId: string | number, upliftId: string | number) =>

View file

@ -12,7 +12,7 @@ function isAuthLoginRequest(request: Request): boolean {
}
/** Ky's beforeRequest sees request.body as a stream; check options.body for FormData. */
export function shouldStripJsonContentType(body: unknown): boolean {
export function shouldStripJsonContentType(body: unknown): body is FormData {
return body instanceof FormData;
}
@ -29,9 +29,13 @@ export const api: KyInstance = ky.create({
if (token) {
request.headers.set("Authorization", token);
}
// Let the runtime set multipart boundary; default JSON Content-Type breaks FormData.
// why: the instance default is application/json. Deleting that header after
// Request construction drops the multipart boundary, so the API never sees `file`.
// Rebuild from the original FormData with no Content-Type so the runtime sets
// multipart/form-data including the boundary.
if (shouldStripJsonContentType(options.body)) {
request.headers.delete("Content-Type");
return new Request(request, { body: options.body });
}
},
],

View file

@ -0,0 +1,187 @@
import { useState, type ReactNode } from "react";
import { CircularProgress } from "@mui/material";
import { AlertTriangle, Ban } from "lucide-react";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { Text } from "@/components/ui/text";
import { CancelUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/cancel-uplift-dialog";
import { RevokeUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog";
import { WorkOrderUpliftCreateForm } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form";
import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderUpliftStatus } from "@/domain/work-orders/types/work-order-uplift";
import {
useCancelWorkOrderUplift,
useCreateWorkOrderUplift,
useRevokeWorkOrderUplift,
useWorkOrderUplifts,
} from "@/domain/work-orders/use-cases/use-work-order-uplifts";
import {
formatUpliftMoney,
getRemainingUpliftAllowance,
hasOpenWorkOrderUplift,
isWorkOrderUpliftsReadOnly,
upliftRevokeRequiresReason,
} from "@/domain/work-orders/utils/uplift-display-utils";
import { useAuthContext } from "@/providers/auth-context";
type RevokeTarget = {
id: string | number;
status: WorkOrderUpliftStatus;
amount: number;
};
type SlideOverUpliftsTabProps = {
workOrderId: string | number;
workOrderType: string;
status: WorkOrderTableRow["status"];
};
function revokeDialogDescription(amount: number | undefined): string {
const money = amount == null ? "" : `${formatUpliftMoney(amount)} `;
return `${money}goes back to your auto-approval allowance. This is recorded in the log.`;
}
function UpliftNotice({
background,
icon,
children,
}: {
background: string;
icon: ReactNode;
children: string;
}) {
return (
<div
className="flex items-start gap-2"
style={{ background, borderRadius: "var(--radius)", padding: "10px 12px" }}
>
{icon}
<Text
as="span"
variant="description"
sx={{ fontSize: 12.5, color: "var(--color-text-secondary)" }}
>
{children}
</Text>
</div>
);
}
export function SlideOverUpliftsTab({
workOrderId,
workOrderType,
status,
}: SlideOverUpliftsTabProps) {
const { user } = useAuthContext();
const readOnly = isWorkOrderUpliftsReadOnly(status);
const { data: uplifts = [], isLoading, error, refetch } = useWorkOrderUplifts(workOrderId);
const createUplift = useCreateWorkOrderUplift(workOrderId);
const cancelUplift = useCancelWorkOrderUplift(workOrderId);
const revokeUplift = useRevokeWorkOrderUplift(workOrderId);
const [revokeTarget, setRevokeTarget] = useState<RevokeTarget | null>(null);
const [cancelTargetId, setCancelTargetId] = useState<string | number | null>(null);
const hasOpenRequest = hasOpenWorkOrderUplift(uplifts);
const showBody = !isLoading && !error;
const showEmpty = showBody && uplifts.length === 0;
const showForm = showBody && !readOnly && !hasOpenRequest;
const actionPending = createUplift.isPending || cancelUplift.isPending || revokeUplift.isPending;
return (
<div className="flex flex-col gap-4">
{readOnly && (
<UpliftNotice
background="var(--color-bg-muted)"
icon={
<Ban
size={14}
aria-hidden
className="mt-px shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
}
>
This work order is closed. Uplifts can no longer be requested.
</UpliftNotice>
)}
{showBody && !readOnly && hasOpenRequest && (
<UpliftNotice
background="var(--status-pending-bg)"
icon={
<AlertTriangle
size={14}
aria-hidden
className="mt-px shrink-0"
style={{ color: "var(--warning)" }}
/>
}
>
A request is pending admin approval. Cancel it if you need to submit a different amount.
</UpliftNotice>
)}
{Boolean(error) && (
<QueryErrorPanel
compact
message={error instanceof Error ? error.message : "Failed to load uplifts."}
onRetry={() => void refetch()}
/>
)}
{isLoading && (
<div className="flex justify-center py-6">
<CircularProgress size={24} />
</div>
)}
{showForm && (
<WorkOrderUpliftCreateForm
pending={createUplift.isPending}
remainingAllowance={getRemainingUpliftAllowance(workOrderType, uplifts)}
onSubmit={(input) => createUplift.mutateAsync(input)}
/>
)}
{showBody &&
uplifts.map((uplift) => (
<WorkOrderUpliftListItem
key={String(uplift.id)}
uplift={uplift}
readOnly={readOnly}
currentUserId={user?.id}
pendingAction={actionPending}
onCancel={readOnly ? undefined : () => setCancelTargetId(uplift.id)}
onRevoke={() =>
setRevokeTarget({ id: uplift.id, status: uplift.status, amount: uplift.amount })
}
/>
))}
{showEmpty && (
<Text as="p" variant="description" tone="muted" className="py-6 text-center">
No uplifts on this work order.
</Text>
)}
<CancelUpliftDialog
open={cancelTargetId != null}
pending={cancelUplift.isPending}
onClose={() => setCancelTargetId(null)}
onConfirm={() => {
if (cancelTargetId == null) return;
cancelUplift.mutate(cancelTargetId, { onSuccess: () => setCancelTargetId(null) });
}}
/>
<RevokeUpliftDialog
open={revokeTarget != null}
pending={revokeUplift.isPending}
requireReason={
revokeTarget != null ? upliftRevokeRequiresReason(revokeTarget.status) : false
}
title="Revoke this uplift?"
description={revokeDialogDescription(revokeTarget?.amount)}
onClose={() => setRevokeTarget(null)}
onConfirm={(reason) => {
if (revokeTarget == null) return;
revokeUplift.mutate(
{ upliftId: revokeTarget.id, reason },
{ onSuccess: () => setRevokeTarget(null) },
);
}}
/>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import type {
WorkOrderTablePatch,
@ -27,11 +27,12 @@ import { useSlideOverMediaState } from "./use-slide-over-media-state";
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
import type { SlideOverSaveOptions } from "./work-order-slide-over";
export type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras";
export type SlideOverTab = "info" | "comments" | "completion" | "uplifts" | "extras" | "audit";
type UseWorkOrderSlideOverArgs = {
row: WorkOrderTableRow | null;
editMode: boolean;
initialTab?: SlideOverTab;
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
onClose: () => void;
saving?: boolean;
@ -109,6 +110,7 @@ function slideOverErrorMessage(error: unknown) {
export function useWorkOrderSlideOver({
row,
editMode,
initialTab = "info",
onSave,
onClose,
saving,
@ -129,7 +131,7 @@ export function useWorkOrderSlideOver({
const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? "");
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
const [tab, setTab] = useState<SlideOverTab>("info");
const [tab, setTab] = useState<SlideOverTab>(initialTab);
const { detailForRow, infoSource } = resolveSlideOverSource(detail, row);
const readFlags = slideOverReadFlags(infoSource?.status);
@ -161,6 +163,12 @@ export function useWorkOrderSlideOver({
closeDisabled,
setTab,
});
const openedRowId = row?.id;
// why: opening a row resets the tab to Info inside edit state; re-apply the requested tab after that.
useEffect(() => {
if (openedRowId == null) return;
setTab(initialTab);
}, [openedRowId, initialTab]);
const uploadCompletionPdf = (file: File) => {
uploadSlideOverCompletionPdf({

View file

@ -3,9 +3,10 @@ import type { SlideOverTab } from "./use-work-order-slide-over";
const SLIDE_OVER_TABS: { id: SlideOverTab; label: string }[] = [
{ id: "info", label: "Info" },
{ id: "comments", label: "Comments" },
{ id: "audit", label: "Audit Log" },
{ id: "completion", label: "Completion Doc" },
{ id: "uplifts", label: "Uplifts" },
{ id: "extras", label: "Extra Docs" },
{ id: "audit", label: "Audit Log" },
];
type WorkOrderSlideOverTabNavProps = {

View file

@ -4,6 +4,7 @@ import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/d
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
import { SlideOverInfoTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab";
import { SlideOverUpliftsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab";
import type {
LocationOption,
VendorDropdownItem,
@ -16,6 +17,23 @@ import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel";
type SlideOverState = ReturnType<typeof useWorkOrderSlideOver>;
function SlideOverUpliftsPanel({
tab,
infoSource,
}: {
tab: SlideOverTab;
infoSource: WorkOrderTableRow | null | undefined;
}) {
if (tab !== "uplifts" || infoSource == null) return null;
return (
<SlideOverUpliftsTab
workOrderId={infoSource.id}
workOrderType={infoSource.type}
status={infoSource.status}
/>
);
}
type WorkOrderSlideOverTabsProps = {
tab: SlideOverTab;
state: SlideOverState;
@ -136,6 +154,8 @@ export function WorkOrderSlideOverTabs({
</WorkOrderSlideOverTabPanel>
)}
<SlideOverUpliftsPanel tab={tab} infoSource={infoSource} />
{tab === "extras" && (
<WorkOrderSlideOverTabPanel
variant="extras"

View file

@ -11,7 +11,7 @@ import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderSlideOver } from "./use-work-order-slide-over";
import { useWorkOrderSlideOver, type SlideOverTab } from "./use-work-order-slide-over";
import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
@ -23,6 +23,7 @@ export type SlideOverSaveOptions = {
type WorkOrderSlideOverProps = {
row: WorkOrderTableRow | null;
editMode: boolean;
initialTab?: SlideOverTab;
users: WorkOrderUser[];
sites: LocationOption[];
vendors: VendorDropdownItem[];
@ -40,6 +41,7 @@ type WorkOrderSlideOverProps = {
export function WorkOrderSlideOver({
row,
editMode,
initialTab = "info",
users,
sites,
vendors,
@ -53,7 +55,7 @@ export function WorkOrderSlideOver({
onClose,
saving,
}: WorkOrderSlideOverProps) {
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving });
const state = useWorkOrderSlideOver({ row, editMode, initialTab, onSave, onClose, saving });
const { infoSource, activeDraft } = state;
return (

View file

@ -1,48 +1,117 @@
import type { ReactNode } from "react";
import { Plus } from "lucide-react";
import type { UpliftSummary } from "@/domain/work-orders/types/work-order-uplift";
import {
getUpliftCellLabel,
getUpliftStatusPillStyle,
getUpliftCellVisual,
type UpliftCellVisual,
} from "@/domain/work-orders/utils/uplift-display-utils";
type UpliftCellProps = {
summary: UpliftSummary;
closed?: boolean;
onOpen?: () => 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 ? (
<span className="text-[11px] text-[var(--color-text-muted)]">{label}</span>
) : (
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
);
}
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 (
<button
type="button"
onClick={(e) => {
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 ? (
<span
className="inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[11px] font-semibold whitespace-nowrap"
style={pillStyle}
>
{label}
</span>
) : (
<span className="text-[11px] font-semibold text-[var(--color-primary)]">Manage</span>
)}
{children}
</button>
);
}
function UpliftCellVisualBody({ visual }: { visual: UpliftCellVisual }) {
if (visual.kind === "plain") {
return <span>{visual.label}</span>;
}
const struck = visual.kind === "rejected";
return (
<>
<span
aria-hidden
className="inline-block shrink-0 rounded-full"
style={{ width: 7, height: 7, background: CELL_DOT[visual.kind] }}
/>
{visual.amount != null && (
<span
style={{
fontSize: 13,
fontWeight: struck ? 400 : 500,
color: struck ? "var(--color-text-muted)" : "var(--color-text-primary)",
textDecoration: struck ? "line-through" : "none",
}}
>
{visual.amount}
</span>
)}
<span style={{ fontSize: 11, color: CELL_STATUS_COLOR[visual.kind] }}>
{visual.statusLabel}
</span>
</>
);
}
export function UpliftCell({ summary, closed = false, onOpen }: UpliftCellProps) {
const label = getUpliftCellLabel(summary);
const visual = getUpliftCellVisual(summary);
if (!onOpen) {
return <span className="text-[11px] text-[var(--color-text-muted)]">{label ?? "—"}</span>;
}
if (!label && closed) {
return (
<UpliftCellButton label="View uplifts" title="No uplift" onOpen={onOpen}>
<span style={{ color: "var(--color-text-muted)", fontWeight: 400 }}>—</span>
</UpliftCellButton>
);
}
if (!label) {
return (
<UpliftCellButton label="Add uplift" title="No uplift" onOpen={onOpen}>
<Plus size={13} aria-hidden style={{ color: "var(--color-text-muted)" }} />
<span style={{ color: "var(--color-text-muted)", fontWeight: 400 }}>Add</span>
</UpliftCellButton>
);
}
return (
<UpliftCellButton label={label} title="View uplifts" onOpen={onOpen}>
{visual != null && <UpliftCellVisualBody visual={visual} />}
</UpliftCellButton>
);
}

View file

@ -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({
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
<UpliftCell
summary={row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY}
onOpen={
canOpenUpliftsDialog(row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY, row.status) &&
handlers.onOpenUplifts
? () => handlers.onOpenUplifts!(row)
: undefined
}
closed={isWorkOrderUpliftsReadOnly(row.status)}
onOpen={handlers.onOpenUplifts ? () => handlers.onOpenUplifts!(row) : undefined}
/>
</td>
<td

View file

@ -1,24 +0,0 @@
import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type WorkOrdersListOverlaysProps = {
activeUpliftsRow: WorkOrderTableRow | null;
onCloseUplifts: () => void;
};
export function WorkOrdersListOverlays({
activeUpliftsRow,
onCloseUplifts,
}: WorkOrdersListOverlaysProps) {
return (
<>
{activeUpliftsRow != null && (
<WorkOrderUpliftsDialog
row={activeUpliftsRow}
open={Boolean(activeUpliftsRow)}
onClose={onCloseUplifts}
/>
)}
</>
);
}

View file

@ -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
<WorkOrderSlideOver
row={page.activeSlideOverRow}
editMode={page.slideOverEdit}
initialTab={page.slideOverTab}
users={users}
sites={locations}
vendors={vendors}
@ -105,11 +105,6 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
}}
/>
)}
<WorkOrdersListOverlays
activeUpliftsRow={page.activeUpliftsRow}
onCloseUplifts={() => page.setUpliftsRow(null)}
/>
</>
);
}

View file

@ -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 (
<Dialog open={open} onClose={guardDialogClose(pending, handleClose)} maxWidth="xs" fullWidth>
<DialogTitle
sx={{
fontFamily: "var(--font-display)",
fontSize: 18,
fontWeight: 700,
color: "var(--color-text-primary)",
}}
>
Cancel uplift request?
</DialogTitle>
<DialogContent>
<Text as="p" variant="description" tone="muted">
This request will be withdrawn. You can submit a new one afterwards.
</Text>
</DialogContent>
<DialogActions sx={{ padding: "12px 20px", gap: 1 }}>
<Button
variant="ghost"
className="min-h-0 px-3 py-1 text-[12px]"
disabled={pending}
onClick={handleClose}
>
Keep request
</Button>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
disabled={pending}
onClick={onConfirm}
sx={{ background: "var(--color-warning)", color: "var(--color-text-inverse)" }}
>
Cancel request
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -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 (
<Dialog open={open} onClose={guardDialogClose(pending, handleClose)} maxWidth="xs" fullWidth>
<DialogTitle>{title}</DialogTitle>
<DialogTitle
sx={{
fontFamily: "var(--font-display)",
fontSize: 18,
fontWeight: 700,
color: "var(--color-text-primary)",
}}
>
{title}
</DialogTitle>
<DialogContent>
<Text as="p" variant="description" className="mb-3">
<Text as="p" variant="description" tone="muted" className="mb-3">
{description}
</Text>
<FieldLabel htmlFor="wo-revoke-uplift-reason" required={requireReason}>
Reason
{requireReason ? "Reason" : "Reason (optional)"}
</FieldLabel>
<TextField
id="wo-revoke-uplift-reason"
fullWidth
multiline
minRows={2}
placeholder="Wrong amount, wrong quote..."
value={reason}
required={requireReason}
onChange={(event) => setReason(event.target.value)}
/>
</DialogContent>
<DialogActions>
<DialogActions sx={{ padding: "12px 20px", gap: 1 }}>
<DialogCancelButton onClick={handleClose} disabled={pending} />
<Button
variant="destructive"
className="min-h-0 px-3 py-1 text-[12px]"
className="min-h-0 gap-1.5 px-3 py-1 text-[12px]"
disabled={pending || reasonMissing}
onClick={handleConfirm}
>
<Undo2 size={13} aria-hidden />
Revoke
</Button>
</DialogActions>

View file

@ -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 (
<div
className="flex items-center gap-2"
style={{
padding: "6px 8px",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-sm)",
}}
>
<Text as="span" variant="caption" className="min-w-0 flex-1 truncate">
{file.name}
</Text>
<button
type="button"
aria-label={`Remove ${file.name}`}
onClick={onRemove}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
>
<X size={11} aria-hidden />
</button>
</div>
);
}
export function UpliftAttachmentPicker({ file, onChange }: UpliftAttachmentPickerProps) {
const inputRef = useRef<HTMLInputElement>(null);
const stageFile = (incoming: FileList | null) => {
const next = firstAllowedFile(incoming);
if (next) onChange(next);
};
return (
<div className="mt-4">
<FieldLabel>Attachment</FieldLabel>
<Text as="p" variant="caption" tone="muted" className="mb-2">
Optional — PDF, JPG, or PNG.
</Text>
<button
type="button"
onClick={() => 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)",
}}
>
<Upload size={14} aria-hidden style={{ color: "var(--color-text-muted)" }} />
<Text as="span" variant="caption" tone="muted">
Drag a file here or click to browse
</Text>
</button>
<input
ref={inputRef}
type="file"
accept="application/pdf,image/jpeg,image/png,.pdf,.jpg,.jpeg,.png"
hidden
onChange={(event) => {
stageFile(event.target.files);
event.target.value = "";
}}
/>
{file != null && (
<div className="mt-2">
<UpliftStagedFile file={file} onRemove={() => onChange(null)} />
</div>
)}
</div>
);
}

View file

@ -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<void>;
onSubmit: (input: {
amount: number;
notes: string;
file: File | null;
}) => void | Promise<unknown>;
};
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<UpliftAllowanceHelperTone, string> = {
muted: "var(--muted-foreground)",
success: "var(--status-completed-text)",
warning: "var(--warning)",
};
function UpliftAllowanceHint({ helper }: { helper: UpliftAllowanceHelper }) {
return (
<Text
as="p"
variant="caption"
className="flex items-center gap-1 whitespace-nowrap"
style={{ color: ALLOWANCE_HINT_COLOR[helper.tone] }}
sx={{
marginTop: "6px",
...(helper.tint
? {
background: "var(--status-pending-bg)",
borderRadius: "var(--radius-sm)",
padding: "3px 6px",
marginLeft: "-6px",
marginRight: "-6px",
}
: {}),
}}
>
{helper.icon === "check" && <Check size={12} aria-hidden className="shrink-0" />}
{helper.icon === "alert" && <AlertTriangle size={12} aria-hidden className="shrink-0" />}
{helper.text}
</Text>
);
}
export function WorkOrderUpliftCreateForm({
@ -27,57 +64,69 @@ export function WorkOrderUpliftCreateForm({
}: WorkOrderUpliftCreateFormProps) {
const [amount, setAmount] = useState("");
const [notes, setNotes] = useState("");
const [file, setFile] = useState<File | null>(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 (
<div className="rounded-lg border border-[var(--color-border)] p-3">
<Text as="p" variant="label" className="mb-2">
Request uplift
</Text>
<div className="grid gap-2">
<div>
<FieldLabel htmlFor="wo-uplift-amount" required>
Amount ($)
</FieldLabel>
<TextField
id="wo-uplift-amount"
type="number"
size="small"
fullWidth
placeholder="0.00"
value={amount}
onChange={(event) => setAmount(event.target.value)}
slotProps={{ htmlInput: { min: 0, step: "0.01" } }}
/>
<UpliftAllowanceHint helper={helper} />
<div className="mt-4">
<FieldLabel htmlFor="wo-uplift-notes" required>
Notes
</FieldLabel>
<TextField
label="Amount"
type="number"
size="small"
value={amount}
onChange={(event) => setAmount(event.target.value)}
helperText={amountHelperText(parsedAmount, remainingAllowance)}
slotProps={{ htmlInput: { min: 0, step: "0.01" } }}
/>
<TextField
label="Notes"
id="wo-uplift-notes"
size="small"
fullWidth
multiline
minRows={2}
rows={3}
placeholder="Describe the additional work and why it is needed..."
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
<TextField
label="Attachments"
size="small"
disabled
helperText="Attachment upload will be wired when BE accepts files."
/>
</div>
<UpliftAttachmentPicker file={file} onChange={setFile} />
<div className="mt-3 flex justify-end">
<Button
variant="contained"
size="small"
className="gap-1.5"
disabled={!canSubmit || pending}
onClick={() => {
void handleSubmit();
}}
>
Create uplift
<Plus size={13} aria-hidden />
Request uplift
</Button>
</div>
</div>

View file

@ -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 (
<div className="rounded-lg border border-[var(--color-border)] p-3">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span
className="inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold"
style={pillStyle}
<span
className="inline-flex shrink-0 items-center whitespace-nowrap"
style={{
fontSize: 11,
fontWeight: 500,
background: pillStyle.background,
color: pillStyle.color,
borderRadius: "var(--radius-sm)",
padding: "3px 8px",
}}
>
{formatUpliftStatusLabel(uplift.status)}
</span>
);
}
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 (
<div
className="flex items-center gap-2"
style={{
padding: "6px 8px",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-sm)",
background: "var(--color-bg-surface)",
}}
>
<FileText
size={13}
aria-hidden
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
{hasUrl && (
<a
href={file.url}
target="_blank"
rel="noreferrer"
className="min-w-0 flex-1 truncate hover:underline"
style={{ fontSize: 12, color: "var(--color-text-primary)" }}
>
{formatUpliftStatusLabel(uplift.status)}
</span>
<Text as="span" variant="label">
{formatUpliftMoney(uplift.amount)}
</Text>
</div>
{Boolean(uplift.notes) && (
<Text as="p" variant="description" className="mb-2">
{uplift.notes}
</Text>
{file.name}
</a>
)}
{Boolean(uplift.requestedByName) && (
<Text as="p" variant="caption" tone="muted">
Requested by {uplift.requestedByName}
</Text>
)}
{showCancel && (
<Button
size="small"
color="inherit"
disabled={pendingAction}
onClick={onCancel}
className="mt-2"
{!hasUrl && (
<button
type="button"
onClick={downloadEvidence}
className="min-w-0 flex-1 truncate text-left hover:underline"
style={{ fontSize: 12, color: "var(--color-text-primary)" }}
>
Cancel pending
</Button>
)}
{showRevoke && (
<Button
size="small"
color="error"
disabled={pendingAction}
onClick={onRevoke}
className="mt-2"
>
Revoke
</Button>
{file.name}
</button>
)}
</div>
);
}
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 (
<Text
as="p"
variant="caption"
className="mt-2"
sx={{
paddingLeft: "9px",
borderLeft: rejected
? "2px solid color-mix(in srgb, var(--destructive) 45%, transparent)"
: "2px solid var(--color-border-hover)",
color: rejected ? "var(--destructive)" : "var(--muted-foreground)",
lineHeight: 1.5,
fontSize: 12,
}}
>
{uplift.decisionNote}
</Text>
);
}
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 (
<div className="mt-2 flex items-center justify-between gap-2">
<Text as="span" variant="caption" tone="muted">
{formatUpliftCardMeta(uplift, nowMs)}
</Text>
{showCancel && (
<Button variant="outlined" size="small" disabled={pendingAction} onClick={onCancel}>
Cancel request
</Button>
)}
{showRevoke && (
<Tooltip title={readOnly ? CLOSED_REVOKE_HINT : ""} arrow placement="top">
<span>
<Button
variant="text"
size="small"
className="gap-1"
disabled={revokeBlocked}
onClick={onRevoke}
sx={{
color: "var(--color-destructive)",
textTransform: "none",
minWidth: 0,
...(readOnly ? { opacity: 0.5 } : {}),
}}
>
<Undo2 size={12} aria-hidden /> Revoke
</Button>
</span>
</Tooltip>
)}
</div>
);
}
export function WorkOrderUpliftListItem(props: WorkOrderUpliftListItemProps) {
const { uplift } = props;
const isVoid = isVoidUpliftStatus(uplift.status);
const attachments = uplift.attachments ?? [];
const pendingBorder = uplift.status === "pending";
return (
<div
style={{
background: "var(--color-bg-surface)",
border: pendingBorder
? "1px solid color-mix(in srgb, var(--warning) 35%, transparent)"
: "1px solid var(--color-border)",
borderRadius: "var(--radius)",
padding: "12px 16px",
}}
>
<div className="flex items-start justify-between gap-2">
<span
style={{
fontSize: 17,
fontWeight: 500,
color: isVoid ? "var(--muted-foreground)" : "var(--color-text-primary)",
textDecoration: isVoid ? "line-through" : "none",
}}
>
{formatUpliftMoney(uplift.amount)}
</span>
<UpliftStatusPill uplift={uplift} />
</div>
{uplift.notes !== "" && (
<Text
as="p"
variant="description"
className="mt-2"
sx={{ color: "var(--muted-foreground)", lineHeight: 1.5 }}
>
{uplift.notes}
</Text>
)}
{attachments.length > 0 && (
<div className="mt-2 flex flex-col gap-1">
{attachments.map((file) => (
<UpliftFiledAttachment key={file.id} upliftId={uplift.id} file={file} />
))}
</div>
)}
<UpliftDecisionCallout uplift={uplift} />
<UpliftCardActions {...props} />
</div>
);
}

View file

@ -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<void>;
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 && (
<Text as="p" variant="description" tone="muted">
This work order is {readOnlyStatusLabel?.toLowerCase()}. Uplifts are read-only.
</Text>
)}
{Boolean(error) && (
<QueryErrorPanel
compact
message={error instanceof Error ? error.message : "Failed to load uplifts."}
onRetry={onRetry}
/>
)}
{isLoading && (
<div className="flex justify-center py-6">
<CircularProgress size={24} />
</div>
)}
{!isLoading && !error && uplifts.length === 0 && (
<Text as="p" variant="description" tone="muted">
No uplift requests yet.
</Text>
)}
{!isLoading &&
uplifts.map((uplift) => (
<WorkOrderUpliftListItem
key={String(uplift.id)}
uplift={uplift}
readOnly={readOnly}
currentUserId={currentUserId}
pendingAction={actionPending}
onCancel={readOnly ? undefined : () => onCancelUplift(uplift.id)}
onRevoke={readOnly ? undefined : () => onRevokeUplift(uplift.id)}
/>
))}
{!readOnly && !isLoading && hasOpenRequest && (
<Text as="p" variant="description" tone="muted">
Cancel the pending request to file a new one.
</Text>
)}
{!readOnly && !isLoading && !hasOpenRequest && (
<WorkOrderUpliftCreateForm
pending={createPending}
remainingAllowance={remainingAllowance}
onSubmit={onCreate}
/>
)}
</>
);
}

View file

@ -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<RevokeTarget | null>(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 (
<>
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Uplifts — WO {row?.woNumber ?? ""}</DialogTitle>
<DialogContent className="flex flex-col gap-3">
<WorkOrderUpliftsDialogContent
readOnly={readOnly}
currentUserId={user?.id}
readOnlyStatusLabel={row?.status}
isLoading={isLoading}
error={error}
uplifts={uplifts}
remainingAllowance={remainingAllowance}
actionPending={actionPending}
createPending={createUplift.isPending}
onRetry={() => void refetch()}
onCreate={async (input) => {
await createUplift.mutateAsync(input);
}}
onCancelUplift={(upliftId) => cancelUplift.mutate(upliftId)}
onRevokeUplift={handleRevokeUplift}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
<RevokeUpliftDialog
open={revokeTarget != null}
pending={revokeUplift.isPending}
requireReason={
revokeTarget != null ? upliftRevokeRequiresReason(revokeTarget.status) : false
}
title={revokeDialogCopy.title}
description={revokeDialogCopy.description}
onClose={() => setRevokeTarget(null)}
onConfirm={(reason) => {
if (revokeTarget == null) {
return;
}
revokeUplift.mutate(
{ upliftId: revokeTarget.id, reason },
{ onSuccess: () => setRevokeTarget(null) },
);
}}
/>
</>
);
}

View file

@ -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<WorkOrderTableRow | null>(null);
const [slideOverEdit, setSlideOverEdit] = useState(false);
const [slideOverTab, setSlideOverTab] = useState<SlideOverTab>("info");
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
const [upliftsRow, setUpliftsRow] = useState<WorkOrderTableRow | null>(null);
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
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,

View file

@ -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}

View file

@ -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<Record<string, unknown>>(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<WorkOrderUplift[]> => {
const data = await apiGet<unknown>(API_PATHS.workOrder.uplifts(workOrderId));
@ -30,13 +46,37 @@ export const workOrderUpliftsApi = {
workOrderId: string | number,
input: CreateWorkOrderUpliftInput,
): Promise<WorkOrderUplift> => {
const data = await apiPost<unknown>(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<unknown>(API_PATHS.workOrder.uplifts(workOrderId), body);
return mapWorkOrderUplift(handleApiResponse(data));
},
uploadEvidence: async (
workOrderId: string | number,
file: File,
): Promise<UpliftEvidenceSnapshot> => {
const body = new FormData();
body.append("file", file);
const data = await apiPostForm<unknown>(API_PATHS.workOrder.upliftEvidence(workOrderId), body);
return readEvidenceSnapshot(data);
},
getEvidenceStatus: async (
workOrderId: string | number,
documentId: number,
): Promise<UpliftEvidenceSnapshot> => {
const data = await apiGet<unknown>(
API_PATHS.workOrder.upliftEvidenceStatus(workOrderId, documentId),
);
return readEvidenceSnapshot(data);
},
cancel: async (
workOrderId: string | number,
upliftId: string | number,

View file

@ -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<string, unknown>): 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),
};
}

View file

@ -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 = {

View file

@ -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<UpliftEvidenceSnapshot>;
getEvidenceStatus: (
workOrderId: string | number,
documentId: number,
) => Promise<UpliftEvidenceSnapshot>;
create: (workOrderId: string | number, input: CreateUpliftBody) => Promise<WorkOrderUplift>;
sleep?: (ms: number) => Promise<void>;
};
function sleep(ms: number): Promise<void> {
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<number> {
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<WorkOrderUplift> {
const evidenceDocumentId = input.file
? await waitForPassedScan(workOrderId, input.file, deps)
: undefined;
return deps.create(workOrderId, {
amount: input.amount,
notes: input.notes,
evidenceDocumentId,
});
}

View file

@ -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");

View file

@ -16,17 +16,26 @@ export function formatUpliftMoney(value: number | null | undefined): string {
}).format(value);
}
const STATUS_LABELS: Record<WorkOrderUpliftStatus, string> = {
const PILL_LABELS: Record<WorkOrderUpliftStatus, string> = {
pending: "Pending approval",
approved: "Approved",
auto_approved: "Auto-approved",
rejected: "Rejected",
cancelled: "Cancelled",
revoked: "Revoked",
};
const CELL_STATUS_WORD: Record<WorkOrderUpliftStatus, string> = {
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 };
}

View file

@ -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=");
});
});

View file

@ -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(
<UpliftCell summary={{ hasUplift: false, pendingCount: 0 }} onOpen={onOpen} />,
{ 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(
<UpliftCell summary={{ hasUplift: false, pendingCount: 0 }} closed onOpen={onOpen} />,
{ 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(<UpliftCell summary={{ hasUplift: false, pendingCount: 0 }} />, {
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(<WorkOrderUpliftsDialog row={baseRow} open onClose={vi.fn()} />, {
withAuth: true,
describe("slide-over section order", () => {
it("places Uplifts before Extra Docs and keeps Audit Log last", () => {
renderWithProviders(<WorkOrderSlideOverTabNav tab="info" onTabChange={vi.fn()} />, {
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(<WorkOrderUpliftsDialog row={baseRow} open onClose={vi.fn()} />, {
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(
<WorkOrderUpliftsDialog row={{ ...baseRow, status: "Completed" }} open onClose={vi.fn()} />,
<SlideOverUpliftsTab
workOrderId={baseRow.id}
workOrderType={baseRow.type}
status={baseRow.status}
/>,
{ 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(
<SlideOverUpliftsTab
workOrderId={baseRow.id}
workOrderType={baseRow.type}
status={baseRow.status}
/>,
{ 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(
<SlideOverUpliftsTab
workOrderId={baseRow.id}
workOrderType={baseRow.type}
status="Completed"
/>,
{ 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(
<SlideOverUpliftsTab
workOrderId={baseRow.id}
workOrderType={baseRow.type}
status={baseRow.status}
/>,
{ 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(
<WorkOrderUpliftListItem
uplift={{
...pendingUplift,
status: "auto_approved",
amount: 60,
notes: "asdasd",
requestedById: "dispatcher-1",
requestedByName: "Adam Moussa",
requestedAt: "2026-09-24T15:00:00.000Z",
}}
readOnly={false}
currentUserId="dispatcher-1"
onRevoke={vi.fn()}
/>,
{ 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(
<WorkOrderUpliftListItem
uplift={{
...pendingUplift,
status: "auto_approved",
attachments: [{ id: "22", name: "quote.png", url: "" }],
}}
readOnly={false}
currentUserId="someone-else"
/>,
{ withAuth: false },
);
expect(screen.getByRole("button", { name: "quote.png" })).toBeInTheDocument();
});
it("keeps revoke visible and disabled when the work order is closed", () => {
renderWithProviders(
<WorkOrderUpliftListItem
uplift={{ ...pendingUplift, status: "auto_approved", requestedById: "dispatcher-1" }}
readOnly
currentUserId="dispatcher-1"
onRevoke={vi.fn()}
/>,
{ withAuth: false },
);
expect(screen.getByRole("button", { name: /revoke/i })).toBeDisabled();
});
});
describe("RevokeUpliftDialog copy", () => {
it("explains that the amount returns to the allowance", () => {
renderWithProviders(
<RevokeUpliftDialog
open
title="Revoke this uplift?"
description="$60 goes back to your auto-approval allowance. This is recorded in the log."
onClose={vi.fn()}
onConfirm={vi.fn()}
/>,
{ 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();
});
});

View file

@ -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 () => {

View file

@ -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", () => {

View file

@ -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<Parameters<typeof submitWorkOrderUplift>[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);
});
});

View file

@ -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",
});
});
});