mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-05 21:12:07 +00:00
feat(work-orders): day-scoped week navigation (SH-240)
Let dispatchers pick one week and one weekday so the board replaces rows instead of stacking the full week.
This commit is contained in:
parent
772d7f94df
commit
2c14b08945
19 changed files with 660 additions and 252 deletions
124
e2e/work-orders/week-day-navigation.spec.ts
Normal file
124
e2e/work-orders/week-day-navigation.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 overflow-x-auto px-4 pb-2"
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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}
|
||||||
|
{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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters";
|
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 { 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 { WoTypeSegment } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment";
|
||||||
|
|
||||||
type WorkOrdersFilterBarProps = {
|
type WorkOrdersFilterBarProps = {
|
||||||
weekMonday: string;
|
weekMonday: string;
|
||||||
onWeekChange: (mondayIso: string) => void;
|
onWeekChange: (mondayIso: string) => void;
|
||||||
|
selectedDayIso: string;
|
||||||
|
onSelectedDayChange: (dayIso: string) => void;
|
||||||
dispatcherIds: Set<string>;
|
dispatcherIds: Set<string>;
|
||||||
onDispatcherChange: (ids: Set<string>) => void;
|
onDispatcherChange: (ids: Set<string>) => void;
|
||||||
typeFilter: WorkOrderTypeFilter;
|
typeFilter: WorkOrderTypeFilter;
|
||||||
|
|
@ -22,6 +25,8 @@ type WorkOrdersFilterBarProps = {
|
||||||
export function WorkOrdersFilterBar({
|
export function WorkOrdersFilterBar({
|
||||||
weekMonday,
|
weekMonday,
|
||||||
onWeekChange,
|
onWeekChange,
|
||||||
|
selectedDayIso,
|
||||||
|
onSelectedDayChange,
|
||||||
dispatcherIds,
|
dispatcherIds,
|
||||||
onDispatcherChange,
|
onDispatcherChange,
|
||||||
typeFilter,
|
typeFilter,
|
||||||
|
|
@ -37,21 +42,29 @@ export function WorkOrdersFilterBar({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
aria-disabled={disabled}
|
||||||
>
|
>
|
||||||
<DispatcherFilter
|
<div className="flex h-14 items-center gap-3 px-4">
|
||||||
selectedIds={dispatcherIds}
|
<DispatcherFilter
|
||||||
onChange={onDispatcherChange}
|
selectedIds={dispatcherIds}
|
||||||
users={users}
|
onChange={onDispatcherChange}
|
||||||
usersLoading={usersLoading}
|
users={users}
|
||||||
currentUserId={currentUserId}
|
usersLoading={usersLoading}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<WeekSelector 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>
|
||||||
|
<DayTabs
|
||||||
|
weekMonday={weekMonday}
|
||||||
|
selectedDayIso={selectedDayIso}
|
||||||
|
onChange={onSelectedDayChange}
|
||||||
disabled={disabled}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { Fragment, type ReactNode } from "react";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import {
|
import {
|
||||||
UNASSIGNED_DAY_KEY,
|
UNASSIGNED_DAY_KEY,
|
||||||
UNSCHEDULED_DAY_KEY,
|
|
||||||
type WorkOrderTableRow,
|
type WorkOrderTableRow,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||||
|
|
@ -21,11 +20,6 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
|
||||||
}
|
}
|
||||||
|
|
||||||
const unassignedRows = tableData.unassigned ?? [];
|
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 =
|
const showUnassignedLoading =
|
||||||
tableData.unassignedOpen &&
|
tableData.unassignedOpen &&
|
||||||
unassignedRows.length === 0 &&
|
unassignedRows.length === 0 &&
|
||||||
|
|
@ -79,24 +73,6 @@ export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBo
|
||||||
{unassignedRows.map((row) => renderRow(row))}
|
{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) => (
|
{tableData.displayGroups.map((group) => (
|
||||||
<Fragment key={group.key}>
|
<Fragment key={group.key}>
|
||||||
<WoTableSectionHeader
|
<WoTableSectionHeader
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,10 @@ export function WoTable({
|
||||||
if (tableData.isLoading) {
|
if (tableData.isLoading) {
|
||||||
return (
|
return (
|
||||||
<WoTableShell>
|
<WoTableShell>
|
||||||
<WoTableSkeleton weekMonday={weekMonday} />
|
<WoTableSkeleton
|
||||||
|
weekMonday={weekMonday}
|
||||||
|
selectedDayIso={tableData.displayGroups[0]?.date}
|
||||||
|
/>
|
||||||
</WoTableShell>
|
</WoTableShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Skeleton } from "@mui/material";
|
import { Skeleton } from "@mui/material";
|
||||||
import { LoadingRegion } from "@/components/ui/loading-region";
|
import { LoadingRegion } from "@/components/ui/loading-region";
|
||||||
import { TableSkeletonRows } from "@/components/ui/table-skeleton-rows";
|
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 { WoTableHead } from "@/app/(protected)/workorders/_components/list/table/wo-table-head";
|
||||||
import {
|
import {
|
||||||
WO_TABLE_COLS,
|
WO_TABLE_COLS,
|
||||||
|
|
@ -11,13 +11,26 @@ import {
|
||||||
|
|
||||||
type WoTableSkeletonProps = {
|
type WoTableSkeletonProps = {
|
||||||
weekMonday: string;
|
weekMonday: string;
|
||||||
|
selectedDayIso?: string;
|
||||||
rowsPerDay?: number;
|
rowsPerDay?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SKELETON_COLS = WO_TABLE_COLS.map((col) => ({ key: col.key, width: col.w }));
|
const SKELETON_COLS = WO_TABLE_COLS.map((col) => ({ key: col.key, width: col.w }));
|
||||||
|
|
||||||
export function WoTableSkeleton({ weekMonday, rowsPerDay = 2 }: WoTableSkeletonProps) {
|
function skeletonDayIso(weekMonday: string, selectedDayIso?: string): string {
|
||||||
const days = buildWeekSkeleton(weekMonday);
|
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 (
|
return (
|
||||||
<LoadingRegion label="Loading work orders" className="h-full">
|
<LoadingRegion label="Loading work orders" className="h-full">
|
||||||
|
|
@ -30,9 +43,8 @@ export function WoTableSkeleton({ weekMonday, rowsPerDay = 2 }: WoTableSkeletonP
|
||||||
>
|
>
|
||||||
<WoTableHead />
|
<WoTableHead />
|
||||||
<tbody>
|
<tbody>
|
||||||
{days.map((day) => (
|
<DaySkeletonSection label="Unassigned" rows={1} />
|
||||||
<DaySkeletonSection key={day.key} label={day.label} rows={rowsPerDay} />
|
<DaySkeletonSection label={formatDayTabLabel(dayIso)} rows={rowsPerDay} />
|
||||||
))}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</LoadingRegion>
|
</LoadingRegion>
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,10 @@ import {
|
||||||
type WeekPickerProps = {
|
type WeekPickerProps = {
|
||||||
selected: string;
|
selected: string;
|
||||||
onPick: (mondayIso: string) => void;
|
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 init = /^\d{4}-\d{2}-\d{2}$/.test(selected) ? selected : todayIso();
|
||||||
const [iy, im] = init.split("-").map(Number);
|
const [iy, im] = init.split("-").map(Number);
|
||||||
const [viewY, setViewY] = useState(iy);
|
const [viewY, setViewY] = useState(iy);
|
||||||
|
|
@ -66,12 +67,14 @@ export function WeekPicker({ selected, onPick }: WeekPickerProps) {
|
||||||
{weeks.map((mon) => {
|
{weeks.map((mon) => {
|
||||||
const isSel = Boolean(selected) && selected === mon;
|
const isSel = Boolean(selected) && selected === mon;
|
||||||
const isThis = mon === thisMonday;
|
const isThis = mon === thisMonday;
|
||||||
|
const isDisabled = Boolean(isWeekDisabled?.(mon));
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={mon}
|
key={mon}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={isDisabled}
|
||||||
onClick={() => onPick(mon)}
|
onClick={() => onPick(mon)}
|
||||||
className="flex items-center transition-colors"
|
className="flex items-center transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
style={{
|
style={{
|
||||||
height: 36,
|
height: 36,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ import {
|
||||||
toBoardQueryParams,
|
toBoardQueryParams,
|
||||||
} from "@/domain/work-orders/utils/board-query-params";
|
} from "@/domain/work-orders/utils/board-query-params";
|
||||||
import { compactWeekLabel, currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
import { compactWeekLabel, currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||||
|
import {
|
||||||
|
firstWeekday,
|
||||||
|
initialSelectedDay,
|
||||||
|
weekdaysMonFri,
|
||||||
|
} from "@/domain/work-orders/utils/week-day-nav-utils";
|
||||||
|
|
||||||
export function useWorkOrdersListFilters() {
|
export function useWorkOrdersListFilters() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
@ -27,6 +32,9 @@ export function useWorkOrdersListFilters() {
|
||||||
const [sortBy, setSortBy] = useState("");
|
const [sortBy, setSortBy] = useState("");
|
||||||
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
|
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
|
||||||
const [weekMonday, setWeekMondayState] = useState(currentWeekMonday);
|
const [weekMonday, setWeekMondayState] = useState(currentWeekMonday);
|
||||||
|
const [selectedDayIso, setSelectedDayIsoState] = useState(() =>
|
||||||
|
initialSelectedDay(currentWeekMonday()),
|
||||||
|
);
|
||||||
const [dispatcherIds, setDispatcherIdsState] = useState<Set<string>>(() => new Set());
|
const [dispatcherIds, setDispatcherIdsState] = useState<Set<string>>(() => new Set());
|
||||||
const [typeFilter, setTypeFilterState] = useState<WorkOrderTypeFilter>("All");
|
const [typeFilter, setTypeFilterState] = useState<WorkOrderTypeFilter>("All");
|
||||||
|
|
||||||
|
|
@ -42,6 +50,7 @@ export function useWorkOrdersListFilters() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const weekLabel = useMemo(() => compactWeekLabel(weekMonday), [weekMonday]);
|
const weekLabel = useMemo(() => compactWeekLabel(weekMonday), [weekMonday]);
|
||||||
|
const weekDays = useMemo(() => weekdaysMonFri(weekMonday), [weekMonday]);
|
||||||
|
|
||||||
const boardParams = useMemo<WorkOrderBoardQueryParams>(
|
const boardParams = useMemo<WorkOrderBoardQueryParams>(
|
||||||
() => toBoardQueryParams(barFilters, debouncedSearch),
|
() => toBoardQueryParams(barFilters, debouncedSearch),
|
||||||
|
|
@ -55,9 +64,14 @@ export function useWorkOrdersListFilters() {
|
||||||
|
|
||||||
const setWeekMonday = (value: string) => {
|
const setWeekMonday = (value: string) => {
|
||||||
setWeekMondayState(value);
|
setWeekMondayState(value);
|
||||||
|
setSelectedDayIsoState(firstWeekday(value));
|
||||||
setPage(0);
|
setPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setSelectedDayIso = (value: string) => {
|
||||||
|
setSelectedDayIsoState(value);
|
||||||
|
};
|
||||||
|
|
||||||
const setDispatcherIds = (value: Set<string>) => {
|
const setDispatcherIds = (value: Set<string>) => {
|
||||||
setDispatcherIdsState(new Set(value));
|
setDispatcherIdsState(new Set(value));
|
||||||
setPage(0);
|
setPage(0);
|
||||||
|
|
@ -123,6 +137,8 @@ export function useWorkOrdersListFilters() {
|
||||||
sortBy,
|
sortBy,
|
||||||
sortDir,
|
sortDir,
|
||||||
weekMonday,
|
weekMonday,
|
||||||
|
selectedDayIso,
|
||||||
|
weekDays,
|
||||||
dispatcherIds,
|
dispatcherIds,
|
||||||
typeFilter,
|
typeFilter,
|
||||||
barFilters,
|
barFilters,
|
||||||
|
|
@ -139,6 +155,7 @@ export function useWorkOrdersListFilters() {
|
||||||
setPage,
|
setPage,
|
||||||
setPageSize,
|
setPageSize,
|
||||||
setWeekMonday,
|
setWeekMonday,
|
||||||
|
setSelectedDayIso,
|
||||||
setDispatcherIds,
|
setDispatcherIds,
|
||||||
setTypeFilter,
|
setTypeFilter,
|
||||||
setSortBy,
|
setSortBy,
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/us
|
||||||
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
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 } from "@/domain/work-orders/utils/week-day-nav-utils";
|
||||||
import {
|
import {
|
||||||
buildDayGroups,
|
|
||||||
composeBoardSections,
|
composeBoardSections,
|
||||||
filterByBarAndSearch,
|
filterByBarAndSearch,
|
||||||
mergeWeeklyAndUnassignedQueue,
|
mergeWeeklyAndUnassignedQueue,
|
||||||
|
|
@ -74,19 +74,21 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
});
|
});
|
||||||
}, [advActive, advResults, displayRows, filtered, weeklyIds, queueIds]);
|
}, [advActive, advResults, displayRows, filtered, weeklyIds, queueIds]);
|
||||||
|
|
||||||
const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
|
const dayGroups = useMemo(
|
||||||
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
|
() =>
|
||||||
|
advActive
|
||||||
|
? []
|
||||||
|
: [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)],
|
||||||
|
[advActive, filters.selectedDayIso, filters.weekMonday, scheduled],
|
||||||
|
);
|
||||||
|
|
||||||
const searchActive = filters.search.trim() !== "";
|
const searchActive = filters.search.trim() !== "";
|
||||||
const visibleRows = advActive ? advResults : filtered;
|
const visibleRows = advActive ? advResults : filtered;
|
||||||
const noSearchResults = visibleRows.length === 0 && searchActive;
|
const noSearchResults = visibleRows.length === 0 && searchActive;
|
||||||
const isEmptyWeek =
|
const isEmptyWeek =
|
||||||
!advActive &&
|
!advActive && scheduled.length === 0 && unassigned.length === 0 && !searchActive;
|
||||||
scheduled.length === 0 &&
|
const displayGroups = dayGroups;
|
||||||
unassigned.length === 0 &&
|
const dayVisibleCount = displayGroups[0]?.items.length ?? 0;
|
||||||
unscheduled.length === 0 &&
|
|
||||||
!searchActive;
|
|
||||||
const displayGroups = isEmptyWeek ? weekSkeleton : dayGroups;
|
|
||||||
|
|
||||||
const todayKey =
|
const todayKey =
|
||||||
filters.weekMonday === currentWeekMonday()
|
filters.weekMonday === currentWeekMonday()
|
||||||
|
|
@ -138,9 +140,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
cancelDraft: local.cancelDraft,
|
cancelDraft: local.cancelDraft,
|
||||||
addRow: local.addRow,
|
addRow: local.addRow,
|
||||||
scopeTotalCount: data?.totalCount ?? 0,
|
scopeTotalCount: data?.totalCount ?? 0,
|
||||||
totalCount: advActive
|
totalCount: advActive ? (data?.totalCount ?? advResults.length) : dayVisibleCount,
|
||||||
? (data?.totalCount ?? advResults.length)
|
|
||||||
: (data?.returnedCount ?? weeklyItems.length),
|
|
||||||
search: filters.search,
|
search: filters.search,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ export default function WorkOrdersListPage() {
|
||||||
<WorkOrdersFilterBar
|
<WorkOrdersFilterBar
|
||||||
weekMonday={filters.weekMonday}
|
weekMonday={filters.weekMonday}
|
||||||
onWeekChange={filters.setWeekMonday}
|
onWeekChange={filters.setWeekMonday}
|
||||||
|
selectedDayIso={filters.selectedDayIso}
|
||||||
|
onSelectedDayChange={filters.setSelectedDayIso}
|
||||||
dispatcherIds={filters.dispatcherIds}
|
dispatcherIds={filters.dispatcherIds}
|
||||||
onDispatcherChange={filters.setDispatcherIds}
|
onDispatcherChange={filters.setDispatcherIds}
|
||||||
typeFilter={filters.typeFilter}
|
typeFilter={filters.typeFilter}
|
||||||
|
|
|
||||||
56
src/domain/work-orders/utils/week-day-nav-utils.ts
Normal file
56
src/domain/work-orders/utils/week-day-nav-utils.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
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, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
102
src/test/app/(protected)/workorders/week-day-nav.test.tsx
Normal file
102
src/test/app/(protected)/workorders/week-day-nav.test.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -180,7 +180,7 @@ function UnassignedAssignHarness({
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("WoTableBody Unassigned queue (SH-165)", () => {
|
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 datedUnassigned = makeRow({ id: "u-dated", woNumber: "24991" });
|
||||||
const undatedUnassigned = makeRow({
|
const undatedUnassigned = makeRow({
|
||||||
id: "us1",
|
id: "us1",
|
||||||
|
|
@ -235,26 +235,21 @@ describe("WoTableBody Unassigned queue (SH-165)", () => {
|
||||||
const unassignedHeader = screen.getByRole("button", {
|
const unassignedHeader = screen.getByRole("button", {
|
||||||
name: "Unassigned, 2 work orders, expanded",
|
name: "Unassigned, 2 work orders, expanded",
|
||||||
});
|
});
|
||||||
const unscheduledHeader = screen.getByRole("button", {
|
|
||||||
name: "Unscheduled, 1 work order, expanded",
|
|
||||||
});
|
|
||||||
const dayLabel = screen.getByText("TUE · 06/02");
|
const dayLabel = screen.getByText("TUE · 06/02");
|
||||||
|
|
||||||
expect(unassignedHeader.compareDocumentPosition(screen.getByTestId("row-u-dated"))).toBe(
|
expect(unassignedHeader.compareDocumentPosition(screen.getByTestId("row-u-dated"))).toBe(
|
||||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
);
|
);
|
||||||
expect(unassignedHeader.compareDocumentPosition(unscheduledHeader)).toBe(
|
expect(unassignedHeader.compareDocumentPosition(dayLabel)).toBe(
|
||||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
||||||
);
|
|
||||||
expect(unscheduledHeader.compareDocumentPosition(dayLabel)).toBe(
|
|
||||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /Unscheduled/ })).not.toBeInTheDocument();
|
||||||
expect(screen.getByTestId("row-u-dated")).toBeInTheDocument();
|
expect(screen.getByTestId("row-u-dated")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("row-us1")).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.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 () => {
|
it("collapses and expands the Unassigned section via the disclosure control", async () => {
|
||||||
|
|
|
||||||
|
|
@ -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 { ReturnTypeOfUseWorkOrdersTableData } from "@/app/(protected)/workorders/_components/list/table/wo-table-types";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
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 = {
|
const unscheduledRow: WorkOrderTableRow = {
|
||||||
id: 42,
|
id: 42,
|
||||||
woNumber: "WO-42",
|
woNumber: "WO-42",
|
||||||
|
|
@ -67,6 +65,8 @@ const handlers: WoTableRowHandlers = {
|
||||||
onScheduleDate: vi.fn(),
|
onScheduleDate: vi.fn(),
|
||||||
onScheduleWeek: vi.fn(),
|
onScheduleWeek: vi.fn(),
|
||||||
onOpenSlideOver: vi.fn(),
|
onOpenSlideOver: vi.fn(),
|
||||||
|
onOpenCompDoc: vi.fn(),
|
||||||
|
onOpenUplifts: vi.fn(),
|
||||||
onReorder: vi.fn(),
|
onReorder: vi.fn(),
|
||||||
onDragStart: vi.fn(),
|
onDragStart: vi.fn(),
|
||||||
onDragOver: vi.fn(),
|
onDragOver: vi.fn(),
|
||||||
|
|
@ -74,55 +74,47 @@ const handlers: WoTableRowHandlers = {
|
||||||
onDragEnd: vi.fn(),
|
onDragEnd: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Mirrors WoTable.renderRow: body `true` → Schedule On only. */
|
describe("WoTableBody unscheduled exclusion (SH-240)", () => {
|
||||||
function renderUnscheduledHarness() {
|
it("does not render Unscheduled rows in the day-tab board view", () => {
|
||||||
const tableData = {
|
const tableData = {
|
||||||
advActive: false,
|
advActive: false,
|
||||||
unassigned: [],
|
unassigned: [],
|
||||||
unassignedError: null,
|
unassignedError: null,
|
||||||
unassignedLoading: false,
|
unassignedLoading: false,
|
||||||
unassignedOpen: true,
|
unassignedOpen: true,
|
||||||
setUnassignedOpen: vi.fn(),
|
setUnassignedOpen: vi.fn(),
|
||||||
unscheduled: [unscheduledRow],
|
unscheduled: [unscheduledRow],
|
||||||
unschedOpen: true,
|
unschedOpen: true,
|
||||||
draftDayKey: null,
|
draftDayKey: null,
|
||||||
displayGroups: [],
|
displayGroups: [],
|
||||||
todayKey: null,
|
todayKey: null,
|
||||||
startDayDraft: vi.fn(),
|
startDayDraft: vi.fn(),
|
||||||
setUnschedOpen: vi.fn(),
|
setUnschedOpen: vi.fn(),
|
||||||
} as unknown as ReturnTypeOfUseWorkOrdersTableData;
|
} as unknown as ReturnTypeOfUseWorkOrdersTableData;
|
||||||
|
|
||||||
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
||||||
<tr key={String(row.id)}>
|
<tr key={String(row.id)}>
|
||||||
<WoTableRowCells
|
<WoTableRowCells
|
||||||
row={row}
|
row={row}
|
||||||
allRows={[row]}
|
allRows={[row]}
|
||||||
users={[]}
|
users={[]}
|
||||||
sites={[]}
|
sites={[]}
|
||||||
forceScheduleHighlight={forceScheduleHighlight}
|
forceScheduleHighlight={forceScheduleHighlight}
|
||||||
forceDueDateHighlight={false}
|
forceDueDateHighlight={false}
|
||||||
handlers={handlers}
|
handlers={handlers}
|
||||||
onPatchRow={vi.fn()}
|
onPatchRow={vi.fn()}
|
||||||
/>
|
/>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|
||||||
return renderWithProviders(
|
renderWithProviders(
|
||||||
<table>
|
<table>
|
||||||
<WoTableBody tableData={tableData} renderRow={renderRow} renderInlineRow={() => null} />
|
<WoTableBody tableData={tableData} renderRow={renderRow} renderInlineRow={() => null} />
|
||||||
</table>,
|
</table>,
|
||||||
{ withAuth: false },
|
{ withAuth: false },
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
describe("WoTableBody unscheduled Schedule On highlight (SH-165)", () => {
|
expect(screen.queryByRole("button", { name: /Unscheduled/ })).not.toBeInTheDocument();
|
||||||
it("force-highlights Schedule On for Unscheduled rows, not Due Date", () => {
|
expect(screen.queryByText("WO-42")).not.toBeInTheDocument();
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,9 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () =
|
||||||
clearAdvancedFilterChip: vi.fn(),
|
clearAdvancedFilterChip: vi.fn(),
|
||||||
activeFilterCount: advApplied ? 1 : 0,
|
activeFilterCount: advApplied ? 1 : 0,
|
||||||
weekMonday: "2026-07-20",
|
weekMonday: "2026-07-20",
|
||||||
|
selectedDayIso: "2026-07-20",
|
||||||
setWeekMonday: vi.fn(),
|
setWeekMonday: vi.fn(),
|
||||||
|
setSelectedDayIso: vi.fn(),
|
||||||
dispatcherIds: new Set<string>(),
|
dispatcherIds: new Set<string>(),
|
||||||
setDispatcherIds: vi.fn(),
|
setDispatcherIds: vi.fn(),
|
||||||
typeFilter: "All",
|
typeFilter: "All",
|
||||||
|
|
@ -251,8 +253,8 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: "All dispatchers" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "All dispatchers" })).toBeDisabled();
|
||||||
expect(screen.getByRole("button", { name: "Previous weeks" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Week 7/20–7/24" })).toBeDisabled();
|
||||||
expect(screen.getByRole("button", { name: "Next weeks" })).toBeDisabled();
|
expect(screen.getByRole("tab", { name: "Mon · 07/20" })).toBeDisabled();
|
||||||
expect(screen.getByRole("button", { name: "All" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "All" })).toBeDisabled();
|
||||||
expect(screen.getByRole("button", { name: "PM" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "PM" })).toBeDisabled();
|
||||||
expect(screen.getByText("42 work orders")).toBeInTheDocument();
|
expect(screen.getByText("42 work orders")).toBeInTheDocument();
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,9 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () =
|
||||||
clearAdvancedFilterChip: vi.fn(),
|
clearAdvancedFilterChip: vi.fn(),
|
||||||
activeFilterCount: 0,
|
activeFilterCount: 0,
|
||||||
weekMonday: "2026-07-20",
|
weekMonday: "2026-07-20",
|
||||||
|
selectedDayIso: "2026-07-20",
|
||||||
setWeekMonday: vi.fn(),
|
setWeekMonday: vi.fn(),
|
||||||
|
setSelectedDayIso: vi.fn(),
|
||||||
dispatcherIds: new Set<string>(),
|
dispatcherIds: new Set<string>(),
|
||||||
setDispatcherIds: vi.fn(),
|
setDispatcherIds: vi.fn(),
|
||||||
typeFilter: "All",
|
typeFilter: "All",
|
||||||
|
|
|
||||||
122
src/test/domain/work-orders/utils/week-day-nav-utils.test.ts
Normal file
122
src/test/domain/work-orders/utils/week-day-nav-utils.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
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,
|
||||||
|
rowMatchesSelectedDay,
|
||||||
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue