From 4550acf1ea6eada1bded8b399defa48e1db8919a Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 12:05:03 -0300 Subject: [PATCH 1/6] feat(work-orders): add board advanced search UI --- .../list/advanced-search/adv-filter-pill.tsx | 78 +++++ .../list/advanced-search/adv-multi-select.tsx | 100 +++++++ .../list/advanced-search/adv-section.tsx | 19 ++ .../list/advanced-search/adv-type-grid.tsx | 43 +++ .../work-orders-advanced-search-sheet.tsx | 273 ++++++++++++++++++ 5 files changed, 513 insertions(+) create mode 100644 src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-pill.tsx create mode 100644 src/app/(protected)/workorders/_components/list/advanced-search/adv-multi-select.tsx create mode 100644 src/app/(protected)/workorders/_components/list/advanced-search/adv-section.tsx create mode 100644 src/app/(protected)/workorders/_components/list/advanced-search/adv-type-grid.tsx create mode 100644 src/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet.tsx diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-pill.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-pill.tsx new file mode 100644 index 00000000..5b7c3394 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-pill.tsx @@ -0,0 +1,78 @@ +import { DotTogglePill } from "@/components/ui/dot-toggle-pill"; + +import { TogglePill } from "@/components/ui/toggle-pill"; + +import { ToggleSegment } from "@/components/ui/toggle-segment"; + +type AdvFilterPillProps = { + label: string; + + selected: boolean; + + onClick: () => void; + + className?: string; +}; + +export function AdvFilterPill({ label, selected, onClick, className }: AdvFilterPillProps) { + return ( + + {label} + + ); +} + +type AdvStatusPillProps = { + label: string; + + dotColor: string; + + selected: boolean; + + selectedBg?: string; + + selectedText?: string; + + onClick: () => void; +}; + +export function AdvStatusPill({ + label, + + dotColor, + + selected, + + selectedBg, + + selectedText, + + onClick, +}: AdvStatusPillProps) { + return ( + + ); +} + +type AdvDocPillProps = { + label: string; + + selected: boolean; + + onClick: () => void; +}; + +export function AdvDocPill({ label, selected, onClick }: AdvDocPillProps) { + return ( + + {label} + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-multi-select.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-multi-select.tsx new file mode 100644 index 00000000..f9f877aa --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-multi-select.tsx @@ -0,0 +1,100 @@ +import { useState, type ReactNode } from "react"; + +import { CheckboxListItem } from "@/components/ui/checkbox-list-item"; + +import { SearchInput } from "@/components/ui/search-input"; + +import { SelectAllAction } from "@/components/ui/select-all-action"; + +type AdvMultiSelectOption = { + value: string; + + label: string; +}; + +type AdvMultiSelectProps = { + options: AdvMultiSelectOption[]; + + selected: Set; + + onToggle: (value: string) => void; + + onBulkToggle?: (values: string[], select: boolean) => void; + + placeholder: string; + + renderLeft?: (value: string) => ReactNode; +}; + +export function AdvMultiSelect({ + options, + + selected, + + onToggle, + + onBulkToggle, + + placeholder, + + renderLeft, +}: AdvMultiSelectProps) { + const [query, setQuery] = useState(""); + + const list = query + ? options.filter((option) => option.label.toLowerCase().includes(query.toLowerCase())) + : options; + + const anySelected = selected.size > 0; + + return ( +
+ + +
+ + {selected.size} of {options.length} selected + + + + anySelected + ? onBulkToggle?.( + options.map((option) => option.value), + + false, + ) + : onBulkToggle?.( + list.map((option) => option.value), + + true, + ) + } + /> +
+ +
+ {list.map((option) => ( + onToggle(option.value)} + /> + ))} + + {list.length === 0 && ( +
No matches
+ )} +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-section.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-section.tsx new file mode 100644 index 00000000..f7195508 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-section.tsx @@ -0,0 +1,19 @@ +import type { ElementType, ReactNode } from "react"; + +import { CollapsibleSection } from "@/components/ui/collapsible-section"; + +type AdvSectionProps = { + icon?: ElementType; + + label: string; + + count?: number; + + defaultOpen?: boolean; + + children: ReactNode; +}; + +export function AdvSection(props: AdvSectionProps) { + return ; +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-type-grid.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-type-grid.tsx new file mode 100644 index 00000000..b1762731 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-type-grid.tsx @@ -0,0 +1,43 @@ +import { SelectAllAction } from "@/components/ui/select-all-action"; + +import { ToggleCard } from "@/components/ui/toggle-card"; + +import { + WO_TYPE_DESCRIPTIONS, + WO_TYPES, +} from "@/domain/work-orders/types/work-order-advanced-filters"; + +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; + +type AdvTypeGridProps = { + selected: Set; + + onToggle: (type: WOType) => void; + + onSelectAll: (types: WOType[], select: boolean) => void; +}; + +export function AdvTypeGrid({ selected, onToggle, onSelectAll }: AdvTypeGridProps) { + return ( + <> +
+ 0} + onClick={() => onSelectAll([...WO_TYPES], selected.size === 0)} + /> +
+ +
+ {WO_TYPES.map((type) => ( + onToggle(type)} + /> + ))} +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet.tsx new file mode 100644 index 00000000..06a3b283 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet.tsx @@ -0,0 +1,273 @@ +import { useEffect, useState } from "react"; +import { + CalendarDays, + CheckCircle2, + FileText, + Flame, + MapPin, + SlidersHorizontal, + Tag, + Truck, + UserX, + Users, +} from "lucide-react"; +import { AdvDocPill, AdvFilterPill, AdvStatusPill } from "./adv-filter-pill"; +import { AdvMultiSelect } from "./adv-multi-select"; +import { AdvSection } from "./adv-section"; +import { AdvTypeGrid } from "./adv-type-grid"; +import { Button } from "@/components/ui/button"; +import { DateField } from "@/components/ui/date-input"; +import { IconCloseButton } from "@/components/ui/icon-close-button"; +import { SelectAllAction } from "@/components/ui/select-all-action"; +import { SideSheet } from "@/components/ui/side-sheet"; +import { + ALL_WIZARD_STATUSES, + ASSIGNEE_FILTER_UNASSIGNED, + COMPLETION_DOC_OPTIONS, + DATE_RANGE_OPTIONS, + DOC_FILTER_LABELS, + EMPTY_ADVANCED_FILTERS, + type AdvancedFilterSetKey, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import { WIZARD_STATUS_DOTS } from "@/domain/work-orders/types/work-order-wizard"; +import { + cloneAdvancedFilters, + countAdvancedFilters, + isDefaultDateRange, +} from "@/domain/work-orders/utils/advanced-filters-utils"; + +type WorkOrdersAdvancedSearchSheetProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + initial: WorkOrderAdvancedFilters; + onApply: (filters: WorkOrderAdvancedFilters) => void; + onClearAll: () => void; + sites: { id: string | number; name: string }[]; + users: WorkOrderUser[]; + pmTypes: string[]; + vendorTechs: string[]; +}; + +export function WorkOrdersAdvancedSearchSheet({ + open, + onOpenChange, + initial, + onApply, + onClearAll, + sites, + users, + pmTypes, + vendorTechs, +}: WorkOrdersAdvancedSearchSheetProps) { + const [draft, setDraft] = useState(initial); + + useEffect(() => { + if (open) { + setDraft(cloneAdvancedFilters(initial)); + } + }, [open, initial]); + + const activeCount = countAdvancedFilters(draft); + + const toggleIn = (key: AdvancedFilterSetKey, value: string) => { + setDraft((prev) => { + const next = new Set(prev[key] as Set); + if (next.has(value)) { + next.delete(value); + } else { + next.add(value); + } + return { ...prev, [key]: next }; + }); + }; + + const setMany = (key: AdvancedFilterSetKey, values: string[], select: boolean) => { + setDraft((prev) => { + const next = new Set(prev[key] as Set); + values.forEach((value) => (select ? next.add(value) : next.delete(value))); + return { ...prev, [key]: next }; + }); + }; + + const selectAllRow = (key: AdvancedFilterSetKey, all: string[], selectedSize: number) => ( +
+ 0} + onClick={() => setMany(key, all, selectedSize === 0)} + /> +
+ ); + + const handleClearAll = () => { + setDraft(cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS)); + onClearAll(); + }; + + const handleApply = () => { + onApply(cloneAdvancedFilters(draft)); + onOpenChange(false); + }; + + return ( + onOpenChange(false)} aria-label="Advanced Filters"> + Advanced Filters + +
+
+ + + + Advanced Filters + + + {activeCount > 0 && ( + · {activeCount} active + )} +
+ onOpenChange(false)} /> +
+ +
+ +
+ {DATE_RANGE_OPTIONS.map((option) => ( + setDraft((prev) => ({ ...prev, dateRange: option.key }))} + /> + ))} +
+ {draft.dateRange === "custom" && ( +
+ setDraft((prev) => ({ ...prev, customFrom: value }))} + /> + setDraft((prev) => ({ ...prev, customTo: value }))} + /> +
+ )} +
+ + + ({ + value: String(site.id), + label: site.name, + }))} + selected={draft.sites} + onToggle={(value) => toggleIn("sites", value)} + onBulkToggle={(values, select) => setMany("sites", values, select)} + placeholder="Search site…" + /> + + + + toggleIn("types", type)} + onSelectAll={(types, select) => setMany("types", types, select)} + /> + + + + ({ value: String(user.id), label: user.name })), + ]} + selected={draft.dispatchers} + onToggle={(value) => toggleIn("dispatchers", value)} + onBulkToggle={(values, select) => setMany("dispatchers", values, select)} + placeholder="Search assigned to…" + renderLeft={(id) => + id === ASSIGNEE_FILTER_UNASSIGNED ? ( + + ) : ( + + ) + } + /> + + + + {selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)} +
+ {ALL_WIZARD_STATUSES.map((status) => ( + toggleIn("statuses", status)} + /> + ))} +
+
+ + + ({ value: pmType, label: pmType }))} + selected={draft.pmTypes} + onToggle={(value) => toggleIn("pmTypes", value)} + onBulkToggle={(values, select) => setMany("pmTypes", values, select)} + placeholder="Search pm type…" + /> + + + + ({ value: name, label: name }))} + selected={draft.vendorTechs} + onToggle={(value) => toggleIn("vendorTechs", value)} + onBulkToggle={(values, select) => setMany("vendorTechs", values, select)} + placeholder="Search vendor / tech…" + /> + + + + {selectAllRow("docs", [...COMPLETION_DOC_OPTIONS], draft.docs.size)} +
+ {COMPLETION_DOC_OPTIONS.map((doc) => ( + toggleIn("docs", doc)} + /> + ))} +
+
+
+ +
+ +
+ {activeCount > 0 && ( + + {activeCount} filter set + + )} +
+ +
+
+ ); +} From 7df076f011ed0163ce0a685ca088dc980556087b Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 22 Jul 2026 17:25:14 -0300 Subject: [PATCH 2/6] fix(work-orders): wire Advanced Search sheet and cover SH-121 flow --- src/app/(protected)/workorders/index.tsx | 20 +++ ...ork-orders-advanced-search-wiring.test.tsx | 154 ++++++++++++++++++ ...ork-orders-board-core-affordances.test.tsx | 22 ++- 3 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 8229b28e..d3ead5c3 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -3,6 +3,7 @@ import { Box } from "@mui/material"; import { useNavigate } from "react-router"; import { PageHeader } from "@/components/ui/page-header"; import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal"; +import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet"; import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar"; import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header"; @@ -44,6 +45,11 @@ export default function WorkOrdersListPage() { [problemOptions], ); + const vendorTechs = useMemo( + () => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))], + [vendors], + ); + const totalCount = tableData.totalCount; const scopeTotalCount = tableData.scopeTotalCount; @@ -62,6 +68,7 @@ export default function WorkOrdersListPage() { filters.setAdvOpen(true)} activeFilterCount={filters.activeFilterCount} isFetching={tableData.isFetching && !tableData.isLoading} /> @@ -104,6 +111,19 @@ export default function WorkOrdersListPage() { }} onOpenMedia={setMediaRow} onClearSearch={() => filters.handleSearchChange("")} + onOpenAdvancedFilters={() => filters.setAdvOpen(true)} + /> + + {activeMediaRow && ( diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx new file mode 100644 index 00000000..ab94481a --- /dev/null +++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx @@ -0,0 +1,154 @@ +import { fireEvent, screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import WorkOrdersListPage from "@/app/(protected)/workorders/index"; +import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import { renderWithProviders } from "@/test/test-utils"; + +const setAdvOpen = vi.fn(); +const applyAdvancedFilters = vi.fn(); +const clearAdvancedFilters = vi.fn(); +let advOpen = false; +let advApplied: typeof EMPTY_ADVANCED_FILTERS | null = null; + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + useNavigate: () => vi.fn(), + }; +}); + +vi.mock("@/providers/auth-context", () => ({ + useAuthContext: () => ({ user: { id: "u1" } }), +})); + +vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({ + useWorkOrdersListFilters: () => ({ + search: "", + handleSearchChange: vi.fn(), + advOpen, + setAdvOpen: (open: boolean) => { + advOpen = open; + setAdvOpen(open); + }, + advApplied, + advInitial: advApplied ?? EMPTY_ADVANCED_FILTERS, + applyAdvancedFilters: (filters: typeof EMPTY_ADVANCED_FILTERS) => { + advApplied = filters; + applyAdvancedFilters(filters); + }, + clearAdvancedFilters: () => { + advApplied = null; + clearAdvancedFilters(); + }, + clearAdvancedFilterChip: vi.fn(), + activeFilterCount: advApplied ? 1 : 0, + weekMonday: "2026-07-20", + setWeekMonday: vi.fn(), + dispatcherIds: new Set(), + setDispatcherIds: vi.fn(), + typeFilter: "All", + setTypeFilter: vi.fn(), + }), +})); + +vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({ + useWorkOrdersTableData: () => ({ + rows: [], + totalCount: 0, + scopeTotalCount: 0, + isFetching: false, + isLoading: false, + setSelectedId: vi.fn(), + patchRow: vi.fn(), + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({ + useWorkOrderTableMutations: () => ({ patchField: vi.fn() }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({ + useWorkOrderUsers: () => ({ + data: [{ id: "u1", name: "Dispatcher One" }], + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderLocations: () => ({ + data: [{ id: "loc-1", name: "Seattle" }], + }), + useWorkOrderVendors: () => ({ + data: [{ companyName: "Vendor Co", email: "real@vendor.com" }], + }), +})); + +vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({ + useDropdownOptionsByCategory: () => ({ data: [{ value: "HVAC" }] }), +})); + +vi.mock("@/domain/work-orders/mocks/is-wo-table-mock-enabled", () => ({ + isWoTableMockEnabled: () => false, +})); + +vi.mock("@/app/(protected)/workorders/_components/list/table/wo-table", () => ({ + WoTable: () => null, +})); + +vi.mock("@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar", () => ({ + WorkOrdersFilterBar: () => null, +})); + +vi.mock("@/app/(protected)/workorders/_components/media/media-upload-modal", () => ({ + MediaUploadModal: () => null, +})); + +describe("WorkOrders Advanced Search page wiring (SH-121)", () => { + beforeEach(() => { + advOpen = false; + advApplied = null; + setAdvOpen.mockClear(); + applyAdvancedFilters.mockClear(); + clearAdvancedFilters.mockClear(); + }); + + it("opens the sheet from Advanced Filters, applies, then clears", () => { + const view = renderWithProviders(, { + route: "/workorders", + withAuth: false, + }); + + expect(screen.queryByRole("button", { name: /apply filters/i })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" })); + expect(setAdvOpen).toHaveBeenCalledWith(true); + + advOpen = true; + view.rerender(); + + const sheet = screen.getByRole("dialog", { name: "Advanced Filters" }); + expect(within(sheet).getByRole("button", { name: /apply filters/i })).toBeInTheDocument(); + + // Expand filter sections that start collapsed and assert option inputs are wired. + for (const label of ["Site", "Assigned to", "PM type", "Vendor / tech"]) { + fireEvent.click(within(sheet).getByRole("button", { name: new RegExp(label, "i") })); + } + expect(within(sheet).getByText("Seattle")).toBeInTheDocument(); + expect(within(sheet).getByText("Dispatcher One")).toBeInTheDocument(); + expect(within(sheet).getByText("HVAC")).toBeInTheDocument(); + expect(within(sheet).getByText("Vendor Co")).toBeInTheDocument(); + + fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i })); + expect(applyAdvancedFilters).toHaveBeenCalled(); + expect(setAdvOpen).toHaveBeenCalledWith(false); + + advOpen = true; + view.rerender(); + fireEvent.click( + within(screen.getByRole("dialog", { name: "Advanced Filters" })).getByRole("button", { + name: /clear all/i, + }), + ); + expect(clearAdvancedFilters).toHaveBeenCalled(); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx index effb2edc..92cd898b 100644 --- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx @@ -25,10 +25,24 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () = search: "", handleSearchChange: vi.fn(), setAdvOpen: vi.fn(), - activeFilterCount: 0, + advOpen: false, advApplied: null, - clearAdvancedFilterChip: vi.fn(), + advInitial: { + dateRange: "this-week", + customFrom: "", + customTo: "", + sites: new Set(), + types: new Set(), + dispatchers: new Set(), + statuses: new Set(), + pmTypes: new Set(), + vendorTechs: new Set(), + docs: new Set(), + }, + applyAdvancedFilters: vi.fn(), clearAdvancedFilters: vi.fn(), + clearAdvancedFilterChip: vi.fn(), + activeFilterCount: 0, weekMonday: "2026-07-20", setWeekMonday: vi.fn(), dispatcherIds: new Set(), @@ -165,15 +179,15 @@ describe("WorkOrdersListHeader slice affordances", () => { }); describe("WorkOrdersListPage board-core public interface", () => { - it("does not render New WO or Advanced Filters, and View navigates to detail", () => { + it("does not render New WO, and View navigates to detail", () => { navigate.mockReset(); renderWithProviders(, { route: "/workorders", withAuth: false }); expect(screen.queryByRole("button", { name: /new wo/i })).not.toBeInTheDocument(); - expect(screen.queryByLabelText("Advanced Filters")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /view doc|draft/i })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /view canceled row/i })); expect(navigate).toHaveBeenCalledWith("/workorders/77"); }); From 7fb4334405c672e68bbf2933231a6c2c1a1371f2 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 12:08:20 -0300 Subject: [PATCH 3/6] feat(work-orders): add board wizard shell UI --- .../_components/wizard/new-wo-wizard.tsx | 222 ++++++++++++++++++ .../_components/wizard/wizard-date-field.tsx | 145 ++++++++++++ .../_components/wizard/wizard-footer.tsx | 110 +++++++++ .../_components/wizard/wizard-sidebar.tsx | 219 +++++++++++++++++ .../wizard/wizard-step-location-service.tsx | 167 +++++++++++++ .../wizard/wizard-step-type-schedule.tsx | 196 ++++++++++++++++ .../wizard/wizard-step-vendor-time.tsx | 208 ++++++++++++++++ 7 files changed, 1267 insertions(+) create mode 100644 src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx create mode 100644 src/app/(protected)/workorders/_components/wizard/wizard-date-field.tsx create mode 100644 src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx create mode 100644 src/app/(protected)/workorders/_components/wizard/wizard-sidebar.tsx create mode 100644 src/app/(protected)/workorders/_components/wizard/wizard-step-location-service.tsx create mode 100644 src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx create mode 100644 src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx diff --git a/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx b/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx new file mode 100644 index 00000000..97e1ec64 --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx @@ -0,0 +1,222 @@ +import { useEffect, useMemo, useState } from "react"; +import { Dialog } from "@mui/material"; +import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; +import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; +import { mapWizardDraftToFormValues } from "@/domain/work-orders/mappers/wizard-draft-mapper"; +import { isWoWizardMockEnabled } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled"; +import { + getMockWoWizardLocationDetail, + MOCK_WO_WIZARD_LOCATIONS, + MOCK_WO_WIZARD_SERVICES, + MOCK_WO_WIZARD_USERS, + MOCK_WO_WIZARD_VENDORS, +} from "@/domain/work-orders/mocks/wo-wizard-mock-data"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import { + EMPTY_WIZARD_DRAFT, + WIZARD_STEPS, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; +import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; +import { + useWorkOrderLocations, + useWorkOrderVendors, +} from "@/domain/work-orders/use-cases/use-work-order-detail"; +import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard"; +import { WizardFooter } from "@/app/(protected)/workorders/_components/wizard/wizard-footer"; +import { WizardSidebar } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar"; +import { WizardStepLocationService } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service"; +import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule"; +import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time"; + +type NewWoWizardProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + users: WorkOrderUser[]; +}; + +export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) { + const mockFallback = isWoWizardMockEnabled(); + const [step, setStep] = useState(1); + const [draft, setDraft] = useState({ + ...EMPTY_WIZARD_DRAFT, + status: "Incomplete", + }); + const [pocAutoFilledFor, setPocAutoFilledFor] = useState(""); + + const { data: apiLocations = [] } = useWorkOrderLocations(); + const { data: apiProblems = [] } = useDropdownOptionsByCategory("Problem"); + const isMockLocation = draft.locationId.startsWith("mock-"); + const { data: apiLocationDetail } = useLocationDetail( + isMockLocation ? undefined : draft.locationId || undefined, + ); + const mockLocationDetail = isMockLocation + ? getMockWoWizardLocationDetail(draft.locationId) + : undefined; + const locationDetail = mockLocationDetail ?? apiLocationDetail; + const { data: apiVendors = [] } = useWorkOrderVendors( + undefined, + isMockLocation ? undefined : locationDetail?.zipCode, + ); + const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false)); + + const wizardUsers = useMemo(() => { + if (users.length > 0) { + return users; + } + return mockFallback ? MOCK_WO_WIZARD_USERS : []; + }, [mockFallback, users]); + const locations = + apiLocations.length > 0 ? apiLocations : mockFallback ? MOCK_WO_WIZARD_LOCATIONS : []; + const problems = + apiProblems.length > 0 ? apiProblems : mockFallback ? MOCK_WO_WIZARD_SERVICES : []; + const vendors = apiVendors.length > 0 ? apiVendors : mockFallback ? MOCK_WO_WIZARD_VENDORS : []; + + useEffect(() => { + if (open) { + setStep(1); + setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" }); + setPocAutoFilledFor(""); + } + }, [open]); + + useEffect(() => { + if (!draft.locationId || !locationDetail) { + return; + } + if (pocAutoFilledFor === draft.locationId) { + return; + } + setDraft((current) => ({ + ...current, + pocName: locationDetail.contact ?? current.pocName, + pocPhone: locationDetail.phone ?? current.pocPhone, + })); + setPocAutoFilledFor(draft.locationId); + }, [draft.locationId, locationDetail, pocAutoFilledFor]); + + const setField = ( + key: K, + value: WorkOrderWizardDraft[K], + ) => { + setDraft((current) => ({ ...current, [key]: value })); + }; + + const patchDraft = (patch: Partial) => { + setDraft((current) => ({ ...current, ...patch })); + if (patch.locationId !== undefined && patch.locationId !== pocAutoFilledFor) { + setPocAutoFilledFor(""); + } + }; + + const handleCreate = () => { + createMutation.mutate(mapWizardDraftToFormValues(draft)); + }; + + return ( + onOpenChange(false)} + maxWidth={false} + slotProps={{ + ...dialogBlurBackdropSlotProps, + paper: { + className: "flex flex-col overflow-hidden p-0", + style: { + borderRadius: 12, + width: 768, + maxWidth: 768, + height: 680, + maxHeight: 680, + }, + }, + }} + > + Guided setup for creating a new work order + +
+ + +
+
+
+ Step {step} of {WIZARD_STEPS.length} +
+

+ {WIZARD_STEPS[step - 1].title} +

+

+ {WIZARD_STEPS[step - 1].subtitle} +

+
+ + {step === 1 && ( + + )} + + {step === 2 && ( + ({ id: p.id, value: p.value }))} + onUpdate={setField} + onPatch={patchDraft} + /> + )} + + {step === 3 && ( + + )} +
+
+ + onOpenChange(false)} + onBack={() => setStep((s) => s - 1)} + onContinue={() => setStep((s) => s + 1)} + onCreate={handleCreate} + isCreating={createMutation.isPending} + /> +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-date-field.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-date-field.tsx new file mode 100644 index 00000000..ad8396d0 --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-date-field.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { CalendarDays, CalendarIcon, ChevronDown } from "lucide-react"; +import { Popover } from "@mui/material"; +import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar"; +import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker"; +import { fmtDate, fmtWeekRange } from "@/domain/work-orders/utils/wizard-date-utils"; +import { MenuAnchorButton } from "@/components/ui/menu-anchor-button"; +import { useMenuAnchor } from "@/hooks/use-menu-anchor"; + +type WizardDateFieldProps = { + value: string; + placeholder: string; + onChange: (iso: string) => void; + allowWeekMode?: boolean; + targetWeek?: string; + onChangeWeek?: (weekStart: string) => void; +}; + +export function WizardDateField({ + value, + placeholder, + onChange, + allowWeekMode, + targetWeek = "", + onChangeWeek, +}: WizardDateFieldProps) { + const { anchorEl, open, setAnchor, close } = useMenuAnchor(); + const [cellMode, setCellMode] = useState<"week" | "date">( + allowWeekMode && !value && targetWeek ? "week" : "date", + ); + const showWeek = Boolean(allowWeekMode && !value && targetWeek); + + return ( + <> + { + (e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 0 2px var(--color-primary)"; + }} + onBlur={(e) => { + (e.currentTarget as HTMLButtonElement).style.boxShadow = "none"; + }} + aria-haspopup="dialog" + > + {showWeek ? ( + + ) : ( + + )} + + {showWeek ? fmtWeekRange(targetWeek) : value ? fmtDate(value) : placeholder} + + + + + e.stopPropagation(), + }, + }} + > + {allowWeekMode && ( +
+ {(["week", "date"] as const).map((m) => { + const activeMode = cellMode === m; + return ( + + ); + })} +
+ )} + + {allowWeekMode && cellMode === "week" ? ( + { + onChange(""); + onChangeWeek?.(mon); + close(); + }} + /> + ) : ( + { + onChange(iso); + close(); + }} + /> + )} +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx new file mode 100644 index 00000000..4240f19a --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx @@ -0,0 +1,110 @@ +import { Check, ChevronLeft, ChevronRight, Lightbulb } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + WIZARD_STEPS, + canContinueWizardStep1, + canCreateWorkOrderFromWizard, + isWizardStepValid, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; + +type WizardFooterProps = { + step: number; + draft: WorkOrderWizardDraft; + onCancel: () => void; + onBack: () => void; + onContinue: () => void; + onCreate: () => void; + isCreating: boolean; +}; + +export function WizardFooter({ + step, + draft, + onCancel, + onBack, + onContinue, + onCreate, + isCreating, +}: WizardFooterProps) { + const stepValid = isWizardStepValid(step, draft); + const canCreate = canCreateWorkOrderFromWizard(draft); + const canContinue1 = canContinueWizardStep1(draft); + + return ( +
+ {step === 1 ? ( + + ) : ( + + )} + +
+ {step === 1 && !canContinue1 && ( + + Pick a type to continue + + )} +
+ + {step < WIZARD_STEPS.length ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-sidebar.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-sidebar.tsx new file mode 100644 index 00000000..3db0de5b --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-sidebar.tsx @@ -0,0 +1,219 @@ +import { ClipboardList } from "lucide-react"; +import { + WIZARD_STATUS_DOTS, + WIZARD_STEPS, + type WizardWOStatus, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; +import { + fmtDate, + fmtWeekRange, + formatApptRange, +} from "@/domain/work-orders/utils/wizard-date-utils"; +import { + ColorDot, + StepCircle, +} from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; + +type WizardSidebarProps = { + step: number; + draft: WorkOrderWizardDraft; +}; + +export function WizardSidebar({ step, draft }: WizardSidebarProps) { + const summary: [string, string][] = [ + ["WO #", draft.woNumber ? `#${draft.woNumber}` : "—"], + ["Type", draft.type || "—"], + [ + "Schedule on", + draft.scheduledOn + ? draft.scheduleWeekOnly + ? fmtWeekRange(draft.scheduledOn) + : fmtDate(draft.scheduledOn) + : "—", + ], + ["Due date", fmtDate(draft.dueDate) || "—"], + ["Assigned to", draft.assigneeName || "—"], + ["Site", draft.locationName || "—"], + [ + "POC", + draft.pocName + ? draft.pocPhone + ? `${draft.pocName} · ${draft.pocPhone}` + : draft.pocName + : "—", + ], + ["Service", draft.pm || "—"], + ["Technician", draft.vendorName || "—"], + ["Company", draft.vendorName || "—"], + ["Appt time", draft.apptStart ? formatApptRange(draft.apptStart, draft.apptEnd) : "—"], + ["Status", draft.status || "Incomplete"], + ]; + + const statusLabel = draft.status || "Incomplete"; + const statusValue = statusLabel as WizardWOStatus; + const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete; + + return ( +
+
+ + + +
+
+ Operations +
+
+ New work order +
+
+
+ +
+ {WIZARD_STEPS.map((s, i) => { + const n = i + 1; + const done = n < step; + const active = n === step; + const last = i === WIZARD_STEPS.length - 1; + return ( +
+
+ + {!last && ( +
+ )} +
+
+
+ {s.title} +
+
+ {s.nav} +
+
+
+ ); + })} +
+ +
+
+ This work order +
+ {summary.map(([label, val], i) => ( +
+ + {label} + + {label === "Status" ? ( + + + + {statusLabel} + + + ) : ( + + {val} + + )} +
+ ))} +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service.tsx new file mode 100644 index 00000000..8d602150 --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service.tsx @@ -0,0 +1,167 @@ +import { ExternalLink, Info, Mail, MapPin, Phone } from "lucide-react"; +import type { LocationOption } from "@/domain/work-orders/types/work-order"; +import type { Location } from "@/domain/locations/types/location"; +import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; +import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; +import { + DLG_INPUT_CLS, + DLG_INPUT_STYLE, +} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles"; +import { + Detail, + WizLabel, + WizSubLabel, +} from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; + +type ServiceOption = { id: string | number; value: string }; + +type WizardStepLocationServiceProps = { + draft: WorkOrderWizardDraft; + locations: LocationOption[]; + locationDetail: Location | undefined; + services: ServiceOption[]; + onUpdate: (key: K, value: WorkOrderWizardDraft[K]) => void; + onPatch: (patch: Partial) => void; +}; + +function buildMapsUrl(location: Location): string { + const parts = [location.address, location.city, location.state, location.zipCode] + .filter(Boolean) + .join(", "); + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(parts)}`; +} + +export function WizardStepLocationService({ + draft, + locations, + locationDetail, + services, + onUpdate, + onPatch, +}: WizardStepLocationServiceProps) { + return ( +
+
+ Site + + } + searchPlaceholder="Search site code or city…" + options={locations.map((loc) => ({ + key: String(loc.id), + label: loc.name, + }))} + onPick={(id) => { + const loc = locations.find((l) => String(l.id) === id); + onPatch({ + locationId: id, + locationName: loc?.name ?? "", + }); + }} + /> + {!draft.locationId && ( +

+ Site is required to continue. +

+ )} +
+ + {locationDetail && draft.locationId && ( + <> +
+ + {locationDetail.phone && } + {locationDetail.contactEmail && ( + + )} + {locationDetail.address && ( + + Open in Google Maps + + )} +
+
+ Point of contact +

+ Auto-filled from records — edit if outdated +

+
+ onUpdate("pocName", e.target.value)} + placeholder="POC name" + className={DLG_INPUT_CLS} + style={DLG_INPUT_STYLE} + /> + onUpdate("pocPhone", e.target.value)} + placeholder="POC phone" + className={DLG_INPUT_CLS} + style={DLG_INPUT_STYLE} + /> +
+