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