mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 22:52:06 +00:00
Merge remote-tracking branch 'origin/dev' into feat/ab/sh-281-company-directory
This commit is contained in:
commit
83df262ab6
15 changed files with 547 additions and 20 deletions
|
|
@ -14,13 +14,13 @@ type AdvDateRangeSectionProps = {
|
||||||
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
|
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function dateRangeSectionCount(draft: WorkOrderAdvancedFilters): number {
|
||||||
|
return (isDefaultDateRange(draft.dateRange) ? 0 : 1) + (draft.unscheduledOnly ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
export function AdvDateRangeSection({ draft, setDraft }: AdvDateRangeSectionProps) {
|
export function AdvDateRangeSection({ draft, setDraft }: AdvDateRangeSectionProps) {
|
||||||
return (
|
return (
|
||||||
<AdvSection
|
<AdvSection icon={CalendarDays} label="Date range" count={dateRangeSectionCount(draft)}>
|
||||||
icon={CalendarDays}
|
|
||||||
label="Date range"
|
|
||||||
count={isDefaultDateRange(draft.dateRange) ? 0 : 1}
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{DATE_RANGE_OPTIONS.map((option) => (
|
{DATE_RANGE_OPTIONS.map((option) => (
|
||||||
<AdvFilterPill
|
<AdvFilterPill
|
||||||
|
|
@ -30,6 +30,11 @@ export function AdvDateRangeSection({ draft, setDraft }: AdvDateRangeSectionProp
|
||||||
onClick={() => setDraft((prev) => ({ ...prev, dateRange: option.key }))}
|
onClick={() => setDraft((prev) => ({ ...prev, dateRange: option.key }))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
<AdvFilterPill
|
||||||
|
label="Unscheduled"
|
||||||
|
selected={draft.unscheduledOnly}
|
||||||
|
onClick={() => setDraft((prev) => ({ ...prev, unscheduledOnly: !prev.unscheduledOnly }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{draft.dateRange === "custom" && (
|
{draft.dateRange === "custom" && (
|
||||||
<div className="mt-2.5 flex items-center gap-2">
|
<div className="mt-2.5 flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,11 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
});
|
});
|
||||||
const { displayRows, ...localUi } = local;
|
const { displayRows, ...localUi } = local;
|
||||||
|
|
||||||
const advResults = useMemo(() => (advActive ? displayRows : []), [advActive, displayRows]);
|
// SH-338: the server owns rows/totals for advanced search (incl. Unscheduled).
|
||||||
|
const advResults = useMemo(
|
||||||
|
() => (advActive ? displayRows : EMPTY_WEEKLY_ITEMS),
|
||||||
|
[advActive, displayRows],
|
||||||
|
);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (advActive) {
|
if (advActive) {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ export interface WorkOrderAdvancedFilters {
|
||||||
dateRange: DateRangeKey;
|
dateRange: DateRangeKey;
|
||||||
customFrom: string;
|
customFrom: string;
|
||||||
customTo: string;
|
customTo: string;
|
||||||
|
/** invariant: independent of DateRangeKey; never sent as datePreset=Unscheduled. */
|
||||||
|
unscheduledOnly: boolean;
|
||||||
sites: Set<string>;
|
sites: Set<string>;
|
||||||
types: Set<WOType>;
|
types: Set<WOType>;
|
||||||
severities: Set<WorkOrderSeverity>;
|
severities: Set<WorkOrderSeverity>;
|
||||||
|
|
@ -48,6 +50,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
||||||
dateRange: "this-week",
|
dateRange: "this-week",
|
||||||
customFrom: "",
|
customFrom: "",
|
||||||
customTo: "",
|
customTo: "",
|
||||||
|
unscheduledOnly: false,
|
||||||
sites: new Set(),
|
sites: new Set(),
|
||||||
types: new Set(),
|
types: new Set(),
|
||||||
severities: new Set(),
|
severities: new Set(),
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,12 @@ export interface WorkOrderBoardSearchQueryParams {
|
||||||
avetaOnly?: boolean;
|
avetaOnly?: boolean;
|
||||||
flagColors?: string[];
|
flagColors?: string[];
|
||||||
internalOnly?: boolean;
|
internalOnly?: boolean;
|
||||||
|
/**
|
||||||
|
* SH-338: server-side Unscheduled facet. `true` makes the server ignore the
|
||||||
|
* date range only; every other facet still applies. Absent/false keeps the
|
||||||
|
* legacy wire behavior.
|
||||||
|
*/
|
||||||
|
unscheduledOnly?: boolean;
|
||||||
hasUplift?: boolean;
|
hasUplift?: boolean;
|
||||||
upliftStatuses?: string[];
|
upliftStatuses?: string[];
|
||||||
myWorkOrders?: boolean;
|
myWorkOrders?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -60,21 +60,29 @@ function severityLabel(level: WorkOrderSeverity): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function appendDateChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) {
|
export function appendDateChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) {
|
||||||
if (filters.dateRange === "this-week") return;
|
if (filters.dateRange !== "this-week") {
|
||||||
const value =
|
const value =
|
||||||
filters.dateRange === "custom"
|
filters.dateRange === "custom"
|
||||||
? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}`
|
? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}`
|
||||||
: (DATE_RANGE_OPTIONS.find((o) => o.key === filters.dateRange)?.label ?? "Custom");
|
: (DATE_RANGE_OPTIONS.find((o) => o.key === filters.dateRange)?.label ?? "Custom");
|
||||||
|
chips.push({
|
||||||
|
key: "date",
|
||||||
|
group: "Date",
|
||||||
|
value,
|
||||||
|
clear: (current) => ({
|
||||||
|
...current,
|
||||||
|
dateRange: "this-week",
|
||||||
|
customFrom: "",
|
||||||
|
customTo: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!filters.unscheduledOnly) return;
|
||||||
chips.push({
|
chips.push({
|
||||||
key: "date",
|
key: "unscheduled",
|
||||||
group: "Date",
|
group: "Date",
|
||||||
value,
|
value: "Unscheduled",
|
||||||
clear: (current) => ({
|
clear: (current) => ({ ...current, unscheduledOnly: false }),
|
||||||
...current,
|
|
||||||
dateRange: "this-week",
|
|
||||||
customFrom: "",
|
|
||||||
customTo: "",
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ type AdvancedFilterPredicate = (
|
||||||
) => boolean;
|
) => boolean;
|
||||||
|
|
||||||
const matchesDateRange: AdvancedFilterPredicate = (row, filters) => {
|
const matchesDateRange: AdvancedFilterPredicate = (row, filters) => {
|
||||||
|
if (filters.unscheduledOnly) {
|
||||||
|
return !row.scheduledOn;
|
||||||
|
}
|
||||||
const [from, to] = dateRangeFor(filters);
|
const [from, to] = dateRangeFor(filters);
|
||||||
return !row.scheduledOn || (row.scheduledOn >= from && row.scheduledOn <= to);
|
return !row.scheduledOn || (row.scheduledOn >= from && row.scheduledOn <= to);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte
|
||||||
docs: new Set(filters.docs),
|
docs: new Set(filters.docs),
|
||||||
flagColors: new Set(filters.flagColors ?? []),
|
flagColors: new Set(filters.flagColors ?? []),
|
||||||
upliftStatuses: new Set(filters.upliftStatuses ?? []),
|
upliftStatuses: new Set(filters.upliftStatuses ?? []),
|
||||||
|
unscheduledOnly: Boolean(filters.unscheduledOnly),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,6 +53,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool
|
||||||
Boolean(filters.flagColors?.size),
|
Boolean(filters.flagColors?.size),
|
||||||
filters.internalOnly,
|
filters.internalOnly,
|
||||||
filters.hasUplift,
|
filters.hasUplift,
|
||||||
|
filters.unscheduledOnly,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
|
export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
|
||||||
|
|
|
||||||
|
|
@ -155,10 +155,15 @@ export function toBoardQueryParams(
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAdvancedSearchDate(filters: WorkOrderAdvancedFilters): {
|
function resolveAdvancedSearchDate(filters: WorkOrderAdvancedFilters): {
|
||||||
datePreset: WorkOrderBoardSearchQueryParams["datePreset"];
|
datePreset?: WorkOrderBoardSearchQueryParams["datePreset"];
|
||||||
dateFrom?: string;
|
dateFrom?: string;
|
||||||
dateTo?: string;
|
dateTo?: string;
|
||||||
} {
|
} {
|
||||||
|
// SH-338: server-side unscheduledOnly owns the date-range exemption; the
|
||||||
|
// client must not fake a Custom window and sends no date params.
|
||||||
|
if (filters.unscheduledOnly) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
if (filters.dateRange === EMPTY_ADVANCED_FILTERS.dateRange) {
|
if (filters.dateRange === EMPTY_ADVANCED_FILTERS.dateRange) {
|
||||||
return {
|
return {
|
||||||
datePreset: "Custom",
|
datePreset: "Custom",
|
||||||
|
|
@ -216,6 +221,7 @@ export function toBoardSearchQueryParams(input: {
|
||||||
pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined,
|
pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined,
|
||||||
vendorIds: resolveVendorIds(filters.vendorTechs, input.vendorIdsByCompanyName),
|
vendorIds: resolveVendorIds(filters.vendorTechs, input.vendorIdsByCompanyName),
|
||||||
docStatuses: mapDocStatuses(filters.docs),
|
docStatuses: mapDocStatuses(filters.docs),
|
||||||
|
unscheduledOnly: filters.unscheduledOnly || undefined,
|
||||||
...mapSearchFacetParams(filters),
|
...mapSearchFacetParams(filters),
|
||||||
sortBy: input.sortBy || "scheduledDate",
|
sortBy: input.sortBy || "scheduledDate",
|
||||||
sortDir: input.sortDir === "desc" ? "desc" : "asc",
|
sortDir: input.sortDir === "desc" ? "desc" : "asc",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { WorkOrderBoardSearchQueryParams } from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const { searchBoard, getBoard, emptyList } = vi.hoisted(() => ({
|
||||||
|
searchBoard: vi.fn(),
|
||||||
|
getBoard: vi.fn(),
|
||||||
|
emptyList: [] as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
|
||||||
|
workOrdersApi: {
|
||||||
|
searchBoard: (...args: unknown[]) => searchBoard(...args),
|
||||||
|
getBoard: (...args: unknown[]) => getBoard(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||||
|
useWorkOrderLocations: () => ({ data: emptyList }),
|
||||||
|
useWorkOrderVendors: () => ({ data: emptyList }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/locations/use-cases/use-location-sites", () => ({
|
||||||
|
useLocationSites: () => ({ data: emptyList }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/list/table/use-wo-table-inline-create", () => ({
|
||||||
|
useWoTableInlineCreate: () => ({ handleInlineSave: vi.fn(), saving: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||||
|
|
||||||
|
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
||||||
|
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||||
|
import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
return {
|
||||||
|
id: "1",
|
||||||
|
woNumber: "WO-1",
|
||||||
|
site: "CLV2",
|
||||||
|
type: "PM",
|
||||||
|
severity: null,
|
||||||
|
dispatcherId: "",
|
||||||
|
dispatcherName: "",
|
||||||
|
scheduledOn: "",
|
||||||
|
completedDate: "",
|
||||||
|
targetWeek: "",
|
||||||
|
dayGroup: "",
|
||||||
|
dayLabel: "",
|
||||||
|
location: "Cleveland, OH",
|
||||||
|
locationId: "",
|
||||||
|
pm: "Backflow",
|
||||||
|
serviceNotes: "",
|
||||||
|
extraServices: [],
|
||||||
|
company: "",
|
||||||
|
tech: "",
|
||||||
|
techPhone: "",
|
||||||
|
apptStart: "",
|
||||||
|
apptEnd: "",
|
||||||
|
apptTime: "—",
|
||||||
|
status: "Incomplete",
|
||||||
|
docStatus: "No",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
dueDate: "",
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
isAddOn: false,
|
||||||
|
originalWeek: "",
|
||||||
|
originalDate: "",
|
||||||
|
isPastDue: false,
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
mediaCount: 0,
|
||||||
|
title: "",
|
||||||
|
priority: "",
|
||||||
|
vendorId: "",
|
||||||
|
primaryDispatchId: null,
|
||||||
|
rowVersion: "v1",
|
||||||
|
dispatchRowVersion: "",
|
||||||
|
flagColor: null,
|
||||||
|
additionalContacts: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyBoardResult() {
|
||||||
|
return {
|
||||||
|
items: [] as WorkOrderTableRow[],
|
||||||
|
scheduled: [] as WorkOrderTableRow[],
|
||||||
|
unscheduled: [] as WorkOrderTableRow[],
|
||||||
|
totalCount: 0,
|
||||||
|
returnedCount: 0,
|
||||||
|
page: 0,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pagedResult(rows: WorkOrderTableRow[], page: number, pageSize: number) {
|
||||||
|
const items = rows.slice(page * pageSize, page * pageSize + pageSize);
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
scheduled: items,
|
||||||
|
unscheduled: [] as WorkOrderTableRow[],
|
||||||
|
totalCount: rows.length,
|
||||||
|
returnedCount: items.length,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastSearchParams(): WorkOrderBoardSearchQueryParams {
|
||||||
|
const calls = searchBoard.mock.calls;
|
||||||
|
return calls[calls.length - 1]?.[0] as WorkOrderBoardSearchQueryParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnscheduledServerOwnedHarness() {
|
||||||
|
const filters = useWorkOrdersListFilters();
|
||||||
|
const tableData = useWorkOrdersTableData(filters);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="harness-state">
|
||||||
|
{`adv=${String(tableData.advActive)} rows=${tableData.advResults.length} total=${tableData.scopeTotalCount}`}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
filters.setPageSize(PAGE_SIZE);
|
||||||
|
filters.applyAdvancedFilters({ ...EMPTY_ADVANCED_FILTERS, unscheduledOnly: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Apply unscheduled
|
||||||
|
</button>
|
||||||
|
<WoTable
|
||||||
|
tableData={tableData}
|
||||||
|
users={[]}
|
||||||
|
sites={[]}
|
||||||
|
vendors={[]}
|
||||||
|
pmTypes={[]}
|
||||||
|
weekMonday={filters.weekMonday}
|
||||||
|
onNavigateToDay={filters.navigateToDay}
|
||||||
|
onOpenSlideOver={() => undefined}
|
||||||
|
onOpenMedia={() => undefined}
|
||||||
|
onPatch={() => undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("unscheduled advanced search is server-owned (SH-338)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
searchBoard.mockReset();
|
||||||
|
getBoard.mockReset();
|
||||||
|
getBoard.mockResolvedValue(emptyBoardResult());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends unscheduledOnly=true with no date window and pages server rows/totals", async () => {
|
||||||
|
// Server owns totals: page 0 returns 3 rows while totalCount spans 2 pages.
|
||||||
|
const pageZero = [
|
||||||
|
makeRow({ id: "wo-0", woNumber: "WO-0", scheduledOn: "" }),
|
||||||
|
// Server owns row filtering: a dated row on the page must still render.
|
||||||
|
makeRow({ id: "wo-1", woNumber: "WO-1", scheduledOn: "2026-08-17" }),
|
||||||
|
makeRow({ id: "wo-2", woNumber: "WO-2", scheduledOn: "" }),
|
||||||
|
];
|
||||||
|
const pageOne = [
|
||||||
|
makeRow({ id: "wo-25", woNumber: "WO-25", scheduledOn: "" }),
|
||||||
|
makeRow({ id: "wo-26", woNumber: "WO-26", scheduledOn: "" }),
|
||||||
|
];
|
||||||
|
const serverTotal = PAGE_SIZE + 2;
|
||||||
|
searchBoard.mockImplementation(async (params: WorkOrderBoardSearchQueryParams) => ({
|
||||||
|
items: (params.page ?? 0) === 0 ? pageZero : pageOne,
|
||||||
|
scheduled: (params.page ?? 0) === 0 ? pageZero : pageOne,
|
||||||
|
unscheduled: [] as WorkOrderTableRow[],
|
||||||
|
totalCount: serverTotal,
|
||||||
|
returnedCount: ((params.page ?? 0) === 0 ? pageZero : pageOne).length,
|
||||||
|
page: params.page ?? 0,
|
||||||
|
pageSize: params.pageSize ?? PAGE_SIZE,
|
||||||
|
}));
|
||||||
|
|
||||||
|
renderWithProviders(<UnscheduledServerOwnedHarness />, { withAuth: false });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply unscheduled" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("harness-state")).toHaveTextContent("adv=true rows=3");
|
||||||
|
});
|
||||||
|
const params = lastSearchParams();
|
||||||
|
expect(params.unscheduledOnly).toBe(true);
|
||||||
|
expect(params.datePreset).toBeUndefined();
|
||||||
|
expect(params.dateFrom).toBeUndefined();
|
||||||
|
expect(params.dateTo).toBeUndefined();
|
||||||
|
expect(params.page).toBe(0);
|
||||||
|
expect(params.pageSize).toBe(PAGE_SIZE);
|
||||||
|
|
||||||
|
expect(screen.getByText("WO-0")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("WO-1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("WO-2")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("WO-25")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(`RESULTS · ${serverTotal} items`)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Page 1 of 2")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("WO-25")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("WO-26")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Page 2 of 2")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(lastSearchParams().page).toBe(1);
|
||||||
|
expect(screen.getByTestId("harness-state")).toHaveTextContent("rows=2");
|
||||||
|
expect(screen.getByText(`RESULTS · ${serverTotal} items`)).toBeInTheDocument();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
it("shows the adv empty state when the server returns zero unscheduled results", async () => {
|
||||||
|
searchBoard.mockResolvedValue(pagedResult([], 0, PAGE_SIZE));
|
||||||
|
|
||||||
|
renderWithProviders(<UnscheduledServerOwnedHarness />, { withAuth: false });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply unscheduled" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("No work orders match these filters")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("harness-state")).toHaveTextContent("adv=true rows=0 total=0");
|
||||||
|
expect(screen.queryByText(/Page \d+ of \d+/)).not.toBeInTheDocument();
|
||||||
|
}, 15000);
|
||||||
|
});
|
||||||
|
|
@ -22,4 +22,24 @@ describe("WorkOrdersAdvancedFiltersBanner count semantics", () => {
|
||||||
|
|
||||||
expect(screen.getByText("150 work orders")).toBeInTheDocument();
|
expect(screen.getByText("150 work orders")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows Date / Unscheduled and the filtered list count when the toggle is on", () => {
|
||||||
|
render(
|
||||||
|
<WorkOrdersAdvancedFiltersBanner
|
||||||
|
applied={{
|
||||||
|
...EMPTY_ADVANCED_FILTERS,
|
||||||
|
unscheduledOnly: true,
|
||||||
|
}}
|
||||||
|
totalCount={3}
|
||||||
|
sites={[]}
|
||||||
|
users={[]}
|
||||||
|
onClearChip={() => undefined}
|
||||||
|
onClearAll={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Date")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Unscheduled")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("3 work orders")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -343,6 +343,53 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
|
||||||
expect(screen.getByRole("button", { name: /work order types, all types/i })).toBeEnabled();
|
expect(screen.getByRole("button", { name: /work order types, all types/i })).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies Unscheduled from Date range and sends unscheduledOnly without a faked window", () => {
|
||||||
|
const view = renderWithProviders(<WorkOrdersListPage />, {
|
||||||
|
route: "/workorders",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
|
||||||
|
advOpen = true;
|
||||||
|
view.rerender(<WorkOrdersListPage />);
|
||||||
|
|
||||||
|
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: /Date range/i }));
|
||||||
|
|
||||||
|
const custom = within(sheet).getByRole("button", { name: /^Custom$/i });
|
||||||
|
const unscheduled = within(sheet).getByRole("button", { name: /^Unscheduled$/i });
|
||||||
|
expect(custom.compareDocumentPosition(unscheduled) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(unscheduled).toHaveAttribute("aria-pressed", "false");
|
||||||
|
fireEvent.click(unscheduled);
|
||||||
|
expect(unscheduled).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(within(sheet).getByRole("button", { name: /^This week$/i })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(within(sheet).queryByLabelText(/^From$/i)).not.toBeInTheDocument();
|
||||||
|
expect(within(sheet).queryByLabelText(/^To$/i)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(custom);
|
||||||
|
expect(within(sheet).getByLabelText(/^From$/i)).toBeInTheDocument();
|
||||||
|
expect(unscheduled).toHaveAttribute("aria-pressed", "true");
|
||||||
|
|
||||||
|
fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i }));
|
||||||
|
|
||||||
|
const appliedCalls = applyAdvancedFilters.mock.calls;
|
||||||
|
const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS;
|
||||||
|
expect(applied.unscheduledOnly).toBe(true);
|
||||||
|
expect(applied.dateRange).toBe("custom");
|
||||||
|
|
||||||
|
const params = toBoardSearchQueryParams({ filters: applied });
|
||||||
|
expect(params.unscheduledOnly).toBe(true);
|
||||||
|
expect(params.datePreset).toBeUndefined();
|
||||||
|
expect(params.dateFrom).toBeUndefined();
|
||||||
|
expect(params.dateTo).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("places Aveta Compliance immediately above Uplift", () => {
|
it("places Aveta Compliance immediately above Uplift", () => {
|
||||||
const view = renderWithProviders(<WorkOrdersListPage />, {
|
const view = renderWithProviders(<WorkOrdersListPage />, {
|
||||||
route: "/workorders",
|
route: "/workorders",
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,42 @@ describe("workOrdersApi.searchBoard", () => {
|
||||||
expect(result.items[0]?.pm).toBe("PM Filter");
|
expect(result.items[0]?.pm).toBe("PM Filter");
|
||||||
expect(result.items[0]?.scheduledOn).toBe("2026-07-15");
|
expect(result.items[0]?.scheduledOn).toBe("2026-07-15");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serializes unscheduledOnly=true with no date window on the exact wire shape (SH-338)", async () => {
|
||||||
|
await workOrdersApi.searchBoard({
|
||||||
|
page: 0,
|
||||||
|
pageSize: 50,
|
||||||
|
unscheduledOnly: true,
|
||||||
|
statuses: [3],
|
||||||
|
sortBy: "scheduledDate",
|
||||||
|
sortDir: "asc",
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams;
|
||||||
|
expect(String(searchParams)).toBe(
|
||||||
|
"page=0&pageSize=50&unscheduledOnly=true&statuses=3&sortBy=scheduledDate&sortDir=asc",
|
||||||
|
);
|
||||||
|
expect(searchParams.get("unscheduledOnly")).toBe("true");
|
||||||
|
expect(searchParams.has("datePreset")).toBe(false);
|
||||||
|
expect(searchParams.has("dateFrom")).toBe(false);
|
||||||
|
expect(searchParams.has("dateTo")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the legacy wire shape (datePreset, no unscheduledOnly) when the toggle is off", async () => {
|
||||||
|
await workOrdersApi.searchBoard({
|
||||||
|
datePreset: "Custom",
|
||||||
|
dateFrom: "2000-01-01",
|
||||||
|
dateTo: "2099-12-31",
|
||||||
|
page: 0,
|
||||||
|
pageSize: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams;
|
||||||
|
expect(searchParams.has("unscheduledOnly")).toBe(false);
|
||||||
|
expect(searchParams.get("datePreset")).toBe("Custom");
|
||||||
|
expect(searchParams.get("dateFrom")).toBe("2000-01-01");
|
||||||
|
expect(searchParams.get("dateTo")).toBe("2099-12-31");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("workOrdersApi.findByWoNumber", () => {
|
describe("workOrdersApi.findByWoNumber", () => {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,24 @@ describe("countAdvancedFilters", () => {
|
||||||
};
|
};
|
||||||
expect(countAdvancedFilters(filters)).toBe(3);
|
expect(countAdvancedFilters(filters)).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("counts Unscheduled +1 with the this-week default still in place", () => {
|
||||||
|
const filters = {
|
||||||
|
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
|
||||||
|
unscheduledOnly: true,
|
||||||
|
};
|
||||||
|
expect(filters.dateRange).toBe("this-week");
|
||||||
|
expect(countAdvancedFilters(filters)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts Unscheduled independently of a non-default date range", () => {
|
||||||
|
const filters = {
|
||||||
|
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
|
||||||
|
dateRange: "last-week" as const,
|
||||||
|
unscheduledOnly: true,
|
||||||
|
};
|
||||||
|
expect(countAdvancedFilters(filters)).toBe(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildAdvancedFilterChips", () => {
|
describe("buildAdvancedFilterChips", () => {
|
||||||
|
|
@ -66,6 +84,27 @@ describe("buildAdvancedFilterChips", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds a Date / Unscheduled chip whose clear only turns the toggle off", () => {
|
||||||
|
const filters = {
|
||||||
|
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
|
||||||
|
dateRange: "last-week" as const,
|
||||||
|
unscheduledOnly: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const chips = buildAdvancedFilterChips(filters);
|
||||||
|
const unscheduled = chips.find((chip) => chip.key === "unscheduled");
|
||||||
|
expect(unscheduled).toMatchObject({
|
||||||
|
key: "unscheduled",
|
||||||
|
group: "Date",
|
||||||
|
value: "Unscheduled",
|
||||||
|
});
|
||||||
|
expect(chips.some((chip) => chip.key === "date" && chip.value === "Last week")).toBe(true);
|
||||||
|
|
||||||
|
const cleared = unscheduled?.clear(filters);
|
||||||
|
expect(cleared?.unscheduledOnly).toBe(false);
|
||||||
|
expect(cleared?.dateRange).toBe("last-week");
|
||||||
|
});
|
||||||
|
|
||||||
it("creates a separate unassigned chip for dispatchers", () => {
|
it("creates a separate unassigned chip for dispatchers", () => {
|
||||||
const filters = {
|
const filters = {
|
||||||
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
|
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
|
||||||
|
|
@ -101,6 +140,15 @@ describe("clearAdvancedFilterGroup", () => {
|
||||||
expect(next).toBeNull();
|
expect(next).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears only Unscheduled and returns null when it was the last chip", () => {
|
||||||
|
const filters = {
|
||||||
|
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
|
||||||
|
unscheduledOnly: true,
|
||||||
|
};
|
||||||
|
const next = clearAdvancedFilterGroup(filters, "unscheduled");
|
||||||
|
expect(next).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("builds chips for severity, indicators, flags, internal WO#, and uplift", () => {
|
it("builds chips for severity, indicators, flags, internal WO#, and uplift", () => {
|
||||||
const filters = {
|
const filters = {
|
||||||
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
|
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
WORK_ORDER_TYPE_API,
|
WORK_ORDER_TYPE_API,
|
||||||
} from "@/domain/work-orders/types/work-order-board";
|
} from "@/domain/work-orders/types/work-order-board";
|
||||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
import {
|
import {
|
||||||
BOARD_SEARCH_MAX_PAGE_SIZE,
|
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||||
buildBoardSearchParamsRecord,
|
buildBoardSearchParamsRecord,
|
||||||
|
|
@ -88,6 +89,7 @@ describe("toBoardSearchQueryParams", () => {
|
||||||
expect(params.datePreset).toBe("Custom");
|
expect(params.datePreset).toBe("Custom");
|
||||||
expect(params.dateFrom).toBe("2000-01-01");
|
expect(params.dateFrom).toBe("2000-01-01");
|
||||||
expect(params.dateTo).toBe("2099-12-31");
|
expect(params.dateTo).toBe("2099-12-31");
|
||||||
|
expect(params.unscheduledOnly).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards chrome pageSize 50 instead of the search max", () => {
|
it("forwards chrome pageSize 50 instead of the search max", () => {
|
||||||
|
|
@ -133,6 +135,59 @@ describe("toBoardSearchQueryParams", () => {
|
||||||
expect(thisWeek.dateTo).toBe("2099-12-31");
|
expect(thisWeek.dateTo).toBe("2099-12-31");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends unscheduledOnly=true with no faked Custom date window (SH-338)", () => {
|
||||||
|
const withDefaultRange = toBoardSearchQueryParams({
|
||||||
|
filters: { ...EMPTY_ADVANCED_FILTERS, unscheduledOnly: true },
|
||||||
|
});
|
||||||
|
expect(withDefaultRange.unscheduledOnly).toBe(true);
|
||||||
|
expect(withDefaultRange.datePreset).toBeUndefined();
|
||||||
|
expect(withDefaultRange.dateFrom).toBeUndefined();
|
||||||
|
expect(withDefaultRange.dateTo).toBeUndefined();
|
||||||
|
|
||||||
|
const overridingLastWeek = toBoardSearchQueryParams({
|
||||||
|
filters: {
|
||||||
|
...EMPTY_ADVANCED_FILTERS,
|
||||||
|
dateRange: "last-week",
|
||||||
|
unscheduledOnly: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(overridingLastWeek.unscheduledOnly).toBe(true);
|
||||||
|
expect(overridingLastWeek.datePreset).toBeUndefined();
|
||||||
|
expect(overridingLastWeek.dateFrom).toBeUndefined();
|
||||||
|
expect(overridingLastWeek.dateTo).toBeUndefined();
|
||||||
|
|
||||||
|
const record = buildBoardSearchParamsRecord(overridingLastWeek);
|
||||||
|
expect(record.unscheduledOnly).toBe(true);
|
||||||
|
expect(record).not.toHaveProperty("datePreset");
|
||||||
|
expect(record).not.toHaveProperty("dateFrom");
|
||||||
|
expect(record).not.toHaveProperty("dateTo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps applying every other facet alongside unscheduledOnly", () => {
|
||||||
|
const params = toBoardSearchQueryParams({
|
||||||
|
filters: {
|
||||||
|
...EMPTY_ADVANCED_FILTERS,
|
||||||
|
unscheduledOnly: true,
|
||||||
|
statuses: new Set(["Scheduled"]),
|
||||||
|
severities: new Set([4]),
|
||||||
|
pmTypes: new Set(["HVAC"]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(params.unscheduledOnly).toBe(true);
|
||||||
|
expect(params.statuses).toEqual([LIFECYCLE_STATUS_API.Scheduled]);
|
||||||
|
expect(params.severities).toEqual([4]);
|
||||||
|
expect(params.pmTypes).toEqual(["HVAC"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits unscheduledOnly and keeps the wide Custom default when the toggle is off", () => {
|
||||||
|
const params = toBoardSearchQueryParams({
|
||||||
|
filters: { ...EMPTY_ADVANCED_FILTERS, dateRange: "last-week" },
|
||||||
|
});
|
||||||
|
expect(params.unscheduledOnly).toBeUndefined();
|
||||||
|
expect(params.datePreset).toBe("LastWeek");
|
||||||
|
expect(buildBoardSearchParamsRecord(params)).not.toHaveProperty("unscheduledOnly");
|
||||||
|
});
|
||||||
|
|
||||||
it("maps Overdue-only advanced filter to overdue=true without types", () => {
|
it("maps Overdue-only advanced filter to overdue=true without types", () => {
|
||||||
const params = toBoardSearchQueryParams({
|
const params = toBoardSearchQueryParams({
|
||||||
filters: {
|
filters: {
|
||||||
|
|
@ -188,6 +243,7 @@ describe("toBoardSearchQueryParams", () => {
|
||||||
expect(params.internalOnly).toBeUndefined();
|
expect(params.internalOnly).toBeUndefined();
|
||||||
expect(params.hasUplift).toBeUndefined();
|
expect(params.hasUplift).toBeUndefined();
|
||||||
expect(params.upliftStatuses).toBeUndefined();
|
expect(params.upliftStatuses).toBeUndefined();
|
||||||
|
expect(params.unscheduledOnly).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps service types and completion docs onto board search params", () => {
|
it("maps service types and completion docs onto board search params", () => {
|
||||||
|
|
@ -350,3 +406,30 @@ describe("toUnassignedQueueQueryParams", () => {
|
||||||
expect(record).not.toHaveProperty("weekEnd");
|
expect(record).not.toHaveProperty("weekEnd");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("boardSearch query-key isolation", () => {
|
||||||
|
it("separates unscheduledOnly=true from the same facets without it", () => {
|
||||||
|
const without = toBoardSearchQueryParams({
|
||||||
|
filters: { ...EMPTY_ADVANCED_FILTERS, rescheduled: true },
|
||||||
|
});
|
||||||
|
const withUnscheduled = toBoardSearchQueryParams({
|
||||||
|
filters: { ...EMPTY_ADVANCED_FILTERS, rescheduled: true, unscheduledOnly: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryKeys.workOrders.boardSearch(without)).not.toEqual(
|
||||||
|
queryKeys.workOrders.boardSearch(withUnscheduled),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("separates pages under the same unscheduledOnly filter", () => {
|
||||||
|
const base = toBoardSearchQueryParams({
|
||||||
|
filters: { ...EMPTY_ADVANCED_FILTERS, unscheduledOnly: true },
|
||||||
|
page: 0,
|
||||||
|
});
|
||||||
|
const pageOne = { ...base, page: 1 };
|
||||||
|
|
||||||
|
expect(queryKeys.workOrders.boardSearch(base)).not.toEqual(
|
||||||
|
queryKeys.workOrders.boardSearch(pageOne),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -431,6 +431,29 @@ describe("matchesAdvancedFilters", () => {
|
||||||
matchesAdvancedFilters(makeRow({ scheduledOn: weekMonday, avetaRequired: false }), filters),
|
matchesAdvancedFilters(makeRow({ scheduledOn: weekMonday, avetaRequired: false }), filters),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps only undated rows when Unscheduled overrides any date range", () => {
|
||||||
|
const filters = {
|
||||||
|
...EMPTY_ADVANCED_FILTERS,
|
||||||
|
dateRange: "last-week" as const,
|
||||||
|
unscheduledOnly: true,
|
||||||
|
};
|
||||||
|
const dated = makeRow({ scheduledOn: "2026-06-23" });
|
||||||
|
const weekOnly = makeRow({
|
||||||
|
scheduledOn: "",
|
||||||
|
targetWeek: "2026-07-06",
|
||||||
|
scheduleWeekOnly: true,
|
||||||
|
});
|
||||||
|
const undatedAssigned = makeRow({
|
||||||
|
scheduledOn: "",
|
||||||
|
dispatcherId: "u1",
|
||||||
|
dispatcherName: "Alice",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(matchesAdvancedFilters(dated, filters)).toBe(false);
|
||||||
|
expect(matchesAdvancedFilters(weekOnly, filters)).toBe(true);
|
||||||
|
expect(matchesAdvancedFilters(undatedAssigned, filters)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("computeIsPastDue", () => {
|
describe("computeIsPastDue", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue