diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index ad1a89e8..b348fd21 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -65,6 +65,8 @@ interface MockState { listUrls: string[]; createdBody?: Record; updatedBody?: Record; + patchedBody?: Record; + patchedCompanyId?: string; deletedId?: string; } @@ -175,6 +177,52 @@ async function mockVendorApi( return; } + if (request.method() === "PATCH" && pathCompanyId) { + state.patchedBody = request.postDataJSON(); + state.patchedCompanyId = pathCompanyId; + const anchor = vendorRecords.find((vendor) => String(vendor.CompanyId) === pathCompanyId); + if (!anchor) { + await fulfillJson(route, { message: "Vendor roster not found" }, 404); + return; + } + const added = Array.isArray(state.patchedBody.addTechnicians) + ? (state.patchedBody.addTechnicians as Array>) + : []; + await fulfillJson(route, { + companyId: anchor.CompanyId, + rowVersion: "rv-patched", + name: anchor.CompanyName, + companyPhone: anchor.CompanyPhone, + email: anchor.Email, + address: anchor.Address, + city: anchor.City, + state: anchor.State, + zip: anchor.Zip, + googleMapsUrl: anchor.GoogleMapsUrl, + notes: anchor.Notes, + technicians: [ + ...vendorRecords + .filter((vendor) => vendor.CompanyId === anchor.CompanyId) + .map((vendor) => ({ + id: vendor.Id, + contactName: vendor.ContactName, + phone: vendor.Phone, + email: vendor.Email, + preferredContact: vendor.PreferredContact ?? "Phone", + tradeSpecialties: vendor.TradeSpecialties, + isActive: vendor.IsActive, + totalJobs: vendor.TotalJobs, + })), + ...added.map((technician, index) => ({ + id: 900 + index, + totalJobs: 0, + ...technician, + })), + ], + }); + return; + } + if (request.method() === "PUT" && pathCompanyId) { state.updatedBody = request.postDataJSON(); const technicians = Array.isArray(state.updatedBody.technicians) @@ -409,6 +457,11 @@ test.describe("Vendor directory prototype parity", () => { "https://maps.google.com/gateway", ); await expect(page.getByLabel("Preferred Contact")).toHaveCount(0); + await expect(page.getByLabel("Technician name (optional)")).toHaveCount(1); + await expect(page.getByLabel("Technician name (optional)")).toHaveValue(""); + await expect( + page.getByRole("dialog", { name: /Add Vendor/ }).getByText("Adam Whyte"), + ).toHaveCount(0); await page.getByRole("button", { name: "Add technician" }).click(); await page.getByLabel("Technician name (optional)").last().fill("New Technician"); const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last(); @@ -420,27 +473,33 @@ test.describe("Vendor directory prototype parity", () => { await page.getByLabel("Notes (optional)").fill("Created in browser E2E"); await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0); - expect(state.updatedBody).toMatchObject({ - name: "Gateway Plumbing", - companyPhone: "(314) 555-0100", - notes: "Created in browser E2E", - rowVersion: "rv-1", - }); - expect(state.updatedBody?.technicians).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - contactName: "New Technician", - tradeSpecialties: "Plumbing, HVAC", - }), - ]), - ); - const submittedTechnicians = Array.isArray(state.updatedBody?.technicians) - ? (state.updatedBody.technicians as Array>) + expect(state.patchedCompanyId).toBe("101"); + expect(state.patchedBody).toMatchObject({ rowVersion: "rv-1" }); + // Only the field the user actually typed into is transmitted. Address/phone/email were + // populated by selecting the company and never edited, so they must not be sent — otherwise a + // concurrent edit by another user to any of them would be silently reverted on resubmit. + expect(state.patchedBody?.companyFields).toEqual({ notes: "Created in browser E2E" }); + expect(state.patchedBody?.companyFields).not.toHaveProperty("companyPhone"); + expect(state.patchedBody?.companyFields).not.toHaveProperty("address"); + expect(state.patchedBody?.companyFields).not.toHaveProperty("email"); + expect(state.patchedBody?.companyFields).not.toHaveProperty("name"); + expect(state.patchedBody?.addTechnicians).toEqual([ + expect.objectContaining({ + contactName: "New Technician", + tradeSpecialties: "Plumbing, HVAC", + }), + ]); + const patchedTechnicians = Array.isArray(state.patchedBody?.addTechnicians) + ? (state.patchedBody.addTechnicians as Array>) : []; - const newTechnician = submittedTechnicians.find( + const newTechnician = patchedTechnicians.find( (technician) => technician.contactName === "New Technician", ); expect(newTechnician?.preferredContact).toBeUndefined(); + expect(patchedTechnicians.some((technician) => technician.contactName === "Adam Whyte")).toBe( + false, + ); + expect(state.updatedBody).toBeUndefined(); await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); 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)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts index 6bf89b1f..96f5e3b7 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo, useState } from "react"; +import { toast } from "react-toastify"; import { useForm, useWatch, type FieldErrors } from "react-hook-form"; import { emptyVendorCompanyRosterForm, @@ -8,10 +9,38 @@ import { } from "@/domain/vendors/schemas/vendor-roster-schema"; import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster"; import { + buildAdditiveRosterPatch, getSingleStatusOnlyChange, useSaveVendorCompanyRoster, + type EditedCompanyFields, } from "@/domain/vendors/use-cases/use-save-vendor-company-roster"; import { useVendorRosterResolver } from "./use-vendor-roster-resolver"; + +const EDITABLE_COMPANY_FIELDS = [ + "name", + "companyPhone", + "email", + "address", + "city", + "state", + "zip", + "googleMapsUrl", + "notes", +] as const; + +/** + * Company fields the user actually typed into, taken from react-hook-form dirty state. + * Selecting a company calls reset(), so loaded values are not dirty; only genuine edits are. + * The additive PATCH must send these rather than a form-vs-roster diff, which would resend + * stale values for fields another user changed while a conflict was being resolved. + */ +function pickEditedCompanyFields(dirtyFields: Record): EditedCompanyFields { + const edited: EditedCompanyFields = {}; + for (const field of EDITABLE_COMPANY_FIELDS) { + if (dirtyFields[field] === true) edited[field] = true; + } + return edited; +} import { toFormValues, useVendorRosterSelection } from "./use-vendor-roster-selection"; import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets"; import { isVendorRosterConflictError } from "@/domain/vendors/lib/vendor-roster-conflict"; @@ -104,6 +133,10 @@ export function useVendorRosterForm({ mode: "onChange", }); const { control, handleSubmit, reset, formState } = form; + // react-hook-form exposes formState through a Proxy that only tracks properties read during + // render. Reading dirtyFields inside the submit callback would not subscribe and would come + // back empty, so it is resolved here on every render. + const editedCompanyFields = pickEditedCompanyFields(formState.dirtyFields); const watched = useWatch({ control }); const clearConflict = useCallback(() => setConflict(null), []); @@ -129,17 +162,39 @@ export function useVendorRosterForm({ clearConflict(); }, [clearConflict, createDefaults, reset, resetSelection]); - const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster; - const isUpdate = mode === "update" || selection.selectedRoster != null; - const submit = (formValues: VendorCompanyRosterFormValues) => { setConflict(null); + const values = withoutBlankNewTechnicians(formValues); + if (mode === "create" && selection.selectedRoster != null) { + const selected = selection.selectedRoster; + if (!buildAdditiveRosterPatch(selected, values, selected.rowVersion, editedCompanyFields)) { + toast.error("Enter at least one technician to add to this vendor."); + return; + } + save.mutate( + { + mode: "add", + values, + companyId: selected.companyId, + rowVersion: selected.rowVersion, + originalRoster: selected, + editedCompanyFields, + }, + { + onSuccess: (data) => onSuccess?.(data), + onError: (error) => { + if (isVendorRosterConflictError(error)) setConflict(error.conflict); + }, + }, + ); + return; + } save.mutate( { - mode: isUpdate ? "update" : "create", - values: withoutBlankNewTechnicians(formValues), - companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined, - rowVersion: isUpdate ? committedRoster?.rowVersion : undefined, + mode: mode === "update" ? "update" : "create", + values, + companyId: mode === "update" ? (routeRoster?.companyId ?? companyId) : undefined, + rowVersion: mode === "update" ? routeRoster?.rowVersion : undefined, originalRoster: mode === "update" ? routeRoster : undefined, }, { diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts index cce00e18..24d51ce8 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-selection.ts @@ -1,6 +1,7 @@ import { useCallback, useRef, useState } from "react"; import { useQueryClient, type UseQueryResult } from "@tanstack/react-query"; import { + emptyRosterTechnician, emptyVendorCompanyRosterForm, type VendorCompanyRosterFormValues, } from "@/domain/vendors/schemas/vendor-roster-schema"; @@ -53,6 +54,20 @@ export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFo }; } +export function toCompanyFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFormValues { + return { ...toFormValues(roster), technicians: [] }; +} + +function withCompanyFieldsOnly( + roster: VendorCompanyRoster, +): (current: VendorCompanyRosterFormValues) => VendorCompanyRosterFormValues { + return (current) => ({ + ...toCompanyFormValues(roster), + technicians: + current.technicians.length > 0 ? current.technicians : [{ ...emptyRosterTechnician }], + }); +} + export function useVendorRosterSelection({ mode, reset, @@ -72,6 +87,13 @@ export function useVendorRosterSelection({ queryClient.fetchQuery({ queryKey: queryKeys.vendors.roster("companyId", id), queryFn: () => vendorCompanyRosterApi.get({ companyId: id }), + // SH-250: this fetch exists to obtain the freshest rowVersion, so it must not + // serve the global 5-minute staleTime cache. A conflict never invalidates this + // key (useSaveVendorCompanyRoster invalidates only on success, and its onError + // returns early for conflicts), and the Add flow has no mounted observer on it. + // Without staleTime: 0 the Reload button re-seeds the same stale rowVersion and + // the retry 409s again indefinitely. + staleTime: 0, }), [queryClient], ); @@ -91,7 +113,7 @@ export function useVendorRosterSelection({ try { const roster = await fetchRosterByCompany(company.companyId); if (requestIdRef.current !== requestId) return; - reset(toFormValues(roster)); + reset(withCompanyFieldsOnly(roster)); setSelectedRoster(roster); clearConflict(); retryTargetRef.current = null; @@ -107,7 +129,11 @@ export function useVendorRosterSelection({ (nextName = "") => { requestIdRef.current += 1; if (selectedRoster) { - reset({ ...emptyVendorCompanyRosterForm, name: nextName }); + reset((current) => ({ + ...emptyVendorCompanyRosterForm, + name: nextName, + technicians: current.technicians, + })); } setSelectedRoster(null); clearConflict(); @@ -134,7 +160,11 @@ export function useVendorRosterSelection({ void fetchRosterByCompany(selectedRoster.companyId) .then((roster) => { if (requestIdRef.current !== requestId) return; - reset(toFormValues(roster)); + // SH-250: refresh the visible company fields alongside rowVersion. + // Refreshing only selectedRoster left the form on pre-conflict values, + // so a retry diffed stale fields against the reloaded roster and could + // overwrite the concurrent company update that caused the 409. + reset(withCompanyFieldsOnly(roster)); setSelectedRoster(roster); retryTargetRef.current = null; }) 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..1745af5a --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/filter-bar/day-tabs.tsx @@ -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 ( +
+ {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..49f985f4 --- /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} + {Boolean(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..3f0bdb13 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,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; onDispatcherChange: (ids: Set) => 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 (
- - - -
- {resultLabel} +
+ +
+ + {resultLabel} + +
+
+ + +
+
+ +
); } diff --git a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts index 1a6b51c5..aa6b8599 100644 --- a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts +++ b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts @@ -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, 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) => ( 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 ( + + + + ); +} + +function WoTableEmptyShell({ message, actionLabel, onAction }: WoTableEmptyShellProps) { + return ( + + + + ); +} + 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 ( - - - + ); } @@ -135,25 +163,26 @@ export function WoTable({ if (emptyAdv) { return ( - - - + ); } - if (!tableData.advActive && tableData.noSearchResults) { + const showSearchEmpty = + !tableData.advActive && + tableData.noSearchResults && + isUnassignedQueueReady(tableData.unassignedLoading, tableData.unassignedError); + + if (showSearchEmpty) { return ( - - - + ); } 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 (