mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 23:53:21 +00:00
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:
parent
c30e8aa74b
commit
32466c01d2
32 changed files with 1604 additions and 466 deletions
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
73
src/domain/work-orders/use-cases/submit-work-order-uplift.ts
Normal file
73
src/domain/work-orders/use-cases/submit-work-order-uplift.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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=");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue