mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 05:43:12 +00:00
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:
commit
cbfed3dde8
13 changed files with 629 additions and 328 deletions
|
|
@ -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" });
|
||||||
|
}
|
||||||
|
|
@ -2,12 +2,27 @@ import { toast } from "react-toastify";
|
||||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||||
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
|
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 {
|
export function openUpliftEvidence(row: UpliftQueueItem): void {
|
||||||
void upliftsApi
|
void upliftsApi
|
||||||
.downloadEvidence(row.id, row.evidenceFileName || "uplift-evidence")
|
.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) => {
|
.catch((error: unknown) => {
|
||||||
toast.error(
|
tab?.close();
|
||||||
error instanceof Error ? error.message : "Unable to download evidence right now.",
|
reportEvidenceError(error);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useMemo, type ReactNode } from "react";
|
import { useMemo, type ReactNode } from "react";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
|
@ -7,21 +8,30 @@ import {
|
||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
Divider,
|
||||||
|
IconButton,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mui/material";
|
} 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 { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
||||||
import { Text } from "@/components/ui/text";
|
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 { useWorkOrderUplifts } from "@/domain/work-orders/use-cases/use-work-order-uplifts";
|
||||||
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
|
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 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 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 ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
|
||||||
const UNAVAILABLE_LABEL = "Unavailable";
|
const UNAVAILABLE_LABEL = "Unavailable";
|
||||||
|
const BORDERED_BLOCK_SX = {
|
||||||
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
borderRadius: 1,
|
||||||
|
px: 1.75,
|
||||||
|
py: 1.25,
|
||||||
|
} as const;
|
||||||
|
|
||||||
type ModalCallbacks = {
|
type ModalCallbacks = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -31,24 +41,122 @@ type ModalCallbacks = {
|
||||||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ExposureBreakdown = { auto: number | null; admin: number | null; total: number | null };
|
||||||
|
|
||||||
function DetailField({ label, children }: { label: string; children: ReactNode }) {
|
function DetailField({ label, children }: { label: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Text variant="label" tone="muted" className="block">
|
<Text variant="label" tone="muted" className="block">
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
{children}
|
<Text variant="body">{children}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
|
function SubsectionTitle({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Text variant="label" as="h4" className="mb-1.5 mt-4 block">
|
||||||
<Text variant="heading" className="mb-1 block">
|
{children}
|
||||||
{title}
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</Text>
|
||||||
<Box sx={{ display: "grid", gap: 1.5 }}>{children}</Box>
|
<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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -67,142 +175,61 @@ function UpliftAttachments({
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const canOpen = item.evidenceDocumentId != null;
|
||||||
const extraCount =
|
const extraCount =
|
||||||
typeof item.attachmentCount === "number" && item.attachmentCount > 1
|
typeof item.attachmentCount === "number" && item.attachmentCount > 1
|
||||||
? item.attachmentCount - 1
|
? item.attachmentCount - 1
|
||||||
: 0;
|
: 0;
|
||||||
return (
|
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
|
<Chip
|
||||||
size="small"
|
variant="outlined"
|
||||||
label={item.evidenceFileName || "Attachment"}
|
label={item.evidenceFileName || "Attachment"}
|
||||||
onClick={item.evidenceDocumentId != null ? () => onOpenAttachment(item) : undefined}
|
title={canOpen ? "Opens in a new tab" : undefined}
|
||||||
clickable={item.evidenceDocumentId != null}
|
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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkOrderSection({
|
function UpliftRequestSection({
|
||||||
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({
|
|
||||||
item,
|
item,
|
||||||
|
breakdown,
|
||||||
|
breakdownUnavailable,
|
||||||
onOpenAttachment,
|
onOpenAttachment,
|
||||||
}: {
|
}: {
|
||||||
item: UpliftQueueItem;
|
item: UpliftQueueItem;
|
||||||
|
breakdown: ExposureBreakdown;
|
||||||
|
breakdownUnavailable: boolean;
|
||||||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||||
}) {
|
}) {
|
||||||
const waitingClass = item.requestedAt
|
|
||||||
? getWaitTimeTextClass(waitTimeColor(item.requestedAt))
|
|
||||||
: undefined;
|
|
||||||
return (
|
return (
|
||||||
<DetailSection title="Request">
|
<Box component="section" aria-label="Uplift request">
|
||||||
<DetailField label="Amount">
|
<Text variant="heading" className="mb-2 block">
|
||||||
<Text variant="body">
|
Uplift request
|
||||||
{formatUpliftMoney(item.currentNTE ?? 0)} → {formatUpliftMoney(item.requestedNTE)}
|
|
||||||
</Text>
|
|
||||||
</DetailField>
|
|
||||||
<DetailField label="Requested by">
|
|
||||||
<Text variant="body">{item.requestedByVendorName || "—"}</Text>
|
|
||||||
</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>
|
</Text>
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.75 }}>
|
||||||
|
<DetailField label="Requested By">
|
||||||
|
{item.requestedByVendorName || "Unknown requester"}
|
||||||
|
</DetailField>
|
||||||
|
<DetailField label="Requested At">
|
||||||
|
{formatUpliftDateTime(item.requestedAt) || "Not recorded"}
|
||||||
|
</DetailField>
|
||||||
|
</Box>
|
||||||
|
<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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -223,6 +250,19 @@ function PendingModalActions({
|
||||||
const tooltip = canDecide ? "" : `Requires Tier ${item.requiredTier} role`;
|
const tooltip = canDecide ? "" : `Requires Tier ${item.requiredTier} role`;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Tooltip title={tooltip}>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
disabled={!canDecide}
|
||||||
|
onClick={() => onReject(item)}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip title={tooltip}>
|
<Tooltip title={tooltip}>
|
||||||
<span>
|
<span>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -236,73 +276,61 @@ function PendingModalActions({
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</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,
|
item,
|
||||||
closed,
|
|
||||||
canRevoke,
|
canRevoke,
|
||||||
approvePending,
|
|
||||||
revokePending,
|
revokePending,
|
||||||
onApprove,
|
|
||||||
onReject,
|
|
||||||
onRevoke,
|
onRevoke,
|
||||||
}: {
|
}: {
|
||||||
item: UpliftQueueItem;
|
item: UpliftQueueItem;
|
||||||
closed: boolean;
|
|
||||||
canRevoke: boolean;
|
canRevoke: boolean;
|
||||||
approvePending: boolean;
|
|
||||||
revokePending: boolean;
|
revokePending: boolean;
|
||||||
onApprove: (row: UpliftQueueItem) => void;
|
|
||||||
onReject: (row: UpliftQueueItem) => void;
|
|
||||||
onRevoke: (row: UpliftQueueItem) => void;
|
onRevoke: (row: UpliftQueueItem) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const closed = item.workOrderClosed === true;
|
||||||
const revokeTooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : "";
|
const revokeTooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : "";
|
||||||
return (
|
return (
|
||||||
<>
|
<Tooltip title={revokeTooltip}>
|
||||||
{item.status === "Pending" && (
|
<span>
|
||||||
<PendingModalActions
|
<Button
|
||||||
item={item}
|
size="small"
|
||||||
approvePending={approvePending}
|
variant="outlined"
|
||||||
onApprove={onApprove}
|
color="error"
|
||||||
onReject={onReject}
|
disabled={!canRevoke || closed || item.workOrderId == null || revokePending}
|
||||||
/>
|
onClick={() => onRevoke(item)}
|
||||||
)}
|
>
|
||||||
{item.status === "Approved" && (
|
Revoke
|
||||||
<Tooltip title={revokeTooltip}>
|
</Button>
|
||||||
<span>
|
</span>
|
||||||
<Button
|
</Tooltip>
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
disabled={!canRevoke || closed || item.workOrderId == null || revokePending}
|
|
||||||
onClick={() => onRevoke(item)}
|
|
||||||
>
|
|
||||||
Revoke
|
|
||||||
</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,
|
item,
|
||||||
canRevoke,
|
canRevoke,
|
||||||
approvePending,
|
approvePending,
|
||||||
|
|
@ -312,98 +340,102 @@ export function UpliftDetailModal({
|
||||||
onReject,
|
onReject,
|
||||||
onRevoke,
|
onRevoke,
|
||||||
onOpenAttachment,
|
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;
|
item: UpliftQueueItem | null;
|
||||||
canRevoke: boolean;
|
canRevoke: boolean;
|
||||||
approvePending: boolean;
|
approvePending: boolean;
|
||||||
revokePending: boolean;
|
revokePending: boolean;
|
||||||
} & ModalCallbacks) {
|
} & ModalCallbacks) {
|
||||||
const open = item != null;
|
if (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) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog open onClose={rest.onClose} maxWidth="sm" fullWidth aria-label="Uplift request details">
|
||||||
open={open}
|
<UpliftDetailContent item={item} {...rest} />
|
||||||
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>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ import {
|
||||||
} from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
|
} from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
|
||||||
import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs";
|
import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs";
|
||||||
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
|
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 { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
||||||
import { isAdminUser } from "@/lib/auth/user-utils";
|
import { isAdminUser } from "@/lib/auth/user-utils";
|
||||||
import { useAuthContext } from "@/providers/auth-context";
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
|
|
@ -181,7 +184,7 @@ export default function UpliftQueuePage() {
|
||||||
onApprove={handleApprove}
|
onApprove={handleApprove}
|
||||||
onReject={handleRejectRequest}
|
onReject={handleRejectRequest}
|
||||||
onRevoke={handleRevokeRequest}
|
onRevoke={handleRevokeRequest}
|
||||||
onOpenAttachment={openUpliftEvidence}
|
onOpenAttachment={openUpliftEvidenceInNewTab}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<UpliftDecisionDialogs controller={controller} />
|
<UpliftDecisionDialogs controller={controller} />
|
||||||
|
|
|
||||||
|
|
@ -106,28 +106,61 @@ export const upliftsApi = {
|
||||||
id: string | number,
|
id: string | number,
|
||||||
fallbackFileName = "uplift-evidence",
|
fallbackFileName = "uplift-evidence",
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
let response: Response;
|
const response = await fetchEvidence(id, "upliftsApi.downloadEvidence");
|
||||||
try {
|
|
||||||
response = await apiRequestRaw(
|
|
||||||
"get",
|
|
||||||
evidenceUrl(id),
|
|
||||||
undefined,
|
|
||||||
"upliftsApi.downloadEvidence",
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof HTTPError) {
|
|
||||||
throw evidenceHttpError(error.response.status);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const fileName = readContentDispositionFilename(response, fallbackFileName);
|
saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
|
||||||
const url = URL.createObjectURL(blob);
|
},
|
||||||
const anchor = window.document.createElement("a");
|
|
||||||
anchor.href = url;
|
/**
|
||||||
anchor.download = fileName;
|
* Renders the evidence in `tab`, which the caller opens synchronously on click so
|
||||||
anchor.click();
|
* popup blockers allow it. Only inert types render inline on the app origin; any
|
||||||
URL.revokeObjectURL(url);
|
* 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 {
|
||||||
|
return await apiRequestRaw("get", evidenceUrl(id), undefined, operation);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HTTPError) {
|
||||||
|
throw evidenceHttpError(error.response.status);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,9 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
|
||||||
"trade",
|
"trade",
|
||||||
"Trade",
|
"Trade",
|
||||||
),
|
),
|
||||||
|
technicianName: readString(item, "technicianName", "TechnicianName"),
|
||||||
|
workOrderDispatcherName: readString(item, "workOrderDispatcherName", "WorkOrderDispatcherName"),
|
||||||
|
workOrderScheduledDate: readString(item, "workOrderScheduledDate", "WorkOrderScheduledDate"),
|
||||||
attachmentCount: readNumber(
|
attachmentCount: readNumber(
|
||||||
item,
|
item,
|
||||||
"attachmentCount",
|
"attachmentCount",
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ export interface UpliftQueueItem extends UpliftRequest {
|
||||||
woNumber: string;
|
woNumber: string;
|
||||||
site: string;
|
site: string;
|
||||||
serviceName: string;
|
serviceName: string;
|
||||||
|
technicianName: string;
|
||||||
|
workOrderDispatcherName: string;
|
||||||
|
workOrderScheduledDate: string;
|
||||||
attachmentCount: number | null;
|
attachmentCount: number | null;
|
||||||
approvedOnWoAuto: number | null;
|
approvedOnWoAuto: number | null;
|
||||||
approvedOnWoAdmin: number | null;
|
approvedOnWoAdmin: number | null;
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,9 @@ const approvedItem: UpliftQueueItem = {
|
||||||
woNumber: "WO-55",
|
woNumber: "WO-55",
|
||||||
site: "Site B",
|
site: "Site B",
|
||||||
serviceName: "HVAC service",
|
serviceName: "HVAC service",
|
||||||
|
technicianName: "",
|
||||||
|
workOrderDispatcherName: "",
|
||||||
|
workOrderScheduledDate: "",
|
||||||
attachmentCount: 3,
|
attachmentCount: 3,
|
||||||
approvedOnWoAuto: 100,
|
approvedOnWoAuto: 100,
|
||||||
approvedOnWoAdmin: 400,
|
approvedOnWoAdmin: 400,
|
||||||
|
|
|
||||||
|
|
@ -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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
|
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
|
||||||
import { ApiError } from "@/api/api-error";
|
import { ApiError } from "@/api/api-error";
|
||||||
|
|
@ -80,6 +80,9 @@ const dispatcherItem: UpliftQueueItem = {
|
||||||
woNumber: "WO-99",
|
woNumber: "WO-99",
|
||||||
site: "Site A",
|
site: "Site A",
|
||||||
serviceName: "Plumbing repair",
|
serviceName: "Plumbing repair",
|
||||||
|
technicianName: "Tom Tech",
|
||||||
|
workOrderDispatcherName: "Dana Ruiz",
|
||||||
|
workOrderScheduledDate: "2026-04-10T00:00:00",
|
||||||
attachmentCount: null,
|
attachmentCount: null,
|
||||||
approvedOnWoAuto: null,
|
approvedOnWoAuto: null,
|
||||||
approvedOnWoAdmin: null,
|
approvedOnWoAdmin: null,
|
||||||
|
|
@ -113,17 +116,31 @@ describe("UpliftDetailModal permission failures (Dispatcher 403)", () => {
|
||||||
woUpliftsMock.calls = 0;
|
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();
|
renderWithGovernedClient();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(boardDetailMock.calls).toBeGreaterThan(0);
|
|
||||||
expect(woUpliftsMock.calls).toBeGreaterThan(0);
|
expect(woUpliftsMock.calls).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
it("marks the approved-on-WO breakdown unavailable without a toast and keeps Revoke admin-only", async () => {
|
||||||
expect(screen.getAllByText("Unavailable").length).toBeGreaterThanOrEqual(3);
|
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" });
|
const revoke = screen.getByRole("button", { name: "Revoke" });
|
||||||
expect(revoke).toBeDisabled();
|
expect(revoke).toBeDisabled();
|
||||||
|
|
|
||||||
|
|
@ -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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
|
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
|
||||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
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 }));
|
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", () => ({
|
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 }));
|
const canApproveState = vi.hoisted(() => ({ data: true as boolean | undefined }));
|
||||||
|
|
@ -28,7 +23,7 @@ const baseItem: UpliftQueueItem = {
|
||||||
requestedNTE: 250,
|
requestedNTE: 250,
|
||||||
delta: 150,
|
delta: 150,
|
||||||
vendorReason: "",
|
vendorReason: "",
|
||||||
requestedAt: "2026-01-15T10:00:00Z",
|
requestedAt: "2026-01-15T15:00:00Z",
|
||||||
requestedByVendorName: "Gateway",
|
requestedByVendorName: "Gateway",
|
||||||
decidedAt: "",
|
decidedAt: "",
|
||||||
decidedByName: "",
|
decidedByName: "",
|
||||||
|
|
@ -50,6 +45,9 @@ const baseItem: UpliftQueueItem = {
|
||||||
woNumber: "WO-99",
|
woNumber: "WO-99",
|
||||||
site: "Site A",
|
site: "Site A",
|
||||||
serviceName: "Plumbing repair",
|
serviceName: "Plumbing repair",
|
||||||
|
technicianName: "",
|
||||||
|
workOrderDispatcherName: "",
|
||||||
|
workOrderScheduledDate: "",
|
||||||
attachmentCount: null,
|
attachmentCount: null,
|
||||||
approvedOnWoAuto: null,
|
approvedOnWoAuto: null,
|
||||||
approvedOnWoAdmin: null,
|
approvedOnWoAdmin: null,
|
||||||
|
|
@ -58,76 +56,150 @@ const baseItem: UpliftQueueItem = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderModal(overrides: Partial<UpliftQueueItem> = {}, canRevoke = true) {
|
function renderModal(overrides: Partial<UpliftQueueItem> = {}, canRevoke = true) {
|
||||||
const onApprove = vi.fn();
|
const callbacks = {
|
||||||
const onReject = vi.fn();
|
onClose: vi.fn(),
|
||||||
const onRevoke = vi.fn();
|
onApprove: vi.fn(),
|
||||||
const onOpenAttachment = vi.fn();
|
onReject: vi.fn(),
|
||||||
|
onRevoke: vi.fn(),
|
||||||
|
onOpenAttachment: vi.fn(),
|
||||||
|
};
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<UpliftDetailModal
|
<UpliftDetailModal
|
||||||
item={{ ...baseItem, ...overrides }}
|
item={{ ...baseItem, ...overrides }}
|
||||||
canRevoke={canRevoke}
|
canRevoke={canRevoke}
|
||||||
approvePending={false}
|
approvePending={false}
|
||||||
revokePending={false}
|
revokePending={false}
|
||||||
onClose={vi.fn()}
|
{...callbacks}
|
||||||
onApprove={onApprove}
|
|
||||||
onReject={onReject}
|
|
||||||
onRevoke={onRevoke}
|
|
||||||
onOpenAttachment={onOpenAttachment}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
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", () => {
|
describe("UpliftDetailModal", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
canApproveState.data = true;
|
canApproveState.data = true;
|
||||||
boardDetail.data = {
|
|
||||||
info: { woNumber: "WO-99", site: "Site A", status: "Open", dueDate: "" },
|
|
||||||
};
|
|
||||||
woUplifts.data = [
|
woUplifts.data = [
|
||||||
{ id: 1, status: "auto_approved", amount: 100 },
|
{ id: 1, status: "auto_approved", amount: 100 },
|
||||||
{ id: 2, status: "approved", amount: 250 },
|
{ 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();
|
renderModal();
|
||||||
|
|
||||||
expect(screen.getByText("Unscheduled")).toBeInTheDocument();
|
const dialog = screen.getByRole("dialog");
|
||||||
expect(screen.getByText("No justification provided.")).toBeInTheDocument();
|
const regions = within(dialog).getAllByRole("region");
|
||||||
expect(screen.getByText("No attachments")).toBeInTheDocument();
|
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({
|
const { onOpenAttachment } = renderModal({
|
||||||
evidenceDocumentId: "document-1",
|
evidenceDocumentId: "document-1",
|
||||||
evidenceFileName: "quote.pdf",
|
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(onOpenAttachment).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ evidenceDocumentId: "document-1" }),
|
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();
|
renderModal();
|
||||||
|
|
||||||
expect(screen.getByText("Auto-approved")).toBeInTheDocument();
|
const request = section("Uplift request");
|
||||||
expect(screen.getByText("Admin-approved")).toBeInTheDocument();
|
expect(within(request).getByText("Approved on WO")).toBeInTheDocument();
|
||||||
expect(screen.getByText("$100.00")).toBeInTheDocument();
|
expect(within(request).getByText("Auto-approved (within allowance)")).toBeInTheDocument();
|
||||||
expect(screen.getByText("$250.00")).toBeInTheDocument();
|
expect(within(request).getByText("Admin-approved")).toBeInTheDocument();
|
||||||
expect(screen.getByText("$350.00")).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 { 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" }));
|
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
|
||||||
expect(onApprove).toHaveBeenCalled();
|
expect(onApprove).toHaveBeenCalled();
|
||||||
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
|
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
|
||||||
expect(onReject).toHaveBeenCalled();
|
expect(onReject).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
@ -148,12 +220,17 @@ describe("UpliftDetailModal", () => {
|
||||||
expect(onReject).not.toHaveBeenCalled();
|
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 });
|
const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: true });
|
||||||
|
|
||||||
expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument();
|
||||||
const revoke = screen.getByRole("button", { name: "Revoke" });
|
const revoke = screen.getByRole("button", { name: "Revoke" });
|
||||||
expect(revoke).toBeDisabled();
|
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);
|
fireEvent.click(revoke);
|
||||||
expect(onRevoke).not.toHaveBeenCalled();
|
expect(onRevoke).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
@ -169,6 +246,26 @@ describe("UpliftDetailModal", () => {
|
||||||
expect(onRevoke).not.toHaveBeenCalled();
|
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", () => {
|
it("does not render a heading nested inside the dialog title heading", () => {
|
||||||
renderModal();
|
renderModal();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,9 @@ const pendingItem: UpliftQueueItem = {
|
||||||
woNumber: "WO-99",
|
woNumber: "WO-99",
|
||||||
site: "Site A",
|
site: "Site A",
|
||||||
serviceName: "Plumbing repair",
|
serviceName: "Plumbing repair",
|
||||||
|
technicianName: "",
|
||||||
|
workOrderDispatcherName: "",
|
||||||
|
workOrderScheduledDate: "",
|
||||||
attachmentCount: null,
|
attachmentCount: null,
|
||||||
approvedOnWoAuto: null,
|
approvedOnWoAuto: null,
|
||||||
approvedOnWoAdmin: null,
|
approvedOnWoAdmin: null,
|
||||||
|
|
|
||||||
|
|
@ -42,4 +42,53 @@ describe("upliftsApi", () => {
|
||||||
|
|
||||||
await expect(upliftsApi.canApprove(2)).resolves.toBe(false);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -108,9 +108,25 @@ describe("mapUpliftQueueItem", () => {
|
||||||
expect(result.workOrderClosed).toBe(false);
|
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", () => {
|
it("tolerates absent optional queue fields", () => {
|
||||||
const result = mapUpliftQueueItem({ id: 3 });
|
const result = mapUpliftQueueItem({ id: 3 });
|
||||||
|
|
||||||
|
expect(result.technicianName).toBe("");
|
||||||
|
expect(result.workOrderDispatcherName).toBe("");
|
||||||
|
expect(result.workOrderScheduledDate).toBe("");
|
||||||
expect(result.woNumber).toBe("");
|
expect(result.woNumber).toBe("");
|
||||||
expect(result.site).toBe("");
|
expect(result.site).toBe("");
|
||||||
expect(result.serviceName).toBe("");
|
expect(result.serviceName).toBe("");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue