mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 04:12:09 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx # src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx
This commit is contained in:
commit
06f70c193d
30 changed files with 821 additions and 200 deletions
|
|
@ -23,6 +23,8 @@ const BODY_CELL_SX = { fontSize: 13, padding: "8px 12px" } as const;
|
||||||
type CompletionTemplatesTableProps = {
|
type CompletionTemplatesTableProps = {
|
||||||
templates: CompletionTemplate[];
|
templates: CompletionTemplate[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
/** The load failed: the page shows the error, so there is no search result to explain. */
|
||||||
|
hasError: boolean;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
onView: (template: CompletionTemplate) => void;
|
onView: (template: CompletionTemplate) => void;
|
||||||
onEdit: (template: CompletionTemplate) => void;
|
onEdit: (template: CompletionTemplate) => void;
|
||||||
|
|
@ -79,7 +81,7 @@ function TemplateRow({
|
||||||
canEdit,
|
canEdit,
|
||||||
onView,
|
onView,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: Omit<CompletionTemplatesTableProps, "templates" | "isLoading"> & {
|
}: Omit<CompletionTemplatesTableProps, "templates" | "isLoading" | "hasError"> & {
|
||||||
template: CompletionTemplate;
|
template: CompletionTemplate;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -120,6 +122,7 @@ function TemplateRow({
|
||||||
export function CompletionTemplatesTable({
|
export function CompletionTemplatesTable({
|
||||||
templates,
|
templates,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
hasError,
|
||||||
canEdit,
|
canEdit,
|
||||||
onView,
|
onView,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
|
@ -143,7 +146,7 @@ export function CompletionTemplatesTable({
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading && <TableLoadingRow colSpan={4} />}
|
{isLoading && <TableLoadingRow colSpan={4} />}
|
||||||
{!isLoading && templates.length === 0 && <EmptyRow />}
|
{!isLoading && !hasError && templates.length === 0 && <EmptyRow />}
|
||||||
{templates.map((template) => (
|
{templates.map((template) => (
|
||||||
<TemplateRow
|
<TemplateRow
|
||||||
key={template.id}
|
key={template.id}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ export default function CompletionDocsPage() {
|
||||||
<CompletionTemplatesTable
|
<CompletionTemplatesTable
|
||||||
templates={templates}
|
templates={templates}
|
||||||
isLoading={filtered.isPending}
|
isLoading={filtered.isPending}
|
||||||
|
hasError={filtered.error !== null}
|
||||||
canEdit={permissions.canEdit}
|
canEdit={permissions.canEdit}
|
||||||
onView={(template) => setDialog({ template, mode: "view" })}
|
onView={(template) => setDialog({ template, mode: "view" })}
|
||||||
onEdit={(template) => setDialog({ template, mode: "edit" })}
|
onEdit={(template) => setDialog({ template, mode: "edit" })}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
export const UPLIFT_APPROVAL_TABS = ["pending", "approved", "rejected"] as const;
|
||||||
|
export type UpliftApprovalTab = (typeof UPLIFT_APPROVAL_TABS)[number];
|
||||||
|
|
||||||
|
export function isUpliftApprovalTab(value: string): value is UpliftApprovalTab {
|
||||||
|
return (UPLIFT_APPROVAL_TABS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PENDING_HEADER_CELLS = [
|
||||||
|
"Work Order",
|
||||||
|
"Service / Vendor",
|
||||||
|
"Amount",
|
||||||
|
"Approved on WO",
|
||||||
|
"Notes",
|
||||||
|
"Attachments",
|
||||||
|
"Requested By",
|
||||||
|
"Waiting",
|
||||||
|
"Actions",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const APPROVED_HEADER_CELLS = [
|
||||||
|
"Work Order",
|
||||||
|
"Service / Vendor",
|
||||||
|
"Amount",
|
||||||
|
"Approved on WO",
|
||||||
|
"Notes",
|
||||||
|
"Attachments",
|
||||||
|
"Requested By",
|
||||||
|
"Approved By",
|
||||||
|
"Approved At",
|
||||||
|
"Actions",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// A rejection is terminal, so the archive ends with the decision and its reason
|
||||||
|
// instead of an Actions column.
|
||||||
|
const REJECTED_HEADER_CELLS = [
|
||||||
|
"Work Order",
|
||||||
|
"Service / Vendor",
|
||||||
|
"Amount",
|
||||||
|
"Approved on WO",
|
||||||
|
"Notes",
|
||||||
|
"Attachments",
|
||||||
|
"Requested By",
|
||||||
|
"Rejected By",
|
||||||
|
"Rejected At",
|
||||||
|
"Rejection Reason",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const UPLIFT_APPROVAL_HEADER_CELLS: Record<UpliftApprovalTab, readonly string[]> = {
|
||||||
|
pending: PENDING_HEADER_CELLS,
|
||||||
|
approved: APPROVED_HEADER_CELLS,
|
||||||
|
rejected: REJECTED_HEADER_CELLS,
|
||||||
|
};
|
||||||
|
|
@ -16,14 +16,20 @@ import {
|
||||||
formatUpliftMoney,
|
formatUpliftMoney,
|
||||||
isGrantedAmount,
|
isGrantedAmount,
|
||||||
} from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
} from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
||||||
|
import {
|
||||||
|
UPLIFT_APPROVAL_HEADER_CELLS,
|
||||||
|
type UpliftApprovalTab,
|
||||||
|
} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs";
|
||||||
|
import {
|
||||||
|
RejectedEmptyState,
|
||||||
|
RejectedRowCells,
|
||||||
|
} from "@/app/(protected)/uplifts/_components/uplift-rejected-cells";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
|
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
|
||||||
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
|
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
|
||||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||||
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||||
|
|
||||||
export type UpliftApprovalTab = "pending" | "approved";
|
|
||||||
|
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -236,65 +242,84 @@ function UpliftApprovalRow({
|
||||||
<AttachmentsCell row={row} onOpenAttachment={onOpenAttachment} />
|
<AttachmentsCell row={row} onOpenAttachment={onOpenAttachment} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.requestedByVendorName || "—"}</TableCell>
|
<TableCell>{row.requestedByVendorName || "—"}</TableCell>
|
||||||
{tab === "pending" ? (
|
{tab === "pending" && (
|
||||||
<TableCell>
|
<>
|
||||||
<WaitingCell row={row} />
|
<TableCell>
|
||||||
</TableCell>
|
<WaitingCell row={row} />
|
||||||
) : (
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<PendingRowActions
|
||||||
|
row={row}
|
||||||
|
onApprove={onApprove}
|
||||||
|
onReject={onReject}
|
||||||
|
isDecisionPending={isDecisionPending}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tab === "approved" && (
|
||||||
<>
|
<>
|
||||||
<TableCell>{row.decidedByName || "—"}</TableCell>
|
<TableCell>{row.decidedByName || "—"}</TableCell>
|
||||||
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
|
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ApprovedRowActions
|
||||||
|
row={row}
|
||||||
|
canRevoke={canRevoke}
|
||||||
|
onRevoke={onRevoke}
|
||||||
|
isDecisionPending={isDecisionPending}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<TableCell>
|
{tab === "rejected" && <RejectedRowCells row={row} />}
|
||||||
{tab === "pending" ? (
|
</TableRow>
|
||||||
<PendingRowActions
|
);
|
||||||
row={row}
|
}
|
||||||
onApprove={onApprove}
|
|
||||||
onReject={onReject}
|
function UpliftApprovalsEmptyState({ tab }: { tab: UpliftApprovalTab }) {
|
||||||
isDecisionPending={isDecisionPending}
|
if (tab === "rejected") return <RejectedEmptyState />;
|
||||||
/>
|
if (tab === "approved") {
|
||||||
) : (
|
return (
|
||||||
<ApprovedRowActions
|
<Box>
|
||||||
row={row}
|
<Text variant="description">No approved uplifts yet</Text>
|
||||||
canRevoke={canRevoke}
|
<Text variant="caption" tone="muted">
|
||||||
onRevoke={onRevoke}
|
Uplifts you approve are archived here for reference.
|
||||||
isDecisionPending={isDecisionPending}
|
</Text>
|
||||||
/>
|
</Box>
|
||||||
)}
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text variant="description" tone="muted">
|
||||||
|
No uplifts waiting for approval.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed queue is not an empty one: the page's error alert explains it, so the
|
||||||
|
// empty-state copy must not also claim the list has nothing in it.
|
||||||
|
function UpliftApprovalsEmptyRow({
|
||||||
|
tab,
|
||||||
|
colSpan,
|
||||||
|
hasError,
|
||||||
|
}: {
|
||||||
|
tab: UpliftApprovalTab;
|
||||||
|
colSpan: number;
|
||||||
|
hasError: boolean;
|
||||||
|
}) {
|
||||||
|
if (hasError) return null;
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={colSpan} align="center" className="py-10">
|
||||||
|
<UpliftApprovalsEmptyState tab={tab} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PENDING_HEADER_CELLS = [
|
|
||||||
"Work Order",
|
|
||||||
"Service / Vendor",
|
|
||||||
"Amount",
|
|
||||||
"Approved on WO",
|
|
||||||
"Notes",
|
|
||||||
"Attachments",
|
|
||||||
"Requested By",
|
|
||||||
"Waiting",
|
|
||||||
"Actions",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const APPROVED_HEADER_CELLS = [
|
|
||||||
"Work Order",
|
|
||||||
"Service / Vendor",
|
|
||||||
"Amount",
|
|
||||||
"Approved on WO",
|
|
||||||
"Notes",
|
|
||||||
"Attachments",
|
|
||||||
"Requested By",
|
|
||||||
"Approved By",
|
|
||||||
"Approved At",
|
|
||||||
"Actions",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function UpliftApprovalsTable({
|
export function UpliftApprovalsTable({
|
||||||
tab,
|
tab,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
hasError,
|
||||||
rows,
|
rows,
|
||||||
canRevoke,
|
canRevoke,
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
|
|
@ -306,6 +331,7 @@ export function UpliftApprovalsTable({
|
||||||
}: {
|
}: {
|
||||||
tab: UpliftApprovalTab;
|
tab: UpliftApprovalTab;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
hasError: boolean;
|
||||||
rows: UpliftQueueItem[];
|
rows: UpliftQueueItem[];
|
||||||
canRevoke: boolean;
|
canRevoke: boolean;
|
||||||
onOpenDetail: (row: UpliftQueueItem) => void;
|
onOpenDetail: (row: UpliftQueueItem) => void;
|
||||||
|
|
@ -315,7 +341,7 @@ export function UpliftApprovalsTable({
|
||||||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||||
isDecisionPending: boolean;
|
isDecisionPending: boolean;
|
||||||
}) {
|
}) {
|
||||||
const headerCells = tab === "pending" ? PENDING_HEADER_CELLS : APPROVED_HEADER_CELLS;
|
const headerCells = UPLIFT_APPROVAL_HEADER_CELLS[tab];
|
||||||
return (
|
return (
|
||||||
<TableContainer component={Paper} variant="outlined">
|
<TableContainer component={Paper} variant="outlined">
|
||||||
<Table size="small" aria-label={`${tab} uplift requests`}>
|
<Table size="small" aria-label={`${tab} uplift requests`}>
|
||||||
|
|
@ -336,22 +362,7 @@ export function UpliftApprovalsTable({
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<TableRow>
|
<UpliftApprovalsEmptyRow tab={tab} colSpan={headerCells.length} hasError={hasError} />
|
||||||
<TableCell colSpan={headerCells.length} align="center" className="py-10">
|
|
||||||
{tab === "pending" ? (
|
|
||||||
<Text variant="description" tone="muted">
|
|
||||||
No uplifts waiting for approval.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Box>
|
|
||||||
<Text variant="description">No approved uplifts yet</Text>
|
|
||||||
<Text variant="caption" tone="muted">
|
|
||||||
Uplifts you approve are archived here for reference.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
) : (
|
||||||
rows.map((row) => (
|
rows.map((row) => (
|
||||||
<UpliftApprovalRow
|
<UpliftApprovalRow
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import HighlightOffOutlinedIcon from "@mui/icons-material/HighlightOffOutlined";
|
||||||
|
import { Box, TableCell } from "@mui/material";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||||
|
import { formatDateTime } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
export function RejectedRowCells({ row }: { row: UpliftQueueItem }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableCell>{row.decidedByName || "—"}</TableCell>
|
||||||
|
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
|
||||||
|
<TableCell sx={{ maxWidth: 260 }}>
|
||||||
|
<Text variant="caption" sx={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{row.decisionNote || "—"}
|
||||||
|
</Text>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RejectedEmptyState() {
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col items-center gap-2">
|
||||||
|
<HighlightOffOutlinedIcon color="disabled" aria-hidden />
|
||||||
|
<Text variant="description">No rejected uplifts.</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
|
import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
|
||||||
import {
|
import {
|
||||||
UpliftApprovalsTable,
|
UPLIFT_APPROVAL_TABS,
|
||||||
|
isUpliftApprovalTab,
|
||||||
type UpliftApprovalTab,
|
type UpliftApprovalTab,
|
||||||
} from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
|
} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs";
|
||||||
|
import { UpliftApprovalsTable } 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 {
|
import {
|
||||||
|
|
@ -16,6 +18,7 @@ import { useAuthContext } from "@/providers/auth-context";
|
||||||
import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
|
import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
|
||||||
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
|
@ -39,58 +42,71 @@ function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal:
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TAB_LABELS: Record<UpliftApprovalTab, string> = {
|
||||||
|
pending: "Pending",
|
||||||
|
approved: "Approved",
|
||||||
|
rejected: "Rejected",
|
||||||
|
};
|
||||||
|
|
||||||
|
// A tab's total is only known once its queue has loaded at least once; until then the
|
||||||
|
// pill and pager must not claim a count (a loading or failed queue is not an empty one).
|
||||||
|
// A failed refetch keeps the last loaded page, so its total stays usable.
|
||||||
|
function knownTotal(query: { data?: UpliftsQueueResult | undefined }): number | null {
|
||||||
|
return query.data ? query.data.totalCount : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabCountPill({ tab, count }: { tab: UpliftApprovalTab; count: number | null }) {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={count ?? "—"}
|
||||||
|
aria-label={count == null ? `${tab} uplift count unavailable` : `${count} ${tab} uplifts`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function UpliftApprovalsTabs({
|
function UpliftApprovalsTabs({
|
||||||
tab,
|
tab,
|
||||||
pendingCount,
|
counts,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
tab: UpliftApprovalTab;
|
tab: UpliftApprovalTab;
|
||||||
pendingCount: number;
|
counts: Record<UpliftApprovalTab, number | null>;
|
||||||
onChange: (_event: unknown, value: string) => void;
|
onChange: (_event: unknown, value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Tabs value={tab} onChange={onChange}>
|
<Tabs value={tab} onChange={onChange}>
|
||||||
<Tab
|
{UPLIFT_APPROVAL_TABS.map((value) => (
|
||||||
value="pending"
|
<Tab
|
||||||
label={
|
key={value}
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
value={value}
|
||||||
<span>Pending</span>
|
label={
|
||||||
<Chip
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
size="small"
|
<span>{TAB_LABELS[value]}</span>
|
||||||
label={pendingCount}
|
<TabCountPill tab={value} count={counts[value]} />
|
||||||
aria-label={`${pendingCount} pending uplifts`}
|
</Stack>
|
||||||
/>
|
}
|
||||||
</Stack>
|
/>
|
||||||
}
|
))}
|
||||||
/>
|
|
||||||
<Tab value="approved" label="Approved" />
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UpliftApprovalsPagination({
|
function UpliftApprovalsPagination({
|
||||||
tab,
|
|
||||||
count,
|
count,
|
||||||
pendingPage,
|
page,
|
||||||
approvedPage,
|
onPageChange,
|
||||||
onPendingPageChange,
|
|
||||||
onApprovedPageChange,
|
|
||||||
}: {
|
}: {
|
||||||
tab: UpliftApprovalTab;
|
|
||||||
count: number;
|
count: number;
|
||||||
pendingPage: number;
|
page: number;
|
||||||
approvedPage: number;
|
onPageChange: (page: number) => void;
|
||||||
onPendingPageChange: (page: number) => void;
|
|
||||||
onApprovedPageChange: (page: number) => void;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<TablePagination
|
<TablePagination
|
||||||
component="div"
|
component="div"
|
||||||
count={count}
|
count={count}
|
||||||
page={tab === "pending" ? pendingPage : approvedPage}
|
page={page}
|
||||||
onPageChange={(_event, nextPage) =>
|
onPageChange={(_event, nextPage) => onPageChange(nextPage)}
|
||||||
tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage)
|
|
||||||
}
|
|
||||||
rowsPerPage={PAGE_SIZE}
|
rowsPerPage={PAGE_SIZE}
|
||||||
rowsPerPageOptions={[PAGE_SIZE]}
|
rowsPerPageOptions={[PAGE_SIZE]}
|
||||||
/>
|
/>
|
||||||
|
|
@ -108,22 +124,31 @@ function UpliftQueueError({ error }: { error: unknown }) {
|
||||||
|
|
||||||
export default function UpliftQueuePage() {
|
export default function UpliftQueuePage() {
|
||||||
const [tab, setTab] = useState<UpliftApprovalTab>("pending");
|
const [tab, setTab] = useState<UpliftApprovalTab>("pending");
|
||||||
const [pendingPage, setPendingPage] = useState(0);
|
const [pages, setPages] = useState<Record<UpliftApprovalTab, number>>({
|
||||||
const [approvedPage, setApprovedPage] = useState(0);
|
pending: 0,
|
||||||
|
approved: 0,
|
||||||
|
rejected: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const { user } = useAuthContext();
|
const { user } = useAuthContext();
|
||||||
const canRevokeUplifts = isAdminUser(user?.userRoles);
|
const canRevokeUplifts = isAdminUser(user?.userRoles);
|
||||||
|
|
||||||
const pendingQuery = useUpliftsQueue({
|
const pendingQuery = useUpliftsQueue({
|
||||||
status: "Pending",
|
status: "Pending",
|
||||||
page: pendingPage + 1,
|
page: pages.pending + 1,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
const approvedQuery = useUpliftsQueue({
|
const approvedQuery = useUpliftsQueue({
|
||||||
status: "Approved",
|
status: "Approved",
|
||||||
page: approvedPage + 1,
|
page: pages.approved + 1,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
|
const rejectedQuery = useUpliftsQueue({
|
||||||
|
status: "Rejected",
|
||||||
|
page: pages.rejected + 1,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
});
|
||||||
|
const queries = { pending: pendingQuery, approved: approvedQuery, rejected: rejectedQuery };
|
||||||
|
|
||||||
const controller = useUpliftApprovalController();
|
const controller = useUpliftApprovalController();
|
||||||
const {
|
const {
|
||||||
|
|
@ -138,13 +163,18 @@ export default function UpliftQueuePage() {
|
||||||
openDetail,
|
openDetail,
|
||||||
} = controller;
|
} = controller;
|
||||||
|
|
||||||
const activeQuery = tab === "pending" ? pendingQuery : approvedQuery;
|
const activeQuery = queries[tab];
|
||||||
const rows = activeQuery.data?.items ?? [];
|
const rows = activeQuery.data?.items ?? [];
|
||||||
const pendingCount = pendingQuery.data?.totalCount ?? 0;
|
const counts = {
|
||||||
|
pending: knownTotal(pendingQuery),
|
||||||
|
approved: knownTotal(approvedQuery),
|
||||||
|
rejected: knownTotal(rejectedQuery),
|
||||||
|
};
|
||||||
|
const activeTotal = counts[tab];
|
||||||
const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null;
|
const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null;
|
||||||
|
|
||||||
const handleTabChange = (_event: unknown, value: string) => {
|
const handleTabChange = (_event: unknown, value: string) => {
|
||||||
if (value === "pending" || value === "approved") {
|
if (isUpliftApprovalTab(value)) {
|
||||||
setTab(value);
|
setTab(value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -152,11 +182,12 @@ export default function UpliftQueuePage() {
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
|
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
|
||||||
<UpliftApprovalsTabs tab={tab} pendingCount={pendingCount} onChange={handleTabChange} />
|
<UpliftApprovalsTabs tab={tab} counts={counts} onChange={handleTabChange} />
|
||||||
<UpliftQueueError error={activeQuery.error} />
|
<UpliftQueueError error={activeQuery.error} />
|
||||||
<UpliftApprovalsTable
|
<UpliftApprovalsTable
|
||||||
tab={tab}
|
tab={tab}
|
||||||
isLoading={activeQuery.isLoading}
|
isLoading={activeQuery.isLoading}
|
||||||
|
hasError={Boolean(activeQuery.error)}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
canRevoke={canRevokeUplifts}
|
canRevoke={canRevokeUplifts}
|
||||||
onOpenDetail={openDetail}
|
onOpenDetail={openDetail}
|
||||||
|
|
@ -166,14 +197,13 @@ export default function UpliftQueuePage() {
|
||||||
onOpenAttachment={openUpliftEvidence}
|
onOpenAttachment={openUpliftEvidence}
|
||||||
isDecisionPending={approvePending || rejectPending || revokePending}
|
isDecisionPending={approvePending || rejectPending || revokePending}
|
||||||
/>
|
/>
|
||||||
<UpliftApprovalsPagination
|
{activeTotal != null && (
|
||||||
tab={tab}
|
<UpliftApprovalsPagination
|
||||||
count={activeQuery.data?.totalCount ?? 0}
|
count={activeTotal}
|
||||||
pendingPage={pendingPage}
|
page={pages[tab]}
|
||||||
approvedPage={approvedPage}
|
onPageChange={(page) => setPages((current) => ({ ...current, [tab]: page }))}
|
||||||
onPendingPageChange={setPendingPage}
|
/>
|
||||||
onApprovedPageChange={setApprovedPage}
|
)}
|
||||||
/>
|
|
||||||
{detailItem != null && (
|
{detailItem != null && (
|
||||||
<UpliftDetailModal
|
<UpliftDetailModal
|
||||||
item={detailItem}
|
item={detailItem}
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,11 @@ export function AdvancedSearchFilterSections({
|
||||||
/>
|
/>
|
||||||
</AdvSection>
|
</AdvSection>
|
||||||
|
|
||||||
<AdvSection icon={CheckCircle2} label="Status" count={draft.statuses.size}>
|
<AdvSection
|
||||||
|
icon={CheckCircle2}
|
||||||
|
label="Status"
|
||||||
|
count={draft.statuses.size + (draft.pastDue ? 1 : 0)}
|
||||||
|
>
|
||||||
{selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
|
{selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{ALL_WIZARD_STATUSES.map((status) => (
|
{ALL_WIZARD_STATUSES.map((status) => (
|
||||||
|
|
@ -122,6 +126,14 @@ export function AdvancedSearchFilterSections({
|
||||||
onClick={() => toggleIn("statuses", status)}
|
onClick={() => toggleIn("statuses", status)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
<AdvStatusPill
|
||||||
|
label="Past Due"
|
||||||
|
dotColor="var(--status-pastdue-text)"
|
||||||
|
selected={draft.pastDue}
|
||||||
|
selectedBg="var(--status-pastdue-bg)"
|
||||||
|
selectedText="var(--status-pastdue-text)"
|
||||||
|
onClick={() => setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AdvSection>
|
</AdvSection>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,13 @@ const SERVICE_WORK_ORDER_TYPE_API = {
|
||||||
Reactive: 6,
|
Reactive: 6,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const TYPE_TO_API: Record<Exclude<WOType, "Overdue">, number> = {
|
// The registry is keyed on PM, Reactive and Emergency; Overdue is PM work the
|
||||||
|
// client never confirmed, so it lists the PM catalog.
|
||||||
|
const TYPE_TO_API: Record<WOType, number> = {
|
||||||
PM: WORK_ORDER_TYPE_API.PM,
|
PM: WORK_ORDER_TYPE_API.PM,
|
||||||
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
||||||
Emergency: WORK_ORDER_TYPE_API.Emergency,
|
Emergency: WORK_ORDER_TYPE_API.Emergency,
|
||||||
|
Overdue: WORK_ORDER_TYPE_API.PM,
|
||||||
};
|
};
|
||||||
|
|
||||||
function toApiInput(input: ServiceInput) {
|
function toApiInput(input: ServiceInput) {
|
||||||
|
|
@ -51,7 +54,6 @@ export const servicesApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
getActiveForWorkOrderType: async (workOrderType: WOType): Promise<Service[]> => {
|
getActiveForWorkOrderType: async (workOrderType: WOType): Promise<Service[]> => {
|
||||||
if (workOrderType === "Overdue") return [];
|
|
||||||
const data = await apiGet<unknown>(API_PATHS.services.list, {
|
const data = await apiGet<unknown>(API_PATHS.services.list, {
|
||||||
searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] },
|
searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ export function useServicesForWorkOrderType(
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.services.byWorkOrderType(effectiveType),
|
queryKey: queryKeys.services.byWorkOrderType(effectiveType),
|
||||||
queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType),
|
queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType),
|
||||||
enabled: effectiveType !== "Overdue",
|
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,8 @@ export function useUpliftsQueue(
|
||||||
queryKey: queryKeys.uplifts.queue(params as Record<string, unknown>),
|
queryKey: queryKeys.uplifts.queue(params as Record<string, unknown>),
|
||||||
queryFn: () => upliftsApi.list(params),
|
queryFn: () => upliftsApi.list(params),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
// The Uplift Approvals page reports a failed queue inline, so the global toast
|
||||||
|
// would announce the same failure twice.
|
||||||
|
meta: { suppressErrorToast: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
|
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import { typeNeedsSeverity } from "@/domain/work-orders/types/work-order-severity";
|
||||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
|
import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
|
||||||
|
|
||||||
|
|
@ -64,7 +65,7 @@ export function assignWizardServicePocFields(
|
||||||
payload.vendorNotes = vendorNotes;
|
payload.vendorNotes = vendorNotes;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (draft.severity != null) {
|
if (draft.severity != null && typeNeedsSeverity(draft.type)) {
|
||||||
payload.severity = draft.severity;
|
payload.severity = draft.severity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ const WO_TYPE_TO_API: Record<WOType, WorkOrderTypeApi> = {
|
||||||
|
|
||||||
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
||||||
|
|
||||||
Overdue: WORK_ORDER_TYPE_API.Other,
|
Overdue: WORK_ORDER_TYPE_API.Overdue,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Leading token of a location label, e.g. `BK5` from `BK5 · Dallas` or `CHI-1042` from `CHI-1042 — Chicago`. */
|
/** Leading token of a location label, e.g. `BK5` from `BK5 · Dallas` or `CHI-1042` from `CHI-1042 — Chicago`. */
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ export interface WorkOrderAdvancedFilters {
|
||||||
dispatchers: Set<string>;
|
dispatchers: Set<string>;
|
||||||
regions: Set<string>;
|
regions: Set<string>;
|
||||||
statuses: Set<WizardWOStatus>;
|
statuses: Set<WizardWOStatus>;
|
||||||
|
/** Past Due overlay (any type); narrows the other filters, independent of the Overdue type. */
|
||||||
|
pastDue: boolean;
|
||||||
rescheduled: boolean;
|
rescheduled: boolean;
|
||||||
carriedOver: boolean;
|
carriedOver: boolean;
|
||||||
addOn: boolean;
|
addOn: boolean;
|
||||||
|
|
@ -61,6 +63,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
||||||
dispatchers: new Set(),
|
dispatchers: new Set(),
|
||||||
regions: new Set(),
|
regions: new Set(),
|
||||||
statuses: new Set(),
|
statuses: new Set(),
|
||||||
|
pastDue: false,
|
||||||
rescheduled: false,
|
rescheduled: false,
|
||||||
carriedOver: false,
|
carriedOver: false,
|
||||||
addOn: false,
|
addOn: false,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export const WORK_ORDER_TYPE_API = {
|
||||||
Inspection: 5,
|
Inspection: 5,
|
||||||
Reactive: 6,
|
Reactive: 6,
|
||||||
AddOn: 7,
|
AddOn: 7,
|
||||||
|
Overdue: 8,
|
||||||
Other: 99,
|
Other: 99,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
@ -64,6 +65,7 @@ export const WORK_ORDER_TYPE_API_TO_FE: Record<number, WOType | ""> = {
|
||||||
[WORK_ORDER_TYPE_API.PM]: "PM",
|
[WORK_ORDER_TYPE_API.PM]: "PM",
|
||||||
[WORK_ORDER_TYPE_API.Reactive]: "Reactive",
|
[WORK_ORDER_TYPE_API.Reactive]: "Reactive",
|
||||||
[WORK_ORDER_TYPE_API.Emergency]: "Emergency",
|
[WORK_ORDER_TYPE_API.Emergency]: "Emergency",
|
||||||
|
[WORK_ORDER_TYPE_API.Overdue]: "Overdue",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LIFECYCLE_STATUS_API_TO_FE: Record<number, WizardWOStatus | "Canceled"> = {
|
export const LIFECYCLE_STATUS_API_TO_FE: Record<number, WizardWOStatus | "Canceled"> = {
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,15 @@ export function appendStatusPmVendorDocChips(
|
||||||
clear: (current) => ({ ...current, statuses: new Set() }),
|
clear: (current) => ({ ...current, statuses: new Set() }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (filters.pastDue) {
|
||||||
|
chips.push({
|
||||||
|
key: "pastdue",
|
||||||
|
group: "Status",
|
||||||
|
value: "Past Due",
|
||||||
|
clear: (current) => ({ ...current, pastDue: false }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pushSetChip({
|
pushSetChip({
|
||||||
chips,
|
chips,
|
||||||
key: "pm",
|
key: "pm",
|
||||||
|
|
|
||||||
|
|
@ -45,12 +45,10 @@ const matchesDateRange: AdvancedFilterPredicate = (row, filters) => {
|
||||||
const matchesSite: AdvancedFilterPredicate = (row, filters) =>
|
const matchesSite: AdvancedFilterPredicate = (row, filters) =>
|
||||||
!filters.sites.size || filters.sites.has(row.site);
|
!filters.sites.size || filters.sites.has(row.site);
|
||||||
|
|
||||||
const matchesType: AdvancedFilterPredicate = (row, filters) => {
|
const matchesType: AdvancedFilterPredicate = (row, filters) =>
|
||||||
if (!filters.types.size) return true;
|
!filters.types.size || filters.types.has(row.type as never);
|
||||||
const overdueMatch = filters.types.has("Overdue") && row.isPastDue;
|
|
||||||
const typeMatch = filters.types.has(row.type as never);
|
const matchesPastDue: AdvancedFilterPredicate = (row, filters) => !filters.pastDue || row.isPastDue;
|
||||||
return overdueMatch || typeMatch;
|
|
||||||
};
|
|
||||||
|
|
||||||
const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => {
|
const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => {
|
||||||
if (!filters.dispatchers.size) return true;
|
if (!filters.dispatchers.size) return true;
|
||||||
|
|
@ -95,6 +93,7 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
|
||||||
matchesType,
|
matchesType,
|
||||||
matchesDispatcher,
|
matchesDispatcher,
|
||||||
matchesStatus,
|
matchesStatus,
|
||||||
|
matchesPastDue,
|
||||||
matchesPmType,
|
matchesPmType,
|
||||||
matchesVendorTech,
|
matchesVendorTech,
|
||||||
matchesDoc,
|
matchesDoc,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte
|
||||||
upliftStatuses: new Set(filters.upliftStatuses ?? []),
|
upliftStatuses: new Set(filters.upliftStatuses ?? []),
|
||||||
workOrderIds: new Set(filters.workOrderIds ?? []),
|
workOrderIds: new Set(filters.workOrderIds ?? []),
|
||||||
unscheduledOnly: Boolean(filters.unscheduledOnly),
|
unscheduledOnly: Boolean(filters.unscheduledOnly),
|
||||||
|
pastDue: Boolean(filters.pastDue),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,6 +48,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
|
||||||
Boolean(filters.dispatchers?.size),
|
Boolean(filters.dispatchers?.size),
|
||||||
Boolean(filters.regions?.size),
|
Boolean(filters.regions?.size),
|
||||||
Boolean(filters.statuses?.size),
|
Boolean(filters.statuses?.size),
|
||||||
|
Boolean(filters.pastDue),
|
||||||
filters.rescheduled,
|
filters.rescheduled,
|
||||||
filters.carriedOver,
|
filters.carriedOver,
|
||||||
filters.addOn,
|
filters.addOn,
|
||||||
|
|
|
||||||
|
|
@ -42,30 +42,19 @@ function mapDispatchers(ids: Iterable<string>): string[] | undefined {
|
||||||
return list.length > 0 ? list : undefined;
|
return list.length > 0 ? list : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maps UI type filters to API `types` + separate `overdue` flag. Overdue is never sent as types=99. */
|
/** Maps UI type filters to API `types`. The Overdue type is sent as its own enum; past due uses `overdue`. */
|
||||||
function mapWoTypeFilters(types: Iterable<WOType | string>): {
|
function mapWoTypeFilters(types: Iterable<WOType | string>): number[] | undefined {
|
||||||
types?: number[];
|
|
||||||
overdue?: boolean;
|
|
||||||
} {
|
|
||||||
const list: number[] = [];
|
const list: number[] = [];
|
||||||
let overdue = false;
|
|
||||||
for (const type of types) {
|
for (const type of types) {
|
||||||
if (type === "All") {
|
if (type === "All") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (type === "Overdue") {
|
|
||||||
overdue = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const api = mapWizardTypeToApi(type as WOType);
|
const api = mapWizardTypeToApi(type as WOType);
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
list.push(api);
|
list.push(api);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return list.length > 0 ? list : undefined;
|
||||||
types: list.length > 0 ? list : undefined,
|
|
||||||
overdue: overdue || undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapStatuses(statuses: Iterable<WizardWOStatus | string>): number[] | undefined {
|
function mapStatuses(statuses: Iterable<WizardWOStatus | string>): number[] | undefined {
|
||||||
|
|
@ -147,14 +136,11 @@ export function toBoardQueryParams(
|
||||||
search: string,
|
search: string,
|
||||||
): WorkOrderBoardQueryParams {
|
): WorkOrderBoardQueryParams {
|
||||||
const [, weekTo] = weekRange(bar.weekMonday);
|
const [, weekTo] = weekRange(bar.weekMonday);
|
||||||
const typeFilters = bar.types.size > 0 ? mapWoTypeFilters(bar.types) : {};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
weekStart: bar.weekMonday,
|
weekStart: bar.weekMonday,
|
||||||
weekEnd: weekTo,
|
weekEnd: weekTo,
|
||||||
dispatchers: mapDispatchers(bar.dispatcherIds),
|
dispatchers: mapDispatchers(bar.dispatcherIds),
|
||||||
types: typeFilters.types,
|
types: mapWoTypeFilters(bar.types),
|
||||||
overdue: typeFilters.overdue,
|
|
||||||
search: search.trim().length >= 2 ? search.trim() : undefined,
|
search: search.trim().length >= 2 ? search.trim() : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -211,7 +197,6 @@ export function toBoardSearchQueryParams(input: {
|
||||||
BOARD_SEARCH_MAX_PAGE_SIZE,
|
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||||
);
|
);
|
||||||
|
|
||||||
const typeFilters = mapWoTypeFilters(filters.types);
|
|
||||||
const trimmedSearch = input.search?.trim();
|
const trimmedSearch = input.search?.trim();
|
||||||
const search = trimmedSearch && trimmedSearch.length >= 2 ? trimmedSearch : undefined;
|
const search = trimmedSearch && trimmedSearch.length >= 2 ? trimmedSearch : undefined;
|
||||||
|
|
||||||
|
|
@ -224,8 +209,8 @@ export function toBoardSearchQueryParams(input: {
|
||||||
dateTo: date.dateTo,
|
dateTo: date.dateTo,
|
||||||
includeDateless: date.includeDateless,
|
includeDateless: date.includeDateless,
|
||||||
sites: filters.sites.size > 0 ? [...filters.sites] : undefined,
|
sites: filters.sites.size > 0 ? [...filters.sites] : undefined,
|
||||||
types: typeFilters.types,
|
types: mapWoTypeFilters(filters.types),
|
||||||
overdue: typeFilters.overdue,
|
overdue: filters.pastDue || undefined,
|
||||||
dispatchers: mapDispatchers(filters.dispatchers),
|
dispatchers: mapDispatchers(filters.dispatchers),
|
||||||
regions: filters.regions.size > 0 ? [...filters.regions] : undefined,
|
regions: filters.regions.size > 0 ? [...filters.regions] : undefined,
|
||||||
statuses: mapStatuses(filters.statuses),
|
statuses: mapStatuses(filters.statuses),
|
||||||
|
|
@ -261,10 +246,6 @@ export function buildBoardSearchParamsRecord(
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function woTypeFilterIsOverdue(type: string): boolean {
|
|
||||||
return type === "Overdue";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Week-independent GET /board/search params for the pinned Unassigned queue. */
|
/** Week-independent GET /board/search params for the pinned Unassigned queue. */
|
||||||
export function toUnassignedQueueQueryParams(
|
export function toUnassignedQueueQueryParams(
|
||||||
page = 0,
|
page = 0,
|
||||||
|
|
|
||||||
|
|
@ -65,12 +65,7 @@ function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowMatchesBarTypes(row: WorkOrderTableRow, types: Set<WOType>): boolean {
|
function rowMatchesBarTypes(row: WorkOrderTableRow, types: Set<WOType>): boolean {
|
||||||
if (types.size === 0) {
|
return types.size === 0 || types.has(row.type as WOType);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const overdueMatch = types.has("Overdue") && row.isPastDue;
|
|
||||||
const typeMatch = types.has(row.type as WOType);
|
|
||||||
return overdueMatch || typeMatch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterByBarAndSearch(
|
export function filterByBarAndSearch(
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,21 @@ describe("Completion Docs page", { timeout: 15_000 }, () => {
|
||||||
expect(screen.queryByRole("row", { name: /Backflow Test Report/ })).not.toBeInTheDocument();
|
expect(screen.queryByRole("row", { name: /Backflow Test Report/ })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports a failed load without the empty-search message, including after a failed Retry", async () => {
|
||||||
|
api.apiGet.mockRejectedValue(new Error("Templates are unavailable"));
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const retry = await screen.findByRole("button", { name: "Retry" });
|
||||||
|
expect(screen.getByText("Templates are unavailable")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("No templates found")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const callsBeforeRetry = api.apiGet.mock.calls.length;
|
||||||
|
await user.click(retry);
|
||||||
|
await waitFor(() => expect(api.apiGet.mock.calls.length).toBeGreaterThan(callsBeforeRetry));
|
||||||
|
expect(await screen.findByText("Templates are unavailable")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("No templates found")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("opens a read-only detail panel from a row with every part of the template", async () => {
|
it("opens a read-only detail panel from a row with every part of the template", async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
const dialog = await openTemplate("Backflow Test Report");
|
const dialog = await openTemplate("Backflow Test Report");
|
||||||
|
|
|
||||||
|
|
@ -235,12 +235,23 @@ describe("Edit template", { timeout: 15_000 }, () => {
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stays closed when the user closes it while a save is still in flight", async () => {
|
it.each([
|
||||||
let finishSave: (value: unknown) => void = () => undefined;
|
[
|
||||||
api.apiPut.mockImplementationOnce(
|
"Escape",
|
||||||
() =>
|
async () => {
|
||||||
|
// The disabled Save button drops focus; Escape is pressed from inside the form.
|
||||||
|
screen.getByRole("textbox", { name: /Document Title/ }).focus();
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
},
|
||||||
|
],
|
||||||
|
["the Close button", () => user.click(screen.getByRole("button", { name: "Close" }))],
|
||||||
|
])("stays closed when dismissed with %s while a save is in flight", async (_, dismiss) => {
|
||||||
|
let finishSave: () => void = () => {};
|
||||||
|
const respond = api.apiPut.getMockImplementation()!;
|
||||||
|
api.apiPut.mockImplementation(
|
||||||
|
(...args: unknown[]) =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
finishSave = resolve;
|
finishSave = () => resolve(respond(...args));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const dialog = await openTemplate("Backflow Test Report");
|
const dialog = await openTemplate("Backflow Test Report");
|
||||||
|
|
@ -249,9 +260,9 @@ describe("Edit template", { timeout: 15_000 }, () => {
|
||||||
await user.click(within(dialog).getByRole("button", { name: /Save changes/ }));
|
await user.click(within(dialog).getByRole("button", { name: /Save changes/ }));
|
||||||
await waitFor(() => expect(api.apiPut).toHaveBeenCalled());
|
await waitFor(() => expect(api.apiPut).toHaveBeenCalled());
|
||||||
|
|
||||||
await user.click(within(dialog).getByRole("button", { name: "Close" }));
|
await dismiss();
|
||||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||||
finishSave({ ...BACKFLOW, name: "Backflow Test Report v2" });
|
finishSave();
|
||||||
|
|
||||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template updated"));
|
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template updated"));
|
||||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ describe("Uplift Approvals approved tab", () => {
|
||||||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||||
renderWithProviders(<UpliftQueuePage />);
|
renderWithProviders(<UpliftQueuePage />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
|
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
|
||||||
expect(screen.getByRole("columnheader", { name: "Approved By" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "Approved By" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("columnheader", { name: "Approved At" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "Approved At" })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("columnheader", { name: "Waiting" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("columnheader", { name: "Waiting" })).not.toBeInTheDocument();
|
||||||
|
|
@ -125,7 +125,7 @@ describe("Uplift Approvals approved tab", () => {
|
||||||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||||
renderWithProviders(<UpliftQueuePage />);
|
renderWithProviders(<UpliftQueuePage />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
|
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
|
||||||
const revoke = await screen.findByRole("button", { name: "Revoke" });
|
const revoke = await screen.findByRole("button", { name: "Revoke" });
|
||||||
expect(revoke).toBeEnabled();
|
expect(revoke).toBeEnabled();
|
||||||
fireEvent.click(revoke);
|
fireEvent.click(revoke);
|
||||||
|
|
@ -149,7 +149,7 @@ describe("Uplift Approvals approved tab", () => {
|
||||||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||||
renderWithProviders(<UpliftQueuePage />);
|
renderWithProviders(<UpliftQueuePage />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
|
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
|
||||||
const revoke = await screen.findByRole("button", { name: "Revoke" });
|
const revoke = await screen.findByRole("button", { name: "Revoke" });
|
||||||
expect(revoke).toBeDisabled();
|
expect(revoke).toBeDisabled();
|
||||||
fireEvent.mouseOver(revoke);
|
fireEvent.mouseOver(revoke);
|
||||||
|
|
@ -172,7 +172,7 @@ describe("Uplift Approvals approved tab", () => {
|
||||||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||||
renderWithProviders(<UpliftQueuePage />);
|
renderWithProviders(<UpliftQueuePage />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
|
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
|
||||||
expect(await screen.findByText("No approved uplifts yet")).toBeInTheDocument();
|
expect(await screen.findByText("No approved uplifts yet")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Uplifts you approve are archived here for reference."),
|
screen.getByText("Uplifts you approve are archived here for reference."),
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ describe("Uplift Approvals shows one amount per request", () => {
|
||||||
queueState.data = queueOf(approvedItem);
|
queueState.data = queueOf(approvedItem);
|
||||||
await renderQueue();
|
await renderQueue();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
|
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
|
||||||
expect(amountCell()).toHaveTextContent(UPLIFT);
|
expect(amountCell()).toHaveTextContent(UPLIFT);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Revoke" }));
|
fireEvent.click(screen.getByRole("button", { name: "Revoke" }));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
import { fireEvent, screen, within } from "@testing-library/react";
|
||||||
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type {
|
||||||
|
UpliftQueueItem,
|
||||||
|
UpliftsQueueParams,
|
||||||
|
UpliftsQueueResult,
|
||||||
|
} from "@/domain/uplifts/types/uplift";
|
||||||
|
import { formatDateTime } from "@/lib/time-utils";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const queueState = vi.hoisted(() => ({
|
||||||
|
byStatus: {} as Record<string, UpliftsQueueResult | undefined>,
|
||||||
|
errorByStatus: {} as Record<string, Error | undefined>,
|
||||||
|
requestedStatuses: [] as Array<string | undefined>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseItem: UpliftQueueItem = {
|
||||||
|
id: 1,
|
||||||
|
status: "Pending",
|
||||||
|
currentNTE: 500,
|
||||||
|
requestedNTE: 750,
|
||||||
|
delta: 250,
|
||||||
|
vendorReason: "Second hydro-jetting pass on the adjoining line",
|
||||||
|
requestedAt: "2026-06-21T10:05:00Z",
|
||||||
|
requestedByVendorName: "Alyssa Dispatcher",
|
||||||
|
decidedAt: "",
|
||||||
|
decidedByName: "",
|
||||||
|
decisionNote: "",
|
||||||
|
requiredTier: 1,
|
||||||
|
canDecide: true,
|
||||||
|
expiresAt: "",
|
||||||
|
notificationStatus: "",
|
||||||
|
notificationError: "",
|
||||||
|
evidenceDocumentId: "doc-9",
|
||||||
|
evidenceFileName: "quote.pdf",
|
||||||
|
evidenceContentType: "application/pdf",
|
||||||
|
evidenceSizeBytes: 1024,
|
||||||
|
dispatchNumber: "DSP-1",
|
||||||
|
poNumber: "PO-1",
|
||||||
|
vendorCompanyName: "Drain Cleaning Bros",
|
||||||
|
workOrderId: 10,
|
||||||
|
dispatchId: 20,
|
||||||
|
woNumber: "WO-25603",
|
||||||
|
site: "CHI2",
|
||||||
|
serviceName: "Drains",
|
||||||
|
technicianName: "",
|
||||||
|
workOrderDispatcherName: "",
|
||||||
|
workOrderScheduledDate: "",
|
||||||
|
attachmentCount: 1,
|
||||||
|
approvedOnWoAuto: null,
|
||||||
|
approvedOnWoAdmin: null,
|
||||||
|
approvedOnWoTotal: null,
|
||||||
|
workOrderClosed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newestRejection: UpliftQueueItem = {
|
||||||
|
...baseItem,
|
||||||
|
id: 31,
|
||||||
|
status: "Rejected",
|
||||||
|
woNumber: "WO-31",
|
||||||
|
decidedAt: "2026-06-21T16:40:00Z",
|
||||||
|
decidedByName: "Adam Moussa",
|
||||||
|
decisionNote: "Outside this work order's scope — raise a separate reactive WO.",
|
||||||
|
canDecide: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const olderRejection: UpliftQueueItem = {
|
||||||
|
...baseItem,
|
||||||
|
id: 30,
|
||||||
|
status: "Rejected",
|
||||||
|
woNumber: "WO-30",
|
||||||
|
requestedByVendorName: "Derek Dispatcher",
|
||||||
|
decidedAt: "2026-05-26T15:05:00Z",
|
||||||
|
decidedByName: "Grace Admin",
|
||||||
|
decisionNote: "Quote is above market for this scope.",
|
||||||
|
canDecide: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingItem: UpliftQueueItem = { ...baseItem, id: 40, woNumber: "WO-PENDING" };
|
||||||
|
const approvedItem: UpliftQueueItem = {
|
||||||
|
...baseItem,
|
||||||
|
id: 50,
|
||||||
|
status: "Approved",
|
||||||
|
woNumber: "WO-APPROVED",
|
||||||
|
decidedAt: "2026-06-01T09:00:00Z",
|
||||||
|
decidedByName: "Adam Moussa",
|
||||||
|
};
|
||||||
|
|
||||||
|
function page(items: UpliftQueueItem[], totalCount = items.length): UpliftsQueueResult {
|
||||||
|
return { items, totalCount, page: 1, pageSize: 25, pendingExposureTotal: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
|
||||||
|
useUpliftsQueue: (params: UpliftsQueueParams) => {
|
||||||
|
queueState.requestedStatuses.push(params.status);
|
||||||
|
const data = queueState.byStatus[params.status ?? ""];
|
||||||
|
const error = queueState.errorByStatus[params.status ?? ""] ?? null;
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
isLoading: data === undefined && error === null,
|
||||||
|
isFetching: false,
|
||||||
|
error,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
|
||||||
|
useApproveUplift: () => ({ isPending: false, mutate: vi.fn() }),
|
||||||
|
useRejectUplift: () => ({ isPending: false, mutate: vi.fn() }),
|
||||||
|
useRevokeUplift: () => ({ isPending: false, mutate: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/providers/auth-context", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<object>()),
|
||||||
|
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
|
||||||
|
useUpliftsCanApprove: () => ({ data: true }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function openRejectedTab() {
|
||||||
|
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||||
|
renderWithProviders(<UpliftQueuePage />);
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /^Rejected/ }));
|
||||||
|
return screen.getByRole("table", { name: "rejected uplift requests" });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Uplift Approvals rejected tab", () => {
|
||||||
|
// Load the page module once up front so the first test's budget is not spent on a
|
||||||
|
// cold import of the whole route.
|
||||||
|
beforeAll(async () => {
|
||||||
|
await import("@/app/(protected)/uplifts/index");
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queueState.requestedStatuses = [];
|
||||||
|
queueState.errorByStatus = {};
|
||||||
|
queueState.byStatus = {
|
||||||
|
Pending: page([pendingItem]),
|
||||||
|
Approved: page([approvedItem]),
|
||||||
|
Rejected: page([newestRejection, olderRejection], 12),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the rejected total in the tab count pill next to the other tabs' pills", async () => {
|
||||||
|
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||||
|
renderWithProviders(<UpliftQueuePage />);
|
||||||
|
|
||||||
|
const rejectedTab = screen.getByRole("tab", { name: /^Rejected/ });
|
||||||
|
expect(within(rejectedTab).getByLabelText("12 rejected uplifts")).toHaveTextContent("12");
|
||||||
|
expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("1 approved uplifts")).toBeInTheDocument();
|
||||||
|
expect(queueState.requestedStatuses).toContain("Rejected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never claims a count for a queue that is still loading or failed to load", async () => {
|
||||||
|
queueState.byStatus.Rejected = undefined;
|
||||||
|
queueState.errorByStatus.Rejected = new Error("Forbidden");
|
||||||
|
queueState.byStatus.Approved = undefined;
|
||||||
|
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||||
|
renderWithProviders(<UpliftQueuePage />);
|
||||||
|
|
||||||
|
const rejectedPill = screen.getByLabelText("rejected uplift count unavailable");
|
||||||
|
expect(rejectedPill).toHaveTextContent("—");
|
||||||
|
expect(screen.getByLabelText("approved uplift count unavailable")).toHaveTextContent("—");
|
||||||
|
expect(screen.queryByLabelText("0 rejected uplifts")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("0 approved uplifts")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the count and pager for kept rows when a rejected refetch fails", async () => {
|
||||||
|
queueState.errorByStatus.Rejected = new Error("Failed to load uplift queue");
|
||||||
|
const table = await openRejectedTab();
|
||||||
|
|
||||||
|
expect(screen.getByRole("alert")).toHaveTextContent("Failed to load uplift queue");
|
||||||
|
expect(within(table).getAllByRole("row")).toHaveLength(3);
|
||||||
|
expect(screen.getByLabelText("12 rejected uplifts")).toHaveTextContent("12");
|
||||||
|
expect(screen.getByText(/of 12/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the load error instead of an empty archive when the rejected queue fails", async () => {
|
||||||
|
queueState.byStatus.Rejected = undefined;
|
||||||
|
queueState.errorByStatus.Rejected = new Error("Failed to load uplift queue");
|
||||||
|
const table = await openRejectedTab();
|
||||||
|
|
||||||
|
expect(screen.getByRole("alert")).toHaveTextContent("Failed to load uplift queue");
|
||||||
|
expect(within(table).queryByText("No rejected uplifts.")).not.toBeInTheDocument();
|
||||||
|
expect(within(table).queryByTestId("HighlightOffOutlinedIcon")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/of 0/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists only rejected uplifts, in the server's most-recently-rejected order", async () => {
|
||||||
|
const table = await openRejectedTab();
|
||||||
|
|
||||||
|
const dataRows = within(table).getAllByRole("row").slice(1);
|
||||||
|
expect(dataRows).toHaveLength(2);
|
||||||
|
expect(dataRows[0]).toHaveTextContent("WO-31");
|
||||||
|
expect(dataRows[1]).toHaveTextContent("WO-30");
|
||||||
|
expect(within(table).queryByText("WO-PENDING")).not.toBeInTheDocument();
|
||||||
|
expect(within(table).queryByText("WO-APPROVED")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows who requested, who rejected, when, and the rejection reason inline", async () => {
|
||||||
|
const table = await openRejectedTab();
|
||||||
|
|
||||||
|
for (const header of ["Requested By", "Rejected By", "Rejected At", "Rejection Reason"]) {
|
||||||
|
expect(within(table).getByRole("columnheader", { name: header })).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
const [, firstRow] = within(table).getAllByRole("row");
|
||||||
|
expect(firstRow).toHaveTextContent("Alyssa Dispatcher");
|
||||||
|
expect(firstRow).toHaveTextContent("Adam Moussa");
|
||||||
|
expect(firstRow).toHaveTextContent(formatDateTime(newestRejection.decidedAt));
|
||||||
|
expect(
|
||||||
|
within(firstRow).getByText("Outside this work order's scope — raise a separate reactive WO."),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no action on a rejected uplift", async () => {
|
||||||
|
const table = await openRejectedTab();
|
||||||
|
|
||||||
|
expect(within(table).queryByRole("columnheader", { name: "Actions" })).not.toBeInTheDocument();
|
||||||
|
for (const name of [/^Approve/, /^Reject$/, /^Revoke/]) {
|
||||||
|
expect(within(table).queryByRole("button", { name })).not.toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an icon and the empty copy when nothing has been rejected", async () => {
|
||||||
|
queueState.byStatus.Rejected = page([]);
|
||||||
|
const table = await openRejectedTab();
|
||||||
|
|
||||||
|
expect(within(table).getByText("No rejected uplifts.")).toBeInTheDocument();
|
||||||
|
expect(within(table).getByTestId("HighlightOffOutlinedIcon")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("0 rejected uplifts")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
|
||||||
|
import { AdvancedSearchFilterSections } from "@/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections";
|
||||||
|
import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
|
||||||
|
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
|
||||||
|
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
|
||||||
|
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
|
import {
|
||||||
|
EMPTY_ADVANCED_FILTERS,
|
||||||
|
type WorkOrderAdvancedFilters,
|
||||||
|
} from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
|
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import {
|
||||||
|
canContinueWizardStep1,
|
||||||
|
EMPTY_WIZARD_DRAFT,
|
||||||
|
type WorkOrderWizardDraft,
|
||||||
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { buildAdvancedFilterChips } from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||||
|
import { getClosabilityGaps } from "@/domain/work-orders/utils/wo-closability";
|
||||||
|
|
||||||
|
const USERS = [{ id: "sarah", name: "Sarah Chen" }];
|
||||||
|
|
||||||
|
describe("Overdue work order type", () => {
|
||||||
|
it("offers Overdue in the Create WO wizard and never asks for a severity", () => {
|
||||||
|
const onPatch = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<WizardStepTypeSchedule
|
||||||
|
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM" }}
|
||||||
|
users={USERS}
|
||||||
|
onUpdate={vi.fn()}
|
||||||
|
onPatch={onPatch}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /No client response — due date passed/ }));
|
||||||
|
expect(onPatch).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ type: "Overdue", severity: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const overdueDraft: WorkOrderWizardDraft = { ...EMPTY_WIZARD_DRAFT, type: "Overdue" };
|
||||||
|
rerender(
|
||||||
|
<WizardStepTypeSchedule
|
||||||
|
draft={overdueDraft}
|
||||||
|
users={USERS}
|
||||||
|
onUpdate={vi.fn()}
|
||||||
|
onPatch={onPatch}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/Severity \(from APM\)/)).not.toBeInTheDocument();
|
||||||
|
expect(canContinueWizardStep1(overdueDraft)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the work order with the Overdue enum and no severity", () => {
|
||||||
|
const payload = mapWizardDraftToBoardCreatePayload({
|
||||||
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
type: "Overdue",
|
||||||
|
severity: 2,
|
||||||
|
locationId: "1",
|
||||||
|
siteCode: "BK5",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.workOrderType).toBe(WORK_ORDER_TYPE_API.Overdue);
|
||||||
|
expect(payload.severity ?? null).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("corrects a type inline to Overdue without opening the severity dialog", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<TypeCell value="Reactive" severity={3} onSave={onSave} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Type of WO, Reactive" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: /^Overdue$/ }));
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith("Overdue", null);
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the Overdue enum back from the board as the Overdue type", () => {
|
||||||
|
const row = mapWorkOrderTableRow({ id: 1, workOrderType: WORK_ORDER_TYPE_API.Overdue });
|
||||||
|
expect(row.type).toBe("Overdue");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes with the completion document, not photos", () => {
|
||||||
|
const base = {
|
||||||
|
id: "1",
|
||||||
|
dispatcherId: "sarah",
|
||||||
|
dispatcherName: "Sarah Chen",
|
||||||
|
company: "Gateway Plumbing",
|
||||||
|
vendorId: "",
|
||||||
|
tech: "",
|
||||||
|
completedDate: "2026-09-24",
|
||||||
|
woNumber: "24971",
|
||||||
|
pm: "Backflow Preventers",
|
||||||
|
mediaCount: 0,
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "No" as const }),
|
||||||
|
).toEqual(["Completion document"]);
|
||||||
|
expect(
|
||||||
|
getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "Yes" as const }),
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
id: "1",
|
||||||
|
type: "Overdue",
|
||||||
|
pm: "Backflow Preventers",
|
||||||
|
docStatus: "No",
|
||||||
|
rowVersion: "1",
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<SlideOverCompletionTab
|
||||||
|
row={row as WorkOrderTableRow}
|
||||||
|
isReadOnly={false}
|
||||||
|
media={[]}
|
||||||
|
onOpenCompletionDoc={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Completion Document")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /open completion document/i })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a separate Past Due status toggle in the advanced filters", () => {
|
||||||
|
let draft: WorkOrderAdvancedFilters = { ...EMPTY_ADVANCED_FILTERS };
|
||||||
|
const setDraft = vi.fn(
|
||||||
|
(update: (prev: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters) => {
|
||||||
|
draft = update(draft);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<AdvancedSearchFilterSections
|
||||||
|
draft={draft}
|
||||||
|
setDraft={setDraft as never}
|
||||||
|
toggleIn={vi.fn()}
|
||||||
|
setMany={vi.fn()}
|
||||||
|
sites={[]}
|
||||||
|
users={[]}
|
||||||
|
pmTypes={[]}
|
||||||
|
vendorTechs={[]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Status/ }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Past Due/ }));
|
||||||
|
|
||||||
|
expect(draft.pastDue).toBe(true);
|
||||||
|
expect(draft.types.size).toBe(0);
|
||||||
|
expect(buildAdvancedFilterChips(draft)).toContainEqual(
|
||||||
|
expect.objectContaining({ group: "Status", value: "Past Due" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -78,11 +78,14 @@ describe("servicesApi", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not request services for the unsupported Overdue type", async () => {
|
it("lists the PM catalog for the Overdue type", async () => {
|
||||||
const services = await servicesApi.getActiveForWorkOrderType("Overdue");
|
vi.mocked(apiGet).mockResolvedValue([]);
|
||||||
|
|
||||||
expect(apiGet).not.toHaveBeenCalled();
|
await servicesApi.getActiveForWorkOrderType("Overdue");
|
||||||
expect(services).toEqual([]);
|
|
||||||
|
expect(apiGet).toHaveBeenCalledWith(API_PATHS.services.list, {
|
||||||
|
searchParams: { isActive: true, workOrderType: 2 },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates, updates, and deactivates through the registry routes", async () => {
|
it("creates, updates, and deactivates through the registry routes", async () => {
|
||||||
|
|
|
||||||
40
src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx
Normal file
40
src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
|
||||||
|
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
||||||
|
import { createAppQueryClient } from "@/lib/query/query-client";
|
||||||
|
|
||||||
|
const toastMocks = vi.hoisted(() => ({ error: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("react-toastify", () => ({
|
||||||
|
toast: { error: toastMocks.error, success: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
toastMocks.error.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useUpliftsQueue", () => {
|
||||||
|
it("does not show the global error toast, since the approvals page reports the failure inline", async () => {
|
||||||
|
vi.spyOn(upliftsApi, "list").mockRejectedValue(new Error("Failed to load uplift queue"));
|
||||||
|
// The real app query client runs the QueryCache's global onError handler, which
|
||||||
|
// toasts unless the query opts out.
|
||||||
|
const queryClient = createAppQueryClient();
|
||||||
|
queryClient.setDefaultOptions({ queries: { retry: false } });
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useUpliftsQueue({ status: "Rejected", page: 1, pageSize: 25 }),
|
||||||
|
{
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.error).toBeInstanceOf(Error));
|
||||||
|
expect(toastMocks.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -36,7 +36,9 @@ describe("mapWizardTypeToApi", () => {
|
||||||
|
|
||||||
expect(mapWizardTypeToApi("Reactive")).toBe(WORK_ORDER_TYPE_API.Reactive);
|
expect(mapWizardTypeToApi("Reactive")).toBe(WORK_ORDER_TYPE_API.Reactive);
|
||||||
|
|
||||||
expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Other);
|
expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Overdue);
|
||||||
|
|
||||||
|
expect(WORK_ORDER_TYPE_API.Overdue).toBe(8);
|
||||||
|
|
||||||
expect(mapWizardTypeToApi("")).toBeNull();
|
expect(mapWizardTypeToApi("")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -60,16 +60,16 @@ describe("toBoardQueryParams", () => {
|
||||||
expect(params.overdue).toBeUndefined();
|
expect(params.overdue).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps Overdue bar filter to overdue=true without types", () => {
|
it("maps the Overdue bar filter to the Overdue type, not the past-due flag", () => {
|
||||||
const params = toBoardQueryParams({ ...baseBar, types: new Set(["Overdue"]) }, "");
|
const params = toBoardQueryParams({ ...baseBar, types: new Set(["Overdue"]) }, "");
|
||||||
expect(params.overdue).toBe(true);
|
expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]);
|
||||||
expect(params.types).toBeUndefined();
|
expect(params.overdue).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps Overdue with real types as OR (types + overdue)", () => {
|
it("maps Overdue with other types as a plain type union", () => {
|
||||||
const params = toBoardQueryParams({ ...baseBar, types: new Set(["PM", "Overdue"]) }, "");
|
const params = toBoardQueryParams({ ...baseBar, types: new Set(["PM", "Overdue"]) }, "");
|
||||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]);
|
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM, WORK_ORDER_TYPE_API.Overdue]);
|
||||||
expect(params.overdue).toBe(true);
|
expect(params.overdue).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits types when the set is empty", () => {
|
it("omits types when the set is empty", () => {
|
||||||
|
|
@ -222,22 +222,23 @@ describe("toBoardSearchQueryParams", () => {
|
||||||
expect(buildBoardSearchParamsRecord(params)).not.toHaveProperty("unscheduledOnly");
|
expect(buildBoardSearchParamsRecord(params)).not.toHaveProperty("unscheduledOnly");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps Overdue-only advanced filter to overdue=true without types", () => {
|
it("maps the Overdue type advanced filter to types without the past-due flag", () => {
|
||||||
const params = toBoardSearchQueryParams({
|
const params = toBoardSearchQueryParams({
|
||||||
filters: {
|
filters: {
|
||||||
...EMPTY_ADVANCED_FILTERS,
|
...EMPTY_ADVANCED_FILTERS,
|
||||||
types: new Set(["Overdue"]),
|
types: new Set(["Overdue"]),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(params.overdue).toBe(true);
|
expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]);
|
||||||
expect(params.types).toBeUndefined();
|
expect(params.overdue).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps Overdue with real types as OR (types + overdue)", () => {
|
it("maps the Past Due status toggle to overdue=true alongside types", () => {
|
||||||
const params = toBoardSearchQueryParams({
|
const params = toBoardSearchQueryParams({
|
||||||
filters: {
|
filters: {
|
||||||
...EMPTY_ADVANCED_FILTERS,
|
...EMPTY_ADVANCED_FILTERS,
|
||||||
types: new Set(["PM", "Overdue"]),
|
types: new Set(["PM"]),
|
||||||
|
pastDue: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]);
|
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]);
|
||||||
|
|
|
||||||
|
|
@ -172,26 +172,26 @@ describe("filterByBarAndSearch", () => {
|
||||||
expect(ids).toEqual(["em", "rx"]);
|
expect(ids).toEqual(["em", "rx"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches Overdue via isPastDue even when row.type is not Overdue", () => {
|
it("matches the Overdue type only, never a past-due row of another type", () => {
|
||||||
const pastDuePm = makeRow({
|
const pastDuePm = makeRow({
|
||||||
id: "pd",
|
id: "pd",
|
||||||
type: "PM",
|
type: "PM",
|
||||||
isPastDue: true,
|
isPastDue: true,
|
||||||
scheduledOn: "2026-06-03",
|
scheduledOn: "2026-06-03",
|
||||||
});
|
});
|
||||||
const currentPm = makeRow({
|
const overdue = makeRow({
|
||||||
id: "now",
|
id: "ov",
|
||||||
type: "PM",
|
type: "Overdue",
|
||||||
isPastDue: false,
|
isPastDue: false,
|
||||||
scheduledOn: "2026-06-03",
|
scheduledOn: "2026-06-03",
|
||||||
});
|
});
|
||||||
|
|
||||||
const ids = filterByBarAndSearch(
|
const ids = filterByBarAndSearch(
|
||||||
[pastDuePm, currentPm],
|
[pastDuePm, overdue],
|
||||||
bar(weekMonday, { types: new Set(["Overdue"]) }),
|
bar(weekMonday, { types: new Set(["Overdue"]) }),
|
||||||
"",
|
"",
|
||||||
).map((row) => row.id);
|
).map((row) => row.id);
|
||||||
expect(ids).toEqual(["pd"]);
|
expect(ids).toEqual(["ov"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides a row after type correction when the bar filter no longer matches", () => {
|
it("hides a row after type correction when the bar filter no longer matches", () => {
|
||||||
|
|
@ -395,13 +395,8 @@ describe("matchesAdvancedFilters", () => {
|
||||||
expect(matchesAdvancedFilters(row, noMatch)).toBe(false);
|
expect(matchesAdvancedFilters(row, noMatch)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches Overdue when isPastDue is true", () => {
|
it("matches the Overdue type filter by type, independent of isPastDue", () => {
|
||||||
const weekMonday = mondayOf(todayIso());
|
const weekMonday = mondayOf(todayIso());
|
||||||
const pastDue = makeRow({
|
|
||||||
scheduledOn: weekMonday,
|
|
||||||
...deriveDayFields(weekMonday),
|
|
||||||
isPastDue: true,
|
|
||||||
});
|
|
||||||
const filters = {
|
const filters = {
|
||||||
...EMPTY_ADVANCED_FILTERS,
|
...EMPTY_ADVANCED_FILTERS,
|
||||||
dateRange: "custom" as const,
|
dateRange: "custom" as const,
|
||||||
|
|
@ -409,9 +404,39 @@ describe("matchesAdvancedFilters", () => {
|
||||||
customTo: "9999-12-31",
|
customTo: "9999-12-31",
|
||||||
types: new Set(["Overdue" as const]),
|
types: new Set(["Overdue" as const]),
|
||||||
};
|
};
|
||||||
expect(matchesAdvancedFilters(pastDue, filters)).toBe(true);
|
const overdue = makeRow({ type: "Overdue", isPastDue: false, scheduledOn: weekMonday });
|
||||||
|
const pastDuePm = makeRow({ type: "PM", isPastDue: true, scheduledOn: weekMonday });
|
||||||
|
expect(matchesAdvancedFilters(overdue, filters)).toBe(true);
|
||||||
|
expect(matchesAdvancedFilters(pastDuePm, filters)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("narrows to past-due rows with the Past Due status toggle", () => {
|
||||||
|
const weekMonday = mondayOf(todayIso());
|
||||||
|
const filters = {
|
||||||
|
...EMPTY_ADVANCED_FILTERS,
|
||||||
|
dateRange: "custom" as const,
|
||||||
|
customFrom: "0000-01-01",
|
||||||
|
customTo: "9999-12-31",
|
||||||
|
types: new Set(["PM" as const]),
|
||||||
|
pastDue: true,
|
||||||
|
};
|
||||||
expect(
|
expect(
|
||||||
matchesAdvancedFilters(makeRow({ isPastDue: false, scheduledOn: weekMonday }), filters),
|
matchesAdvancedFilters(
|
||||||
|
makeRow({ type: "PM", isPastDue: true, scheduledOn: weekMonday }),
|
||||||
|
filters,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
matchesAdvancedFilters(
|
||||||
|
makeRow({ type: "PM", isPastDue: false, scheduledOn: weekMonday }),
|
||||||
|
filters,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
matchesAdvancedFilters(
|
||||||
|
makeRow({ type: "Overdue", isPastDue: true, scheduledOn: weekMonday }),
|
||||||
|
filters,
|
||||||
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue