diff --git a/e2e/work-orders/week-day-navigation.spec.ts b/e2e/work-orders/week-day-navigation.spec.ts
new file mode 100644
index 00000000..5ec5402b
--- /dev/null
+++ b/e2e/work-orders/week-day-navigation.spec.ts
@@ -0,0 +1,124 @@
+import { expect, test, type Page, type Route } from "@playwright/test";
+
+const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
+
+const TEST_USER = {
+ token: "wo-week-day-e2e-token",
+ expiration: "2030-01-01T00:00:00.000Z",
+ email: "admin@seahavenind.com",
+ userRoles: "Admin",
+ phoneNumber: "5551234567",
+ fullname: "Work Order E2E Admin",
+ id: "1",
+};
+
+async function fulfillJson(route: Route, body: unknown, status = 200) {
+ await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
+}
+
+function boardRow(id: number, woNumber: string, scheduledDate: string | null) {
+ return {
+ id,
+ woNumber,
+ workOrderType: 2,
+ siteCode: woNumber,
+ lifecycleStatusLabel: "Scheduled",
+ scheduledDate,
+ dispatcherId: "1",
+ dispatcherName: "Pat Dispatcher",
+ pm: "HVAC",
+ };
+}
+
+async function mockWorkOrderBoard(page: Page) {
+ await page.route("**/api/**", (route) => {
+ const pathname = new URL(route.request().url()).pathname;
+ if (!pathname.startsWith("/api/")) {
+ return route.fallback();
+ }
+ if (pathname.includes("/workorders/board")) {
+ return route.fallback();
+ }
+ return fulfillJson(route, { data: [] });
+ });
+ await page.route("**/api/workorders/board*", (route) => {
+ if (route.request().url().includes("board/search")) {
+ return route.fallback();
+ }
+ const url = new URL(route.request().url());
+ const weekStart = url.searchParams.get("weekStart") ?? "2026-08-17";
+ const scheduled =
+ weekStart === "2026-08-17"
+ ? [boardRow(1, "MON-WO", "2026-08-17"), boardRow(2, "WED-WO", "2026-08-19")]
+ : [boardRow(4, "PREV-MON-WO", "2026-08-10")];
+ return fulfillJson(route, {
+ weekStart,
+ weekEnd: weekStart === "2026-08-17" ? "2026-08-21" : "2026-08-14",
+ counts: { returned: scheduled.length + 1, total: scheduled.length + 1 },
+ unscheduled: [boardRow(3, "UNSCHED-WO", null)],
+ scheduled,
+ });
+ });
+ await page.route("**/api/workorders/board/search*", (route) =>
+ fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 }),
+ );
+}
+
+test.describe("Work Orders week/day navigation (SH-240)", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.clock.install({ time: new Date(FROZEN_NOW) });
+ await page.clock.resume();
+ await page.addInitScript(
+ (user) => localStorage.setItem("auth", JSON.stringify(user)),
+ TEST_USER,
+ );
+ await mockWorkOrderBoard(page);
+ });
+
+ test("scopes the table to one day and excludes Unscheduled", async ({ page }) => {
+ await page.goto("/workorders");
+ await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible({
+ timeout: 15_000,
+ });
+ await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Week 8/17–8/21" })).toContainText("Live");
+
+ await expect(page.getByRole("tab", { name: "Mon · 08/17" })).toBeVisible();
+ await expect(page.getByRole("tab", { name: "Wed · 08/19" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+
+ await expect(page.getByText("WED-WO").first()).toBeVisible();
+ await expect(page.getByText("MON-WO")).toHaveCount(0);
+ await expect(page.getByText("UNSCHED-WO")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: /Unscheduled/ })).toHaveCount(0);
+
+ await page.getByRole("tab", { name: "Mon · 08/17" }).click();
+ await expect(page.getByRole("tab", { name: "Mon · 08/17" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await expect(page.getByText("MON-WO").first()).toBeVisible();
+ await expect(page.getByText("WED-WO")).toHaveCount(0);
+ await expect(page.getByText("UNSCHED-WO")).toHaveCount(0);
+ });
+
+ test("updates day tabs and selects Monday when the week changes", async ({ page }) => {
+ await page.goto("/workorders");
+ await expect(page.getByRole("tab", { name: "Wed · 08/19" })).toBeVisible({ timeout: 15_000 });
+
+ await page.getByRole("button", { name: "Week 8/17–8/21" }).click();
+ await page.getByRole("button", { name: /Aug 10/ }).click();
+
+ await expect(page.getByRole("button", { name: "Week 8/10–8/14" })).toBeVisible();
+ await expect(page.getByRole("tab", { name: "Mon · 08/10" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ await expect(page.getByRole("tab", { name: "Fri · 08/14" })).toBeVisible();
+ await expect(page.getByRole("tab", { name: "Mon · 08/17" })).toHaveCount(0);
+ await expect(page.getByText("PREV-MON-WO").first()).toBeVisible();
+ await expect(page.getByText("UNSCHED-WO")).toHaveCount(0);
+ });
+});
diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/day-tabs.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/day-tabs.tsx
new file mode 100644
index 00000000..94308b41
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/filter-bar/day-tabs.tsx
@@ -0,0 +1,39 @@
+import { NavPillButton } from "@/components/ui/nav-pill-button";
+import { formatDayTabLabel, weekdaysMonFri } from "@/domain/work-orders/utils/week-day-nav-utils";
+
+type DayTabsProps = {
+ weekMonday: string;
+ selectedDayIso: string;
+ onChange: (dayIso: string) => void;
+ disabled?: boolean;
+};
+
+export function DayTabs({ weekMonday, selectedDayIso, onChange, disabled = false }: DayTabsProps) {
+ const days = weekdaysMonFri(weekMonday);
+
+ return (
+
+ {days.map((iso) => {
+ const label = formatDayTabLabel(iso);
+ const active = iso === selectedDayIso;
+ return (
+ onChange(iso)}
+ >
+ {label}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/week-nav.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/week-nav.tsx
deleted file mode 100644
index ab8fc786..00000000
--- a/src/app/(protected)/workorders/_components/list/filter-bar/week-nav.tsx
+++ /dev/null
@@ -1,133 +0,0 @@
-import { useEffect, useMemo, useState } from "react";
-
-import { ChevronLeft, ChevronRight } from "lucide-react";
-
-import { NavArrowButton } from "@/components/ui/nav-arrow-button";
-
-import { NavPillButton } from "@/components/ui/nav-pill-button";
-
-import {
- buildWeekWindow,
- isFutureWeek,
- isLiveWeek,
-} from "@/domain/work-orders/utils/list-week-utils";
-
-type WeekNavProps = {
- weekMonday: string;
-
- onChange: (mondayIso: string) => void;
-
- disabled?: boolean;
-};
-
-const VISIBLE_COUNT = 3;
-
-const WINDOW_BEFORE = 12;
-
-const WINDOW_AFTER = 1;
-
-function WeekLiveBadge() {
- return (
-
- Live
-
- );
-}
-
-export function WeekNav({ weekMonday, onChange, disabled = false }: WeekNavProps) {
- const weeks = useMemo(
- () => buildWeekWindow(weekMonday, WINDOW_BEFORE, WINDOW_AFTER),
-
- [weekMonday],
- );
-
- const activeIndex = useMemo(
- () => weeks.findIndex((week) => week.mondayIso === weekMonday),
-
- [weeks, weekMonday],
- );
-
- const [offset, setOffset] = useState(() => Math.max(0, activeIndex >= 0 ? activeIndex - 1 : 0));
-
- useEffect(() => {
- if (activeIndex < 0) return;
-
- const maxOffset = Math.max(0, weeks.length - VISIBLE_COUNT);
-
- setOffset((current) => {
- if (activeIndex < current) return Math.min(activeIndex, maxOffset);
-
- if (activeIndex > current + VISIBLE_COUNT - 1) {
- return Math.min(Math.max(0, activeIndex - (VISIBLE_COUNT - 1)), maxOffset);
- }
-
- return current;
- });
- }, [activeIndex, weeks.length]);
-
- const visible = weeks.slice(offset, offset + VISIBLE_COUNT);
-
- const canGoBack = offset > 0;
-
- const canGoForward = offset + VISIBLE_COUNT < weeks.length;
-
- return (
-
- setOffset((current) => current - 1)}
- aria-label="Previous weeks"
- >
-
-
-
- {visible.map((week) => {
- const isActive = weekMonday === week.mondayIso;
-
- const isDisabled = isFutureWeek(week.mondayIso);
-
- const showLiveBadge = isLiveWeek(week.mondayIso);
-
- return (
- onChange(week.mondayIso)}
- >
- {week.label}
-
- {showLiveBadge && }
-
- );
- })}
-
- setOffset((current) => current + 1)}
- aria-label="Next weeks"
- >
-
-
-
- );
-}
diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/week-selector.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/week-selector.tsx
new file mode 100644
index 00000000..b7403964
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/filter-bar/week-selector.tsx
@@ -0,0 +1,81 @@
+import { Popover } from "@mui/material";
+import { ChevronDown } from "lucide-react";
+import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker";
+import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
+import {
+ compactWeekLabel,
+ isFutureWeek,
+ isLiveWeek,
+} from "@/domain/work-orders/utils/list-week-utils";
+import { useMenuAnchor } from "@/hooks/use-menu-anchor";
+import { cn } from "@/lib/utils";
+
+type WeekSelectorProps = {
+ weekMonday: string;
+ onChange: (mondayIso: string) => void;
+ disabled?: boolean;
+};
+
+function WeekLiveBadge() {
+ return (
+
+ Live
+
+ );
+}
+
+export function WeekSelector({ weekMonday, onChange, disabled = false }: WeekSelectorProps) {
+ const { anchorEl, open, setAnchor, close } = useMenuAnchor();
+ const live = isLiveWeek(weekMonday);
+ const label = compactWeekLabel(weekMonday);
+
+ const handlePick = (mondayIso: string) => {
+ if (isFutureWeek(mondayIso)) {
+ return;
+ }
+ onChange(mondayIso);
+ close();
+ };
+
+ return (
+ <>
+
+ {label}
+ {live && }
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx b/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx
index 3a1c114f..85c71275 100644
--- a/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx
+++ b/src/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar.tsx
@@ -1,12 +1,15 @@
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters";
+import { DayTabs } from "@/app/(protected)/workorders/_components/list/filter-bar/day-tabs";
import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter";
-import { WeekNav } from "@/app/(protected)/workorders/_components/list/filter-bar/week-nav";
+import { WeekSelector } from "@/app/(protected)/workorders/_components/list/filter-bar/week-selector";
import { WoTypeSegment } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment";
type WorkOrdersFilterBarProps = {
weekMonday: string;
onWeekChange: (mondayIso: string) => void;
+ selectedDayIso: string;
+ onSelectedDayChange: (dayIso: string) => void;
dispatcherIds: Set;
onDispatcherChange: (ids: Set) => void;
typeFilter: WorkOrderTypeFilter;
@@ -22,6 +25,8 @@ type WorkOrdersFilterBarProps = {
export function WorkOrdersFilterBar({
weekMonday,
onWeekChange,
+ selectedDayIso,
+ onSelectedDayChange,
dispatcherIds,
onDispatcherChange,
typeFilter,
@@ -37,21 +42,29 @@ export function WorkOrdersFilterBar({
return (
-
+
+
+
+
+ {resultLabel}
+
+
-
-
-
- {resultLabel}
);
}
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx
index 7e51c960..270943a4 100644
--- a/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx
+++ b/src/app/(protected)/workorders/_components/list/table/wo-table-body.tsx
@@ -2,7 +2,6 @@ import { Fragment, type ReactNode } from "react";
import { Text } from "@/components/ui/text";
import {
UNASSIGNED_DAY_KEY,
- UNSCHEDULED_DAY_KEY,
type WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
@@ -21,11 +20,6 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
}
const unassignedRows = tableData.unassigned ?? [];
- const showUnscheduled =
- (tableData.unscheduled?.length ?? 0) > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY;
- // SH-165: "still loading" and "zero unassigned" are distinct states. Without
- // this split the pin announced "No unassigned work orders." while the queue
- // was still paging board/search on first mount.
const showUnassignedLoading =
tableData.unassignedOpen &&
unassignedRows.length === 0 &&
@@ -79,24 +73,6 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
{unassignedRows.map((row) => renderRow(row))}
>
)}
- {showUnscheduled && (
- <>
- tableData.setUnschedOpen((v) => !v)}
- onAddWo={() => tableData.startDayDraft(UNSCHEDULED_DAY_KEY)}
- />
- {tableData.unschedOpen && (
- <>
- {renderInlineRow(UNSCHEDULED_DAY_KEY)}
- {tableData.unscheduled.map((row) => renderRow(row, true))}
- >
- )}
- >
- )}
{tableData.displayGroups.map((group) => (
-
+
);
}
diff --git a/src/app/(protected)/workorders/_components/loading/wo-table-skeleton.tsx b/src/app/(protected)/workorders/_components/loading/wo-table-skeleton.tsx
index 85cee325..d5c3e8fa 100644
--- a/src/app/(protected)/workorders/_components/loading/wo-table-skeleton.tsx
+++ b/src/app/(protected)/workorders/_components/loading/wo-table-skeleton.tsx
@@ -1,7 +1,7 @@
import { Skeleton } from "@mui/material";
import { LoadingRegion } from "@/components/ui/loading-region";
import { TableSkeletonRows } from "@/components/ui/table-skeleton-rows";
-import { buildWeekSkeleton } from "@/domain/work-orders/utils/table-day-utils";
+import { formatDayTabLabel, weekdaysMonFri } from "@/domain/work-orders/utils/week-day-nav-utils";
import { WoTableHead } from "@/app/(protected)/workorders/_components/list/table/wo-table-head";
import {
WO_TABLE_COLS,
@@ -11,13 +11,26 @@ import {
type WoTableSkeletonProps = {
weekMonday: string;
+ selectedDayIso?: string;
rowsPerDay?: number;
};
const SKELETON_COLS = WO_TABLE_COLS.map((col) => ({ key: col.key, width: col.w }));
-export function WoTableSkeleton({ weekMonday, rowsPerDay = 2 }: WoTableSkeletonProps) {
- const days = buildWeekSkeleton(weekMonday);
+function skeletonDayIso(weekMonday: string, selectedDayIso?: string): string {
+ const days = weekdaysMonFri(weekMonday);
+ if (selectedDayIso && days.includes(selectedDayIso)) {
+ return selectedDayIso;
+ }
+ return weekMonday;
+}
+
+export function WoTableSkeleton({
+ weekMonday,
+ selectedDayIso,
+ rowsPerDay = 2,
+}: WoTableSkeletonProps) {
+ const dayIso = skeletonDayIso(weekMonday, selectedDayIso);
return (
@@ -30,9 +43,8 @@ export function WoTableSkeleton({ weekMonday, rowsPerDay = 2 }: WoTableSkeletonP
>
- {days.map((day) => (
-
- ))}
+
+
diff --git a/src/app/(protected)/workorders/_components/wizard/week-picker.tsx b/src/app/(protected)/workorders/_components/wizard/week-picker.tsx
index 233939b0..faf128b1 100644
--- a/src/app/(protected)/workorders/_components/wizard/week-picker.tsx
+++ b/src/app/(protected)/workorders/_components/wizard/week-picker.tsx
@@ -11,9 +11,10 @@ import {
type WeekPickerProps = {
selected: string;
onPick: (mondayIso: string) => void;
+ isWeekDisabled?: (mondayIso: string) => boolean;
};
-export function WeekPicker({ selected, onPick }: WeekPickerProps) {
+export function WeekPicker({ selected, onPick, isWeekDisabled }: WeekPickerProps) {
const init = /^\d{4}-\d{2}-\d{2}$/.test(selected) ? selected : todayIso();
const [iy, im] = init.split("-").map(Number);
const [viewY, setViewY] = useState(iy);
@@ -66,12 +67,14 @@ export function WeekPicker({ selected, onPick }: WeekPickerProps) {
{weeks.map((mon) => {
const isSel = Boolean(selected) && selected === mon;
const isThis = mon === thisMonday;
+ const isDisabled = Boolean(isWeekDisabled?.(mon));
return (