Merge pull request #226 from Sea-Haven-Industries/feat/ab/sh-209-uplift-detail-modal

SH-209: Align the Uplift Detail modal with the spec
This commit is contained in:
Alexandre Brandizzi 2026-09-18 23:33:55 +00:00 • committed by GitHub
commit cbfed3dde8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 629 additions and 328 deletions

View file

@ -0,0 +1,27 @@
const DATE_FORMAT: Intl.DateTimeFormatOptions = {
month: "short",
day: "numeric",
year: "numeric",
};
/** Formats an API UTC instant as local "Jan 15, 2026 · 10:00 AM"; "" when absent or invalid. */
export function formatUpliftDateTime(value: string): string {
if (!value) return "";
const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
const date = new Date(hasZone ? value : `${value}Z`);
if (Number.isNaN(date.getTime())) return "";
const time = date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
return `${date.toLocaleDateString("en-US", DATE_FORMAT)} · ${time}`;
}
/**
* Formats a work-order schedule as "Apr 10, 2026". The schedule is a calendar day,
* so only its date part is read and it never shifts with the viewer's time zone.
*/
export function formatUpliftCalendarDate(value: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
if (match == null) return "";
const [, year, month, day] = match;
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
return date.toLocaleDateString("en-US", { ...DATE_FORMAT, timeZone: "UTC" });
}

View file

@ -2,12 +2,27 @@ import { toast } from "react-toastify";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
function reportEvidenceError(error: unknown): void {
toast.error(error instanceof Error ? error.message : "Unable to download evidence right now.");
}
export function openUpliftEvidence(row: UpliftQueueItem): void {
void upliftsApi
.downloadEvidence(row.id, row.evidenceFileName || "uplift-evidence")
.catch(reportEvidenceError);
}
export function openUpliftEvidenceInNewTab(row: UpliftQueueItem): void {
// Open the tab inside the click handler; a tab opened after the fetch resolves
// is treated as an unsolicited popup and blocked.
const tab = window.open("about:blank", "_blank");
if (tab != null) {
tab.opener = null;
}
void upliftsApi
.openEvidence(row.id, tab, row.evidenceFileName || "uplift-evidence")
.catch((error: unknown) => {
toast.error(
error instanceof Error ? error.message : "Unable to download evidence right now.",
);
tab?.close();
reportEvidenceError(error);
});
}

View file

@ -1,4 +1,5 @@
import { useMemo, type ReactNode } from "react";
import CloseIcon from "@mui/icons-material/Close";
import {
Box,
Button,
@ -7,21 +8,30 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Divider,
IconButton,
Tooltip,
} from "@mui/material";
import {
formatUpliftCalendarDate,
formatUpliftDateTime,
} from "@/app/(protected)/uplifts/_components/format-uplift-dates";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { Text } from "@/components/ui/text";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useWorkOrderUplifts } from "@/domain/work-orders/use-cases/use-work-order-uplifts";
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-display-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
const UNAVAILABLE_LABEL = "Unavailable";
const BORDERED_BLOCK_SX = {
border: 1,
borderColor: "divider",
borderRadius: 1,
px: 1.75,
py: 1.25,
} as const;
type ModalCallbacks = {
onClose: () => void;
@ -31,24 +41,122 @@ type ModalCallbacks = {
onOpenAttachment: (row: UpliftQueueItem) => void;
};
type ExposureBreakdown = { auto: number | null; admin: number | null; total: number | null };
function DetailField({ label, children }: { label: string; children: ReactNode }) {
return (
<Box>
<Text variant="label" tone="muted" className="block">
{label}
</Text>
{children}
<Text variant="body">{children}</Text>
</Box>
);
}
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
function SubsectionTitle({ children }: { children: ReactNode }) {
return (
<Box>
<Text variant="heading" className="mb-1 block">
{title}
<Text variant="label" as="h4" className="mb-1.5 mt-4 block">
{children}
</Text>
<Box sx={{ display: "grid", gap: 1.5 }}>{children}</Box>
);
}
function vendorTechnicianLabel(item: UpliftQueueItem): string {
const parts = [item.vendorCompanyName.trim(), item.technicianName.trim()].filter(Boolean);
const unique = parts.filter((part, index) => parts.indexOf(part) === index);
return unique.length > 0 ? unique.join(" · ") : "Not assigned";
}
function WorkOrderSection({ item }: { item: UpliftQueueItem }) {
return (
<Box component="section" aria-label="Work order">
<Text variant="heading" className="mb-2 block">
Work order
</Text>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.75 }}>
<DetailField label="Service">{item.serviceName || "Not specified"}</DetailField>
<DetailField label="Vendor / Technician">{vendorTechnicianLabel(item)}</DetailField>
<DetailField label="Assigned Dispatcher">
{item.workOrderDispatcherName || "Unassigned"}
</DetailField>
<DetailField label="Scheduled">
{formatUpliftCalendarDate(item.workOrderScheduledDate) || "Unscheduled"}
</DetailField>
</Box>
</Box>
);
}
function JustificationBlock({ notes }: { notes: string }) {
const text = notes.trim();
return (
<Box sx={{ ...BORDERED_BLOCK_SX, minHeight: 60 }} data-testid="uplift-justification">
{text.length > 0 && <Text variant="body">{text}</Text>}
{text.length === 0 && (
<Text variant="description" tone="muted" className="italic">
No justification provided.
</Text>
)}
</Box>
);
}
function BreakdownRow({
label,
value,
strong,
unavailable,
}: {
label: string;
value: number | null;
strong: boolean;
unavailable: boolean;
}) {
const fallback = unavailable ? UNAVAILABLE_LABEL : "—";
return (
<Box
sx={{
display: "flex",
justifyContent: "space-between",
...(strong ? { borderTop: 1, borderColor: "divider", mt: 1, pt: 1 } : { py: 0.25 }),
}}
>
<Text variant={strong ? "body" : "description"} tone={strong ? "default" : "muted"}>
{label}
</Text>
<Text
variant={strong ? "body" : "description"}
className={strong ? "font-medium" : undefined}
>
{value != null ? formatUpliftMoney(value) : fallback}
</Text>
</Box>
);
}
function ApprovedOnWoBreakdown({
breakdown,
unavailable,
}: {
breakdown: ExposureBreakdown;
unavailable: boolean;
}) {
return (
<Box sx={BORDERED_BLOCK_SX}>
<BreakdownRow
label="Auto-approved (within allowance)"
value={breakdown.auto}
strong={false}
unavailable={unavailable}
/>
<BreakdownRow
label="Admin-approved"
value={breakdown.admin}
strong={false}
unavailable={unavailable}
/>
<BreakdownRow label="Total" value={breakdown.total} strong unavailable={unavailable} />
</Box>
);
}
@ -67,142 +175,61 @@ function UpliftAttachments({
</Text>
);
}
const canOpen = item.evidenceDocumentId != null;
const extraCount =
typeof item.attachmentCount === "number" && item.attachmentCount > 1
? item.attachmentCount - 1
: 0;
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, flexWrap: "wrap" }}>
<Box
data-testid="uplift-attachments"
sx={{ display: "flex", flexWrap: "nowrap", gap: 1, overflowX: "auto", pb: 0.5 }}
>
<Chip
size="small"
variant="outlined"
label={item.evidenceFileName || "Attachment"}
onClick={item.evidenceDocumentId != null ? () => onOpenAttachment(item) : undefined}
clickable={item.evidenceDocumentId != null}
title={canOpen ? "Opens in a new tab" : undefined}
onClick={canOpen ? () => onOpenAttachment(item) : undefined}
clickable={canOpen}
sx={{ flexShrink: 0, maxWidth: 176 }}
/>
{extraCount > 0 && <Chip size="small" label={`+${extraCount}`} />}
{extraCount > 0 && (
<Chip variant="outlined" label={`+${extraCount}`} sx={{ flexShrink: 0 }} />
)}
</Box>
);
}
function WorkOrderSection({
item,
info,
unavailable,
}: {
item: UpliftQueueItem;
info?: WorkOrderTableRow;
unavailable: boolean;
}) {
const statusFallback = unavailable ? UNAVAILABLE_LABEL : "—";
return (
<DetailSection title="Work order">
<DetailField label="Work order #">
<Text variant="body">{item.woNumber || "—"}</Text>
</DetailField>
<DetailField label="Site">
<Text variant="body">{info?.site || item.site || "—"}</Text>
</DetailField>
<DetailField label="Status">
<Text variant="body">{info?.status || statusFallback}</Text>
</DetailField>
<DetailField label="Scheduled">
<Text variant="body">
{info?.scheduledOn || (unavailable ? UNAVAILABLE_LABEL : "Unscheduled")}
</Text>
</DetailField>
<DetailField label="Vendor">
<Text variant="body">{item.vendorCompanyName || "—"}</Text>
</DetailField>
</DetailSection>
);
}
function RequestSection({
function UpliftRequestSection({
item,
breakdown,
breakdownUnavailable,
onOpenAttachment,
}: {
item: UpliftQueueItem;
breakdown: ExposureBreakdown;
breakdownUnavailable: boolean;
onOpenAttachment: (row: UpliftQueueItem) => void;
}) {
const waitingClass = item.requestedAt
? getWaitTimeTextClass(waitTimeColor(item.requestedAt))
: undefined;
return (
<DetailSection title="Request">
<DetailField label="Amount">
<Text variant="body">
{formatUpliftMoney(item.currentNTE ?? 0)} → {formatUpliftMoney(item.requestedNTE)}
<Box component="section" aria-label="Uplift request">
<Text variant="heading" className="mb-2 block">
Uplift request
</Text>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.75 }}>
<DetailField label="Requested By">
{item.requestedByVendorName || "Unknown requester"}
</DetailField>
<DetailField label="Requested by">
<Text variant="body">{item.requestedByVendorName || "—"}</Text>
<DetailField label="Requested At">
{formatUpliftDateTime(item.requestedAt) || "Not recorded"}
</DetailField>
<DetailField label="Waiting">
<Text variant="body" className={waitingClass}>
{item.requestedAt ? timeSince(item.requestedAt) : "—"}
</Text>
</DetailField>
<DetailField label="Notes">
<Text variant="description">{item.vendorReason || "No justification provided."}</Text>
</DetailField>
<DetailField label="Attachments">
<UpliftAttachments item={item} onOpenAttachment={onOpenAttachment} />
</DetailField>
</DetailSection>
);
}
function ApprovedOnWoBreakdown({
auto,
admin,
total,
unavailable,
}: {
auto: number | null;
admin: number | null;
total: number | null;
unavailable: boolean;
}) {
const rows: Array<{ label: string; value: number | null; strong: boolean }> = [
{ label: "Auto-approved", value: auto, strong: false },
{ label: "Admin-approved", value: admin, strong: false },
{ label: "Total", value: total, strong: true },
];
return (
<Box sx={{ display: "grid", gap: 0.5 }}>
{rows.map((row) => (
<BreakdownRow key={row.label} unavailable={unavailable} {...row} />
))}
</Box>
);
}
function BreakdownRow({
label,
value,
strong,
unavailable,
}: {
label: string;
value: number | null;
strong: boolean;
unavailable: boolean;
}) {
return (
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Text
variant={strong ? "body" : "description"}
tone={strong ? "default" : "muted"}
className={strong ? "font-medium" : undefined}
>
{label}
</Text>
<Text
variant={strong ? "body" : "description"}
className={strong ? "font-medium" : undefined}
>
{value != null ? formatUpliftMoney(value) : unavailable ? UNAVAILABLE_LABEL : "—"}
</Text>
<SubsectionTitle>Justification</SubsectionTitle>
<JustificationBlock notes={item.vendorReason} />
<SubsectionTitle>Approved on WO</SubsectionTitle>
<ApprovedOnWoBreakdown breakdown={breakdown} unavailable={breakdownUnavailable} />
<SubsectionTitle>Attachments</SubsectionTitle>
<UpliftAttachments item={item} onOpenAttachment={onOpenAttachment} />
</Box>
);
}
@ -223,6 +250,19 @@ function PendingModalActions({
const tooltip = canDecide ? "" : `Requires Tier ${item.requiredTier} role`;
return (
<>
<Tooltip title={tooltip}>
<span>
<Button
size="small"
variant="outlined"
color="error"
disabled={!canDecide}
onClick={() => onReject(item)}
>
Reject
</Button>
</span>
</Tooltip>
<Tooltip title={tooltip}>
<span>
<Button
@ -236,54 +276,24 @@ function PendingModalActions({
</Button>
</span>
</Tooltip>
<Tooltip title={tooltip}>
<span>
<Button
size="small"
variant="contained"
color="error"
disabled={!canDecide}
onClick={() => onReject(item)}
>
Reject
</Button>
</span>
</Tooltip>
</>
);
}
function UpliftModalActions({
function RevokeModalAction({
item,
closed,
canRevoke,
approvePending,
revokePending,
onApprove,
onReject,
onRevoke,
}: {
item: UpliftQueueItem;
closed: boolean;
canRevoke: boolean;
approvePending: boolean;
revokePending: boolean;
onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void;
onRevoke: (row: UpliftQueueItem) => void;
}) {
const closed = item.workOrderClosed === true;
const revokeTooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : "";
return (
<>
{item.status === "Pending" && (
<PendingModalActions
item={item}
approvePending={approvePending}
onApprove={onApprove}
onReject={onReject}
/>
)}
{item.status === "Approved" && (
<Tooltip title={revokeTooltip}>
<span>
<Button
@ -297,12 +307,30 @@ function UpliftModalActions({
</Button>
</span>
</Tooltip>
)}
</>
);
}
export function UpliftDetailModal({
function useExposureBreakdown(item: UpliftQueueItem) {
// Permission failures (e.g. 403 for account-scoped users) are handled quietly:
// the breakdown falls back to "Unavailable" instead of toasting.
const woUpliftsQuery = useWorkOrderUplifts(item.workOrderId ?? null, {
suppressErrorToast: true,
});
const breakdown = useMemo<ExposureBreakdown>(() => {
const woUplifts = woUpliftsQuery.data ?? [];
const autoFallback = sumUpliftAmounts(woUplifts, "auto_approved");
const adminFallback = sumUpliftAmounts(woUplifts, "approved");
const hasWoData = woUplifts.length > 0;
return {
auto: item.approvedOnWoAuto ?? (hasWoData ? autoFallback : null),
admin: item.approvedOnWoAdmin ?? (hasWoData ? adminFallback : null),
total: item.approvedOnWoTotal ?? (hasWoData ? autoFallback + adminFallback : null),
};
}, [item.approvedOnWoAuto, item.approvedOnWoAdmin, item.approvedOnWoTotal, woUpliftsQuery.data]);
return { breakdown, unavailable: woUpliftsQuery.isError };
}
function UpliftDetailContent({
item,
canRevoke,
approvePending,
@ -312,98 +340,102 @@ export function UpliftDetailModal({
onReject,
onRevoke,
onOpenAttachment,
}: {
item: UpliftQueueItem;
canRevoke: boolean;
approvePending: boolean;
revokePending: boolean;
} & ModalCallbacks) {
const { breakdown, unavailable } = useExposureBreakdown(item);
const isPending = item.status === "Pending";
const isApproved = item.status === "Approved";
const subtitle = [item.site, item.status].filter(Boolean).join(" · ");
return (
<>
<DialogTitle sx={{ pr: 6 }}>
<Text variant="title" as="div">
Uplift — WO #{item.woNumber || item.dispatchNumber || "—"}
</Text>
<Text variant="description" tone="muted" when={subtitle.length > 0}>
{subtitle}
</Text>
<IconButton
aria-label="Close"
onClick={onClose}
size="small"
sx={{ position: "absolute", top: 12, right: 12 }}
>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
bgcolor: "action.hover",
borderRadius: 1,
px: 2,
py: 1.5,
mb: 2,
}}
>
<Text variant="label" tone="muted">
Amount
</Text>
<Text variant="heading" as="span" tone="warning">
{formatUpliftMoney(item.requestedNTE)}
</Text>
</Box>
<WorkOrderSection item={item} />
<Divider sx={{ my: 2.5 }} />
<UpliftRequestSection
item={item}
breakdown={breakdown}
breakdownUnavailable={unavailable}
onOpenAttachment={onOpenAttachment}
/>
</DialogContent>
{(isPending || isApproved) && (
<DialogActions sx={{ px: 2.5, py: 1.5 }}>
{isPending && (
<PendingModalActions
item={item}
approvePending={approvePending}
onApprove={onApprove}
onReject={onReject}
/>
)}
{isApproved && (
<RevokeModalAction
item={item}
canRevoke={canRevoke}
revokePending={revokePending}
onRevoke={onRevoke}
/>
)}
</DialogActions>
)}
</>
);
}
export function UpliftDetailModal({
item,
...rest
}: {
item: UpliftQueueItem | null;
canRevoke: boolean;
approvePending: boolean;
revokePending: boolean;
} & ModalCallbacks) {
const open = item != null;
// Permission failures (e.g. 403 for account-scoped users) are handled quietly
// in this modal: dependent sections fall back to "Unavailable" instead of toasting.
const quietMeta = { suppressErrorToast: true } as const;
const boardDetail = useWorkOrderBoardDetail(
item != null && item.workOrderId != null ? item.workOrderId : undefined,
true,
quietMeta,
);
const woUpliftsQuery = useWorkOrderUplifts(
item != null && item.workOrderId != null ? item.workOrderId : null,
quietMeta,
);
const info = boardDetail.data?.info;
const closed = useMemo(() => {
if (item?.workOrderClosed === true) return true;
return info ? isWorkOrderUpliftsReadOnly(info.status) : false;
}, [item?.workOrderClosed, info]);
const breakdown = useMemo(() => {
const woUplifts = woUpliftsQuery.data ?? [];
const autoFallback = sumUpliftAmounts(woUplifts, "auto_approved");
const adminFallback = sumUpliftAmounts(woUplifts, "approved");
const hasWoData = woUplifts.length > 0;
return {
auto: item?.approvedOnWoAuto ?? (hasWoData ? autoFallback : null),
admin: item?.approvedOnWoAdmin ?? (hasWoData ? adminFallback : null),
total: item?.approvedOnWoTotal ?? (hasWoData ? autoFallback + adminFallback : null),
};
}, [
item?.approvedOnWoAuto,
item?.approvedOnWoAdmin,
item?.approvedOnWoTotal,
woUpliftsQuery.data,
]);
if (!open) {
if (item == null) {
return null;
}
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
aria-label="Uplift request details"
>
<DialogTitle>
<Text variant="title" as="div">
Uplift request
</Text>
<Text variant="description" tone="muted">
{item.woNumber || item.dispatchNumber || "Work order"} · {item.status}
</Text>
</DialogTitle>
<DialogContent>
<Box sx={{ display: "grid", gap: 3 }}>
<WorkOrderSection item={item} info={info} unavailable={boardDetail.isError} />
<RequestSection item={item} onOpenAttachment={onOpenAttachment} />
<DetailSection title="Approved on work order">
<ApprovedOnWoBreakdown
auto={breakdown.auto}
admin={breakdown.admin}
total={breakdown.total}
unavailable={woUpliftsQuery.isError}
/>
</DetailSection>
</Box>
</DialogContent>
<DialogActions>
<Button size="small" variant="outlined" onClick={onClose}>
Close
</Button>
<UpliftModalActions
item={item}
closed={closed}
canRevoke={canRevoke}
approvePending={approvePending}
revokePending={revokePending}
onApprove={onApprove}
onReject={onReject}
onRevoke={onRevoke}
/>
</DialogActions>
<Dialog open onClose={rest.onClose} maxWidth="sm" fullWidth aria-label="Uplift request details">
<UpliftDetailContent item={item} {...rest} />
</Dialog>
);
}

View file

@ -6,7 +6,10 @@ import {
} from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import { openUpliftEvidence } from "@/app/(protected)/uplifts/_components/open-uplift-evidence";
import {
openUpliftEvidence,
openUpliftEvidenceInNewTab,
} from "@/app/(protected)/uplifts/_components/open-uplift-evidence";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { isAdminUser } from "@/lib/auth/user-utils";
import { useAuthContext } from "@/providers/auth-context";
@ -181,7 +184,7 @@ export default function UpliftQueuePage() {
onApprove={handleApprove}
onReject={handleRejectRequest}
onRevoke={handleRevokeRequest}
onOpenAttachment={openUpliftEvidence}
onOpenAttachment={openUpliftEvidenceInNewTab}
/>
)}
<UpliftDecisionDialogs controller={controller} />

View file

@ -106,28 +106,61 @@ export const upliftsApi = {
id: string | number,
fallbackFileName = "uplift-evidence",
): Promise<void> => {
let response: Response;
const response = await fetchEvidence(id, "upliftsApi.downloadEvidence");
const blob = await response.blob();
saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
},
/**
* Renders the evidence in `tab`, which the caller opens synchronously on click so
* popup blockers allow it. Only inert types render inline on the app origin; any
* other type (HTML, SVG, ...) is downloaded instead and the tab is closed.
*/
openEvidence: async (
id: string | number,
tab: Window | null,
fallbackFileName = "uplift-evidence",
): Promise<void> => {
const response = await fetchEvidence(id, "upliftsApi.openEvidence");
const blob = await response.blob();
const type = blob.type.split(";")[0].trim().toLowerCase();
if (tab == null || !INLINE_EVIDENCE_TYPES.has(type)) {
tab?.close();
saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
return;
}
const url = URL.createObjectURL(new Blob([blob], { type }));
tab.location.href = url;
window.setTimeout(() => URL.revokeObjectURL(url), EVIDENCE_URL_TTL_MS);
},
};
const INLINE_EVIDENCE_TYPES = new Set([
"application/pdf",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
]);
const EVIDENCE_URL_TTL_MS = 60_000;
async function fetchEvidence(id: string | number, operation: string): Promise<Response> {
try {
response = await apiRequestRaw(
"get",
evidenceUrl(id),
undefined,
"upliftsApi.downloadEvidence",
);
return await apiRequestRaw("get", evidenceUrl(id), undefined, operation);
} catch (error) {
if (error instanceof HTTPError) {
throw evidenceHttpError(error.response.status);
}
throw error;
}
}
const blob = await response.blob();
const fileName = readContentDispositionFilename(response, fallbackFileName);
function saveBlob(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
},
};
}

View file

@ -116,6 +116,9 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
"trade",
"Trade",
),
technicianName: readString(item, "technicianName", "TechnicianName"),
workOrderDispatcherName: readString(item, "workOrderDispatcherName", "WorkOrderDispatcherName"),
workOrderScheduledDate: readString(item, "workOrderScheduledDate", "WorkOrderScheduledDate"),
attachmentCount: readNumber(
item,
"attachmentCount",

View file

@ -46,6 +46,9 @@ export interface UpliftQueueItem extends UpliftRequest {
woNumber: string;
site: string;
serviceName: string;
technicianName: string;
workOrderDispatcherName: string;
workOrderScheduledDate: string;
attachmentCount: number | null;
approvedOnWoAuto: number | null;
approvedOnWoAdmin: number | null;

View file

@ -41,6 +41,9 @@ const approvedItem: UpliftQueueItem = {
woNumber: "WO-55",
site: "Site B",
serviceName: "HVAC service",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: 3,
approvedOnWoAuto: 100,
approvedOnWoAdmin: 400,

View file

@ -1,4 +1,4 @@
import { screen, waitFor } from "@testing-library/react";
import { screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import { ApiError } from "@/api/api-error";
@ -80,6 +80,9 @@ const dispatcherItem: UpliftQueueItem = {
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
technicianName: "Tom Tech",
workOrderDispatcherName: "Dana Ruiz",
workOrderScheduledDate: "2026-04-10T00:00:00",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
@ -113,18 +116,32 @@ describe("UpliftDetailModal permission failures (Dispatcher 403)", () => {
woUpliftsMock.calls = 0;
});
it("shows unavailable sections without an error toast and keeps Revoke admin-only", async () => {
it("renders the work order from the queue item without a work-order fetch or error toast", async () => {
renderWithGovernedClient();
await waitFor(() => {
expect(boardDetailMock.calls).toBeGreaterThan(0);
expect(woUpliftsMock.calls).toBeGreaterThan(0);
});
await waitFor(() => {
expect(screen.getAllByText("Unavailable").length).toBeGreaterThanOrEqual(3);
expect(boardDetailMock.calls).toBe(0);
const workOrder = screen.getByRole("region", { name: "Work order" });
expect(within(workOrder).getByText("Dana Ruiz")).toBeInTheDocument();
expect(within(workOrder).getByText("Gateway Plumbing · Tom Tech")).toBeInTheDocument();
expect(within(workOrder).getByText("Apr 10, 2026")).toBeInTheDocument();
expect(within(workOrder).queryByText("Unavailable")).not.toBeInTheDocument();
expect(toastMocks.error).not.toHaveBeenCalled();
});
it("marks the approved-on-WO breakdown unavailable without a toast and keeps Revoke admin-only", async () => {
renderWithGovernedClient();
const request = screen.getByRole("region", { name: "Uplift request" });
for (const label of ["Auto-approved (within allowance)", "Admin-approved", "Total"]) {
const row = within(request).getByText(label).parentElement as HTMLElement;
await waitFor(() => {
expect(within(row).getByText("Unavailable")).toBeInTheDocument();
});
}
const revoke = screen.getByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();
expect(toastMocks.error).not.toHaveBeenCalled();

View file

@ -1,18 +1,13 @@
import { fireEvent, screen } from "@testing-library/react";
import { fireEvent, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { renderWithProviders } from "@/test/test-utils";
const boardDetail = vi.hoisted(() => ({ data: null as Record<string, unknown> | null }));
const woUplifts = vi.hoisted(() => ({ data: null as Array<Record<string, unknown>> | null }));
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({ data: boardDetail.data, isLoading: false, error: null }),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({
useWorkOrderUplifts: () => ({ data: woUplifts.data, isLoading: false, error: null }),
useWorkOrderUplifts: () => ({ data: woUplifts.data, isLoading: false, isError: false }),
}));
const canApproveState = vi.hoisted(() => ({ data: true as boolean | undefined }));
@ -28,7 +23,7 @@ const baseItem: UpliftQueueItem = {
requestedNTE: 250,
delta: 150,
vendorReason: "",
requestedAt: "2026-01-15T10:00:00Z",
requestedAt: "2026-01-15T15:00:00Z",
requestedByVendorName: "Gateway",
decidedAt: "",
decidedByName: "",
@ -50,6 +45,9 @@ const baseItem: UpliftQueueItem = {
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
@ -58,76 +56,150 @@ const baseItem: UpliftQueueItem = {
};
function renderModal(overrides: Partial<UpliftQueueItem> = {}, canRevoke = true) {
const onApprove = vi.fn();
const onReject = vi.fn();
const onRevoke = vi.fn();
const onOpenAttachment = vi.fn();
const callbacks = {
onClose: vi.fn(),
onApprove: vi.fn(),
onReject: vi.fn(),
onRevoke: vi.fn(),
onOpenAttachment: vi.fn(),
};
renderWithProviders(
<UpliftDetailModal
item={{ ...baseItem, ...overrides }}
canRevoke={canRevoke}
approvePending={false}
revokePending={false}
onClose={vi.fn()}
onApprove={onApprove}
onReject={onReject}
onRevoke={onRevoke}
onOpenAttachment={onOpenAttachment}
{...callbacks}
/>,
);
return { onApprove, onReject, onRevoke, onOpenAttachment };
return callbacks;
}
function section(name: string): HTMLElement {
return screen.getByRole("region", { name });
}
function fieldValue(container: HTMLElement, label: string): string {
const labelNode = within(container).getByText(label);
return labelNode.parentElement?.lastElementChild?.textContent ?? "";
}
describe("UpliftDetailModal", () => {
beforeEach(() => {
canApproveState.data = true;
boardDetail.data = {
info: { woNumber: "WO-99", site: "Site A", status: "Open", dueDate: "" },
};
woUplifts.data = [
{ id: 1, status: "auto_approved", amount: 100 },
{ id: 2, status: "approved", amount: 250 },
];
});
it("shows work order and request placeholders for a pending uplift", () => {
it("organizes content into Work order and Uplift request divided by a single rule", () => {
renderModal();
expect(screen.getByText("Unscheduled")).toBeInTheDocument();
expect(screen.getByText("No justification provided.")).toBeInTheDocument();
expect(screen.getByText("No attachments")).toBeInTheDocument();
const dialog = screen.getByRole("dialog");
const regions = within(dialog).getAllByRole("region");
expect(regions.map((region) => region.getAttribute("aria-label"))).toEqual([
"Work order",
"Uplift request",
]);
expect(within(dialog).getAllByRole("separator")).toHaveLength(1);
expect(within(dialog).queryByText("Request")).not.toBeInTheDocument();
});
it("opens an evidence attachment through the provided callback", () => {
it("renders the work order fields from the queue item", () => {
renderModal({
technicianName: "Tom Tech",
workOrderDispatcherName: "Dana Ruiz",
workOrderScheduledDate: "2026-04-10T00:00:00",
});
const workOrder = section("Work order");
expect(fieldValue(workOrder, "Service")).toBe("Plumbing repair");
expect(fieldValue(workOrder, "Vendor / Technician")).toBe("Gateway Plumbing · Tom Tech");
expect(fieldValue(workOrder, "Assigned Dispatcher")).toBe("Dana Ruiz");
expect(fieldValue(workOrder, "Scheduled")).toBe("Apr 10, 2026");
});
it("shows explicit placeholders for an empty work order, never a blank field", () => {
renderModal({ serviceName: "", vendorCompanyName: "", technicianName: "" });
const workOrder = section("Work order");
expect(fieldValue(workOrder, "Service")).toBe("Not specified");
expect(fieldValue(workOrder, "Vendor / Technician")).toBe("Not assigned");
expect(fieldValue(workOrder, "Assigned Dispatcher")).toBe("Unassigned");
expect(fieldValue(workOrder, "Scheduled")).toBe("Unscheduled");
});
it("renders requester, request date and time, and justification in a bordered block", () => {
renderModal({ vendorReason: "Scope grew after inspection" });
const request = section("Uplift request");
expect(fieldValue(request, "Requested By")).toBe("Gateway");
// The instant renders in the viewer's zone, so the calendar day is derived, not fixed.
const localDay = new Date(baseItem.requestedAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
expect(fieldValue(request, "Requested At")).toMatch(
new RegExp(`^${localDay} · \\d{1,2}:\\d{2} [AP]M$`, "u"),
);
expect(screen.getByTestId("uplift-justification")).toHaveTextContent(
"Scope grew after inspection",
);
});
it("shows explicit request placeholders when requester, date, notes and files are missing", () => {
renderModal({ requestedByVendorName: "", requestedAt: "" });
const request = section("Uplift request");
expect(fieldValue(request, "Requested By")).toBe("Unknown requester");
expect(fieldValue(request, "Requested At")).toBe("Not recorded");
const placeholder = within(screen.getByTestId("uplift-justification")).getByText(
"No justification provided.",
);
expect(placeholder).toHaveClass("italic");
expect(within(request).getByText("No attachments")).toBeInTheDocument();
});
it("renders attachments as a horizontal scrolling chip row that opens the file", () => {
const { onOpenAttachment } = renderModal({
evidenceDocumentId: "document-1",
evidenceFileName: "quote.pdf",
attachmentCount: 1,
attachmentCount: 3,
});
fireEvent.click(screen.getByText("quote.pdf"));
const row = screen.getByTestId("uplift-attachments");
expect(row).toHaveStyle({ overflowX: "auto", flexWrap: "nowrap" });
expect(within(row).getByText("+2")).toBeInTheDocument();
fireEvent.click(within(row).getByText("quote.pdf"));
expect(onOpenAttachment).toHaveBeenCalledWith(
expect.objectContaining({ evidenceDocumentId: "document-1" }),
);
});
it("falls back to the work order uplifts for the approved-on-WO breakdown", () => {
it("shows the approved-on-WO exposure breakdown inside the uplift request", () => {
renderModal();
expect(screen.getByText("Auto-approved")).toBeInTheDocument();
expect(screen.getByText("Admin-approved")).toBeInTheDocument();
expect(screen.getByText("$100.00")).toBeInTheDocument();
expect(screen.getByText("$250.00")).toBeInTheDocument();
expect(screen.getByText("$350.00")).toBeInTheDocument();
const request = section("Uplift request");
expect(within(request).getByText("Approved on WO")).toBeInTheDocument();
expect(within(request).getByText("Auto-approved (within allowance)")).toBeInTheDocument();
expect(within(request).getByText("Admin-approved")).toBeInTheDocument();
expect(within(request).getByText("$100.00")).toBeInTheDocument();
expect(within(request).getByText("$350.00")).toBeInTheDocument();
});
it("offers status-specific actions for a pending uplift", () => {
it("offers Reject then Approve for a pending uplift", () => {
const { onApprove, onReject } = renderModal();
const footerButtons = screen
.getAllByRole("button")
.map((button) => button.textContent)
.filter((label) => label === "Reject" || label === "Approve" || label === "Revoke");
expect(footerButtons).toEqual(["Reject", "Approve"]);
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
expect(onApprove).toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
expect(onReject).toHaveBeenCalled();
});
@ -148,12 +220,17 @@ describe("UpliftDetailModal", () => {
expect(onReject).not.toHaveBeenCalled();
});
it("offers Revoke for an approved uplift and disables it on a closed work order", () => {
it("offers Revoke for an approved uplift and disables it with a tooltip on a closed work order", async () => {
const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: true });
expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument();
const revoke = screen.getByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();
fireEvent.mouseOver(revoke);
expect(
await screen.findByText("This work order is closed. Uplifts can no longer be revoked."),
).toBeInTheDocument();
fireEvent.click(revoke);
expect(onRevoke).not.toHaveBeenCalled();
});
@ -169,6 +246,26 @@ describe("UpliftDetailModal", () => {
expect(onRevoke).not.toHaveBeenCalled();
});
it.each(["Rejected", "Withdrawn"])("is a read-only record with no actions when %s", (status) => {
renderModal({ status });
for (const name of ["Approve", "Reject", "Revoke"]) {
expect(screen.queryByRole("button", { name })).not.toBeInTheDocument();
}
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
});
it("closes from the header X without triggering any decision", () => {
const callbacks = renderModal();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
expect(callbacks.onApprove).not.toHaveBeenCalled();
expect(callbacks.onReject).not.toHaveBeenCalled();
expect(callbacks.onRevoke).not.toHaveBeenCalled();
});
it("does not render a heading nested inside the dialog title heading", () => {
renderModal();

View file

@ -42,6 +42,9 @@ const pendingItem: UpliftQueueItem = {
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,

View file

@ -42,4 +42,53 @@ describe("upliftsApi", () => {
await expect(upliftsApi.canApprove(2)).resolves.toBe(false);
});
describe("openEvidence", () => {
function evidenceResponse(body: string, contentType: string): Response {
return new Response(body, {
headers: {
"Content-Type": contentType,
"Content-Disposition": 'attachment; filename="quote.pdf"',
},
});
}
function fakeTab() {
return { location: { href: "about:blank" }, close: vi.fn() };
}
beforeEach(() => {
URL.createObjectURL = vi.fn(() => "blob:evidence");
URL.revokeObjectURL = vi.fn();
});
it("renders an inert evidence type in the tab opened by the click", async () => {
apiRequestRaw.mockResolvedValueOnce(evidenceResponse("%PDF", "application/pdf"));
const tab = fakeTab();
await upliftsApi.openEvidence(7, tab as unknown as Window);
expect(apiRequestRaw).toHaveBeenCalledWith(
"get",
"uplifts/7/evidence",
undefined,
"upliftsApi.openEvidence",
);
expect(tab.location.href).toBe("blob:evidence");
expect(tab.close).not.toHaveBeenCalled();
});
it("downloads a type the browser could execute instead of rendering it on the app origin", async () => {
apiRequestRaw.mockResolvedValueOnce(evidenceResponse("<svg/>", "image/svg+xml"));
const tab = fakeTab();
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
await upliftsApi.openEvidence(8, tab as unknown as Window);
expect(tab.location.href).toBe("about:blank");
expect(tab.close).toHaveBeenCalled();
expect(click).toHaveBeenCalled();
click.mockRestore();
});
});
});

View file

@ -108,9 +108,25 @@ describe("mapUpliftQueueItem", () => {
expect(result.workOrderClosed).toBe(false);
});
it("maps the detail modal work-order context from the queue contract", () => {
const result = mapUpliftQueueItem({
id: 4,
TechnicianName: "Tom Tech",
WorkOrderDispatcherName: "Dana Ruiz",
WorkOrderScheduledDate: "2026-04-10T09:30:00",
});
expect(result.technicianName).toBe("Tom Tech");
expect(result.workOrderDispatcherName).toBe("Dana Ruiz");
expect(result.workOrderScheduledDate).toBe("2026-04-10T09:30:00");
});
it("tolerates absent optional queue fields", () => {
const result = mapUpliftQueueItem({ id: 3 });
expect(result.technicianName).toBe("");
expect(result.workOrderDispatcherName).toBe("");
expect(result.workOrderScheduledDate).toBe("");
expect(result.woNumber).toBe("");
expect(result.site).toBe("");
expect(result.serviceName).toBe("");