mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 08:03:13 +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 = {
|
||||
templates: CompletionTemplate[];
|
||||
isLoading: boolean;
|
||||
/** The load failed: the page shows the error, so there is no search result to explain. */
|
||||
hasError: boolean;
|
||||
canEdit: boolean;
|
||||
onView: (template: CompletionTemplate) => void;
|
||||
onEdit: (template: CompletionTemplate) => void;
|
||||
|
|
@ -79,7 +81,7 @@ function TemplateRow({
|
|||
canEdit,
|
||||
onView,
|
||||
onEdit,
|
||||
}: Omit<CompletionTemplatesTableProps, "templates" | "isLoading"> & {
|
||||
}: Omit<CompletionTemplatesTableProps, "templates" | "isLoading" | "hasError"> & {
|
||||
template: CompletionTemplate;
|
||||
}) {
|
||||
return (
|
||||
|
|
@ -120,6 +122,7 @@ function TemplateRow({
|
|||
export function CompletionTemplatesTable({
|
||||
templates,
|
||||
isLoading,
|
||||
hasError,
|
||||
canEdit,
|
||||
onView,
|
||||
onEdit,
|
||||
|
|
@ -143,7 +146,7 @@ export function CompletionTemplatesTable({
|
|||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading && <TableLoadingRow colSpan={4} />}
|
||||
{!isLoading && templates.length === 0 && <EmptyRow />}
|
||||
{!isLoading && !hasError && templates.length === 0 && <EmptyRow />}
|
||||
{templates.map((template) => (
|
||||
<TemplateRow
|
||||
key={template.id}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ export default function CompletionDocsPage() {
|
|||
<CompletionTemplatesTable
|
||||
templates={templates}
|
||||
isLoading={filtered.isPending}
|
||||
hasError={filtered.error !== null}
|
||||
canEdit={permissions.canEdit}
|
||||
onView={(template) => setDialog({ template, mode: "view" })}
|
||||
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,
|
||||
isGrantedAmount,
|
||||
} 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 { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
|
||||
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
|
||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||
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 ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
|
||||
|
||||
|
|
@ -236,65 +242,84 @@ function UpliftApprovalRow({
|
|||
<AttachmentsCell row={row} onOpenAttachment={onOpenAttachment} />
|
||||
</TableCell>
|
||||
<TableCell>{row.requestedByVendorName || "—"}</TableCell>
|
||||
{tab === "pending" ? (
|
||||
<TableCell>
|
||||
<WaitingCell row={row} />
|
||||
</TableCell>
|
||||
) : (
|
||||
{tab === "pending" && (
|
||||
<>
|
||||
<TableCell>
|
||||
<WaitingCell row={row} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<PendingRowActions
|
||||
row={row}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
isDecisionPending={isDecisionPending}
|
||||
/>
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
{tab === "approved" && (
|
||||
<>
|
||||
<TableCell>{row.decidedByName || "—"}</TableCell>
|
||||
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
|
||||
<TableCell>
|
||||
<ApprovedRowActions
|
||||
row={row}
|
||||
canRevoke={canRevoke}
|
||||
onRevoke={onRevoke}
|
||||
isDecisionPending={isDecisionPending}
|
||||
/>
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
<TableCell>
|
||||
{tab === "pending" ? (
|
||||
<PendingRowActions
|
||||
row={row}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
isDecisionPending={isDecisionPending}
|
||||
/>
|
||||
) : (
|
||||
<ApprovedRowActions
|
||||
row={row}
|
||||
canRevoke={canRevoke}
|
||||
onRevoke={onRevoke}
|
||||
isDecisionPending={isDecisionPending}
|
||||
/>
|
||||
)}
|
||||
{tab === "rejected" && <RejectedRowCells row={row} />}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftApprovalsEmptyState({ tab }: { tab: UpliftApprovalTab }) {
|
||||
if (tab === "rejected") return <RejectedEmptyState />;
|
||||
if (tab === "approved") {
|
||||
return (
|
||||
<Box>
|
||||
<Text variant="description">No approved uplifts yet</Text>
|
||||
<Text variant="caption" tone="muted">
|
||||
Uplifts you approve are archived here for reference.
|
||||
</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>
|
||||
</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({
|
||||
tab,
|
||||
isLoading,
|
||||
hasError,
|
||||
rows,
|
||||
canRevoke,
|
||||
onOpenDetail,
|
||||
|
|
@ -306,6 +331,7 @@ export function UpliftApprovalsTable({
|
|||
}: {
|
||||
tab: UpliftApprovalTab;
|
||||
isLoading: boolean;
|
||||
hasError: boolean;
|
||||
rows: UpliftQueueItem[];
|
||||
canRevoke: boolean;
|
||||
onOpenDetail: (row: UpliftQueueItem) => void;
|
||||
|
|
@ -315,7 +341,7 @@ export function UpliftApprovalsTable({
|
|||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||
isDecisionPending: boolean;
|
||||
}) {
|
||||
const headerCells = tab === "pending" ? PENDING_HEADER_CELLS : APPROVED_HEADER_CELLS;
|
||||
const headerCells = UPLIFT_APPROVAL_HEADER_CELLS[tab];
|
||||
return (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small" aria-label={`${tab} uplift requests`}>
|
||||
|
|
@ -336,22 +362,7 @@ export function UpliftApprovalsTable({
|
|||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<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>
|
||||
<UpliftApprovalsEmptyRow tab={tab} colSpan={headerCells.length} hasError={hasError} />
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<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 { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
|
||||
import {
|
||||
UpliftApprovalsTable,
|
||||
UPLIFT_APPROVAL_TABS,
|
||||
isUpliftApprovalTab,
|
||||
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 { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
|
||||
import {
|
||||
|
|
@ -16,6 +18,7 @@ import { useAuthContext } from "@/providers/auth-context";
|
|||
import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
|
||||
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
|
||||
|
||||
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({
|
||||
tab,
|
||||
pendingCount,
|
||||
counts,
|
||||
onChange,
|
||||
}: {
|
||||
tab: UpliftApprovalTab;
|
||||
pendingCount: number;
|
||||
counts: Record<UpliftApprovalTab, number | null>;
|
||||
onChange: (_event: unknown, value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Tabs value={tab} onChange={onChange}>
|
||||
<Tab
|
||||
value="pending"
|
||||
label={
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<span>Pending</span>
|
||||
<Chip
|
||||
size="small"
|
||||
label={pendingCount}
|
||||
aria-label={`${pendingCount} pending uplifts`}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Tab value="approved" label="Approved" />
|
||||
{UPLIFT_APPROVAL_TABS.map((value) => (
|
||||
<Tab
|
||||
key={value}
|
||||
value={value}
|
||||
label={
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<span>{TAB_LABELS[value]}</span>
|
||||
<TabCountPill tab={value} count={counts[value]} />
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function UpliftApprovalsPagination({
|
||||
tab,
|
||||
count,
|
||||
pendingPage,
|
||||
approvedPage,
|
||||
onPendingPageChange,
|
||||
onApprovedPageChange,
|
||||
page,
|
||||
onPageChange,
|
||||
}: {
|
||||
tab: UpliftApprovalTab;
|
||||
count: number;
|
||||
pendingPage: number;
|
||||
approvedPage: number;
|
||||
onPendingPageChange: (page: number) => void;
|
||||
onApprovedPageChange: (page: number) => void;
|
||||
page: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={count}
|
||||
page={tab === "pending" ? pendingPage : approvedPage}
|
||||
onPageChange={(_event, nextPage) =>
|
||||
tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage)
|
||||
}
|
||||
page={page}
|
||||
onPageChange={(_event, nextPage) => onPageChange(nextPage)}
|
||||
rowsPerPage={PAGE_SIZE}
|
||||
rowsPerPageOptions={[PAGE_SIZE]}
|
||||
/>
|
||||
|
|
@ -108,22 +124,31 @@ function UpliftQueueError({ error }: { error: unknown }) {
|
|||
|
||||
export default function UpliftQueuePage() {
|
||||
const [tab, setTab] = useState<UpliftApprovalTab>("pending");
|
||||
const [pendingPage, setPendingPage] = useState(0);
|
||||
const [approvedPage, setApprovedPage] = useState(0);
|
||||
const [pages, setPages] = useState<Record<UpliftApprovalTab, number>>({
|
||||
pending: 0,
|
||||
approved: 0,
|
||||
rejected: 0,
|
||||
});
|
||||
|
||||
const { user } = useAuthContext();
|
||||
const canRevokeUplifts = isAdminUser(user?.userRoles);
|
||||
|
||||
const pendingQuery = useUpliftsQueue({
|
||||
status: "Pending",
|
||||
page: pendingPage + 1,
|
||||
page: pages.pending + 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
const approvedQuery = useUpliftsQueue({
|
||||
status: "Approved",
|
||||
page: approvedPage + 1,
|
||||
page: pages.approved + 1,
|
||||
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 {
|
||||
|
|
@ -138,13 +163,18 @@ export default function UpliftQueuePage() {
|
|||
openDetail,
|
||||
} = controller;
|
||||
|
||||
const activeQuery = tab === "pending" ? pendingQuery : approvedQuery;
|
||||
const activeQuery = queries[tab];
|
||||
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 handleTabChange = (_event: unknown, value: string) => {
|
||||
if (value === "pending" || value === "approved") {
|
||||
if (isUpliftApprovalTab(value)) {
|
||||
setTab(value);
|
||||
}
|
||||
};
|
||||
|
|
@ -152,11 +182,12 @@ export default function UpliftQueuePage() {
|
|||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
|
||||
<UpliftApprovalsTabs tab={tab} pendingCount={pendingCount} onChange={handleTabChange} />
|
||||
<UpliftApprovalsTabs tab={tab} counts={counts} onChange={handleTabChange} />
|
||||
<UpliftQueueError error={activeQuery.error} />
|
||||
<UpliftApprovalsTable
|
||||
tab={tab}
|
||||
isLoading={activeQuery.isLoading}
|
||||
hasError={Boolean(activeQuery.error)}
|
||||
rows={rows}
|
||||
canRevoke={canRevokeUplifts}
|
||||
onOpenDetail={openDetail}
|
||||
|
|
@ -166,14 +197,13 @@ export default function UpliftQueuePage() {
|
|||
onOpenAttachment={openUpliftEvidence}
|
||||
isDecisionPending={approvePending || rejectPending || revokePending}
|
||||
/>
|
||||
<UpliftApprovalsPagination
|
||||
tab={tab}
|
||||
count={activeQuery.data?.totalCount ?? 0}
|
||||
pendingPage={pendingPage}
|
||||
approvedPage={approvedPage}
|
||||
onPendingPageChange={setPendingPage}
|
||||
onApprovedPageChange={setApprovedPage}
|
||||
/>
|
||||
{activeTotal != null && (
|
||||
<UpliftApprovalsPagination
|
||||
count={activeTotal}
|
||||
page={pages[tab]}
|
||||
onPageChange={(page) => setPages((current) => ({ ...current, [tab]: page }))}
|
||||
/>
|
||||
)}
|
||||
{detailItem != null && (
|
||||
<UpliftDetailModal
|
||||
item={detailItem}
|
||||
|
|
|
|||
|
|
@ -109,7 +109,11 @@ export function AdvancedSearchFilterSections({
|
|||
/>
|
||||
</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)}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ALL_WIZARD_STATUSES.map((status) => (
|
||||
|
|
@ -122,6 +126,14 @@ export function AdvancedSearchFilterSections({
|
|||
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>
|
||||
</AdvSection>
|
||||
|
||||
|
|
|
|||
|
|
@ -20,10 +20,13 @@ const SERVICE_WORK_ORDER_TYPE_API = {
|
|||
Reactive: 6,
|
||||
} 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,
|
||||
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
||||
Emergency: WORK_ORDER_TYPE_API.Emergency,
|
||||
Overdue: WORK_ORDER_TYPE_API.PM,
|
||||
};
|
||||
|
||||
function toApiInput(input: ServiceInput) {
|
||||
|
|
@ -51,7 +54,6 @@ export const servicesApi = {
|
|||
},
|
||||
|
||||
getActiveForWorkOrderType: async (workOrderType: WOType): Promise<Service[]> => {
|
||||
if (workOrderType === "Overdue") return [];
|
||||
const data = await apiGet<unknown>(API_PATHS.services.list, {
|
||||
searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export function useServicesForWorkOrderType(
|
|||
return useQuery({
|
||||
queryKey: queryKeys.services.byWorkOrderType(effectiveType),
|
||||
queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType),
|
||||
enabled: effectiveType !== "Overdue",
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,5 +10,8 @@ export function useUpliftsQueue(
|
|||
queryKey: queryKeys.uplifts.queue(params as Record<string, unknown>),
|
||||
queryFn: () => upliftsApi.list(params),
|
||||
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 { typeNeedsSeverity } from "@/domain/work-orders/types/work-order-severity";
|
||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
|
||||
|
||||
|
|
@ -64,7 +65,7 @@ export function assignWizardServicePocFields(
|
|||
payload.vendorNotes = vendorNotes;
|
||||
}
|
||||
|
||||
if (draft.severity != null) {
|
||||
if (draft.severity != null && typeNeedsSeverity(draft.type)) {
|
||||
payload.severity = draft.severity;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const WO_TYPE_TO_API: Record<WOType, WorkOrderTypeApi> = {
|
|||
|
||||
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`. */
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export interface WorkOrderAdvancedFilters {
|
|||
dispatchers: Set<string>;
|
||||
regions: Set<string>;
|
||||
statuses: Set<WizardWOStatus>;
|
||||
/** Past Due overlay (any type); narrows the other filters, independent of the Overdue type. */
|
||||
pastDue: boolean;
|
||||
rescheduled: boolean;
|
||||
carriedOver: boolean;
|
||||
addOn: boolean;
|
||||
|
|
@ -61,6 +63,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
|||
dispatchers: new Set(),
|
||||
regions: new Set(),
|
||||
statuses: new Set(),
|
||||
pastDue: false,
|
||||
rescheduled: false,
|
||||
carriedOver: false,
|
||||
addOn: false,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const WORK_ORDER_TYPE_API = {
|
|||
Inspection: 5,
|
||||
Reactive: 6,
|
||||
AddOn: 7,
|
||||
Overdue: 8,
|
||||
Other: 99,
|
||||
} 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.Reactive]: "Reactive",
|
||||
[WORK_ORDER_TYPE_API.Emergency]: "Emergency",
|
||||
[WORK_ORDER_TYPE_API.Overdue]: "Overdue",
|
||||
};
|
||||
|
||||
export const LIFECYCLE_STATUS_API_TO_FE: Record<number, WizardWOStatus | "Canceled"> = {
|
||||
|
|
|
|||
|
|
@ -192,6 +192,15 @@ export function appendStatusPmVendorDocChips(
|
|||
clear: (current) => ({ ...current, statuses: new Set() }),
|
||||
});
|
||||
|
||||
if (filters.pastDue) {
|
||||
chips.push({
|
||||
key: "pastdue",
|
||||
group: "Status",
|
||||
value: "Past Due",
|
||||
clear: (current) => ({ ...current, pastDue: false }),
|
||||
});
|
||||
}
|
||||
|
||||
pushSetChip({
|
||||
chips,
|
||||
key: "pm",
|
||||
|
|
|
|||
|
|
@ -45,12 +45,10 @@ const matchesDateRange: AdvancedFilterPredicate = (row, filters) => {
|
|||
const matchesSite: AdvancedFilterPredicate = (row, filters) =>
|
||||
!filters.sites.size || filters.sites.has(row.site);
|
||||
|
||||
const matchesType: AdvancedFilterPredicate = (row, filters) => {
|
||||
if (!filters.types.size) return true;
|
||||
const overdueMatch = filters.types.has("Overdue") && row.isPastDue;
|
||||
const typeMatch = filters.types.has(row.type as never);
|
||||
return overdueMatch || typeMatch;
|
||||
};
|
||||
const matchesType: AdvancedFilterPredicate = (row, filters) =>
|
||||
!filters.types.size || filters.types.has(row.type as never);
|
||||
|
||||
const matchesPastDue: AdvancedFilterPredicate = (row, filters) => !filters.pastDue || row.isPastDue;
|
||||
|
||||
const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => {
|
||||
if (!filters.dispatchers.size) return true;
|
||||
|
|
@ -95,6 +93,7 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
|
|||
matchesType,
|
||||
matchesDispatcher,
|
||||
matchesStatus,
|
||||
matchesPastDue,
|
||||
matchesPmType,
|
||||
matchesVendorTech,
|
||||
matchesDoc,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte
|
|||
upliftStatuses: new Set(filters.upliftStatuses ?? []),
|
||||
workOrderIds: new Set(filters.workOrderIds ?? []),
|
||||
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.regions?.size),
|
||||
Boolean(filters.statuses?.size),
|
||||
Boolean(filters.pastDue),
|
||||
filters.rescheduled,
|
||||
filters.carriedOver,
|
||||
filters.addOn,
|
||||
|
|
|
|||
|
|
@ -42,30 +42,19 @@ function mapDispatchers(ids: Iterable<string>): string[] | undefined {
|
|||
return list.length > 0 ? list : undefined;
|
||||
}
|
||||
|
||||
/** Maps UI type filters to API `types` + separate `overdue` flag. Overdue is never sent as types=99. */
|
||||
function mapWoTypeFilters(types: Iterable<WOType | string>): {
|
||||
types?: number[];
|
||||
overdue?: boolean;
|
||||
} {
|
||||
/** 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>): number[] | undefined {
|
||||
const list: number[] = [];
|
||||
let overdue = false;
|
||||
for (const type of types) {
|
||||
if (type === "All") {
|
||||
continue;
|
||||
}
|
||||
if (type === "Overdue") {
|
||||
overdue = true;
|
||||
continue;
|
||||
}
|
||||
const api = mapWizardTypeToApi(type as WOType);
|
||||
if (api != null) {
|
||||
list.push(api);
|
||||
}
|
||||
}
|
||||
return {
|
||||
types: list.length > 0 ? list : undefined,
|
||||
overdue: overdue || undefined,
|
||||
};
|
||||
return list.length > 0 ? list : undefined;
|
||||
}
|
||||
|
||||
function mapStatuses(statuses: Iterable<WizardWOStatus | string>): number[] | undefined {
|
||||
|
|
@ -147,14 +136,11 @@ export function toBoardQueryParams(
|
|||
search: string,
|
||||
): WorkOrderBoardQueryParams {
|
||||
const [, weekTo] = weekRange(bar.weekMonday);
|
||||
const typeFilters = bar.types.size > 0 ? mapWoTypeFilters(bar.types) : {};
|
||||
|
||||
return {
|
||||
weekStart: bar.weekMonday,
|
||||
weekEnd: weekTo,
|
||||
dispatchers: mapDispatchers(bar.dispatcherIds),
|
||||
types: typeFilters.types,
|
||||
overdue: typeFilters.overdue,
|
||||
types: mapWoTypeFilters(bar.types),
|
||||
search: search.trim().length >= 2 ? search.trim() : undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -211,7 +197,6 @@ export function toBoardSearchQueryParams(input: {
|
|||
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||
);
|
||||
|
||||
const typeFilters = mapWoTypeFilters(filters.types);
|
||||
const trimmedSearch = input.search?.trim();
|
||||
const search = trimmedSearch && trimmedSearch.length >= 2 ? trimmedSearch : undefined;
|
||||
|
||||
|
|
@ -224,8 +209,8 @@ export function toBoardSearchQueryParams(input: {
|
|||
dateTo: date.dateTo,
|
||||
includeDateless: date.includeDateless,
|
||||
sites: filters.sites.size > 0 ? [...filters.sites] : undefined,
|
||||
types: typeFilters.types,
|
||||
overdue: typeFilters.overdue,
|
||||
types: mapWoTypeFilters(filters.types),
|
||||
overdue: filters.pastDue || undefined,
|
||||
dispatchers: mapDispatchers(filters.dispatchers),
|
||||
regions: filters.regions.size > 0 ? [...filters.regions] : undefined,
|
||||
statuses: mapStatuses(filters.statuses),
|
||||
|
|
@ -261,10 +246,6 @@ export function buildBoardSearchParamsRecord(
|
|||
return out;
|
||||
}
|
||||
|
||||
export function woTypeFilterIsOverdue(type: string): boolean {
|
||||
return type === "Overdue";
|
||||
}
|
||||
|
||||
/** Week-independent GET /board/search params for the pinned Unassigned queue. */
|
||||
export function toUnassignedQueueQueryParams(
|
||||
page = 0,
|
||||
|
|
|
|||
|
|
@ -65,12 +65,7 @@ function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean {
|
|||
}
|
||||
|
||||
function rowMatchesBarTypes(row: WorkOrderTableRow, types: Set<WOType>): boolean {
|
||||
if (types.size === 0) {
|
||||
return true;
|
||||
}
|
||||
const overdueMatch = types.has("Overdue") && row.isPastDue;
|
||||
const typeMatch = types.has(row.type as WOType);
|
||||
return overdueMatch || typeMatch;
|
||||
return types.size === 0 || types.has(row.type as WOType);
|
||||
}
|
||||
|
||||
export function filterByBarAndSearch(
|
||||
|
|
|
|||
|
|
@ -109,6 +109,21 @@ describe("Completion Docs page", { timeout: 15_000 }, () => {
|
|||
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 () => {
|
||||
renderPage();
|
||||
const dialog = await openTemplate("Backflow Test Report");
|
||||
|
|
|
|||
|
|
@ -235,12 +235,23 @@ describe("Edit template", { timeout: 15_000 }, () => {
|
|||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays closed when the user closes it while a save is still in flight", async () => {
|
||||
let finishSave: (value: unknown) => void = () => undefined;
|
||||
api.apiPut.mockImplementationOnce(
|
||||
() =>
|
||||
it.each([
|
||||
[
|
||||
"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) => {
|
||||
finishSave = resolve;
|
||||
finishSave = () => resolve(respond(...args));
|
||||
}),
|
||||
);
|
||||
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 waitFor(() => expect(api.apiPut).toHaveBeenCalled());
|
||||
|
||||
await user.click(within(dialog).getByRole("button", { name: "Close" }));
|
||||
await dismiss();
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
finishSave({ ...BACKFLOW, name: "Backflow Test Report v2" });
|
||||
finishSave();
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template updated"));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ describe("Uplift Approvals approved tab", () => {
|
|||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||
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 At" })).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");
|
||||
renderWithProviders(<UpliftQueuePage />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
|
||||
const revoke = await screen.findByRole("button", { name: "Revoke" });
|
||||
expect(revoke).toBeEnabled();
|
||||
fireEvent.click(revoke);
|
||||
|
|
@ -149,7 +149,7 @@ describe("Uplift Approvals approved tab", () => {
|
|||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||
renderWithProviders(<UpliftQueuePage />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
|
||||
const revoke = await screen.findByRole("button", { name: "Revoke" });
|
||||
expect(revoke).toBeDisabled();
|
||||
fireEvent.mouseOver(revoke);
|
||||
|
|
@ -172,7 +172,7 @@ describe("Uplift Approvals approved tab", () => {
|
|||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||
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(
|
||||
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);
|
||||
await renderQueue();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
|
||||
expect(amountCell()).toHaveTextContent(UPLIFT);
|
||||
|
||||
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 () => {
|
||||
const services = await servicesApi.getActiveForWorkOrderType("Overdue");
|
||||
it("lists the PM catalog for the Overdue type", async () => {
|
||||
vi.mocked(apiGet).mockResolvedValue([]);
|
||||
|
||||
expect(apiGet).not.toHaveBeenCalled();
|
||||
expect(services).toEqual([]);
|
||||
await servicesApi.getActiveForWorkOrderType("Overdue");
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith(API_PATHS.services.list, {
|
||||
searchParams: { isActive: true, workOrderType: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
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("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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -60,16 +60,16 @@ describe("toBoardQueryParams", () => {
|
|||
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"]) }, "");
|
||||
expect(params.overdue).toBe(true);
|
||||
expect(params.types).toBeUndefined();
|
||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]);
|
||||
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"]) }, "");
|
||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]);
|
||||
expect(params.overdue).toBe(true);
|
||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM, WORK_ORDER_TYPE_API.Overdue]);
|
||||
expect(params.overdue).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits types when the set is empty", () => {
|
||||
|
|
@ -222,22 +222,23 @@ describe("toBoardSearchQueryParams", () => {
|
|||
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({
|
||||
filters: {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
types: new Set(["Overdue"]),
|
||||
},
|
||||
});
|
||||
expect(params.overdue).toBe(true);
|
||||
expect(params.types).toBeUndefined();
|
||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]);
|
||||
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({
|
||||
filters: {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
types: new Set(["PM", "Overdue"]),
|
||||
types: new Set(["PM"]),
|
||||
pastDue: true,
|
||||
},
|
||||
});
|
||||
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]);
|
||||
|
|
|
|||
|
|
@ -172,26 +172,26 @@ describe("filterByBarAndSearch", () => {
|
|||
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({
|
||||
id: "pd",
|
||||
type: "PM",
|
||||
isPastDue: true,
|
||||
scheduledOn: "2026-06-03",
|
||||
});
|
||||
const currentPm = makeRow({
|
||||
id: "now",
|
||||
type: "PM",
|
||||
const overdue = makeRow({
|
||||
id: "ov",
|
||||
type: "Overdue",
|
||||
isPastDue: false,
|
||||
scheduledOn: "2026-06-03",
|
||||
});
|
||||
|
||||
const ids = filterByBarAndSearch(
|
||||
[pastDuePm, currentPm],
|
||||
[pastDuePm, overdue],
|
||||
bar(weekMonday, { types: new Set(["Overdue"]) }),
|
||||
"",
|
||||
).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", () => {
|
||||
|
|
@ -395,13 +395,8 @@ describe("matchesAdvancedFilters", () => {
|
|||
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 pastDue = makeRow({
|
||||
scheduledOn: weekMonday,
|
||||
...deriveDayFields(weekMonday),
|
||||
isPastDue: true,
|
||||
});
|
||||
const filters = {
|
||||
...EMPTY_ADVANCED_FILTERS,
|
||||
dateRange: "custom" as const,
|
||||
|
|
@ -409,9 +404,39 @@ describe("matchesAdvancedFilters", () => {
|
|||
customTo: "9999-12-31",
|
||||
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(
|
||||
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);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue