diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png index 8a314581..8ee41bac 100644 Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png differ diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png index 8f3a6331..0bcbb17a 100644 Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png differ diff --git a/e2e/dashboard/dashboard.visual.spec.ts b/e2e/dashboard/dashboard.visual.spec.ts index ab51543b..2e60bf41 100644 --- a/e2e/dashboard/dashboard.visual.spec.ts +++ b/e2e/dashboard/dashboard.visual.spec.ts @@ -191,7 +191,11 @@ async function mockDashboardApi(page: Page, userRoles: string) { return; } - if (pathname.includes("/Dashboard/Stats")) return fulfillJson(route, { data: STATS }); + if (pathname.includes("/Dashboard/Stats")) { + // The server reports no unassigned work for a dispatcher-scoped view. + const unassigned = userRoles === "Dispatcher" ? 0 : 11; + return fulfillJson(route, { data: { ...STATS, unassigned } }); + } if (pathname.includes("/Dashboard/Workload")) return fulfillJson(route, { data: WORKLOAD }); if (pathname.includes("/Dashboard/Performance")) return fulfillJson(route, { data: PERFORMANCE }); diff --git a/src/app/(protected)/dashboard/_components/dashboard-kpi-cards.tsx b/src/app/(protected)/dashboard/_components/dashboard-kpi-cards.tsx new file mode 100644 index 00000000..5329b2d2 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dashboard-kpi-cards.tsx @@ -0,0 +1,71 @@ +import { useNavigate } from "react-router"; +import AssignmentLateOutlinedIcon from "@mui/icons-material/AssignmentLateOutlined"; +import EventOutlinedIcon from "@mui/icons-material/EventOutlined"; +import PendingActionsIcon from "@mui/icons-material/PendingActions"; +import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; +import { Box } from "@mui/material"; +import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import type { DashboardStats } from "@/domain/dashboard/types/dashboard-stats"; +import { + avetaPendingDrilldownSearch, + scheduledTomorrowDrilldownSearch, + unassignedDrilldownSearch, + UPLIFTS_ROUTE, + workOrderDrilldownUrl, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; + +interface DashboardKpiCardsProps { + stats?: DashboardStats; + loading: boolean; + range: DashboardRangeKey; +} + +export function DashboardKpiCards({ stats, loading, range }: DashboardKpiCardsProps) { + const navigate = useNavigate(); + const scheduledTomorrow = stats?.scheduledTomorrow ?? 0; + const pendingUplifts = stats?.pendingUplifts ?? 0; + const avetaPending = stats?.avetaPending ?? 0; + const unassigned = stats?.unassigned ?? 0; + + return ( + + } + tone="primary" + value={stats?.scheduledTomorrow} + loading={loading} + active={scheduledTomorrow > 0} + onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))} + /> + } + tone="info" + value={stats?.pendingUplifts} + loading={loading} + active={pendingUplifts > 0} + onSelect={() => navigate(UPLIFTS_ROUTE)} + /> + } + tone="warning" + value={stats?.avetaPending} + loading={loading} + active={avetaPending > 0} + onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))} + /> + } + tone="warning" + value={stats?.unassigned} + loading={loading} + active={unassigned > 0} + onSelect={() => navigate(workOrderDrilldownUrl(unassignedDrilldownSearch(range)))} + /> + + ); +} diff --git a/src/app/(protected)/dashboard/index.tsx b/src/app/(protected)/dashboard/index.tsx index 60795d8f..9dd4f6ff 100644 --- a/src/app/(protected)/dashboard/index.tsx +++ b/src/app/(protected)/dashboard/index.tsx @@ -1,11 +1,7 @@ -import { useNavigate } from "react-router"; -import EventOutlinedIcon from "@mui/icons-material/EventOutlined"; -import PendingActionsIcon from "@mui/icons-material/PendingActions"; -import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import { Box } from "@mui/material"; import { PageHeader } from "@/components/ui/page-header"; import { DashboardHeaderActions } from "@/app/(protected)/dashboard/_components/dashboard-header-actions"; -import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card"; +import { DashboardKpiCards } from "@/app/(protected)/dashboard/_components/dashboard-kpi-cards"; import { DashboardTrendCard } from "@/app/(protected)/dashboard/_components/dashboard-trend-card"; import { DispatcherTablesSection } from "@/app/(protected)/dashboard/_components/dispatcher-tables-section"; import { StatusDistributionCard } from "@/app/(protected)/dashboard/_components/status-distribution-card"; @@ -19,15 +15,8 @@ import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-st import { useDashboardVendorInsights } from "@/domain/dashboard/use-cases/use-dashboard-vendor-insights"; import { useDashboardWorkload } from "@/domain/dashboard/use-cases/use-dashboard-workload"; import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; -import { - avetaPendingDrilldownSearch, - scheduledTomorrowDrilldownSearch, - UPLIFTS_ROUTE, - workOrderDrilldownUrl, -} from "@/domain/dashboard/utils/work-order-drilldown-links"; export default function DashboardPage() { - const navigate = useNavigate(); const scope = useDashboardScope(); const { canViewAll, range, dispatcherId, workloadPage, performancePage } = scope; @@ -62,35 +51,7 @@ export default function DashboardPage() { /> } /> - - } - tone="primary" - value={stats.data?.scheduledTomorrow} - loading={stats.isLoading} - active={(stats.data?.scheduledTomorrow ?? 0) > 0} - onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))} - /> - } - tone="info" - value={stats.data?.pendingUplifts} - loading={stats.isLoading} - active={(stats.data?.pendingUplifts ?? 0) > 0} - onSelect={() => navigate(UPLIFTS_ROUTE)} - /> - } - tone="warning" - value={stats.data?.avetaPending} - loading={stats.isLoading} - active={(stats.data?.avetaPending ?? 0) > 0} - onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))} - /> - + setDraft((prev) => ({ ...prev, dateRange: option.key }))} + onClick={() => + setDraft((prev) => ({ + ...prev, + dateRange: prev.dateRange === option.key ? null : option.key, + })) + } /> ))} { const searchParams: Record = { @@ -223,8 +227,13 @@ export const workOrdersApi = { "Unable to verify the work order number: search result set exceeded the safety page limit.", ); } + // Every week plus undated rows: a WO# must be unique regardless of schedule. const result = await workOrdersApi.searchBoard({ search: normalized, + datePreset: "Custom", + dateFrom: ADVANCED_SEARCH_ALL_WEEKS_FROM, + dateTo: ADVANCED_SEARCH_ALL_WEEKS_TO, + includeDateless: true, page, pageSize, }); diff --git a/src/domain/work-orders/types/work-order-advanced-filters.ts b/src/domain/work-orders/types/work-order-advanced-filters.ts index c187aee8..66f831dc 100644 --- a/src/domain/work-orders/types/work-order-advanced-filters.ts +++ b/src/domain/work-orders/types/work-order-advanced-filters.ts @@ -23,7 +23,8 @@ export type DateRangeKey = export type CompletionDocFilter = "No" | "Yes" | "NN"; export interface WorkOrderAdvancedFilters { - dateRange: DateRangeKey; + /** `null` = no date range selected: search every week. */ + dateRange: DateRangeKey | null; customFrom: string; customTo: string; /** invariant: independent of DateRangeKey; never sent as datePreset=Unscheduled. */ @@ -48,7 +49,7 @@ export interface WorkOrderAdvancedFilters { } export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { - dateRange: "this-week", + dateRange: null, customFrom: "", customTo: "", unscheduledOnly: false, diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 3b06e4fb..317e7cad 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -318,6 +318,11 @@ export interface WorkOrderBoardSearchQueryParams { * legacy wire behavior. */ unscheduledOnly?: boolean; + /** + * `true` adds open rows with no date to the range. Sent only when no + * date range is selected, so an explicit range strictly narrows the results. + */ + includeDateless?: boolean; hasUplift?: boolean; upliftStatuses?: string[]; myWorkOrders?: boolean; diff --git a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts index edc707a0..c7222b82 100644 --- a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts +++ b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts @@ -60,7 +60,7 @@ function severityLabel(level: WorkOrderSeverity): string { } export function appendDateChip(chips: AdvancedFilterChip[], filters: WorkOrderAdvancedFilters) { - if (filters.dateRange !== "this-week") { + if (filters.dateRange !== null) { const value = filters.dateRange === "custom" ? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}` @@ -71,7 +71,7 @@ export function appendDateChip(chips: AdvancedFilterChip[], filters: WorkOrderAd value, clear: (current) => ({ ...current, - dateRange: "this-week", + dateRange: null, customFrom: "", customTo: "", }), diff --git a/src/domain/work-orders/utils/advanced-filter-predicates.ts b/src/domain/work-orders/utils/advanced-filter-predicates.ts index 5217261b..45fb611e 100644 --- a/src/domain/work-orders/utils/advanced-filter-predicates.ts +++ b/src/domain/work-orders/utils/advanced-filter-predicates.ts @@ -2,18 +2,44 @@ import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-o import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { dateRangeFor } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { + ADVANCED_SEARCH_ALL_WEEKS_FROM, + ADVANCED_SEARCH_ALL_WEEKS_TO, +} from "@/domain/work-orders/utils/board-query-params"; +import { isTerminalCarryOverStatus } from "@/domain/work-orders/utils/carry-over-display"; +import { addDaysIso } from "@/domain/work-orders/utils/wizard-date-utils"; type AdvancedFilterPredicate = ( row: WorkOrderTableRow, filters: WorkOrderAdvancedFilters, ) => boolean; +const isOpenUndated = (row: WorkOrderTableRow): boolean => + !row.scheduledOn && !isTerminalCarryOverStatus(row.status); + +// Mirrors the board search (ApplyDateRangeFilter and ApplyUnscheduledOnlyFilter) +// so the page and /board/search keep the same rows: a row is in a range +// when its date is, or when it is week-only and its target week overlaps. With +// no range, every week is searched and open undated rows are added. Closed +// undated rows are never unscheduled. const matchesDateRange: AdvancedFilterPredicate = (row, filters) => { if (filters.unscheduledOnly) { - return !row.scheduledOn; + return isOpenUndated(row); } - const [from, to] = dateRangeFor(filters); - return !row.scheduledOn || (row.scheduledOn >= from && row.scheduledOn <= to); + const [from, to] = dateRangeFor(filters) ?? [ + ADVANCED_SEARCH_ALL_WEEKS_FROM, + ADVANCED_SEARCH_ALL_WEEKS_TO, + ]; + if (row.scheduledOn && row.scheduledOn >= from && row.scheduledOn <= to) return true; + if ( + row.scheduleWeekOnly && + Boolean(row.targetWeek) && + row.targetWeek <= to && + addDaysIso(row.targetWeek, 6) >= from + ) { + return true; + } + return filters.dateRange === null && isOpenUndated(row); }; const matchesSite: AdvancedFilterPredicate = (row, filters) => diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts index 3f8f5ce5..871bd0cd 100644 --- a/src/domain/work-orders/utils/advanced-filters-utils.ts +++ b/src/domain/work-orders/utils/advanced-filters-utils.ts @@ -38,7 +38,7 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte } const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): boolean[] => [ - filters.dateRange !== "this-week", + filters.dateRange !== null, Boolean(filters.sites?.size), Boolean(filters.types?.size), Boolean(filters.severities?.size), @@ -70,7 +70,9 @@ function monthEnd(y: number, m: number): string { return `${y}-${pad2(m + 1)}-${pad2(new Date(y, m + 1, 0).getDate())}`; } -export function dateRangeFor(filters: WorkOrderAdvancedFilters): [string, string] { +/** Inclusive [from, to] for the selected range; `null` when no range is selected. */ +export function dateRangeFor(filters: WorkOrderAdvancedFilters): [string, string] | null { + if (filters.dateRange === null) return null; const today = todayIso(); const ty = Number(today.slice(0, 4)); const tm = Number(today.slice(5, 7)) - 1; @@ -144,6 +146,6 @@ export function cloneAdvancedFilters(filters: WorkOrderAdvancedFilters): WorkOrd return cloneFilters(filters); } -export function isDefaultDateRange(dateRange: DateRangeKey): boolean { - return dateRange === "this-week"; +export function isDefaultDateRange(dateRange: DateRangeKey | null): boolean { + return dateRange === null; } diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index c10519b8..998d0605 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -1,8 +1,5 @@ import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; -import { - EMPTY_ADVANCED_FILTERS, - type WorkOrderAdvancedFilters, -} from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters"; import { BOARD_DISPATCHER_UNASSIGNED, @@ -158,17 +155,21 @@ function resolveAdvancedSearchDate(filters: WorkOrderAdvancedFilters): { datePreset?: WorkOrderBoardSearchQueryParams["datePreset"]; dateFrom?: string; dateTo?: string; + includeDateless?: boolean; } { // 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) { + // No range selected searches every week, undated rows included. Any + // selected range (This week included) strictly narrows the results. + if (filters.dateRange === null) { return { datePreset: "Custom", dateFrom: ADVANCED_SEARCH_ALL_WEEKS_FROM, dateTo: ADVANCED_SEARCH_ALL_WEEKS_TO, + includeDateless: true, }; } @@ -213,6 +214,7 @@ export function toBoardSearchQueryParams(input: { datePreset: date.datePreset, dateFrom: date.dateFrom, dateTo: date.dateTo, + includeDateless: date.includeDateless, sites: filters.sites.size > 0 ? [...filters.sites] : undefined, types: typeFilters.types, overdue: typeFilters.overdue, @@ -267,5 +269,6 @@ export function toUnassignedQueueQueryParams( datePreset: "Custom", dateFrom: UNASSIGNED_QUEUE_DATE_FROM, dateTo: UNASSIGNED_QUEUE_DATE_TO, + includeDateless: true, }; } diff --git a/src/test/app/(protected)/dashboard/dashboard-page.test.tsx b/src/test/app/(protected)/dashboard/dashboard-page.test.tsx index 37c3632e..b74fd594 100644 --- a/src/test/app/(protected)/dashboard/dashboard-page.test.tsx +++ b/src/test/app/(protected)/dashboard/dashboard-page.test.tsx @@ -63,6 +63,7 @@ const statsData = { scheduledTomorrow: 3, pendingUplifts: 5, avetaPending: 0, + unassigned: 4, breakdown: [ { status: "PM", count: 7 }, { status: "Completed", count: 0 }, @@ -88,6 +89,11 @@ function renderDashboard() { return renderWithProviders(); } +function lastNavigation(): string { + const calls = navigate.mock.calls; + return String(calls[calls.length - 1]?.[0]); +} + describe("DashboardPage", () => { beforeEach(() => { authUser.current = { id: "99", userRoles: "Admin" }; @@ -120,6 +126,7 @@ describe("DashboardPage", () => { "Scheduled Tomorrow", "Pending Uplifts", "Aveta Pending", + "Unassigned", "Work Order Breakdown", "Status Distribution", "Dispatcher Workload", @@ -257,6 +264,29 @@ describe("DashboardPage", () => { expect(lastRequestedPage).toBe(1); }); + it("shows unassigned WOs for the selected period and drills into that list (SH-392)", () => { + renderDashboard(); + + const card = screen.getByRole("button", { name: /Unassigned/ }); + expect(card).toHaveTextContent("4"); + fireEvent.click(card); + + const weekUrl = new URL(lastNavigation(), "http://x"); + expect(weekUrl.pathname).toBe("/workorders"); + expect(weekUrl.searchParams.get("dispatchers")).toBe("__unassigned"); + expect(weekUrl.searchParams.get("statuses")).not.toContain("Completed"); + expect(weekUrl.searchParams.get("dateFrom")).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(weekUrl.searchParams.get("dateTo")).toMatch(/^\d{4}-\d{2}-\d{2}$/); + + fireEvent.click(screen.getByRole("button", { name: "All time" })); + fireEvent.click(screen.getByRole("button", { name: /Unassigned/ })); + + const allTimeUrl = new URL(lastNavigation(), "http://x"); + expect(allTimeUrl.searchParams.get("dispatchers")).toBe("__unassigned"); + expect(allTimeUrl.searchParams.has("dateFrom")).toBe(false); + expect(allTimeUrl.searchParams.has("dateTo")).toBe(false); + }); + it("keeps drilldown dates deterministic on the business clock", () => { const today = businessTodayIso(); diff --git a/src/test/app/(protected)/workorders/adv-date-range-section.test.tsx b/src/test/app/(protected)/workorders/adv-date-range-section.test.tsx new file mode 100644 index 00000000..8ffe1f60 --- /dev/null +++ b/src/test/app/(protected)/workorders/adv-date-range-section.test.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react"; +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { AdvDateRangeSection } from "@/app/(protected)/workorders/_components/list/advanced-search/adv-date-range-section"; +import { + EMPTY_ADVANCED_FILTERS, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { toBoardSearchQueryParams } from "@/domain/work-orders/utils/board-query-params"; +import { renderWithProviders } from "@/test/test-utils"; + +const onDraft = vi.fn<(draft: WorkOrderAdvancedFilters) => void>(); + +function latestDraft(): WorkOrderAdvancedFilters { + return onDraft.mock.calls[onDraft.mock.calls.length - 1][0]; +} + +function Harness() { + const [draft, setDraft] = useState(() => cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS)); + useEffect(() => { + onDraft(draft); + }, [draft]); + return ; +} + +function openSection() { + onDraft.mockClear(); + renderWithProviders(); + const header = screen.queryByRole("button", { name: /Date range/i }); + if (header && !screen.queryByRole("button", { name: /^This week$/i })) { + fireEvent.click(header); + } +} + +describe("Advanced Filters date range (SH-391)", () => { + it("starts with no range selected, so the search spans every week", () => { + openSection(); + + for (const label of ["This week", "Last week", "Last 3 months", "Custom"]) { + expect(screen.getByRole("button", { name: new RegExp(`^${label}$`, "i") })).toHaveAttribute( + "aria-pressed", + "false", + ); + } + expect(toBoardSearchQueryParams({ filters: latestDraft() }).includeDateless).toBe(true); + }); + + it("narrows to This week once picked, and clicking it again clears the range", () => { + openSection(); + const thisWeek = screen.getByRole("button", { name: /^This week$/i }); + + fireEvent.click(thisWeek); + expect(thisWeek).toHaveAttribute("aria-pressed", "true"); + const narrowed = toBoardSearchQueryParams({ filters: latestDraft() }); + expect(narrowed.datePreset).toBe("ThisWeek"); + expect(narrowed.includeDateless).toBeUndefined(); + + fireEvent.click(thisWeek); + expect(thisWeek).toHaveAttribute("aria-pressed", "false"); + expect(latestDraft().dateRange).toBeNull(); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx index 6e64219f..3927bfaf 100644 --- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx @@ -367,7 +367,7 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { expect(unscheduled).toHaveAttribute("aria-pressed", "true"); expect(within(sheet).getByRole("button", { name: /^This week$/i })).toHaveAttribute( "aria-pressed", - "true", + "false", ); expect(within(sheet).queryByLabelText(/^From$/i)).not.toBeInTheDocument(); expect(within(sheet).queryByLabelText(/^To$/i)).not.toBeInTheDocument(); diff --git a/src/test/domain/dashboard/dashboard-api.test.ts b/src/test/domain/dashboard/dashboard-api.test.ts index 95f8c258..27a87695 100644 --- a/src/test/domain/dashboard/dashboard-api.test.ts +++ b/src/test/domain/dashboard/dashboard-api.test.ts @@ -27,6 +27,7 @@ describe("dashboardApi", () => { ScheduledTomorrow: "3", PendingUplifts: 2, AvetaPending: "1", + Unassigned: "6", Breakdown: [ { Status: "Scheduled", Count: "7" }, { Status: "Completed", Count: 4 }, @@ -52,6 +53,7 @@ describe("dashboardApi", () => { scheduledTomorrow: 3, pendingUplifts: 2, avetaPending: 1, + unassigned: 6, breakdown: [ { status: "Scheduled", count: 7 }, { status: "Completed", count: 4 }, diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index 27ce9129..7ea19716 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -420,6 +420,14 @@ describe("workOrdersApi.searchBoard", () => { }); }); +/** A WO# lookup spans every week and undated rows (SH-391). */ +const ALL_WEEKS_SEARCH = { + datePreset: "Custom", + dateFrom: "2000-01-01", + dateTo: "2099-12-31", + includeDateless: "true", +}; + describe("workOrdersApi.findByWoNumber", () => { beforeEach(() => { apiGet.mockReset(); @@ -450,6 +458,7 @@ describe("workOrdersApi.findByWoNumber", () => { const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams; expect(Object.fromEntries(searchParams)).toEqual({ search: "10000001235", + ...ALL_WEEKS_SEARCH, page: "0", pageSize: "100", }); @@ -519,6 +528,7 @@ describe("workOrdersApi.findByWoNumber", () => { const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams; expect(Object.fromEntries(searchParams)).toEqual({ search: "9", + ...ALL_WEEKS_SEARCH, page: "0", pageSize: "100", }); diff --git a/src/test/domain/work-orders/dashboard-drilldown-params.test.ts b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts index 8ff30f72..7faab8b0 100644 --- a/src/test/domain/work-orders/dashboard-drilldown-params.test.ts +++ b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; import { parseDashboardDrilldownParams } from "@/domain/work-orders/utils/dashboard-drilldown-params"; -import { avetaPendingDrilldownSearch } from "@/domain/dashboard/utils/work-order-drilldown-links"; +import { + avetaPendingDrilldownSearch, + unassignedDrilldownSearch, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; +import { NON_TERMINAL_LIFECYCLE_STATUSES } from "@/domain/work-orders/types/work-order-board"; +import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; +import { buildAdvancedFilterChips } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { toBoardSearchQueryParams } from "@/domain/work-orders/utils/board-query-params"; describe("parseDashboardDrilldownParams", () => { it("returns null when no recognized param is present", () => { @@ -60,6 +67,41 @@ describe("parseDashboardDrilldownParams", () => { expect(filters?.regions).toEqual(new Set(["California"])); }); + it("turns the Unassigned tile link into the board search the Dashboard counts (SH-392)", () => { + const allTime = parseDashboardDrilldownParams(unassignedDrilldownSearch("all-time")); + expect(allTime).not.toBeNull(); + expect(toBoardSearchQueryParams({ filters: allTime! })).toMatchObject({ + dispatchers: ["__unassigned__"], + statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES], + datePreset: "Custom", + dateFrom: "2000-01-01", + dateTo: "2099-12-31", + includeDateless: true, + }); + + const week = parseDashboardDrilldownParams(unassignedDrilldownSearch("this-week")); + const weekParams = toBoardSearchQueryParams({ filters: week! }); + expect(week?.dateRange).toBe("custom"); + expect(weekParams).toMatchObject({ + dispatchers: ["__unassigned__"], + statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES], + datePreset: "Custom", + }); + expect(weekParams.includeDateless).toBeUndefined(); + }); + + it("opens the Unassigned tile link as the Unassigned option of Assigned to", () => { + const filters = parseDashboardDrilldownParams(unassignedDrilldownSearch("this-week")); + expect(filters?.dispatchers).toEqual(new Set([ASSIGNEE_FILTER_UNASSIGNED])); + + const chips = buildAdvancedFilterChips(filters!, { dispatcherNameOf: (id) => id }); + const assignedTo = chips.filter((chip) => chip.group === "Assigned to"); + expect(assignedTo.map((chip) => chip.value)).toEqual(["Unassigned"]); + + const cleared = assignedTo[0]!.clear(filters!); + expect(cleared.dispatchers.size).toBe(0); + }); + it("rejects partial or malformed date ranges", () => { expect( parseDashboardDrilldownParams( diff --git a/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts b/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts index e26229d7..f065e4e0 100644 --- a/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts +++ b/src/test/domain/work-orders/utils/advanced-filters-utils.test.ts @@ -40,12 +40,12 @@ describe("countAdvancedFilters", () => { expect(countAdvancedFilters(filters)).toBe(3); }); - it("counts Unscheduled +1 with the this-week default still in place", () => { + it("counts Unscheduled +1 with no date range selected", () => { const filters = { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), unscheduledOnly: true, }; - expect(filters.dateRange).toBe("this-week"); + expect(filters.dateRange).toBeNull(); expect(countAdvancedFilters(filters)).toBe(1); }); diff --git a/src/test/domain/work-orders/utils/board-query-params.test.ts b/src/test/domain/work-orders/utils/board-query-params.test.ts index 486cd60e..3daaf798 100644 --- a/src/test/domain/work-orders/utils/board-query-params.test.ts +++ b/src/test/domain/work-orders/utils/board-query-params.test.ts @@ -130,9 +130,43 @@ describe("toBoardSearchQueryParams", () => { customTo: "2026-06-30", }, }); - expect(thisWeek.datePreset).toBe("Custom"); - expect(thisWeek.dateFrom).toBe("2000-01-01"); - expect(thisWeek.dateTo).toBe("2099-12-31"); + expect(thisWeek.datePreset).toBe("ThisWeek"); + expect(thisWeek.dateFrom).toBeUndefined(); + expect(thisWeek.dateTo).toBeUndefined(); + }); + + it("narrows strictly for every selected range, This week included (SH-391)", () => { + for (const dateRange of ["this-week", "last-week", "last-3-months", "custom"] as const) { + const params = toBoardSearchQueryParams({ + filters: { + ...EMPTY_ADVANCED_FILTERS, + dateRange, + customFrom: "2026-09-21", + customTo: "2026-09-25", + dispatchers: new Set(["__unassigned__"]), + }, + }); + expect(params.includeDateless).toBeUndefined(); + expect(params.dateFrom).not.toBe("2000-01-01"); + } + }); + + it("searches every week, undated rows included, when no range is selected (SH-391)", () => { + const params = toBoardSearchQueryParams({ + filters: { ...EMPTY_ADVANCED_FILTERS, dispatchers: new Set(["__unassigned__"]) }, + }); + expect(EMPTY_ADVANCED_FILTERS.dateRange).toBeNull(); + expect(params).toMatchObject({ + datePreset: "Custom", + dateFrom: "2000-01-01", + dateTo: "2099-12-31", + includeDateless: true, + dispatchers: ["__unassigned__"], + }); + }); + + it("keeps undated rows in the pinned Unassigned queue (SH-391)", () => { + expect(toUnassignedQueueQueryParams().includeDateless).toBe(true); }); it("sends unscheduledOnly=true with no faked Custom date window (SH-338)", () => { diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index 022d36fe..296c7b6d 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -454,6 +454,56 @@ describe("matchesAdvancedFilters", () => { expect(matchesAdvancedFilters(weekOnly, filters)).toBe(true); expect(matchesAdvancedFilters(undatedAssigned, filters)).toBe(true); }); + + it("matches a range the same way the board search does (SH-391)", () => { + const filters = { + ...EMPTY_ADVANCED_FILTERS, + dateRange: "custom" as const, + customFrom: "2026-09-23", + customTo: "2026-09-25", + }; + + // Week-only: a target week overlapping the range matches, whatever date the + // row still carries. + const weekOnlyStaleDate = makeRow({ + scheduledOn: "2026-10-02", + targetWeek: "2026-09-21", + scheduleWeekOnly: true, + }); + const weekOnlyPreviousWeek = makeRow({ + scheduledOn: "", + targetWeek: "2026-09-14", + scheduleWeekOnly: true, + }); + const weekOnlyOutside = makeRow({ + scheduledOn: "", + targetWeek: "2026-09-28", + scheduleWeekOnly: true, + }); + const datedOutsideWithWeek = makeRow({ scheduledOn: "2026-10-02", targetWeek: "2026-09-21" }); + const undated = makeRow({ scheduledOn: "", targetWeek: "" }); + + expect(matchesAdvancedFilters(weekOnlyStaleDate, filters)).toBe(true); + expect(matchesAdvancedFilters(weekOnlyPreviousWeek, filters)).toBe(false); + expect(matchesAdvancedFilters(weekOnlyOutside, filters)).toBe(false); + expect(matchesAdvancedFilters(datedOutsideWithWeek, filters)).toBe(false); + expect(matchesAdvancedFilters(undated, filters)).toBe(false); + // No range: every week, plus open undated rows. Closed undated rows are + // never unscheduled, with or without the Unscheduled filter. + const noRange = { ...filters, dateRange: null }; + const unscheduledOnly = { ...filters, unscheduledOnly: true }; + const completedUndated = makeRow({ scheduledOn: "", targetWeek: "", status: "Completed" }); + const canceledUndated = makeRow({ scheduledOn: "", targetWeek: "", status: "Canceled" }); + + expect(matchesAdvancedFilters(undated, noRange)).toBe(true); + expect(matchesAdvancedFilters(datedOutsideWithWeek, noRange)).toBe(true); + expect(matchesAdvancedFilters(weekOnlyOutside, noRange)).toBe(true); + expect(matchesAdvancedFilters(completedUndated, noRange)).toBe(false); + expect(matchesAdvancedFilters(canceledUndated, noRange)).toBe(false); + expect(matchesAdvancedFilters(undated, unscheduledOnly)).toBe(true); + expect(matchesAdvancedFilters(completedUndated, unscheduledOnly)).toBe(false); + expect(matchesAdvancedFilters(canceledUndated, unscheduledOnly)).toBe(false); + }); }); describe("computeIsPastDue", () => {