Merge branch 'dev' into feat/sh-255-deactivate-toggle-gate

This commit is contained in:
Alexandre Brandizzi 2026-08-20 10:58:31 -03:00 • committed by GitHub
commit 8684cb6c8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1113 additions and 302 deletions

View file

@ -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);
});
});

View file

@ -0,0 +1,25 @@
import { useState } from "react";
import { BrandLockup } from "@/components/common/brand-lockup";
import { cn } from "@/lib/utils";
export type AuthBrandLogoProps = {
className?: string;
};
export function AuthBrandLogo({ className }: AuthBrandLogoProps) {
const [useFallback, setUseFallback] = useState(false);
if (useFallback) {
return <BrandLockup className={className} />;
}
return (
<img
src="/seahaven-logo.svg"
alt="Seahaven"
onError={() => setUseFallback(true)}
className={cn("mx-auto block h-10 w-auto", className)}
/>
);
}

View file

@ -11,14 +11,17 @@ export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
export function AuthCardHeader({ title, subtitle, className, ...props }: AuthCardHeaderProps) {
return (
<div className={cn(className)} {...props}>
<Text variant="title" className="m-0 text-center font-bold text-foreground">
<Text
variant="display"
className="m-0 text-center !font-bold ![font-size:var(--text-xl)] !leading-[1.5] text-foreground"
>
{title}
</Text>
<Text
variant="description"
tone="muted"
when={Boolean(subtitle)}
className="mb-6! mt-1.5! text-center font-normal"
className="mb-6! mt-1.5! text-center font-normal ![font-size:var(--text-base)]"
>
{subtitle}
</Text>

View file

@ -1,10 +1,10 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { AuthBrandLogo } from "@/app/(auth)/_components/auth-brand-logo";
import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header";
import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell";
import { LoginForm } from "@/app/(auth)/_components/login-form";
import { BrandLockup } from "@/components/common/brand-lockup";
import { Text } from "@/components/ui/text";
import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema";
import { useAuthContext } from "@/providers/auth-context";
@ -27,7 +27,7 @@ export default function LoginPage() {
return (
<AuthPageShell>
<BrandLockup className="mb-8" />
<AuthBrandLogo className="mb-8" />
<AuthCardHeader title="Welcome back" subtitle="Sign in to your account" />
<LoginForm
register={register}

View file

@ -0,0 +1,35 @@
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 (
<div className="flex items-center gap-1.5 overflow-x-auto" role="tablist" aria-label="Weekdays">
{days.map((iso) => {
const label = formatDayTabLabel(iso);
const active = iso === selectedDayIso;
return (
<NavPillButton
key={iso}
active={active}
disabled={disabled}
role="tab"
aria-selected={active}
aria-label={label}
onClick={() => onChange(iso)}
>
{label}
</NavPillButton>
);
})}
</div>
);
}

View file

@ -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 (
<span
className="uppercase"
style={{
fontFamily: "var(--font-mono)",
fontSize: 9,
fontWeight: 700,
color: "#ffffff",
background: "var(--success)",
borderRadius: 9999,
padding: "1px 5px",
letterSpacing: "0.04em",
}}
>
Live
</span>
);
}
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 (
<div className="flex shrink-0 items-center gap-1.5">
<NavArrowButton
disabled={disabled || !canGoBack}
onClick={() => setOffset((current) => current - 1)}
aria-label="Previous weeks"
>
<ChevronLeft size={15} />
</NavArrowButton>
{visible.map((week) => {
const isActive = weekMonday === week.mondayIso;
const isDisabled = isFutureWeek(week.mondayIso);
const showLiveBadge = isLiveWeek(week.mondayIso);
return (
<NavPillButton
key={week.mondayIso}
active={isActive}
disabled={disabled || isDisabled}
onClick={() => onChange(week.mondayIso)}
>
{week.label}
{showLiveBadge && <WeekLiveBadge />}
</NavPillButton>
);
})}
<NavArrowButton
disabled={disabled || !canGoForward}
onClick={() => setOffset((current) => current + 1)}
aria-label="Next weeks"
>
<ChevronRight size={15} />
</NavArrowButton>
</div>
);
}

View file

@ -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 (
<span
className="uppercase"
style={{
fontFamily: "var(--font-mono)",
fontSize: 9,
fontWeight: 700,
color: "#ffffff",
background: "var(--success)",
borderRadius: 9999,
padding: "1px 5px",
letterSpacing: "0.04em",
}}
>
Live
</span>
);
}
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 (
<>
<MenuAnchorButton
onAnchor={setAnchor}
open={open}
disabled={disabled}
aria-label={`Week ${label}`}
className={cn(
"flex h-8 shrink-0 items-center gap-1.5 rounded-md border px-[13px] text-[13px] font-semibold",
"border-[#bfe2f5] bg-(--color-primary-tint) text-primary",
)}
>
{label}
{Boolean(live) && <WeekLiveBadge />}
<ChevronDown size={14} aria-hidden />
</MenuAnchorButton>
<Popover
open={open}
anchorEl={anchorEl}
onClose={close}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
>
<div className="p-3">
<WeekPicker selected={weekMonday} onPick={handlePick} isWeekDisabled={isFutureWeek} />
</div>
</Popover>
</>
);
}

View file

@ -1,12 +1,16 @@
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";
import { Text } from "@/components/ui/text";
type WorkOrdersFilterBarProps = {
weekMonday: string;
onWeekChange: (mondayIso: string) => void;
selectedDayIso: string;
onSelectedDayChange: (dayIso: string) => void;
dispatcherIds: Set<string>;
onDispatcherChange: (ids: Set<string>) => void;
typeFilter: WorkOrderTypeFilter;
@ -22,6 +26,8 @@ type WorkOrdersFilterBarProps = {
export function WorkOrdersFilterBar({
weekMonday,
onWeekChange,
selectedDayIso,
onSelectedDayChange,
dispatcherIds,
onDispatcherChange,
typeFilter,
@ -37,21 +43,35 @@ export function WorkOrdersFilterBar({
return (
<div
className="-mx-2.5 flex h-14 shrink-0 items-center gap-3 border-b border-(--color-border) bg-(--color-bg-surface) px-4"
className="-mx-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface)"
aria-disabled={disabled}
>
<DispatcherFilter
selectedIds={dispatcherIds}
onChange={onDispatcherChange}
users={users}
usersLoading={usersLoading}
currentUserId={currentUserId}
disabled={disabled}
/>
<WeekNav weekMonday={weekMonday} onChange={onWeekChange} disabled={disabled} />
<WoTypeSegment value={typeFilter} onChange={onTypeChange} disabled={disabled} />
<div className="flex-1" />
<span className="shrink-0 text-xs font-medium text-muted-foreground">{resultLabel}</span>
<div className="flex items-center gap-3 px-4 pt-2">
<DispatcherFilter
selectedIds={dispatcherIds}
onChange={onDispatcherChange}
users={users}
usersLoading={usersLoading}
currentUserId={currentUserId}
disabled={disabled}
/>
<div className="flex-1" />
<Text variant="caption" tone="muted" className="shrink-0">
{resultLabel}
</Text>
</div>
<div className="flex flex-col gap-1 px-4 pt-1" aria-label="Week and day">
<WeekSelector weekMonday={weekMonday} onChange={onWeekChange} disabled={disabled} />
<DayTabs
weekMonday={weekMonday}
selectedDayIso={selectedDayIso}
onChange={onSelectedDayChange}
disabled={disabled}
/>
</div>
<div className="flex items-center gap-3 px-4 pt-1 pb-2">
<WoTypeSegment value={typeFilter} onChange={onTypeChange} disabled={disabled} />
</div>
</div>
);
}

View file

@ -5,7 +5,6 @@ import type {
} from "@/domain/work-orders/types/work-order-table-row";
import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week";
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify";
import type { WoTableRowHandlers } from "./wo-table-row";
@ -13,7 +12,8 @@ import type { WoTableRowHandlers } from "./wo-table-row";
type UseWoTableRowHandlersOptions = {
tableData: ReturnTypeOfUseWorkOrdersTableData;
weekMonday: string;
onWeekChange: (mondayIso: string) => void;
onNavigateToDay: (dateIso: string) => void;
onOpenAdvancedFilters?: () => void;
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
onOpenUplifts?: (row: WorkOrderTableRow) => void;
@ -26,7 +26,8 @@ type UseWoTableRowHandlersOptions = {
export function useWoTableRowHandlers({
tableData,
weekMonday,
onWeekChange,
onNavigateToDay,
onOpenAdvancedFilters,
onOpenSlideOver,
onOpenCompDoc,
onOpenUplifts,
@ -35,14 +36,7 @@ export function useWoTableRowHandlers({
onScheduleHighlight,
onDuplicateFound,
}: UseWoTableRowHandlersOptions): WoTableRowHandlers {
const {
dragState: dragStateRef,
setDragViz,
reorderRows,
endDrag,
setUnschedOpen,
triggerFlash,
} = tableData;
const { dragState: dragStateRef, setDragViz, reorderRows, endDrag, triggerFlash } = tableData;
return useMemo(
() => ({
@ -80,14 +74,16 @@ export function useWoTableRowHandlers({
onScheduleDate: (id: string | number, date: string) => {
onPatch(id, applySchedulePatch(date, false));
if (shouldNotifyCrossWeek(date, weekMonday)) {
const targetMonday = mondayOf(date);
rescheduleNotify(date, {
onNavigate: () => {
onWeekChange(targetMonday);
onNavigateToDay(date);
setTimeout(() => triggerFlash(id), 180);
},
});
return;
}
onNavigateToDay(date);
triggerFlash(id);
},
onScheduleWeek: (id: string | number, week: string) => {
onPatch(id, {
@ -97,12 +93,10 @@ export function useWoTableRowHandlers({
dayGroup: "",
dayLabel: "",
});
setUnschedOpen(true);
triggerFlash(id);
weekMoveNotify(week, {
onNavigate: () => {
setUnschedOpen(true);
setTimeout(() => triggerFlash(id), 180);
onNavigateToDay(week);
onOpenAdvancedFilters?.();
},
});
},
@ -138,10 +132,10 @@ export function useWoTableRowHandlers({
setDragViz,
reorderRows,
endDrag,
setUnschedOpen,
triggerFlash,
weekMonday,
onWeekChange,
onNavigateToDay,
onOpenAdvancedFilters,
onOpenSlideOver,
onOpenCompDoc,
onOpenUplifts,

View file

@ -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 && (
<>
<WoTableSectionHeader
variant="unscheduled"
label="Unscheduled"
count={tableData.unscheduled.length}
collapsed={!tableData.unschedOpen}
onToggleCollapse={() => 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) => (
<Fragment key={group.key}>
<WoTableSectionHeader

View file

@ -9,6 +9,7 @@ import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { isUnassignedQueueReady } from "@/domain/work-orders/utils/week-day-nav-utils";
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
@ -29,7 +30,7 @@ type WoTableProps = {
vendors: VendorDropdownItem[];
pmTypes: string[];
weekMonday: string;
onWeekChange: (mondayIso: string) => void;
onNavigateToDay: (dateIso: string) => void;
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
/** Omit until completion-doc slice mounts CompDocDialog. */
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
@ -40,6 +41,34 @@ type WoTableProps = {
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
};
type WoTableEmptyShellProps = {
message: string;
actionLabel: string;
onAction?: () => void;
};
function WoTableLoading({
weekMonday,
selectedDayIso,
}: {
weekMonday: string;
selectedDayIso?: string;
}) {
return (
<WoTableShell>
<WoTableSkeleton weekMonday={weekMonday} selectedDayIso={selectedDayIso} />
</WoTableShell>
);
}
function WoTableEmptyShell({ message, actionLabel, onAction }: WoTableEmptyShellProps) {
return (
<WoTableShell>
<WoTableEmptyState message={message} actionLabel={actionLabel} onAction={onAction} />
</WoTableShell>
);
}
export function WoTable({
tableData,
users,
@ -47,7 +76,7 @@ export function WoTable({
vendors,
pmTypes,
weekMonday,
onWeekChange,
onNavigateToDay,
onOpenSlideOver,
onOpenCompDoc,
onOpenUplifts,
@ -70,7 +99,8 @@ export function WoTable({
const handlers = useWoTableRowHandlers({
tableData,
weekMonday,
onWeekChange,
onNavigateToDay,
onOpenAdvancedFilters,
onOpenSlideOver,
onOpenCompDoc,
onOpenUplifts,
@ -125,9 +155,7 @@ export function WoTable({
if (tableData.isLoading) {
return (
<WoTableShell>
<WoTableSkeleton weekMonday={weekMonday} />
</WoTableShell>
<WoTableLoading weekMonday={weekMonday} selectedDayIso={tableData.displayGroups[0]?.date} />
);
}
@ -135,25 +163,26 @@ export function WoTable({
if (emptyAdv) {
return (
<WoTableShell>
<WoTableEmptyState
message="No work orders match these filters"
actionLabel="Adjust filters"
onAction={onOpenAdvancedFilters}
/>
</WoTableShell>
<WoTableEmptyShell
message="No work orders match these filters"
actionLabel="Adjust filters"
onAction={onOpenAdvancedFilters}
/>
);
}
if (!tableData.advActive && tableData.noSearchResults) {
const showSearchEmpty =
!tableData.advActive &&
tableData.noSearchResults &&
isUnassignedQueueReady(tableData.unassignedLoading, tableData.unassignedError);
if (showSearchEmpty) {
return (
<WoTableShell>
<WoTableEmptyState
message="No work orders match your search"
actionLabel="Clear search"
onAction={onClearSearch}
/>
</WoTableShell>
<WoTableEmptyShell
message="No work orders match your search"
actionLabel="Clear search"
onAction={onClearSearch}
/>
);
}

View file

@ -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 (
<LoadingRegion label="Loading work orders" className="h-full">
@ -30,9 +43,8 @@ export function WoTableSkeleton({ weekMonday, rowsPerDay = 2 }: WoTableSkeletonP
>
<WoTableHead />
<tbody>
{days.map((day) => (
<DaySkeletonSection key={day.key} label={day.label} rows={rowsPerDay} />
))}
<DaySkeletonSection label="Unassigned" rows={1} />
<DaySkeletonSection label={formatDayTabLabel(dayIso)} rows={rowsPerDay} />
</tbody>
</table>
</LoadingRegion>

View file

@ -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 (
<button
key={mon}
type="button"
disabled={isDisabled}
onClick={() => onPick(mon)}
className="flex items-center transition-colors"
className="flex items-center transition-colors disabled:cursor-not-allowed disabled:opacity-40"
style={{
height: 36,
borderRadius: 6,

View file

@ -17,6 +17,12 @@ import {
toBoardQueryParams,
} from "@/domain/work-orders/utils/board-query-params";
import { compactWeekLabel, currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
import {
firstWeekday,
initialSelectedDay,
scheduledDayTab,
weekdaysMonFri,
} from "@/domain/work-orders/utils/week-day-nav-utils";
export function useWorkOrdersListFilters() {
const [search, setSearch] = useState("");
@ -27,6 +33,9 @@ export function useWorkOrdersListFilters() {
const [sortBy, setSortBy] = useState("");
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
const [weekMonday, setWeekMondayState] = useState(currentWeekMonday);
const [selectedDayIso, setSelectedDayIsoState] = useState(() =>
initialSelectedDay(currentWeekMonday()),
);
const [dispatcherIds, setDispatcherIdsState] = useState<Set<string>>(() => new Set());
const [typeFilter, setTypeFilterState] = useState<WorkOrderTypeFilter>("All");
@ -42,6 +51,7 @@ export function useWorkOrdersListFilters() {
);
const weekLabel = useMemo(() => compactWeekLabel(weekMonday), [weekMonday]);
const weekDays = useMemo(() => weekdaysMonFri(weekMonday), [weekMonday]);
const boardParams = useMemo<WorkOrderBoardQueryParams>(
() => toBoardQueryParams(barFilters, debouncedSearch),
@ -55,6 +65,18 @@ export function useWorkOrdersListFilters() {
const setWeekMonday = (value: string) => {
setWeekMondayState(value);
setSelectedDayIsoState(firstWeekday(value));
setPage(0);
};
const setSelectedDayIso = (value: string) => {
setSelectedDayIsoState(value);
};
const navigateToDay = (dateIso: string) => {
const tab = scheduledDayTab(dateIso);
setWeekMondayState(tab.weekMonday);
setSelectedDayIsoState(tab.selectedDayIso);
setPage(0);
};
@ -123,6 +145,8 @@ export function useWorkOrdersListFilters() {
sortBy,
sortDir,
weekMonday,
selectedDayIso,
weekDays,
dispatcherIds,
typeFilter,
barFilters,
@ -139,6 +163,8 @@ export function useWorkOrdersListFilters() {
setPage,
setPageSize,
setWeekMonday,
setSelectedDayIso,
navigateToDay,
setDispatcherIds,
setTypeFilter,
setSortBy,

View file

@ -4,9 +4,13 @@ import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/us
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import {
buildSelectedDayGroup,
isDayScopedSearchEmpty,
isUnassignedQueueReady,
} from "@/domain/work-orders/utils/week-day-nav-utils";
import {
buildDayGroups,
composeBoardSections,
filterByBarAndSearch,
mergeWeeklyAndUnassignedQueue,
@ -74,19 +78,27 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
});
}, [advActive, advResults, displayRows, filtered, weeklyIds, queueIds]);
const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
const dayGroups = useMemo(
() =>
advActive
? []
: [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)],
[advActive, filters.selectedDayIso, filters.weekMonday, scheduled],
);
const searchActive = filters.search.trim() !== "";
const visibleRows = advActive ? advResults : filtered;
const noSearchResults = visibleRows.length === 0 && searchActive;
const isEmptyWeek =
const displayGroups = dayGroups;
const dayVisibleCount = displayGroups[0]?.items.length ?? 0;
const noSearchResults =
!advActive &&
scheduled.length === 0 &&
unassigned.length === 0 &&
unscheduled.length === 0 &&
!searchActive;
const displayGroups = isEmptyWeek ? weekSkeleton : dayGroups;
isDayScopedSearchEmpty({
searchActive,
unassignedCount: unassigned.length,
dayItemCount: dayVisibleCount,
unassignedQueueReady: isUnassignedQueueReady(unassignedLoading, unassignedError),
});
const isEmptyWeek =
!advActive && scheduled.length === 0 && unassigned.length === 0 && !searchActive;
const todayKey =
filters.weekMonday === currentWeekMonday()
@ -138,9 +150,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
cancelDraft: local.cancelDraft,
addRow: local.addRow,
scopeTotalCount: data?.totalCount ?? 0,
totalCount: advActive
? (data?.totalCount ?? advResults.length)
: (data?.returnedCount ?? weeklyItems.length),
totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayVisibleCount,
search: filters.search,
};
}

View file

@ -45,6 +45,8 @@ export default function WorkOrdersListPage() {
<WorkOrdersFilterBar
weekMonday={filters.weekMonday}
onWeekChange={filters.setWeekMonday}
selectedDayIso={filters.selectedDayIso}
onSelectedDayChange={filters.setSelectedDayIso}
dispatcherIds={filters.dispatcherIds}
onDispatcherChange={filters.setDispatcherIds}
typeFilter={filters.typeFilter}
@ -88,7 +90,7 @@ export default function WorkOrdersListPage() {
vendors={page.vendors}
pmTypes={page.pmTypes}
weekMonday={filters.weekMonday}
onWeekChange={filters.setWeekMonday}
onNavigateToDay={filters.navigateToDay}
onOpenSlideOver={page.handleOpenSlideOver}
onOpenCompDoc={(row) => openCompletionDoc(row, page.setDocRow)}
onOpenMedia={page.setMediaRow}

View file

@ -18,7 +18,7 @@ type FormFieldProps = FormControlProps & {
const appearanceClassName: Record<FormFieldAppearance, string> = {
default: "gap-1",
auth: "gap-1.5 [&_.MuiFormLabel-root]:mb-0 [&_.MuiFormLabel-root]:!font-sans [&_.MuiFormLabel-root]:![font-size:var(--text-base-sm)] [&_.MuiFormLabel-root]:!font-medium [&_.MuiFormLabel-root]:!leading-[1.5] [&_.MuiFormLabel-root]:!normal-case [&_.MuiFormLabel-root]:!tracking-normal",
auth: "gap-1.5 [&_.MuiFormLabel-root]:mb-0 [&_.MuiFormLabel-root]:!font-sans [&_.MuiFormLabel-root]:![font-size:var(--text-base-sm)] [&_.MuiFormLabel-root]:!font-medium [&_.MuiFormLabel-root]:!leading-[1.5] [&_.MuiFormLabel-root]:!normal-case [&_.MuiFormLabel-root]:!text-foreground [&_.MuiFormLabel-root]:!tracking-normal",
};
const appearanceLabelClassName: Record<FormFieldAppearance, string> = {

View file

@ -0,0 +1,88 @@
import type {
WorkOrderDayGroup,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { isLiveWeek } from "@/domain/work-orders/utils/list-week-utils";
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
import { addDaysIso, mondayOf, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
const DAY_TAB_WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
export function weekdaysMonFri(weekMonday: string): string[] {
return Array.from({ length: 5 }, (_, index) => addDaysIso(weekMonday, index));
}
export function firstWeekday(weekMonday: string): string {
return weekMonday;
}
/** Maps a scheduled date onto the Mon–Fri tab that can show it (weekends fall back to Monday). */
export function scheduledDayTab(dateIso: string): { weekMonday: string; selectedDayIso: string } {
const day = dateIso.slice(0, 10);
const weekMonday = mondayOf(day);
const days = weekdaysMonFri(weekMonday);
return {
weekMonday,
selectedDayIso: days.includes(day) ? day : firstWeekday(weekMonday),
};
}
export function isUnassignedQueueReady(
unassignedLoading: boolean,
unassignedError: Error | null,
): boolean {
return !unassignedLoading && unassignedError == null;
}
export function isDayScopedSearchEmpty(args: {
searchActive: boolean;
unassignedCount: number;
dayItemCount: number;
unassignedQueueReady: boolean;
}): boolean {
return (
args.searchActive &&
args.unassignedQueueReady &&
args.unassignedCount === 0 &&
args.dayItemCount === 0
);
}
export function initialSelectedDay(weekMonday: string, today = todayIso()): string {
if (isLiveWeek(weekMonday) && isDateInWeek(today, weekMonday)) {
return today;
}
return firstWeekday(weekMonday);
}
export function formatDayTabLabel(iso: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
return "";
}
const day = new Date(`${iso}T12:00:00`);
const [, month, date] = iso.split("-");
return `${DAY_TAB_WEEKDAYS[day.getDay()]} · ${month}/${date}`;
}
export function rowMatchesSelectedDay(
row: Pick<WorkOrderTableRow, "scheduledOn">,
selectedDayIso: string,
): boolean {
return Boolean(row.scheduledOn) && row.scheduledOn.slice(0, 10) === selectedDayIso;
}
export function buildSelectedDayGroup(
weekMonday: string,
selectedDayIso: string,
scheduled: WorkOrderTableRow[],
): WorkOrderDayGroup {
const days = weekdaysMonFri(weekMonday);
const date = days.includes(selectedDayIso) ? selectedDayIso : firstWeekday(weekMonday);
const [, month, day] = date.split("-");
return {
key: `${month}/${day}`,
label: formatDayTabLabel(date),
date,
items: scheduled.filter((row) => rowMatchesSelectedDay(row, date)),
};
}

View file

@ -26,7 +26,7 @@ describe("useWoTableRowHandlers onDuplicateFound", () => {
useWoTableRowHandlers({
tableData: buildTableData(),
weekMonday: "2026-07-20",
onWeekChange: vi.fn(),
onNavigateToDay: vi.fn(),
onOpenSlideOver,
onOpenMedia: vi.fn(),
onPatch: vi.fn(),

View file

@ -0,0 +1,116 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useWoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers";
import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
const { rescheduleNotify, weekMoveNotify } = vi.hoisted(() => ({
rescheduleNotify: vi.fn(),
weekMoveNotify: vi.fn(),
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/reschedule-notify", () => ({
rescheduleNotify,
weekMoveNotify,
}));
function buildTableData(triggerFlash = vi.fn()) {
return {
dragState: { current: { dragId: null, overId: null } },
setDragViz: vi.fn(),
reorderRows: vi.fn(),
endDrag: vi.fn(),
triggerFlash,
setSelectedId: vi.fn(),
} as never;
}
describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
it("selects the scheduled weekday in the same week so the row stays visible", () => {
const onNavigateToDay = vi.fn();
const onPatch = vi.fn();
const triggerFlash = vi.fn();
const { result } = renderHook(() =>
useWoTableRowHandlers({
tableData: buildTableData(triggerFlash),
weekMonday: "2026-05-18",
onNavigateToDay,
onOpenSlideOver: vi.fn(),
onOpenMedia: vi.fn(),
onPatch,
onScheduleHighlight: vi.fn(),
}),
);
result.current.onScheduleDate("wo-1", "2026-05-21");
expect(onPatch).toHaveBeenCalledWith("wo-1", applySchedulePatch("2026-05-21", false));
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-21");
expect(triggerFlash).toHaveBeenCalledWith("wo-1");
expect(rescheduleNotify).not.toHaveBeenCalled();
});
it("sends cross-week view-it navigation to the target day, not Monday", () => {
const onNavigateToDay = vi.fn();
const { result } = renderHook(() =>
useWoTableRowHandlers({
tableData: buildTableData(),
weekMonday: "2026-05-18",
onNavigateToDay,
onOpenSlideOver: vi.fn(),
onOpenMedia: vi.fn(),
onPatch: vi.fn(),
onScheduleHighlight: vi.fn(),
}),
);
result.current.onScheduleDate("wo-1", "2026-05-27");
expect(onNavigateToDay).not.toHaveBeenCalled();
expect(rescheduleNotify).toHaveBeenCalledWith(
"2026-05-27",
expect.objectContaining({ onNavigate: expect.any(Function) }),
);
const { onNavigate } = rescheduleNotify.mock.calls[0][1] as { onNavigate: () => void };
onNavigate();
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-27");
});
it("opens Advanced Filters for week-only moves instead of the removed Unscheduled section", () => {
const onNavigateToDay = vi.fn();
const onOpenAdvancedFilters = vi.fn();
const onPatch = vi.fn();
const { result } = renderHook(() =>
useWoTableRowHandlers({
tableData: buildTableData(),
weekMonday: "2026-05-18",
onNavigateToDay,
onOpenAdvancedFilters,
onOpenSlideOver: vi.fn(),
onOpenMedia: vi.fn(),
onPatch,
onScheduleHighlight: vi.fn(),
}),
);
result.current.onScheduleWeek("wo-1", "2026-05-25");
expect(onPatch).toHaveBeenCalledWith(
"wo-1",
expect.objectContaining({
scheduledOn: "",
scheduleWeekOnly: true,
targetWeek: "2026-05-25",
}),
);
expect(weekMoveNotify).toHaveBeenCalled();
const { onNavigate } = weekMoveNotify.mock.calls[0][1] as { onNavigate: () => void };
onNavigate();
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-25");
expect(onOpenAdvancedFilters).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,113 @@
import { act, render, renderHook, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { DayTabs } from "@/app/(protected)/workorders/_components/list/filter-bar/day-tabs";
import { WeekSelector } from "@/app/(protected)/workorders/_components/list/filter-bar/week-selector";
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
import { firstWeekday } from "@/domain/work-orders/utils/week-day-nav-utils";
vi.mock("@/domain/work-orders/utils/wizard-date-utils", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/domain/work-orders/utils/wizard-date-utils")>();
return {
...actual,
todayIso: () => "2026-08-19",
};
});
function WeekDayNavHarness() {
const [weekMonday, setWeekMonday] = useState("2026-05-18");
const [selectedDayIso, setSelectedDayIso] = useState("2026-05-20");
const handleWeekChange = (mondayIso: string) => {
setWeekMonday(mondayIso);
setSelectedDayIso(firstWeekday(mondayIso));
};
return (
<div>
<WeekSelector weekMonday={weekMonday} onChange={handleWeekChange} />
<DayTabs
weekMonday={weekMonday}
selectedDayIso={selectedDayIso}
onChange={setSelectedDayIso}
/>
</div>
);
}
describe("WeekSelector and DayTabs (SH-240)", () => {
it("renders Mon–Fri tabs for the active week", () => {
render(
<DayTabs weekMonday="2026-05-18" selectedDayIso="2026-05-18" onChange={() => undefined} />,
);
expect(screen.getByRole("tab", { name: "Mon · 05/18" })).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.getByRole("tab", { name: "Tue · 05/19" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Wed · 05/20" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Thu · 05/21" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Fri · 05/22" })).toBeInTheDocument();
});
it("selects a day tab without expanding other days", async () => {
const user = userEvent.setup();
render(<WeekDayNavHarness />);
await user.click(screen.getByRole("tab", { name: "Thu · 05/21" }));
expect(screen.getByRole("tab", { name: "Thu · 05/21" })).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.getByRole("tab", { name: "Wed · 05/20" })).toHaveAttribute(
"aria-selected",
"false",
);
});
it("resets to Monday when the week picker selects another week", async () => {
const user = userEvent.setup();
render(<WeekDayNavHarness />);
await user.click(screen.getByRole("button", { name: "Week 5/18–5/22" }));
await user.click(screen.getByRole("button", { name: /May 11/i }));
expect(screen.getByRole("button", { name: "Week 5/11–5/15" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Mon · 05/11" })).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.getByRole("tab", { name: "Fri · 05/15" })).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "Mon · 05/18" })).not.toBeInTheDocument();
});
});
describe("useWorkOrdersListFilters day state (SH-240)", () => {
it("starts on today in the live week and resets to Monday on week change", () => {
const { result } = renderHook(() => useWorkOrdersListFilters());
expect(result.current.weekMonday).toBe("2026-08-17");
expect(result.current.selectedDayIso).toBe("2026-08-19");
act(() => {
result.current.setWeekMonday("2026-08-10");
});
expect(result.current.weekMonday).toBe("2026-08-10");
expect(result.current.selectedDayIso).toBe("2026-08-10");
});
it("follows a scheduled date onto that weekday instead of resetting to Monday", () => {
const { result } = renderHook(() => useWorkOrdersListFilters());
act(() => {
result.current.navigateToDay("2026-05-20");
});
expect(result.current.weekMonday).toBe("2026-05-18");
expect(result.current.selectedDayIso).toBe("2026-05-20");
});
});

View file

@ -0,0 +1,113 @@
import { describe, expect, it, vi } from "vitest";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/domain/locations/use-cases/use-location-sites", () => ({
useLocationSites: () => ({ data: [] }),
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/use-wo-table-inline-create", () => ({
useWoTableInlineCreate: () => ({ handleInlineSave: vi.fn(), saving: false }),
}));
function tableData(
overrides: Partial<ReturnTypeOfUseWorkOrdersTableData> = {},
): ReturnTypeOfUseWorkOrdersTableData {
return {
rows: [],
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
advActive: false,
advResults: [],
unassigned: [],
unassignedError: null,
unassignedLoading: false,
unscheduled: [],
displayGroups: [{ key: "05/18", label: "Mon · 05/18", date: "2026-05-18", items: [] }],
searchActive: true,
noSearchResults: true,
isEmptyWeek: false,
todayKey: null,
draftDayKey: null,
draft: { locationId: "", site: "" },
setDraft: vi.fn(),
unassignedOpen: true,
setUnassignedOpen: vi.fn(),
unschedOpen: true,
setUnschedOpen: vi.fn(),
selectedId: null,
setSelectedId: vi.fn(),
selectedRow: null,
flashId: null,
triggerFlash: vi.fn(),
dragViz: null,
setDragViz: vi.fn(),
dragState: { current: { dragId: null, overId: null } },
patchRow: vi.fn(),
clearRowPatch: vi.fn(),
reorderRows: vi.fn(),
endDrag: vi.fn(),
startDayDraft: vi.fn(),
cancelDraft: vi.fn(),
addRow: vi.fn(),
scopeTotalCount: 0,
totalCount: 0,
search: "no-match",
...overrides,
} as ReturnTypeOfUseWorkOrdersTableData;
}
function renderTable(data: ReturnTypeOfUseWorkOrdersTableData) {
return renderWithProviders(
<WoTable
tableData={data}
users={[]}
sites={[]}
vendors={[]}
pmTypes={[]}
weekMonday="2026-05-18"
onNavigateToDay={vi.fn()}
onOpenSlideOver={vi.fn()}
onOpenMedia={vi.fn()}
onClearSearch={vi.fn()}
onPatch={vi.fn()}
/>,
{ withAuth: false },
);
}
describe("WoTable search empty vs Unassigned queue (SH-165/SH-240)", () => {
it("shows empty search only after the Unassigned queue has settled empty", () => {
const { getByText, queryByText } = renderTable(tableData());
expect(getByText("No work orders match your search")).toBeInTheDocument();
expect(queryByText(/Loading unassigned work orders/)).not.toBeInTheDocument();
});
it("keeps Unassigned loading visible instead of a premature empty search", () => {
const { getByText, queryByText } = renderTable(
tableData({
noSearchResults: true,
unassignedLoading: true,
}),
);
expect(queryByText("No work orders match your search")).not.toBeInTheDocument();
expect(getByText(/Loading unassigned work orders/)).toBeInTheDocument();
});
it("keeps Unassigned error visible instead of hiding it behind empty search", () => {
const { getByText, queryByText } = renderTable(
tableData({
noSearchResults: true,
unassignedError: new Error("queue unavailable"),
}),
);
expect(queryByText("No work orders match your search")).not.toBeInTheDocument();
expect(getByText("queue unavailable")).toBeInTheDocument();
});
});

View file

@ -180,7 +180,7 @@ function UnassignedAssignHarness({
}
describe("WoTableBody Unassigned queue (SH-165)", () => {
it("renders Unassigned above Unscheduled and day groups without duplicate rows", () => {
it("renders Unassigned above the selected day without Unscheduled (SH-240)", () => {
const datedUnassigned = makeRow({ id: "u-dated", woNumber: "24991" });
const undatedUnassigned = makeRow({
id: "us1",
@ -235,26 +235,21 @@ describe("WoTableBody Unassigned queue (SH-165)", () => {
const unassignedHeader = screen.getByRole("button", {
name: "Unassigned, 2 work orders, expanded",
});
const unscheduledHeader = screen.getByRole("button", {
name: "Unscheduled, 1 work order, expanded",
});
const dayLabel = screen.getByText("TUE · 06/02");
expect(unassignedHeader.compareDocumentPosition(screen.getByTestId("row-u-dated"))).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(unassignedHeader.compareDocumentPosition(unscheduledHeader)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(unscheduledHeader.compareDocumentPosition(dayLabel)).toBe(
expect(unassignedHeader.compareDocumentPosition(dayLabel)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(screen.queryByRole("button", { name: /Unscheduled/ })).not.toBeInTheDocument();
expect(screen.getByTestId("row-u-dated")).toBeInTheDocument();
expect(screen.getByTestId("row-us1")).toBeInTheDocument();
expect(screen.getByTestId("row-us-assigned")).toBeInTheDocument();
expect(screen.queryByTestId("row-us-assigned")).not.toBeInTheDocument();
expect(screen.getByTestId("row-day-assigned")).toBeInTheDocument();
expect(screen.getAllByTestId(/row-/)).toHaveLength(4);
expect(screen.getAllByTestId(/row-/)).toHaveLength(3);
});
it("collapses and expands the Unassigned section via the disclosure control", async () => {

View file

@ -7,8 +7,6 @@ import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_component
import type { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
const HIGHLIGHT_BOX_SHADOW = "0 0 0 2px var(--color-accent)";
const unscheduledRow: WorkOrderTableRow = {
id: 42,
woNumber: "WO-42",
@ -67,6 +65,8 @@ const handlers: WoTableRowHandlers = {
onScheduleDate: vi.fn(),
onScheduleWeek: vi.fn(),
onOpenSlideOver: vi.fn(),
onOpenCompDoc: vi.fn(),
onOpenUplifts: vi.fn(),
onReorder: vi.fn(),
onDragStart: vi.fn(),
onDragOver: vi.fn(),
@ -74,55 +74,47 @@ const handlers: WoTableRowHandlers = {
onDragEnd: vi.fn(),
};
/** Mirrors WoTable.renderRow: body `true` → Schedule On only. */
function renderUnscheduledHarness() {
const tableData = {
advActive: false,
unassigned: [],
unassignedError: null,
unassignedLoading: false,
unassignedOpen: true,
setUnassignedOpen: vi.fn(),
unscheduled: [unscheduledRow],
unschedOpen: true,
draftDayKey: null,
displayGroups: [],
todayKey: null,
startDayDraft: vi.fn(),
setUnschedOpen: vi.fn(),
} as unknown as ReturnTypeOfUseWorkOrdersTableData;
describe("WoTableBody unscheduled exclusion (SH-240)", () => {
it("does not render Unscheduled rows in the day-tab board view", () => {
const tableData = {
advActive: false,
unassigned: [],
unassignedError: null,
unassignedLoading: false,
unassignedOpen: true,
setUnassignedOpen: vi.fn(),
unscheduled: [unscheduledRow],
unschedOpen: true,
draftDayKey: null,
displayGroups: [],
todayKey: null,
startDayDraft: vi.fn(),
setUnschedOpen: vi.fn(),
} as unknown as ReturnTypeOfUseWorkOrdersTableData;
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
<tr key={String(row.id)}>
<WoTableRowCells
row={row}
allRows={[row]}
users={[]}
sites={[]}
forceScheduleHighlight={forceScheduleHighlight}
forceDueDateHighlight={false}
handlers={handlers}
onPatchRow={vi.fn()}
/>
</tr>
);
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
<tr key={String(row.id)}>
<WoTableRowCells
row={row}
allRows={[row]}
users={[]}
sites={[]}
forceScheduleHighlight={forceScheduleHighlight}
forceDueDateHighlight={false}
handlers={handlers}
onPatchRow={vi.fn()}
/>
</tr>
);
return renderWithProviders(
<table>
<WoTableBody tableData={tableData} renderRow={renderRow} renderInlineRow={() => null} />
</table>,
{ withAuth: false },
);
}
renderWithProviders(
<table>
<WoTableBody tableData={tableData} renderRow={renderRow} renderInlineRow={() => null} />
</table>,
{ withAuth: false },
);
describe("WoTableBody unscheduled Schedule On highlight (SH-165)", () => {
it("force-highlights Schedule On for Unscheduled rows, not Due Date", () => {
renderUnscheduledHarness();
const scheduleOn = screen.getByRole("button", { name: /^—$/ });
const dueDate = screen.getByRole("button", { name: /07-15/ });
expect(scheduleOn).toHaveStyle({ boxShadow: HIGHLIGHT_BOX_SHADOW });
expect(dueDate).not.toHaveStyle({ boxShadow: HIGHLIGHT_BOX_SHADOW });
expect(screen.queryByRole("button", { name: /Unscheduled/ })).not.toBeInTheDocument();
expect(screen.queryByText("WO-42")).not.toBeInTheDocument();
});
});

View file

@ -48,7 +48,10 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () =
clearAdvancedFilterChip: vi.fn(),
activeFilterCount: advApplied ? 1 : 0,
weekMonday: "2026-07-20",
selectedDayIso: "2026-07-20",
setWeekMonday: vi.fn(),
setSelectedDayIso: vi.fn(),
navigateToDay: vi.fn(),
dispatcherIds: new Set<string>(),
setDispatcherIds: vi.fn(),
typeFilter: "All",
@ -251,8 +254,8 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
});
expect(screen.getByRole("button", { name: "All dispatchers" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Previous weeks" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Next weeks" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Week 7/20–7/24" })).toBeDisabled();
expect(screen.getByRole("tab", { name: "Mon · 07/20" })).toBeDisabled();
expect(screen.getByRole("button", { name: "All" })).toBeDisabled();
expect(screen.getByRole("button", { name: "PM" })).toBeDisabled();
expect(screen.getByText("42 work orders")).toBeInTheDocument();

View file

@ -44,7 +44,10 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () =
clearAdvancedFilterChip: vi.fn(),
activeFilterCount: 0,
weekMonday: "2026-07-20",
selectedDayIso: "2026-07-20",
setWeekMonday: vi.fn(),
setSelectedDayIso: vi.fn(),
navigateToDay: vi.fn(),
dispatcherIds: new Set<string>(),
setDispatcherIds: vi.fn(),
typeFilter: "All",

View file

@ -0,0 +1,181 @@
import { describe, expect, it } from "vitest";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
import {
buildSelectedDayGroup,
firstWeekday,
formatDayTabLabel,
initialSelectedDay,
isDayScopedSearchEmpty,
isUnassignedQueueReady,
rowMatchesSelectedDay,
scheduledDayTab,
weekdaysMonFri,
} from "@/domain/work-orders/utils/week-day-nav-utils";
import { addDaysIso } from "@/domain/work-orders/utils/wizard-date-utils";
function row(overrides: Partial<WorkOrderTableRow>): WorkOrderTableRow {
return {
id: "1",
woNumber: "1",
site: "",
type: "PM",
severity: null,
dispatcherId: "d1",
dispatcherName: "D",
scheduledOn: "",
completedDate: "",
targetWeek: "",
dayGroup: "",
dayLabel: "",
location: "",
locationId: "",
pm: "",
serviceNotes: "",
extraServices: [],
vendorId: "",
company: "",
tech: "",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Incomplete",
docStatus: "No",
pocName: "",
pocPhone: "",
pocNotes: "",
additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
isAddOn: false,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "",
priority: "",
primaryDispatchId: null,
rowVersion: "",
dispatchRowVersion: "",
flagColor: null,
...overrides,
};
}
describe("week-day-nav-utils (SH-240)", () => {
it("lists Mon–Fri of the active week", () => {
expect(weekdaysMonFri("2026-05-18")).toEqual([
"2026-05-18",
"2026-05-19",
"2026-05-20",
"2026-05-21",
"2026-05-22",
]);
});
it("formats day tabs as Title · MM/DD", () => {
expect(formatDayTabLabel("2026-05-18")).toBe("Mon · 05/18");
expect(formatDayTabLabel("2026-05-22")).toBe("Fri · 05/22");
});
it("resets to Monday when the week changes", () => {
expect(firstWeekday("2026-05-25")).toBe("2026-05-25");
});
it("selects today on the live week when today is a weekday", () => {
const liveMonday = currentWeekMonday();
const wednesday = addDaysIso(liveMonday, 2);
expect(initialSelectedDay(liveMonday, wednesday)).toBe(wednesday);
});
it("selects Monday on a non-live week", () => {
expect(initialSelectedDay("2026-05-18", "2026-05-20")).toBe("2026-05-18");
});
it("excludes unscheduled and other days from the selected day group", () => {
const monday = row({
id: "mon",
scheduledOn: "2026-05-18",
dayGroup: "05/18",
dayLabel: "MON · 05/18",
});
const wednesday = row({
id: "wed",
scheduledOn: "2026-05-20",
dayGroup: "05/20",
dayLabel: "WED · 05/20",
});
const unscheduled = row({ id: "us", scheduledOn: "", scheduleWeekOnly: true });
expect(rowMatchesSelectedDay(unscheduled, "2026-05-18")).toBe(false);
expect(rowMatchesSelectedDay(monday, "2026-05-18")).toBe(true);
const group = buildSelectedDayGroup("2026-05-18", "2026-05-18", [
monday,
wednesday,
unscheduled,
]);
expect(group.date).toBe("2026-05-18");
expect(group.label).toBe("Mon · 05/18");
expect(group.items.map((item) => item.id)).toEqual(["mon"]);
});
it("maps a scheduled weekday onto its week and day tab", () => {
expect(scheduledDayTab("2026-05-20")).toEqual({
weekMonday: "2026-05-18",
selectedDayIso: "2026-05-20",
});
});
it("treats search as empty only when the selected day and Unassigned have no hits", () => {
expect(
isDayScopedSearchEmpty({
searchActive: true,
unassignedCount: 0,
dayItemCount: 0,
unassignedQueueReady: true,
}),
).toBe(true);
expect(
isDayScopedSearchEmpty({
searchActive: true,
unassignedCount: 0,
dayItemCount: 1,
unassignedQueueReady: true,
}),
).toBe(false);
expect(
isDayScopedSearchEmpty({
searchActive: true,
unassignedCount: 2,
dayItemCount: 0,
unassignedQueueReady: true,
}),
).toBe(false);
expect(
isDayScopedSearchEmpty({
searchActive: false,
unassignedCount: 0,
dayItemCount: 0,
unassignedQueueReady: true,
}),
).toBe(false);
});
it("does not treat search as empty while the Unassigned queue is loading or failed", () => {
expect(isUnassignedQueueReady(true, null)).toBe(false);
expect(isUnassignedQueueReady(false, new Error("queue unavailable"))).toBe(false);
expect(isUnassignedQueueReady(false, null)).toBe(true);
expect(
isDayScopedSearchEmpty({
searchActive: true,
unassignedCount: 0,
dayItemCount: 0,
unassignedQueueReady: false,
}),
).toBe(false);
});
});