From 315a26c07da80cfb802cc4d908b409b9385b3ab9 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 17:01:38 -0300 Subject: [PATCH 01/15] fix(workorders): use service registry in list controls --- .../tabs/slide-over-info-tab-display-view.tsx | 31 +++++-- .../tabs/slide-over-info-tab-edit-view.tsx | 25 +++++- .../detail/tabs/slide-over-info-tab.tsx | 5 ++ .../detail/work-order-slide-over-tabs.tsx | 4 + .../detail/work-order-slide-over.tsx | 4 + .../advanced-search/adv-filter-sections.tsx | 17 ++-- .../work-orders-advanced-search-sheet.tsx | 7 +- .../table/build-inline-draft-create-input.ts | 2 +- .../list/table/cells/pm-type-cell.tsx | 43 +++++++--- .../list/table/cells/service-notes-dialog.tsx | 24 +++++- .../list/table/save-existing-row-type.ts | 13 ++- .../list/table/to-inline-draft-row.ts | 1 + .../list/table/wo-table-inline-row-cells.tsx | 29 ++++++- .../list/table/wo-table-inline-row.tsx | 4 + .../list/table/wo-table-row-cells.tsx | 5 +- .../list/table/wo-table-row-service-cells.tsx | 11 ++- .../_components/list/table/wo-table-row.tsx | 5 ++ .../_components/list/table/wo-table.tsx | 5 ++ .../list/work-orders-list-page-panels.tsx | 17 +++- .../_hooks/use-work-orders-list-page.ts | 8 ++ src/app/(protected)/workorders/index.tsx | 1 + .../use-services-for-work-order-type.ts | 9 ++ .../utils/services-for-work-order-type.ts | 14 ++++ .../mappers/expand-scalar-board-ops.ts | 6 ++ .../work-orders/types/work-order-board.ts | 1 + .../work-orders/types/work-order-table-row.ts | 2 + .../utils/service-identity-patch.ts | 19 +++++ .../workorders/aveta-service-notes.test.tsx | 44 ++++++++++ .../build-inline-draft-create-input.test.ts | 21 +++++ .../workorders/save-existing-row-type.test.ts | 46 ++++++++++ ...ork-orders-advanced-search-wiring.test.tsx | 84 +++++++++++++++++-- .../work-order-board-patch-mapper.test.ts | 13 +++ .../utils/service-identity-patch.test.ts | 28 +++++++ 33 files changed, 499 insertions(+), 49 deletions(-) create mode 100644 src/domain/services/utils/services-for-work-order-type.ts create mode 100644 src/domain/work-orders/utils/service-identity-patch.ts create mode 100644 src/test/domain/work-orders/utils/service-identity-patch.test.ts diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx index fe4a798c..f7eef6e6 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx @@ -12,6 +12,7 @@ import type { } from "@/domain/work-orders/types/work-order"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; +import type { Service } from "@/domain/services/types/service"; import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils"; import { effectiveCompany, @@ -31,6 +32,7 @@ type SlideOverInfoTabDisplayViewProps = { sites: LocationOption[]; vendors: VendorDropdownItem[]; pmTypes: string[]; + services?: Service[]; }; type FrozenPoc = ReturnType; @@ -115,14 +117,22 @@ function SlideOverSiteField({ return ; } -function SlideOverOpenService({ source }: { source: WorkOrderTableRow }) { +function SlideOverOpenService({ + source, + services, +}: { + source: WorkOrderTableRow; + services: Service[]; +}) { if (!source.pm) { return —; } + const name = + services.find((service) => String(service.id) === String(source.serviceId))?.name ?? source.pm; return ( - - {source.pm} + + {name} {Boolean(source.avetaRequired) && ( )} @@ -134,16 +144,21 @@ function SlideOverServiceField({ source, isCompleted, pmTypes, + services, }: { source: WorkOrderTableRow; isCompleted: boolean; pmTypes: string[]; + services: Service[]; }) { - if (!isCompleted) return ; + if (!isCompleted) return ; return ( {formatApptDisplay(source.apptTime)} - + diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index 2d718b4f..b7b7c0e3 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -16,6 +16,9 @@ import type { } from "@/domain/work-orders/types/work-order"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard"; +import type { Service } from "@/domain/services/types/service"; +import { isServiceEligibleForWorkOrderType } from "@/domain/services/utils/services-for-work-order-type"; +import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch"; import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; import { DocBadge } from "./slide-over-doc-badge"; @@ -30,6 +33,7 @@ type SlideOverInfoTabEditViewProps = { sites: LocationOption[]; vendors: VendorDropdownItem[]; pmTypes: string[]; + services?: Service[]; allOrders: WorkOrderTableRow[]; onDraftChange: (patch: Partial) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; @@ -114,6 +118,7 @@ export function SlideOverInfoTabEditView({ sites, vendors, pmTypes, + services = [], allOrders, onDraftChange, onDuplicateFound, @@ -165,7 +170,18 @@ export function SlideOverInfoTabEditView({ onDraftChange({ type, severity })} + onSave={(type, severity) => { + const selectedService = services.find( + (service) => String(service.id) === String(draft.serviceId), + ); + onDraftChange( + draft.serviceId != null && + selectedService != null && + !isServiceEligibleForWorkOrderType(selectedService, type) + ? { type, severity, serviceId: null, pm: "" } + : { type, severity }, + ); + }} /> @@ -196,14 +212,17 @@ export function SlideOverInfoTabEditView({ onDraftChange({ pm })} + onSave={(pm, id) => onDraftChange(id != null ? { pm, serviceId: id } : { pm })} onSaveDetails={(details) => onDraftChange({ - pm: details.pm, + ...serviceIdentityPatch(draft.serviceId, draft.pm, details.pm, details.serviceId), serviceNotes: details.serviceNotes, extraServices: details.extraServices, avetaRequired: details.avetaRequired, diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx index a9a186f2..f8a63799 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx @@ -4,6 +4,7 @@ import type { WorkOrderUser, } from "@/domain/work-orders/types/work-order"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import type { Service } from "@/domain/services/types/service"; import { SlideOverInfoTabDisplayView } from "./slide-over-info-tab-display-view"; import { SlideOverInfoTabEditView } from "./slide-over-info-tab-edit-view"; @@ -17,6 +18,7 @@ type SlideOverInfoTabProps = { sites: LocationOption[]; vendors: VendorDropdownItem[]; pmTypes: string[]; + services?: Service[]; allOrders: WorkOrderTableRow[]; onDraftChange: (patch: Partial) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; @@ -33,6 +35,7 @@ export function SlideOverInfoTab({ sites, vendors, pmTypes, + services = [], allOrders, onDraftChange, onDuplicateFound, @@ -50,6 +53,7 @@ export function SlideOverInfoTab({ sites={sites} vendors={vendors} pmTypes={pmTypes} + services={services} allOrders={allOrders} onDraftChange={onDraftChange} onDuplicateFound={onDuplicateFound} @@ -67,6 +71,7 @@ export function SlideOverInfoTab({ sites={sites} vendors={vendors} pmTypes={pmTypes} + services={services} /> ); } diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index 12d015fa..1b54d82a 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -12,6 +12,7 @@ import type { import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { resolveSlideOverAvetaRequired } from "@/domain/work-orders/utils/resolve-slide-over-aveta-required"; import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link"; +import type { Service } from "@/domain/services/types/service"; import type { useWorkOrderSlideOver, SlideOverTab } from "./use-work-order-slide-over"; import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel"; @@ -25,6 +26,7 @@ type WorkOrderSlideOverTabsProps = { sites: LocationOption[]; vendors: VendorDropdownItem[]; pmTypes: string[]; + services?: Service[]; allOrders: WorkOrderTableRow[]; commentHighlight?: CommentHighlight | null; onDuplicateFound?: (existing: WorkOrderTableRow) => void; @@ -40,6 +42,7 @@ export function WorkOrderSlideOverTabs({ sites, vendors, pmTypes, + services = [], allOrders, commentHighlight, onDuplicateFound, @@ -63,6 +66,7 @@ export function WorkOrderSlideOverTabs({ sites={sites} vendors={vendors} pmTypes={pmTypes} + services={services} allOrders={allOrders} onDraftChange={state.handleDraftChange} onDuplicateFound={onDuplicateFound} diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx index 762d5aa4..d59009fa 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx @@ -12,6 +12,7 @@ import type { WorkOrderTableRow, } from "@/domain/work-orders/types/work-order-table-row"; import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link"; +import type { Service } from "@/domain/services/types/service"; import { useWorkOrderSlideOver, type SlideOverTab } from "./use-work-order-slide-over"; import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav"; import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs"; @@ -28,6 +29,7 @@ type WorkOrderSlideOverProps = { sites: LocationOption[]; vendors: VendorDropdownItem[]; pmTypes: string[]; + services?: Service[]; allOrders: WorkOrderTableRow[]; onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void; onOpenCompletionDoc: (row: WorkOrderTableRow) => void; @@ -50,6 +52,7 @@ export function WorkOrderSlideOver({ sites, vendors, pmTypes, + services = [], allOrders, onSave, onOpenCompletionDoc, @@ -101,6 +104,7 @@ export function WorkOrderSlideOver({ sites={sites} vendors={vendors} pmTypes={pmTypes} + services={services} allOrders={allOrders} commentHighlight={commentHighlight} onDuplicateFound={onDuplicateFound} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx index 148aeee9..b2d72bad 100644 --- a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx @@ -28,6 +28,7 @@ import { type WorkOrderAdvancedFilters, } from "@/domain/work-orders/types/work-order-advanced-filters"; import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import type { Service } from "@/domain/services/types/service"; import { WIZARD_STATUS_DOTS } from "@/domain/work-orders/types/work-order-wizard"; type AdvancedSearchFilterSectionsProps = { @@ -37,7 +38,8 @@ type AdvancedSearchFilterSectionsProps = { setMany: (key: AdvancedFilterSetKey, values: string[], select: boolean) => void; sites: { id: string | number; name: string }[]; users: WorkOrderUser[]; - pmTypes: string[]; + services?: Service[]; + pmTypes?: string[]; vendorTechs: string[]; }; @@ -48,7 +50,8 @@ export function AdvancedSearchFilterSections({ setMany, sites, users, - pmTypes, + services = [], + pmTypes = [], vendorTechs, }: AdvancedSearchFilterSectionsProps) { const selectAllRow = (key: AdvancedFilterSetKey, all: string[], selectedSize: number) => ( @@ -141,13 +144,17 @@ export function AdvancedSearchFilterSections({ ({ value: pmType, label: pmType }))} + options={ + services.length > 0 + ? services.map((service) => ({ value: service.name, label: service.name })) + : pmTypes.map((name) => ({ value: name, label: name })) + } selected={draft.pmTypes} onToggle={(value) => toggleIn("pmTypes", value)} onBulkToggle={(values, select) => setMany("pmTypes", values, select)} placeholder="Search service…" - renderLeft={(pmType) => ( - + renderLeft={(name) => ( + )} /> 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 index b18415f5..797034f0 100644 --- 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 @@ -6,6 +6,7 @@ import { IconCloseButton } from "@/components/ui/icon-close-button"; import { SideSheet } from "@/components/ui/side-sheet"; import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import type { Service } from "@/domain/services/types/service"; type WorkOrdersAdvancedSearchSheetProps = { open: boolean; @@ -15,7 +16,7 @@ type WorkOrdersAdvancedSearchSheetProps = { onClearAll: () => void; sites: { id: string | number; name: string }[]; users: WorkOrderUser[]; - pmTypes: string[]; + services?: Service[]; vendorTechs: string[]; }; @@ -27,7 +28,7 @@ export function WorkOrdersAdvancedSearchSheet({ onClearAll, sites, users, - pmTypes, + services = [], vendorTechs, }: WorkOrdersAdvancedSearchSheetProps) { const { draft, setDraft, activeCount, toggleIn, setMany, handleClearAll, handleApply } = @@ -59,7 +60,7 @@ export function WorkOrdersAdvancedSearchSheet({ setMany={setMany} sites={sites} users={users} - pmTypes={pmTypes} + services={services} vendorTechs={vendorTechs} /> diff --git a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts index a11d067b..1d6066b7 100644 --- a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts +++ b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts @@ -28,7 +28,7 @@ export function buildInlineDraftCreateInput( scheduledOn: draft.scheduledOn, dueDate: draft.dueDate, pm: draft.pm, - serviceId: null, + serviceId: draft.serviceId ?? null, extraServices: draft.extraServices ?? [], avetaRequired: Boolean(draft.avetaRequired), pmNote: draft.serviceNotes ?? "", diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx index be03e7fe..e5152aa7 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx @@ -13,12 +13,15 @@ import { hasServiceNotesDetails, truncateServiceNotesPreview, } from "@/domain/work-orders/utils/service-notes-ui"; +import type { Service } from "@/domain/services/types/service"; +import { servicesForWorkOrderType } from "@/domain/services/utils/services-for-work-order-type"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; type PMTypeCellProps = { value: string; pmTypes: string[]; q?: string; - onSave: (v: string) => void; + onSave: (v: string, serviceId?: Service["id"]) => void; serviceNotes?: string; extraServices?: string[]; avetaRequired?: boolean; @@ -26,6 +29,9 @@ type PMTypeCellProps = { woNumber?: string; onSaveDetails?: (details: ServiceNotesDetails) => void; viewOnly?: boolean; + services?: Service[]; + serviceId?: string | number | null; + workOrderType?: WOType | ""; }; export function PMTypeCell({ @@ -40,14 +46,22 @@ export function PMTypeCell({ woNumber, onSaveDetails, viewOnly = false, + services, + serviceId, + workOrderType = "", }: PMTypeCellProps) { const [anchorEl, setAnchorEl] = useState(null); const [search, setSearch] = useState(""); const [modalOpen, setModalOpen] = useState(false); + const assignableServices = + services == null ? undefined : servicesForWorkOrderType(services, workOrderType); + const serviceNames = assignableServices?.map((service) => service.name) ?? pmTypes; const filtered = search - ? pmTypes.filter((p) => p.toLowerCase().includes(search.toLowerCase())) - : pmTypes; + ? serviceNames.filter((p) => p.toLowerCase().includes(search.toLowerCase())) + : serviceNames; + const displayValue = + services?.find((service) => String(service.id) === String(serviceId))?.name ?? value; const extras = extraServices ?? []; const hasDetails = hasServiceNotesDetails(serviceNotes, extras, avetaRequired); @@ -63,12 +77,14 @@ export function PMTypeCell({ undefined)} /> @@ -86,17 +102,17 @@ export function PMTypeCell({ className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80" > - {value ? ( + {displayValue ? ( - + ) : ( — @@ -112,7 +128,7 @@ export function PMTypeCell({ return ( <> - {value ? ( + {displayValue ? ( - + ) : ( Select … @@ -161,12 +177,13 @@ export function PMTypeCell({ anchorEl={anchorEl} search={search} filtered={filtered} - value={value} + value={displayValue} showAddDetails={Boolean(onSaveDetails)} onSearchChange={setSearch} onClose={closePopover} onPick={(pm) => { - onSave(pm); + const picked = assignableServices?.find((service) => service.name === pm); + onSave(pm, picked?.id); closePopover(); }} onAddDetails={() => { diff --git a/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx index 124357ce..737c729e 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx @@ -4,12 +4,14 @@ import { Dialog, DialogContent, DialogTitle } from "@mui/material"; import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields"; import { Button } from "@/components/ui/button"; import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; +import type { Service } from "@/domain/services/types/service"; export type ServiceNotesDetails = { pm: string; extraServices: string[]; serviceNotes: string; avetaRequired: boolean; + serviceId?: Service["id"] | null; }; type ServiceNotesDialogProps = { @@ -21,6 +23,8 @@ type ServiceNotesDialogProps = { avetaRequired?: boolean; woNumber?: string; pmTypes: string[]; + services?: Service[]; + serviceId?: string | number | null; viewOnly?: boolean; onSave: (details: ServiceNotesDetails) => void; }; @@ -34,6 +38,8 @@ export function ServiceNotesDialog({ avetaRequired = false, woNumber, pmTypes, + services, + serviceId, viewOnly = false, onSave, }: ServiceNotesDialogProps) { @@ -56,7 +62,20 @@ export function ServiceNotesDialog({ setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value])); }; - const services = pmTypes.map((value) => ({ id: value, value })); + const serviceOptions = services + ? [ + ...services.map((service) => ({ id: service.id, value: service.name })), + ...[pm, ...extraServices] + .filter((value) => value && !services.some((service) => service.name === value)) + .map((value) => ({ id: `historical:${value}`, value })), + ] + : pmTypes.map((value) => ({ id: value, value })); + const saveServiceId = () => { + const selectedId = services?.find((service) => service.name === sel[0])?.id; + if (selectedId != null) return selectedId; + if (sel[0] === pm) return serviceId ?? undefined; + return serviceId == null ? undefined : null; + }; return (
{ onSave({ pm: sel[0] ?? "", + serviceId: saveServiceId(), extraServices: sel.slice(1), serviceNotes: note, avetaRequired: aveta, diff --git a/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts b/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts index 6af21133..e0cb2334 100644 --- a/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts +++ b/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts @@ -5,6 +5,8 @@ import type { WorkOrderTableRow, } from "@/domain/work-orders/types/work-order-table-row"; import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; +import type { Service } from "@/domain/services/types/service"; +import { isServiceEligibleForWorkOrderType } from "@/domain/services/utils/services-for-work-order-type"; import { formatSeverityEscalationToast, shouldAnnounceSeverityEscalation, @@ -18,11 +20,18 @@ type PersistPatchFn = ( export function saveExistingRowType( onPatch: PersistPatchFn, - row: Pick, + row: Pick, type: WOType, severity: WorkOrderSeverity | null, + services: Service[] = [], ) { - const patch = { type, severity }; + const selectedService = services.find((service) => String(service.id) === String(row.serviceId)); + const clearService = + row.serviceId != null && + selectedService != null && + selectedService.isActive && + !isServiceEligibleForWorkOrderType(selectedService, type); + const patch = clearService ? { type, severity, serviceId: null, pm: "" } : { type, severity }; if (!shouldAnnounceSeverityEscalation(row.severity, severity)) { onPatch(row.id, patch); return; diff --git a/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts b/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts index 65bd25a9..f0343c47 100644 --- a/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts +++ b/src/app/(protected)/workorders/_components/list/table/to-inline-draft-row.ts @@ -25,6 +25,7 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) { completedDate: "", dueDate: draft.dueDate, pm: draft.pm, + serviceId: draft.serviceId, serviceNotes: draft.serviceNotes, extraServices: draft.extraServices, company: draft.company, diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx index 01dac15d..3d8f07a8 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx @@ -8,6 +8,9 @@ import type { WorkOrderTableDraft, WorkOrderTableRow, } from "@/domain/work-orders/types/work-order-table-row"; +import type { Service } from "@/domain/services/types/service"; +import { isServiceEligibleForWorkOrderType } from "@/domain/services/utils/services-for-work-order-type"; +import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch"; import { AssignedToCell } from "./cells/assigned-to-cell"; import { ApptTimeCell } from "./cells/appt-time-cell"; import { DateCell } from "./cells/date-cell"; @@ -37,6 +40,7 @@ type WoTableInlineRowCellsProps = { sitePlace?: { city: string; state: string }; vendors: VendorDropdownItem[]; pmTypes: string[]; + services?: Service[]; allRows: { id: string | number; woNumber: string }[]; siteCellRef: RefObject; woCellRef: RefObject; @@ -61,17 +65,30 @@ function InlineFieldCell({ children }: { children: ReactNode }) { function InlineTypeSeverityCell({ draft, + services = [], onPatch, }: { draft: WorkOrderTableDraft; + services: Service[]; onPatch: (patch: Partial) => void; }) { + const selectedService = services.find( + (service) => String(service.id) === String(draft.serviceId), + ); return ( onPatch({ type: t, severity: sev })} + onSave={(t, sev) => + onPatch( + draft.serviceId != null && + selectedService != null && + !isServiceEligibleForWorkOrderType(selectedService, t) + ? { type: t, severity: sev, serviceId: null, pm: "" } + : { type: t, severity: sev }, + ) + } /> ); @@ -174,6 +191,7 @@ export function WoTableInlineRowCells({ sitePlace, vendors, pmTypes, + services = [], allRows, siteCellRef, woCellRef, @@ -202,7 +220,7 @@ export function WoTableInlineRowCells({ onDuplicateFound={onDuplicateFound} /> - + onPatch({ pm })} + onSave={(pm, id) => onPatch(id != null ? { pm, serviceId: id } : { pm })} onSaveDetails={(d) => onPatch({ - pm: d.pm, + ...serviceIdentityPatch(draft.serviceId, draft.pm, d.pm, d.serviceId), extraServices: d.extraServices, serviceNotes: d.serviceNotes, avetaRequired: d.avetaRequired, diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx index ffce7992..c37b37e1 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx @@ -11,6 +11,7 @@ import type { WorkOrderTableDraft, WorkOrderTableRow, } from "@/domain/work-orders/types/work-order-table-row"; +import type { Service } from "@/domain/services/types/service"; import { UNASSIGNED_DAY_KEY, UNSCHEDULED_DAY_KEY, @@ -33,6 +34,7 @@ type WoTableInlineRowProps = { sitePlace?: { city: string; state: string }; vendors: VendorDropdownItem[]; pmTypes: string[]; + services?: Service[]; allRows: { id: string | number; woNumber: string }[]; saving?: boolean; onChange: (draft: WorkOrderTableDraft) => void; @@ -62,6 +64,7 @@ export function WoTableInlineRow({ sitePlace, vendors, pmTypes, + services = [], allRows, saving = false, onChange, @@ -136,6 +139,7 @@ export function WoTableInlineRow({ sitePlace={sitePlace} vendors={vendors} pmTypes={pmTypes} + services={services} allRows={allRows} siteCellRef={siteCellRef} woCellRef={woCellRef} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx index 1cd88e6b..7218d868 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx @@ -17,12 +17,14 @@ import { SiteCell } from "./cells/site-cell"; import { TypeCell } from "./cells/type-cell"; import type { WoTableRowHandlers } from "./wo-table-row"; import { resolveDueDateFloor } from "@/domain/work-orders/utils/due-date-floor"; +import type { Service } from "@/domain/services/types/service"; type WoTableRowCellsProps = { row: WorkOrderTableRow; allRows: WorkOrderTableRow[]; users: WorkOrderUser[]; sites: LocationOption[]; + services?: Service[]; sitePlace?: { city: string; state: string }; search?: string; forceScheduleHighlight?: boolean; @@ -39,6 +41,7 @@ export function WoTableRowCells({ allRows, users, sites, + services = [], sitePlace, search, forceScheduleHighlight, @@ -119,7 +122,7 @@ export function WoTableRowCells({ severity={row.severity} q={search} viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)} - onSave={(t, sev) => saveExistingRowType(handlers.onPatch, row, t, sev)} + onSave={(t, sev) => saveExistingRowType(handlers.onPatch, row, t, sev, services)} /> onPatchRow({ pm })} + onSave={(pm, id) => onPatchRow(id != null ? { pm, serviceId: id } : { pm })} onSaveDetails={(d) => onPatchRow({ - pm: d.pm, + ...serviceIdentityPatch(row.serviceId, row.pm, d.pm, d.serviceId), extraServices: d.extraServices, serviceNotes: d.serviceNotes, avetaRequired: d.avetaRequired, diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx index 8a7814ec..597f85d0 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx @@ -23,6 +23,7 @@ import { resolveFirstCellShadow, resolveWoTableRowStyle, } from "./wo-table-row-style"; +import type { Service } from "@/domain/services/types/service"; export type WoTableRowHandlers = { onRowClick: (row: WorkOrderTableRow) => void; @@ -59,6 +60,7 @@ type WoTableRowProps = { sitePlace?: { city: string; state: string }; vendors: VendorDropdownItem[]; pmTypes: string[]; + services?: Service[]; search?: string; forceScheduleHighlight?: boolean; forceDueDateHighlight?: boolean; @@ -101,6 +103,7 @@ export function WoTableRow({ sitePlace, vendors, pmTypes, + services = [], search, forceScheduleHighlight, forceDueDateHighlight, @@ -197,6 +200,7 @@ export function WoTableRow({ allRows={allRows} users={users} sites={sites} + services={services} sitePlace={sitePlace} search={search} forceScheduleHighlight={forceScheduleHighlight} @@ -210,6 +214,7 @@ export function WoTableRow({ void; onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void; @@ -93,6 +95,7 @@ export function WoTable({ sites, vendors, pmTypes, + services = [], weekMonday, onNavigateToDay, onOpenSlideOver, @@ -141,6 +144,7 @@ export function WoTable({ effectiveSitePlace(row) ?? resolveSitePlace(row.locationId, row.site, sitePlaceLookups) } vendors={vendors} + services={services} pmTypes={pmTypes} search={tableData.search} forceScheduleHighlight={forceScheduleHighlight} @@ -165,6 +169,7 @@ export function WoTable({ sitePlaceLookups, )} vendors={vendors} + services={services} pmTypes={pmTypes} allRows={tableData.rows} saving={saving} diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx index 3f230e88..f7797011 100644 --- a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx @@ -23,8 +23,18 @@ type WorkOrdersListPagePanelsProps = { }; export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps) { - const { filters, tableData, activeDocRow, users, locations, vendors, pmTypes, vendorTechs } = - page; + const { + filters, + tableData, + activeDocRow, + users, + locations, + vendors, + pmTypes, + services, + serviceHistory, + vendorTechs, + } = page; return ( <> @@ -44,7 +54,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps onClearAll={filters.clearAdvancedFilters} sites={locations} users={users} - pmTypes={pmTypes} + services={serviceHistory} vendorTechs={vendorTechs} /> @@ -58,6 +68,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps sites={locations} vendors={vendors} pmTypes={pmTypes} + services={services} allOrders={tableData.rows} onSave={(id, patch, options) => page.patchField(id, patch, options)} onOpenCompletionDoc={(row) => openCompletionDoc(row, page.setDocRow)} diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts index 113dec56..e322964e 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts @@ -9,6 +9,10 @@ import { } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import { + useActiveServices, + useAllServices, +} from "@/domain/services/use-cases/use-services-for-work-order-type"; import { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete"; import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link"; import { @@ -91,6 +95,8 @@ export function useWorkOrdersListPage() { const { data: locations = [] } = useWorkOrderLocations(); const { data: vendors = [] } = useWorkOrderVendors(); const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem"); + const { data: services = [] } = useActiveServices(); + const { data: serviceHistory = [] } = useAllServices(); const pmTypes = useMemo( () => problemOptions.map((option) => option.value).filter(Boolean), @@ -195,6 +201,8 @@ export function useWorkOrdersListPage() { locations, vendors, pmTypes, + services, + serviceHistory, vendorTechs, activeSlideOverRow, activeDocRow, diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 95c54dfe..875662c0 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -110,6 +110,7 @@ export default function WorkOrdersListPage() { sites={page.locations} vendors={page.vendors} pmTypes={page.pmTypes} + services={page.services} weekMonday={filters.weekMonday} onNavigateToDay={filters.navigateToDay} onOpenSlideOver={page.handleOpenSlideOver} diff --git a/src/domain/services/use-cases/use-services-for-work-order-type.ts b/src/domain/services/use-cases/use-services-for-work-order-type.ts index f712051e..0816dd27 100644 --- a/src/domain/services/use-cases/use-services-for-work-order-type.ts +++ b/src/domain/services/use-cases/use-services-for-work-order-type.ts @@ -22,3 +22,12 @@ export function useActiveServices(): UseQueryResult { staleTime: 60 * 1000, }); } + +/** Registry history is available to filters so inactive historical WOs remain findable. */ +export function useAllServices(): UseQueryResult { + return useQuery({ + queryKey: queryKeys.services.list(), + queryFn: () => servicesApi.getAll(), + staleTime: 60 * 1000, + }); +} diff --git a/src/domain/services/utils/services-for-work-order-type.ts b/src/domain/services/utils/services-for-work-order-type.ts new file mode 100644 index 00000000..c11641cf --- /dev/null +++ b/src/domain/services/utils/services-for-work-order-type.ts @@ -0,0 +1,14 @@ +import type { Service, ServiceWorkOrderType } from "@/domain/services/types/service"; +import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; + +export function serviceWorkOrderType(type: WOType | ""): ServiceWorkOrderType { + return type === "Reactive" || type === "Emergency" ? type : "PM"; +} + +export function servicesForWorkOrderType(services: Service[], type: WOType | ""): Service[] { + return services.filter((service) => isServiceEligibleForWorkOrderType(service, type)); +} + +export function isServiceEligibleForWorkOrderType(service: Service, type: WOType | ""): boolean { + return service.isActive && service.supportedWorkOrderTypes.includes(serviceWorkOrderType(type)); +} diff --git a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts index c91f11b6..72d6abe5 100644 --- a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts +++ b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts @@ -27,6 +27,11 @@ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [ field: "severity", toValue: (value) => (value == null ? "" : String(value)), }, + { + key: "serviceId", + field: "serviceId", + toValue: (value) => (value == null ? "" : String(value)), + }, { key: "type", field: "workOrderType", toValue: (value) => value as string }, { key: "site", field: "siteCode", toValue: (value) => value as string }, { key: "status", field: "lifecycleStatus", toValue: (value) => value as string }, @@ -58,6 +63,7 @@ export function expandScalarBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[] for (const rule of SCALAR_BOARD_OP_RULES) { const value = patch[rule.key]; if (value === undefined) continue; + if (rule.key === "pm" && patch.serviceId !== undefined) continue; ops.push({ field: rule.field, value: rule.toValue(value), diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index e5d8a619..489181b3 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -163,6 +163,7 @@ export type WorkOrderBoardEditableField = | "apptTime" | "docStatus" | "completedDate" + | "serviceId" | "pm" | "serviceNotes" | "avetaRequired" diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index 0dd54c32..9b973ebf 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -129,6 +129,7 @@ export interface WorkOrderTableDraft { location: string; locationId: string | number; pm: string; + serviceId?: string | number | null; serviceNotes: string; extraServices: string[]; avetaRequired?: boolean; @@ -164,6 +165,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = { location: "", locationId: "", pm: "", + serviceId: null, serviceNotes: "", extraServices: [], avetaRequired: false, diff --git a/src/domain/work-orders/utils/service-identity-patch.ts b/src/domain/work-orders/utils/service-identity-patch.ts new file mode 100644 index 00000000..6938427c --- /dev/null +++ b/src/domain/work-orders/utils/service-identity-patch.ts @@ -0,0 +1,19 @@ +import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row"; + +export function serviceIdentityPatch( + currentServiceId: string | number | null | undefined, + currentName: string, + nextName: string, + nextServiceId: string | number | null | undefined, +): Pick { + const sameIdentity = + currentServiceId != null && + nextServiceId != null && + String(currentServiceId) === String(nextServiceId); + if (sameIdentity) return {}; + + if (nextServiceId != null) return { pm: nextName, serviceId: nextServiceId }; + if (currentServiceId != null) return { pm: nextName, serviceId: null }; + + return nextName === currentName ? {} : { pm: nextName }; +} diff --git a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx index 3df1516b..389f58ae 100644 --- a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx +++ b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx @@ -5,6 +5,50 @@ import { ServiceNotesDialog } from "@/app/(protected)/workorders/_components/lis import { renderWithProviders } from "@/test/test-utils"; describe("Aveta Service & Notes", () => { + it("shows eligible registry services and saves the selected service identity", () => { + const onSave = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Old snapshot/i })); + + expect(screen.getByRole("button", { name: "QA Test Service" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Leak" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Reactive Only" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "QA Test Service" })); + expect(onSave).toHaveBeenCalledWith("QA Test Service", 701); + }); + it("shows the Aveta shield on the Service cell when required", () => { renderWithProviders( { + it("preserves the selected registry service identity", () => { + const draft = Object.assign( + { + ...EMPTY_TABLE_DRAFT, + site: "BK5", + locationId: "9", + pm: "QA Test Service", + }, + { serviceId: 701 }, + ); + + const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]); + + expect(input.pm).toBe("QA Test Service"); + expect(input.serviceId).toBe(701); + + const payload = mapWizardDraftToBoardCreatePayload(input); + expect(payload.primaryService).toBe("QA Test Service"); + expect(payload.serviceId).toBe(701); + }); + it("defaults type to PM and omits schedule flags when undated", () => { const draft = { ...EMPTY_TABLE_DRAFT, diff --git a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts index 86b6020c..83476f3a 100644 --- a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts +++ b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts @@ -7,6 +7,7 @@ vi.mock("react-toastify", () => ({ })); import { saveExistingRowType } from "@/app/(protected)/workorders/_components/list/table/save-existing-row-type"; +import type { Service } from "@/domain/services/types/service"; const row = { id: 7, woNumber: "00000024965", severity: 3 as const }; @@ -61,4 +62,49 @@ describe("saveExistingRowType", () => { expect(onPatch).toHaveBeenCalledWith(7, { type: "PM", severity: null }); expect(onPatch.mock.calls[0]![2]).toBeUndefined(); }); + + it("clears an active service before changing to an unsupported work-order type", () => { + const onPatch = vi.fn(); + const services: Service[] = [ + { + id: 701, + name: "PM Only Service", + trade: "Electrical", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: true, + supportedWorkOrderTypes: ["PM"], + }, + ]; + + saveExistingRowType(onPatch, { ...row, serviceId: 701 }, "Reactive", null, services); + + expect(onPatch).toHaveBeenCalledWith(7, { + type: "Reactive", + severity: null, + serviceId: null, + pm: "", + }); + }); + + it("preserves an inactive historical service on type change", () => { + const onPatch = vi.fn(); + const services: Service[] = [ + { + id: 701, + name: "Retired Service", + trade: "Electrical", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: false, + supportedWorkOrderTypes: ["PM"], + }, + ]; + + saveExistingRowType(onPatch, { ...row, serviceId: 701 }, "Reactive", null, services); + + expect(onPatch).toHaveBeenCalledWith(7, { type: "Reactive", severity: null }); + }); }); 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 index 3927bfaf..9097d2cc 100644 --- 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 @@ -102,9 +102,65 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ })); vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({ - useDropdownOptionsByCategory: () => ({ data: [{ value: "HVAC" }] }), + useDropdownOptionsByCategory: () => ({ + data: [ + { value: "HVAC" }, + { value: "Leak" }, + { value: "No Power" }, + { value: "Not Cooling" }, + { value: "Other" }, + ], + }), })); +vi.mock("@/domain/services/use-cases/use-services-for-work-order-type", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/domain/services/use-cases/use-services-for-work-order-type") + >(); + return { + ...actual, + useActiveServices: () => ({ + data: [ + { + id: 701, + name: "QA Test Service", + trade: "Electrical", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: true, + supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"], + }, + ], + }), + useAllServices: () => ({ + data: [ + { + id: 701, + name: "QA Test Service", + trade: "Electrical", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: true, + supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"], + }, + { + id: 702, + name: "Retired Service", + trade: "Electrical", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: false, + supportedWorkOrderTypes: ["PM"], + }, + ], + }), + }; +}); + vi.mock("@/domain/work-orders/mocks/is-wo-table-mock-enabled", () => ({ isWoTableMockEnabled: () => false, })); @@ -126,6 +182,24 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { clearAdvancedFilters.mockClear(); }); + it("offers active registry services instead of legacy Problem dropdown values", () => { + const view = renderWithProviders(, { + route: "/workorders", + withAuth: false, + }); + + fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" })); + advOpen = true; + view.rerender(); + + const sheet = screen.getByRole("dialog", { name: "Advanced Filters" }); + fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i })); + + expect(within(sheet).getByText("QA Test Service")).toBeInTheDocument(); + expect(within(sheet).getByText("Retired Service")).toBeInTheDocument(); + expect(within(sheet).queryByText("Leak")).not.toBeInTheDocument(); + }); + it("opens the sheet from Advanced Filters, applies, then clears", () => { const view = renderWithProviders(, { route: "/workorders", @@ -149,7 +223,7 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { } expect(within(sheet).getByText("Seattle")).toBeInTheDocument(); expect(within(sheet).getByText("Dispatcher One")).toBeInTheDocument(); - expect(within(sheet).getByText("HVAC")).toBeInTheDocument(); + expect(within(sheet).getByText("QA Test Service")).toBeInTheDocument(); expect(within(sheet).getByText("Vendor Co")).toBeInTheDocument(); fireEvent.click(within(sheet).getByRole("button", { name: /Aveta Compliance/i })); @@ -218,7 +292,7 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { const sheet = screen.getByRole("dialog", { name: "Advanced Filters" }); fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i })); - fireEvent.click(within(sheet).getByText("HVAC")); + fireEvent.click(within(sheet).getByText("QA Test Service")); fireEvent.click(within(sheet).getByRole("button", { name: /Completion doc/i })); fireEvent.click(within(sheet).getByRole("button", { name: /^Pending$/i })); @@ -230,13 +304,13 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { expect(applyAdvancedFilters).toHaveBeenCalled(); const appliedCalls = applyAdvancedFilters.mock.calls; const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS; - expect(applied.pmTypes.has("HVAC")).toBe(true); + expect(applied.pmTypes.has("QA Test Service")).toBe(true); expect(applied.docs.has("No")).toBe(true); expect(applied.docs.has("Yes")).toBe(true); expect(applied.docs.has("NN")).toBe(true); const params = toBoardSearchQueryParams({ filters: applied }); - expect(params.pmTypes).toEqual(["HVAC"]); + expect(params.pmTypes).toEqual(["QA Test Service"]); expect(params.docStatuses).toEqual([2, 1, 3]); }); diff --git a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts index 9eec3d6d..82acf13f 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts @@ -65,6 +65,19 @@ describe("expandTablePatchToBoardFieldOps", () => { expect(expandTablePatchToBoardFieldOps({ pm: "HVAC" })).toEqual([ { field: "pm", value: "HVAC" }, ]); + expect(expandTablePatchToBoardFieldOps({ pm: "QA Test Service", serviceId: 701 })).toEqual([ + { field: "serviceId", value: "701" }, + ]); + expect(expandTablePatchToBoardFieldOps({ serviceId: null, pm: "" })).toEqual([ + { field: "serviceId", value: "" }, + ]); + expect( + expandTablePatchToBoardFieldOps({ severity: 2, serviceId: null, type: "Reactive", pm: "" }), + ).toEqual([ + { field: "severity", value: "2" }, + { field: "serviceId", value: "" }, + { field: "workOrderType", value: "Reactive" }, + ]); expect(expandTablePatchToBoardFieldOps({ docStatus: "NN" })).toEqual([ { field: "docStatus", value: "NotNeeded" }, ]); diff --git a/src/test/domain/work-orders/utils/service-identity-patch.test.ts b/src/test/domain/work-orders/utils/service-identity-patch.test.ts new file mode 100644 index 00000000..6128d94d --- /dev/null +++ b/src/test/domain/work-orders/utils/service-identity-patch.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch"; + +describe("serviceIdentityPatch", () => { + it("keeps an unchanged registry identity out of unrelated detail saves", () => { + expect(serviceIdentityPatch(701, "Old snapshot", "QA Test Service", 701)).toEqual({}); + }); + + it("emits the registry identity when selecting a registered service", () => { + expect(serviceIdentityPatch(null, "Legacy value", "QA Test Service", 701)).toEqual({ + pm: "QA Test Service", + serviceId: 701, + }); + }); + + it("clears a registry identity when the selection is removed", () => { + expect(serviceIdentityPatch(701, "QA Test Service", "", null)).toEqual({ + pm: "", + serviceId: null, + }); + }); + + it("keeps legacy PM edits label-based", () => { + expect(serviceIdentityPatch(null, "Legacy value", "Another legacy value", undefined)).toEqual({ + pm: "Another legacy value", + }); + }); +}); From 24107ee3b2a3c096076402cb30c0e03e132de6c4 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 17:04:44 -0300 Subject: [PATCH 02/15] fix(workorders): match renamed service filter aliases --- .../utils/advanced-filter-predicates.ts | 13 +++++-- .../work-orders/utils/table-filter-utils.ts | 4 ++- .../utils/table-filter-utils.test.ts | 36 +++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/domain/work-orders/utils/advanced-filter-predicates.ts b/src/domain/work-orders/utils/advanced-filter-predicates.ts index 530bbd70..67891972 100644 --- a/src/domain/work-orders/utils/advanced-filter-predicates.ts +++ b/src/domain/work-orders/utils/advanced-filter-predicates.ts @@ -1,4 +1,5 @@ import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { Service } from "@/domain/services/types/service"; import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { dateRangeFor } from "@/domain/work-orders/utils/advanced-filters-utils"; @@ -12,6 +13,7 @@ import { addDaysIso } from "@/domain/work-orders/utils/wizard-date-utils"; type AdvancedFilterPredicate = ( row: WorkOrderTableRow, filters: WorkOrderAdvancedFilters, + serviceHistory: Service[], ) => boolean; const isOpenUndated = (row: WorkOrderTableRow): boolean => @@ -60,8 +62,15 @@ const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => { const matchesStatus: AdvancedFilterPredicate = (row, filters) => !filters.statuses.size || (!!row.status && filters.statuses.has(row.status as never)); -const matchesPmType: AdvancedFilterPredicate = (row, filters) => - !filters.pmTypes.size || filters.pmTypes.has(row.pm); +const matchesPmType: AdvancedFilterPredicate = (row, filters, serviceHistory) => { + if (!filters.pmTypes.size || filters.pmTypes.has(row.pm)) return true; + if (row.serviceId == null) return false; + + const linkedService = serviceHistory.find( + (service) => String(service.id) === String(row.serviceId), + ); + return linkedService != null && filters.pmTypes.has(linkedService.name); +}; const matchesVendorTech: AdvancedFilterPredicate = (row, filters) => !filters.vendorTechs.size || diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index 2300335f..78196dc1 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -1,4 +1,5 @@ import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { Service } from "@/domain/services/types/service"; import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list"; import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters"; import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; @@ -36,8 +37,9 @@ export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow { export function matchesAdvancedFilters( row: WorkOrderTableRow, filters: WorkOrderAdvancedFilters, + serviceHistory: Service[] = [], ): boolean { - return ADVANCED_FILTER_PREDICATES.every((predicate) => predicate(row, filters)); + return ADVANCED_FILTER_PREDICATES.every((predicate) => predicate(row, filters, serviceHistory)); } function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean { diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index 9798afb8..56097d24 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -370,6 +370,42 @@ describe("buildDayGroups", () => { }); describe("matchesAdvancedFilters", () => { + it("matches inactive renamed registry services across the global date range", () => { + const row = makeRow({ + pm: "Historic Service Name", + serviceId: 701, + scheduledOn: "2024-03-12", + }); + const filters = { + ...EMPTY_ADVANCED_FILTERS, + pmTypes: new Set(["Current Service Name"]), + }; + const serviceHistory = [ + { + id: 701, + name: "Current Service Name", + trade: "Electrical", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: false, + supportedWorkOrderTypes: ["PM" as const], + }, + ]; + + expect(matchesAdvancedFilters(row, filters, serviceHistory)).toBe(true); + expect(row.pm).toBe("Historic Service Name"); + }); + + it("continues matching legacy service labels without a linked registry identity", () => { + const filters = { + ...EMPTY_ADVANCED_FILTERS, + pmTypes: new Set(["Legacy Problem"]), + }; + + expect(matchesAdvancedFilters(makeRow({ pm: "Legacy Problem" }), filters, [])).toBe(true); + }); + it("filters by site and type", () => { const weekMonday = mondayOf(todayIso()); const row = makeRow({ From 18fefef9a39efb4f9de2428c0b200a70d1fe537e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 17:14:10 -0300 Subject: [PATCH 03/15] fix(workorders): preserve service history on slide-over saves --- .../tabs/slide-over-info-tab-display-view.tsx | 4 +- .../list/table/cells/pm-type-cell.tsx | 4 +- .../mappers/expand-scalar-board-ops.ts | 3 +- .../work-orders/utils/slide-over-draft.ts | 2 + .../workorders/aveta-service-notes.test.tsx | 71 +++++++++++++++ ...tab-edit-view-additional-contacts.test.tsx | 87 ++++++++++++++++++- .../utils/slide-over-draft.test.ts | 16 ++++ 7 files changed, 181 insertions(+), 6 deletions(-) diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx index f7eef6e6..1b4b2abf 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view.tsx @@ -127,8 +127,8 @@ function SlideOverOpenService({ if (!source.pm) { return —; } - const name = - services.find((service) => String(service.id) === String(source.serviceId))?.name ?? source.pm; + const linkedService = services.find((service) => String(service.id) === String(source.serviceId)); + const name = linkedService?.isActive ? linkedService.name : source.pm; return ( diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx index e5152aa7..cbb1f9e3 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx @@ -60,8 +60,8 @@ export function PMTypeCell({ const filtered = search ? serviceNames.filter((p) => p.toLowerCase().includes(search.toLowerCase())) : serviceNames; - const displayValue = - services?.find((service) => String(service.id) === String(serviceId))?.name ?? value; + const linkedService = services?.find((service) => String(service.id) === String(serviceId)); + const displayValue = linkedService?.isActive ? linkedService.name : value; const extras = extraServices ?? []; const hasDetails = hasServiceNotesDetails(serviceNotes, extras, avetaRequired); diff --git a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts index 72d6abe5..f6dd5d04 100644 --- a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts +++ b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts @@ -21,7 +21,8 @@ type ScalarBoardOpRule = { /** One row per scalar patch field, in emission order. */ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [ { key: "woNumber", field: "woNumber", toValue: (value) => value as string }, - // Severity before type so Emergency/Reactive never land without required severity. + // Severity before type, then service clear before type because the backend validates + // the currently assigned service against the requested work-order type. { key: "severity", field: "severity", diff --git a/src/domain/work-orders/utils/slide-over-draft.ts b/src/domain/work-orders/utils/slide-over-draft.ts index af8c2679..8fa3ac57 100644 --- a/src/domain/work-orders/utils/slide-over-draft.ts +++ b/src/domain/work-orders/utils/slide-over-draft.ts @@ -15,6 +15,7 @@ export const SLIDE_OVER_EDIT_KEYS = [ "woNumber", "site", "type", + "severity", "dispatcherId", "dispatcherName", "scheduledOn", @@ -26,6 +27,7 @@ export const SLIDE_OVER_EDIT_KEYS = [ "completedDate", "apptTime", "pm", + "serviceId", "serviceNotes", "extraServices", "avetaRequired", diff --git a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx index 389f58ae..ff77c95b 100644 --- a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx +++ b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell"; import { ServiceNotesDialog } from "@/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog"; +import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch"; import { renderWithProviders } from "@/test/test-utils"; describe("Aveta Service & Notes", () => { @@ -49,6 +50,76 @@ describe("Aveta Service & Notes", () => { expect(onSave).toHaveBeenCalledWith("QA Test Service", 701); }); + it("keeps the historical label when its linked registry service is inactive", () => { + renderWithProviders( + , + ); + + expect(screen.getByRole("button", { name: /Historic Service Name/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Renamed Service/i })).not.toBeInTheDocument(); + }); + + it("preserves the historic identity when only service notes change", () => { + const onSaveDetails = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Historic Service Name/i })); + fireEvent.click(screen.getByRole("button", { name: /Add service and notes/i })); + fireEvent.change( + screen.getByPlaceholderText( + "Describe the specific issue, location details, door number, etc.", + ), + { target: { value: "Changed detail only" } }, + ); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + const saved = onSaveDetails.mock.calls[0]![0]; + expect(saved).toMatchObject({ pm: "Historic Service Name", serviceId: 701 }); + expect(serviceIdentityPatch(701, "Historic Service Name", saved.pm, saved.serviceId)).toEqual( + {}, + ); + }); + it("shows the Aveta shield on the Service cell when required", () => { renderWithProviders( ({ - TypeCell: () => null, + TypeCell: ({ onSave }: { onSave: (type: "Reactive", severity: number) => void }) => ( + + ), })); vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({ AssignedToCell: () => null, @@ -121,6 +127,85 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow } describe("SlideOverInfoTabEditView additional contacts", () => { + it("keeps the historic service label in slide-over display for an inactive service", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("Historic Service Name")).toBeInTheDocument(); + expect(screen.queryByText("Renamed Service")).not.toBeInTheDocument(); + }); + + it("saves an incompatible type change only after clearing the registry service", () => { + const baseline = baseRow({ type: "PM", pm: "PM only", serviceId: 701 }); + const onSave = vi.fn(); + const pmOnlyService = { + id: 701, + name: "PM only", + trade: "Electrical", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: true, + supportedWorkOrderTypes: ["PM" as const], + }; + + function SlideOverSaveHarness() { + const [draft, setDraft] = useState(baseline); + return ( + <> + setDraft((current) => ({ ...current, ...patch }))} + /> + + + ); + } + + renderWithProviders(, { withAuth: false }); + fireEvent.click(screen.getByRole("button", { name: "Change type to Reactive" })); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + + expect(onSave).toHaveBeenCalledWith([ + { field: "severity", value: "2" }, + { field: "serviceId", value: "" }, + { field: "workOrderType", value: "Reactive" }, + ]); + }); + it("passes additionalContacts into SiteCell and persists them on save", () => { const onDraftChange = vi.fn(); const contacts = [{ name: "Existing", phone: "555-0000" }]; diff --git a/src/test/domain/work-orders/utils/slide-over-draft.test.ts b/src/test/domain/work-orders/utils/slide-over-draft.test.ts index d0bae108..358f85b3 100644 --- a/src/test/domain/work-orders/utils/slide-over-draft.test.ts +++ b/src/test/domain/work-orders/utils/slide-over-draft.test.ts @@ -7,6 +7,7 @@ import { import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { mergeAutoSchedulePatch } from "@/domain/work-orders/utils/wo-lifecycle-transitions"; +import { expandTablePatchToBoardFieldOps } from "@/domain/work-orders/mappers/work-order-board-patch-mapper"; function baseRow(overrides: Partial = {}): WorkOrderTableRow { return { @@ -68,6 +69,7 @@ describe("slide-over-draft", () => { expect(SLIDE_OVER_EDIT_KEYS).toContain("technicianAssigned"); expect(SLIDE_OVER_EDIT_KEYS).toContain("additionalContacts"); expect(SLIDE_OVER_EDIT_KEYS).toContain("avetaRequired"); + expect(SLIDE_OVER_EDIT_KEYS).toContain("serviceId"); expect(SLIDE_OVER_EDIT_KEYS).toContain("pocName"); expect(SLIDE_OVER_EDIT_KEYS).toContain("pocPhone"); expect(SLIDE_OVER_EDIT_KEYS).toContain("pocNotes"); @@ -77,6 +79,20 @@ describe("slide-over-draft", () => { expect(SLIDE_OVER_EDIT_KEYS).not.toContain("tech"); }); + it("saves an incompatible type change after clearing its registry service", () => { + const baseline = baseRow({ type: "PM", pm: "PM only", serviceId: 701 }); + const draft = baseRow({ type: "Reactive", severity: 2, pm: "", serviceId: null }); + const patch = buildSlideOverPatch(draft, baseline); + + expect(isSlideOverDraftDirty(draft, baseline)).toBe(true); + expect(patch).toMatchObject({ type: "Reactive", severity: 2, serviceId: null, pm: "" }); + expect(expandTablePatchToBoardFieldOps(patch)).toEqual([ + { field: "severity", value: "2" }, + { field: "serviceId", value: "" }, + { field: "workOrderType", value: "Reactive" }, + ]); + }); + it("tracks vendor via vendorId only (company/tech are display companions)", () => { const row = baseRow(); const draft = baseRow({ company: "Other Co", tech: "Pat" }); From e35bd2983ee2233dc7848fea88996ceb07d296be Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 17:16:41 -0300 Subject: [PATCH 04/15] fix(workorders): retain completed service snapshot labels --- .../list/table/cells/pm-type-cell.tsx | 2 +- .../workorders/aveta-service-notes.test.tsx | 28 +++++++++++++++ ...tab-edit-view-additional-contacts.test.tsx | 36 +++++++++++++++++-- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx index cbb1f9e3..55a44859 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx @@ -61,7 +61,7 @@ export function PMTypeCell({ ? serviceNames.filter((p) => p.toLowerCase().includes(search.toLowerCase())) : serviceNames; const linkedService = services?.find((service) => String(service.id) === String(serviceId)); - const displayValue = linkedService?.isActive ? linkedService.name : value; + const displayValue = !viewOnly && linkedService?.isActive ? linkedService.name : value; const extras = extraServices ?? []; const hasDetails = hasServiceNotesDetails(serviceNotes, extras, avetaRequired); diff --git a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx index ff77c95b..a9fe6360 100644 --- a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx +++ b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx @@ -77,6 +77,34 @@ describe("Aveta Service & Notes", () => { expect(screen.queryByRole("button", { name: /Renamed Service/i })).not.toBeInTheDocument(); }); + it("keeps the completed work-order label when its active service is renamed", () => { + renderWithProviders( + , + ); + + expect(screen.getByRole("button", { name: /Saved Completed Label/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Current Active Name/i })).not.toBeInTheDocument(); + }); + it("preserves the historic identity when only service notes change", () => { const onSaveDetails = vi.fn(); renderWithProviders( diff --git a/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx index 53a2654d..b4750b16 100644 --- a/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx @@ -65,9 +65,6 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", ( vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({ ApptTimeCell: () => null, })); -vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({ - PMTypeCell: () => null, -})); vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({ VendorCell: () => null, })); @@ -155,6 +152,39 @@ describe("SlideOverInfoTabEditView additional contacts", () => { expect(screen.queryByText("Renamed Service")).not.toBeInTheDocument(); }); + it("keeps a completed slide-over's saved label after its active service is renamed", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("Saved Completed Label")).toBeInTheDocument(); + expect(screen.queryByText("Current Active Name")).not.toBeInTheDocument(); + }); + it("saves an incompatible type change only after clearing the registry service", () => { const baseline = baseRow({ type: "PM", pm: "PM only", serviceId: 701 }); const onSave = vi.fn(); From 690820f0b956547a77d195d02588127c6b271b65 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 17:47:27 -0300 Subject: [PATCH 05/15] fix(workorders): order service reassignment around type updates --- .../tabs/slide-over-info-tab-edit-view.tsx | 5 +- .../detail/tabs/slide-over-info-tab.tsx | 3 ++ .../detail/work-order-slide-over-tabs.tsx | 3 ++ .../detail/work-order-slide-over.tsx | 3 ++ .../list/table/save-existing-row-type.ts | 7 +-- .../list/table/wo-table-row-cells.tsx | 12 ++++- .../_components/list/table/wo-table-row.tsx | 3 ++ .../_components/list/table/wo-table.tsx | 3 ++ .../list/work-orders-list-page-panels.tsx | 1 + src/app/(protected)/workorders/index.tsx | 1 + .../mappers/expand-scalar-board-ops.ts | 19 ++++++- .../mappers/work-order-board-patch-mapper.ts | 11 +++- .../use-work-order-table-mutations.ts | 4 +- .../workorders/save-existing-row-type.test.ts | 15 +++++- ...tab-edit-view-additional-contacts.test.tsx | 7 +-- .../work-order-board-patch-mapper.test.ts | 26 ++++++++++ .../use-work-order-table-mutations.test.tsx | 52 +++++++++++++++++++ 17 files changed, 161 insertions(+), 14 deletions(-) diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index b7b7c0e3..da8b4264 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -34,6 +34,7 @@ type SlideOverInfoTabEditViewProps = { vendors: VendorDropdownItem[]; pmTypes: string[]; services?: Service[]; + serviceHistory?: Service[]; allOrders: WorkOrderTableRow[]; onDraftChange: (patch: Partial) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; @@ -119,6 +120,7 @@ export function SlideOverInfoTabEditView({ vendors, pmTypes, services = [], + serviceHistory = [], allOrders, onDraftChange, onDuplicateFound, @@ -171,7 +173,8 @@ export function SlideOverInfoTabEditView({ value={draft.type} severity={draft.severity} onSave={(type, severity) => { - const selectedService = services.find( + const typeServices = serviceHistory.length > 0 ? serviceHistory : services; + const selectedService = typeServices.find( (service) => String(service.id) === String(draft.serviceId), ); onDraftChange( diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx index f8a63799..80112b10 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx @@ -19,6 +19,7 @@ type SlideOverInfoTabProps = { vendors: VendorDropdownItem[]; pmTypes: string[]; services?: Service[]; + serviceHistory?: Service[]; allOrders: WorkOrderTableRow[]; onDraftChange: (patch: Partial) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; @@ -36,6 +37,7 @@ export function SlideOverInfoTab({ vendors, pmTypes, services = [], + serviceHistory = [], allOrders, onDraftChange, onDuplicateFound, @@ -54,6 +56,7 @@ export function SlideOverInfoTab({ vendors={vendors} pmTypes={pmTypes} services={services} + serviceHistory={serviceHistory} allOrders={allOrders} onDraftChange={onDraftChange} onDuplicateFound={onDuplicateFound} diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index 1b54d82a..0b1f134f 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -27,6 +27,7 @@ type WorkOrderSlideOverTabsProps = { vendors: VendorDropdownItem[]; pmTypes: string[]; services?: Service[]; + serviceHistory?: Service[]; allOrders: WorkOrderTableRow[]; commentHighlight?: CommentHighlight | null; onDuplicateFound?: (existing: WorkOrderTableRow) => void; @@ -43,6 +44,7 @@ export function WorkOrderSlideOverTabs({ vendors, pmTypes, services = [], + serviceHistory = [], allOrders, commentHighlight, onDuplicateFound, @@ -67,6 +69,7 @@ export function WorkOrderSlideOverTabs({ vendors={vendors} pmTypes={pmTypes} services={services} + serviceHistory={serviceHistory} allOrders={allOrders} onDraftChange={state.handleDraftChange} onDuplicateFound={onDuplicateFound} diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx index d59009fa..f75010f5 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx @@ -30,6 +30,7 @@ type WorkOrderSlideOverProps = { vendors: VendorDropdownItem[]; pmTypes: string[]; services?: Service[]; + serviceHistory?: Service[]; allOrders: WorkOrderTableRow[]; onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void; onOpenCompletionDoc: (row: WorkOrderTableRow) => void; @@ -53,6 +54,7 @@ export function WorkOrderSlideOver({ vendors, pmTypes, services = [], + serviceHistory = [], allOrders, onSave, onOpenCompletionDoc, @@ -105,6 +107,7 @@ export function WorkOrderSlideOver({ vendors={vendors} pmTypes={pmTypes} services={services} + serviceHistory={serviceHistory} allOrders={allOrders} commentHighlight={commentHighlight} onDuplicateFound={onDuplicateFound} diff --git a/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts b/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts index e0cb2334..bc32d6df 100644 --- a/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts +++ b/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts @@ -23,13 +23,14 @@ export function saveExistingRowType( row: Pick, type: WOType, severity: WorkOrderSeverity | null, - services: Service[] = [], + serviceHistory: Service[] = [], ) { - const selectedService = services.find((service) => String(service.id) === String(row.serviceId)); + const selectedService = serviceHistory.find( + (service) => String(service.id) === String(row.serviceId), + ); const clearService = row.serviceId != null && selectedService != null && - selectedService.isActive && !isServiceEligibleForWorkOrderType(selectedService, type); const patch = clearService ? { type, severity, serviceId: null, pm: "" } : { type, severity }; if (!shouldAnnounceSeverityEscalation(row.severity, severity)) { diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx index 7218d868..73227bc5 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx @@ -25,6 +25,7 @@ type WoTableRowCellsProps = { users: WorkOrderUser[]; sites: LocationOption[]; services?: Service[]; + serviceHistory?: Service[]; sitePlace?: { city: string; state: string }; search?: string; forceScheduleHighlight?: boolean; @@ -42,6 +43,7 @@ export function WoTableRowCells({ users, sites, services = [], + serviceHistory = [], sitePlace, search, forceScheduleHighlight, @@ -122,7 +124,15 @@ export function WoTableRowCells({ severity={row.severity} q={search} viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)} - onSave={(t, sev) => saveExistingRowType(handlers.onPatch, row, t, sev, services)} + onSave={(t, sev) => + saveExistingRowType( + handlers.onPatch, + row, + t, + sev, + serviceHistory.length ? serviceHistory : services, + ) + } /> void; onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void; @@ -96,6 +97,7 @@ export function WoTable({ vendors, pmTypes, services = [], + serviceHistory = [], weekMonday, onNavigateToDay, onOpenSlideOver, @@ -145,6 +147,7 @@ export function WoTable({ } vendors={vendors} services={services} + serviceHistory={serviceHistory} pmTypes={pmTypes} search={tableData.search} forceScheduleHighlight={forceScheduleHighlight} diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx index f7797011..dd541536 100644 --- a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx @@ -69,6 +69,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps vendors={vendors} pmTypes={pmTypes} services={services} + serviceHistory={serviceHistory} allOrders={tableData.rows} onSave={(id, patch, options) => page.patchField(id, patch, options)} onOpenCompletionDoc={(row) => openCompletionDoc(row, page.setDocRow)} diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 875662c0..8b697e8e 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -111,6 +111,7 @@ export default function WorkOrdersListPage() { vendors={page.vendors} pmTypes={page.pmTypes} services={page.services} + serviceHistory={page.serviceHistory} weekMonday={filters.weekMonday} onNavigateToDay={filters.navigateToDay} onOpenSlideOver={page.handleOpenSlideOver} diff --git a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts index f6dd5d04..b9372b1d 100644 --- a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts +++ b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts @@ -58,13 +58,27 @@ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [ }, ]; -export function expandScalarBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[] { +export function expandScalarBoardOps( + patch: WorkOrderTablePatch, + currentRow?: { serviceId?: string | number | null; type?: string }, +): BoardFieldOp[] { const ops: BoardFieldOp[] = []; + const reassignmentAcrossType = + patch.type !== undefined && + currentRow?.type !== undefined && + patch.type !== currentRow.type && + patch.serviceId != null && + (currentRow.serviceId == null || String(currentRow.serviceId) !== String(patch.serviceId)); + + if (reassignmentAcrossType && currentRow.serviceId != null) { + ops.push({ field: "serviceId", value: "" }); + } for (const rule of SCALAR_BOARD_OP_RULES) { const value = patch[rule.key]; if (value === undefined) continue; if (rule.key === "pm" && patch.serviceId !== undefined) continue; + if (reassignmentAcrossType && rule.key === "serviceId") continue; ops.push({ field: rule.field, value: rule.toValue(value), @@ -72,6 +86,9 @@ export function expandScalarBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[] ? { technicianAssigned: patch.technicianAssigned } : {}), }); + if (reassignmentAcrossType && rule.key === "type") { + ops.push({ field: "serviceId", value: String(patch.serviceId) }); + } } return ops; diff --git a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts index 3f3b2f17..4ff50515 100644 --- a/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-patch-mapper.ts @@ -82,12 +82,19 @@ function expandPocOp(patch: WorkOrderTablePatch): BoardPocOp[] { * Schedule/week-only fields are emitted first in a stable order; the composite * POC op follows, then scalar fields. */ -export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardOp[] { +export function expandTablePatchToBoardFieldOps( + patch: WorkOrderTablePatch, + currentRow?: Pick, +): BoardOp[] { if (isLocalOnlyTablePatch(patch)) { return []; } - return [...expandScheduleBoardOps(patch), ...expandPocOp(patch), ...expandScalarBoardOps(patch)]; + return [ + ...expandScheduleBoardOps(patch), + ...expandPocOp(patch), + ...expandScalarBoardOps(patch, currentRow), + ]; } export function isDispatchBoardField(field: WorkOrderBoardEditableField): boolean { diff --git a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts index f7684958..9b2016e7 100644 --- a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts @@ -83,12 +83,12 @@ async function applyBoardFieldOps( patch: WorkOrderTablePatch, getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined, ): Promise { - const ops = expandTablePatchToBoardFieldOps(patch); + const current = getRow?.(id); + const ops = expandTablePatchToBoardFieldOps(patch, current); if (ops.length === 0) { throw new BoardPatchValidationError("NoPersistableFields", NO_PERSISTABLE_FIELDS_MESSAGE); } - const current = getRow?.(id); if (!current?.rowVersion) { throw new Error("Missing work order version for board patch. Refresh and retry."); } diff --git a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts index 83476f3a..7754c9cf 100644 --- a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts +++ b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts @@ -88,7 +88,7 @@ describe("saveExistingRowType", () => { }); }); - it("preserves an inactive historical service on type change", () => { + it("clears a known inactive historical service before type change", () => { const onPatch = vi.fn(); const services: Service[] = [ { @@ -105,6 +105,19 @@ describe("saveExistingRowType", () => { saveExistingRowType(onPatch, { ...row, serviceId: 701 }, "Reactive", null, services); + expect(onPatch).toHaveBeenCalledWith(7, { + type: "Reactive", + severity: null, + serviceId: null, + pm: "", + }); + }); + + it("preserves a service when the registry history has not loaded yet", () => { + const onPatch = vi.fn(); + + saveExistingRowType(onPatch, { ...row, serviceId: 701 }, "Reactive", null, []); + expect(onPatch).toHaveBeenCalledWith(7, { type: "Reactive", severity: null }); }); }); diff --git a/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx index b4750b16..19b7be33 100644 --- a/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx @@ -188,14 +188,14 @@ describe("SlideOverInfoTabEditView additional contacts", () => { it("saves an incompatible type change only after clearing the registry service", () => { const baseline = baseRow({ type: "PM", pm: "PM only", serviceId: 701 }); const onSave = vi.fn(); - const pmOnlyService = { + const inactivePmOnlyService = { id: 701, name: "PM only", trade: "Electrical", iconKey: "wrench", requiresCompletionDocument: false, completionDocTemplate: null, - isActive: true, + isActive: false, supportedWorkOrderTypes: ["PM" as const], }; @@ -209,7 +209,8 @@ describe("SlideOverInfoTabEditView additional contacts", () => { sites={[]} vendors={[]} pmTypes={[]} - services={[pmOnlyService]} + services={[]} + serviceHistory={[inactivePmOnlyService]} allOrders={[]} onDraftChange={(patch) => setDraft((current) => ({ ...current, ...patch }))} /> diff --git a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts index 82acf13f..b019618a 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-patch-mapper.test.ts @@ -98,6 +98,32 @@ describe("expandTablePatchToBoardFieldOps", () => { ]); }); + it("clears the current service before changing type and assigns the new service afterward", () => { + expect( + expandTablePatchToBoardFieldOps( + { severity: 2, type: "Emergency", serviceId: 702, pm: "Emergency Service" }, + { serviceId: 701, type: "PM" }, + ), + ).toEqual([ + { field: "serviceId", value: "" }, + { field: "severity", value: "2" }, + { field: "workOrderType", value: "Emergency" }, + { field: "serviceId", value: "702" }, + ]); + }); + + it("keeps same-type service edits as a single assignment", () => { + expect( + expandTablePatchToBoardFieldOps( + { type: "PM", serviceId: 702, pm: "PM Service B" }, + { serviceId: 701, type: "PM" }, + ), + ).toEqual([ + { field: "serviceId", value: "702" }, + { field: "workOrderType", value: "PM" }, + ]); + }); + it("emits service notes modal ops in pm → extraServices → serviceNotes order", () => { expect( expandTablePatchToBoardFieldOps({ diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx index a2a6798c..c4ad6c18 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -783,6 +783,58 @@ describe("useWorkOrderTableMutations type and severity", () => { ); }); + it("chains clear, severity, type, and new service using each returned row version", async () => { + const calls: { field: string; value: string; version: string }[] = []; + rowState = { ...BASE_ROW, type: "PM", serviceId: 701, rowVersion: "wo-v1" }; + patchBoardField.mockImplementation( + async (_id: unknown, payload: { field: string; value: string; workOrderVersion: string }) => { + calls.push({ + field: payload.field, + value: payload.value, + version: payload.workOrderVersion, + }); + rowState = { + ...rowState, + ...(payload.field === "serviceId" + ? { serviceId: payload.value ? Number(payload.value) : null } + : {}), + ...(payload.field === "workOrderType" ? { type: payload.value as "Emergency" } : {}), + ...(payload.field === "severity" ? { severity: 2 as const } : {}), + rowVersion: `wo-v${calls.length + 1}`, + }; + return { ...rowState }; + }, + ); + + const onPatch = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch: () => undefined, + getRow: () => rowState, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(42, { + type: "Emergency", + severity: 2, + serviceId: 702, + pm: "Emergency Service", + }); + }); + + await waitFor(() => expect(calls).toHaveLength(4)); + expect(calls).toEqual([ + { field: "serviceId", value: "", version: "wo-v1" }, + { field: "severity", value: "2", version: "wo-v2" }, + { field: "workOrderType", value: "Emergency", version: "wo-v3" }, + { field: "serviceId", value: "702", version: "wo-v4" }, + ]); + }); + it("applies currentState on 409 conflict instead of clearing", async () => { const currentState = { ...BASE_ROW, type: "PM" as const, severity: null }; patchBoardField.mockRejectedValue(new BoardPatchConflictError("stale", currentState)); From 4a7da7cbd5589eef1963244474c4c64fff2c3c2f Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 17:51:31 -0300 Subject: [PATCH 06/15] fix(workorders): retain compatible inactive services --- .../tabs/slide-over-info-tab-edit-view.tsx | 4 +- .../list/table/save-existing-row-type.ts | 4 +- .../utils/services-for-work-order-type.ts | 7 +- .../workorders/save-existing-row-type.test.ts | 28 ++++++-- ...tab-edit-view-additional-contacts.test.tsx | 70 +++++++++++++++++-- 5 files changed, 99 insertions(+), 14 deletions(-) diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index da8b4264..2a6afbb3 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -17,7 +17,7 @@ import type { import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard"; import type { Service } from "@/domain/services/types/service"; -import { isServiceEligibleForWorkOrderType } from "@/domain/services/utils/services-for-work-order-type"; +import { serviceSupportsWorkOrderType } from "@/domain/services/utils/services-for-work-order-type"; import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch"; import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; @@ -180,7 +180,7 @@ export function SlideOverInfoTabEditView({ onDraftChange( draft.serviceId != null && selectedService != null && - !isServiceEligibleForWorkOrderType(selectedService, type) + !serviceSupportsWorkOrderType(selectedService, type) ? { type, severity, serviceId: null, pm: "" } : { type, severity }, ); diff --git a/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts b/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts index bc32d6df..6e5e0bf7 100644 --- a/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts +++ b/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts @@ -6,7 +6,7 @@ import type { } from "@/domain/work-orders/types/work-order-table-row"; import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; import type { Service } from "@/domain/services/types/service"; -import { isServiceEligibleForWorkOrderType } from "@/domain/services/utils/services-for-work-order-type"; +import { serviceSupportsWorkOrderType } from "@/domain/services/utils/services-for-work-order-type"; import { formatSeverityEscalationToast, shouldAnnounceSeverityEscalation, @@ -31,7 +31,7 @@ export function saveExistingRowType( const clearService = row.serviceId != null && selectedService != null && - !isServiceEligibleForWorkOrderType(selectedService, type); + !serviceSupportsWorkOrderType(selectedService, type); const patch = clearService ? { type, severity, serviceId: null, pm: "" } : { type, severity }; if (!shouldAnnounceSeverityEscalation(row.severity, severity)) { onPatch(row.id, patch); diff --git a/src/domain/services/utils/services-for-work-order-type.ts b/src/domain/services/utils/services-for-work-order-type.ts index c11641cf..6ba6096a 100644 --- a/src/domain/services/utils/services-for-work-order-type.ts +++ b/src/domain/services/utils/services-for-work-order-type.ts @@ -10,5 +10,10 @@ export function servicesForWorkOrderType(services: Service[], type: WOType | "") } export function isServiceEligibleForWorkOrderType(service: Service, type: WOType | ""): boolean { - return service.isActive && service.supportedWorkOrderTypes.includes(serviceWorkOrderType(type)); + return service.isActive && serviceSupportsWorkOrderType(service, type); +} + +/** Whether a service definition supports a WO type, regardless of active assignment status. */ +export function serviceSupportsWorkOrderType(service: Service, type: WOType | ""): boolean { + return service.supportedWorkOrderTypes.includes(serviceWorkOrderType(type)); } diff --git a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts index 7754c9cf..11809966 100644 --- a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts +++ b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts @@ -78,16 +78,36 @@ describe("saveExistingRowType", () => { }, ]; - saveExistingRowType(onPatch, { ...row, serviceId: 701 }, "Reactive", null, services); + saveExistingRowType(onPatch, { ...row, serviceId: 701 }, "Emergency", null, services); expect(onPatch).toHaveBeenCalledWith(7, { - type: "Reactive", + type: "Emergency", severity: null, serviceId: null, pm: "", }); }); + it("preserves a known inactive historical service that supports the new type", () => { + const onPatch = vi.fn(); + const services: Service[] = [ + { + id: 702, + name: "Retired Multi-type Service", + trade: "Electrical", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: false, + supportedWorkOrderTypes: ["PM", "Reactive"], + }, + ]; + + saveExistingRowType(onPatch, { ...row, serviceId: 702 }, "Reactive", null, services); + + expect(onPatch).toHaveBeenCalledWith(7, { type: "Reactive", severity: null }); + }); + it("clears a known inactive historical service before type change", () => { const onPatch = vi.fn(); const services: Service[] = [ @@ -103,10 +123,10 @@ describe("saveExistingRowType", () => { }, ]; - saveExistingRowType(onPatch, { ...row, serviceId: 701 }, "Reactive", null, services); + saveExistingRowType(onPatch, { ...row, serviceId: 701 }, "Emergency", null, services); expect(onPatch).toHaveBeenCalledWith(7, { - type: "Reactive", + type: "Emergency", severity: null, serviceId: null, pm: "", diff --git a/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx index 19b7be33..8457d0ac 100644 --- a/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx @@ -50,10 +50,19 @@ vi.mock( }), ); vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({ - TypeCell: ({ onSave }: { onSave: (type: "Reactive", severity: number) => void }) => ( - + TypeCell: ({ + onSave, + }: { + onSave: (type: "Reactive" | "Emergency", severity: number) => void; + }) => ( + <> + + + ), })); vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({ @@ -227,12 +236,63 @@ describe("SlideOverInfoTabEditView additional contacts", () => { } renderWithProviders(, { withAuth: false }); - fireEvent.click(screen.getByRole("button", { name: "Change type to Reactive" })); + fireEvent.click(screen.getByRole("button", { name: "Change type to Emergency" })); fireEvent.click(screen.getByRole("button", { name: "Save changes" })); expect(onSave).toHaveBeenCalledWith([ { field: "severity", value: "2" }, { field: "serviceId", value: "" }, + { field: "workOrderType", value: "Emergency" }, + ]); + }); + + it("preserves a compatible inactive service during a slide-over type change", () => { + const baseline = baseRow({ type: "PM", pm: "Historical service", serviceId: 702 }); + const onSave = vi.fn(); + const compatibleInactiveService = { + id: 702, + name: "Historical service", + trade: "Electrical", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: false, + supportedWorkOrderTypes: ["PM" as const, "Reactive" as const], + }; + + function SlideOverSaveHarness() { + const [draft, setDraft] = useState(baseline); + return ( + <> + setDraft((current) => ({ ...current, ...patch }))} + /> + + + ); + } + + renderWithProviders(, { withAuth: false }); + fireEvent.click(screen.getByRole("button", { name: "Change type to Reactive" })); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + + expect(onSave).toHaveBeenCalledWith([ + { field: "severity", value: "2" }, { field: "workOrderType", value: "Reactive" }, ]); }); From 64246db1f020a1d8e6d9202979208e8cb240a4db Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 18:10:39 -0300 Subject: [PATCH 07/15] fix(workorders): surface service registry query states --- .../tabs/slide-over-info-tab-edit-view.tsx | 16 +- .../detail/tabs/slide-over-info-tab.tsx | 9 +- .../detail/work-order-slide-over-tabs.tsx | 9 +- .../detail/work-order-slide-over.tsx | 9 +- .../advanced-search/adv-filter-sections.tsx | 56 +++++- .../list/advanced-search/adv-multi-select.tsx | 6 + .../work-orders-advanced-search-sheet.tsx | 6 +- .../list/table/cells/pm-type-cell.tsx | 21 ++ .../table/cells/pm-type-picker-popover.tsx | 50 +++++ .../list/table/save-existing-row-type.ts | 7 +- .../list/table/wo-table-inline-row-cells.tsx | 3 + .../list/table/wo-table-inline-row.tsx | 4 + .../list/table/wo-table-row-cells.tsx | 7 +- .../list/table/wo-table-row-service-cells.tsx | 4 + .../_components/list/table/wo-table-row.tsx | 7 + .../_components/list/table/wo-table.tsx | 8 + .../list/work-orders-list-page-panels.tsx | 6 + .../_hooks/use-work-orders-list-page.ts | 29 ++- src/app/(protected)/workorders/index.tsx | 2 + .../types/service-registry-query-state.ts | 7 + .../workorders/aveta-service-notes.test.tsx | 93 +++++++++ .../workorders/save-existing-row-type.test.ts | 21 +- ...tab-edit-view-additional-contacts.test.tsx | 52 +++++ ...ork-orders-advanced-search-wiring.test.tsx | 181 ++++++++++++++---- 24 files changed, 551 insertions(+), 62 deletions(-) create mode 100644 src/domain/services/types/service-registry-query-state.ts diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index 2a6afbb3..59abae96 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -17,6 +17,7 @@ import type { import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard"; import type { Service } from "@/domain/services/types/service"; +import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; import { serviceSupportsWorkOrderType } from "@/domain/services/utils/services-for-work-order-type"; import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch"; import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; @@ -35,6 +36,8 @@ type SlideOverInfoTabEditViewProps = { pmTypes: string[]; services?: Service[]; serviceHistory?: Service[]; + serviceQuery?: ServiceRegistryQueryState; + serviceHistoryReady?: boolean; allOrders: WorkOrderTableRow[]; onDraftChange: (patch: Partial) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; @@ -120,7 +123,9 @@ export function SlideOverInfoTabEditView({ vendors, pmTypes, services = [], - serviceHistory = [], + serviceHistory, + serviceQuery, + serviceHistoryReady = true, allOrders, onDraftChange, onDuplicateFound, @@ -173,10 +178,10 @@ export function SlideOverInfoTabEditView({ value={draft.type} severity={draft.severity} onSave={(type, severity) => { - const typeServices = serviceHistory.length > 0 ? serviceHistory : services; - const selectedService = typeServices.find( - (service) => String(service.id) === String(draft.serviceId), - ); + const typeServices = serviceHistory ?? services; + const selectedService = serviceHistoryReady + ? typeServices.find((service) => String(service.id) === String(draft.serviceId)) + : undefined; onDraftChange( draft.serviceId != null && selectedService != null && @@ -216,6 +221,7 @@ export function SlideOverInfoTabEditView({ value={draft.pm} pmTypes={pmTypes} services={services} + serviceQuery={serviceQuery} serviceId={draft.serviceId} workOrderType={draft.type} serviceNotes={draft.serviceNotes} diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx index 80112b10..377f1603 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx @@ -5,6 +5,7 @@ import type { } from "@/domain/work-orders/types/work-order"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { Service } from "@/domain/services/types/service"; +import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; import { SlideOverInfoTabDisplayView } from "./slide-over-info-tab-display-view"; import { SlideOverInfoTabEditView } from "./slide-over-info-tab-edit-view"; @@ -20,6 +21,8 @@ type SlideOverInfoTabProps = { pmTypes: string[]; services?: Service[]; serviceHistory?: Service[]; + serviceQuery?: ServiceRegistryQueryState; + serviceHistoryReady?: boolean; allOrders: WorkOrderTableRow[]; onDraftChange: (patch: Partial) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; @@ -37,7 +40,9 @@ export function SlideOverInfoTab({ vendors, pmTypes, services = [], - serviceHistory = [], + serviceHistory, + serviceQuery, + serviceHistoryReady = true, allOrders, onDraftChange, onDuplicateFound, @@ -57,6 +62,8 @@ export function SlideOverInfoTab({ pmTypes={pmTypes} services={services} serviceHistory={serviceHistory} + serviceQuery={serviceQuery} + serviceHistoryReady={serviceHistoryReady} allOrders={allOrders} onDraftChange={onDraftChange} onDuplicateFound={onDuplicateFound} diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index 0b1f134f..14467259 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -13,6 +13,7 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta import { resolveSlideOverAvetaRequired } from "@/domain/work-orders/utils/resolve-slide-over-aveta-required"; import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link"; import type { Service } from "@/domain/services/types/service"; +import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; import type { useWorkOrderSlideOver, SlideOverTab } from "./use-work-order-slide-over"; import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel"; @@ -28,6 +29,8 @@ type WorkOrderSlideOverTabsProps = { pmTypes: string[]; services?: Service[]; serviceHistory?: Service[]; + serviceQuery?: ServiceRegistryQueryState; + serviceHistoryReady?: boolean; allOrders: WorkOrderTableRow[]; commentHighlight?: CommentHighlight | null; onDuplicateFound?: (existing: WorkOrderTableRow) => void; @@ -44,7 +47,9 @@ export function WorkOrderSlideOverTabs({ vendors, pmTypes, services = [], - serviceHistory = [], + serviceHistory, + serviceQuery, + serviceHistoryReady = true, allOrders, commentHighlight, onDuplicateFound, @@ -70,6 +75,8 @@ export function WorkOrderSlideOverTabs({ pmTypes={pmTypes} services={services} serviceHistory={serviceHistory} + serviceQuery={serviceQuery} + serviceHistoryReady={serviceHistoryReady} allOrders={allOrders} onDraftChange={state.handleDraftChange} onDuplicateFound={onDuplicateFound} diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx index f75010f5..8a3a97f2 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx @@ -13,6 +13,7 @@ import type { } from "@/domain/work-orders/types/work-order-table-row"; import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link"; import type { Service } from "@/domain/services/types/service"; +import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; import { useWorkOrderSlideOver, type SlideOverTab } from "./use-work-order-slide-over"; import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav"; import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs"; @@ -31,6 +32,8 @@ type WorkOrderSlideOverProps = { pmTypes: string[]; services?: Service[]; serviceHistory?: Service[]; + serviceQuery?: ServiceRegistryQueryState; + serviceHistoryReady?: boolean; allOrders: WorkOrderTableRow[]; onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void; onOpenCompletionDoc: (row: WorkOrderTableRow) => void; @@ -54,7 +57,9 @@ export function WorkOrderSlideOver({ vendors, pmTypes, services = [], - serviceHistory = [], + serviceHistory, + serviceQuery, + serviceHistoryReady = true, allOrders, onSave, onOpenCompletionDoc, @@ -108,6 +113,8 @@ export function WorkOrderSlideOver({ pmTypes={pmTypes} services={services} serviceHistory={serviceHistory} + serviceQuery={serviceQuery} + serviceHistoryReady={serviceHistoryReady} allOrders={allOrders} commentHighlight={commentHighlight} onDuplicateFound={onDuplicateFound} diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx index b2d72bad..7fb5f6fd 100644 --- a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx @@ -29,6 +29,9 @@ import { } from "@/domain/work-orders/types/work-order-advanced-filters"; import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; import type { Service } from "@/domain/services/types/service"; +import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; +import { QueryErrorPanel } from "@/components/ui/query-error-panel"; +import { Text } from "@/components/ui/text"; import { WIZARD_STATUS_DOTS } from "@/domain/work-orders/types/work-order-wizard"; type AdvancedSearchFilterSectionsProps = { @@ -39,6 +42,7 @@ type AdvancedSearchFilterSectionsProps = { sites: { id: string | number; name: string }[]; users: WorkOrderUser[]; services?: Service[]; + serviceQuery?: ServiceRegistryQueryState; pmTypes?: string[]; vendorTechs: string[]; }; @@ -50,7 +54,8 @@ export function AdvancedSearchFilterSections({ setMany, sites, users, - services = [], + services, + serviceQuery, pmTypes = [], vendorTechs, }: AdvancedSearchFilterSectionsProps) { @@ -62,6 +67,20 @@ export function AdvancedSearchFilterSections({ /> ); + const serviceRegistryManaged = services !== undefined; + const serviceList = services ?? []; + const serviceOptions = serviceRegistryManaged + ? [ + ...serviceList.map((service) => ({ value: service.name, label: service.name })), + ...[...draft.pmTypes] + .filter((name) => !serviceList.some((service) => service.name === name)) + .map((name) => ({ value: name, label: name })), + ] + : pmTypes.map((name) => ({ value: name, label: name })); + const serviceChoicesUnavailable = + serviceRegistryManaged && + (serviceQuery?.isLoading === true || + (serviceQuery?.isError === true && serviceQuery.hasData === false)); return (
@@ -143,16 +162,41 @@ export function AdvancedSearchFilterSections({ + {Boolean(serviceQuery?.isLoading && !serviceQuery.hasData) && ( + + Loading services… + + )} + {Boolean(serviceQuery?.isFetching && serviceQuery.hasData && !serviceQuery.isError) && ( + + Updating services… + + )} + {Boolean(serviceQuery?.isError) && ( + + )} + {Boolean( + serviceRegistryManaged && + serviceQuery?.hasData && + !serviceQuery.isLoading && + !serviceQuery.isError && + serviceList.length === 0, + ) && ( + + No services are available. + + )} 0 - ? services.map((service) => ({ value: service.name, label: service.name })) - : pmTypes.map((name) => ({ value: name, label: name })) - } + options={serviceOptions} selected={draft.pmTypes} onToggle={(value) => toggleIn("pmTypes", value)} onBulkToggle={(values, select) => setMany("pmTypes", values, select)} placeholder="Search service…" + disabled={serviceChoicesUnavailable} renderLeft={(name) => ( )} 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 index f9f877aa..4b7da0dd 100644 --- 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 @@ -24,6 +24,8 @@ type AdvMultiSelectProps = { placeholder: string; renderLeft?: (value: string) => ReactNode; + + disabled?: boolean; }; export function AdvMultiSelect({ @@ -38,6 +40,7 @@ export function AdvMultiSelect({ placeholder, renderLeft, + disabled = false, }: AdvMultiSelectProps) { const [query, setQuery] = useState(""); @@ -55,6 +58,7 @@ export function AdvMultiSelect({ placeholder={placeholder} fullWidth className="mb-2" + disabled={disabled} />
@@ -64,6 +68,7 @@ export function AdvMultiSelect({ anySelected ? onBulkToggle?.( @@ -87,6 +92,7 @@ export function AdvMultiSelect({ selected={selected.has(option.value)} label={option.label} left={renderLeft?.(option.value)} + disabled={disabled} onClick={() => onToggle(option.value)} /> ))} 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 index 797034f0..fd732b42 100644 --- 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 @@ -7,6 +7,7 @@ import { SideSheet } from "@/components/ui/side-sheet"; import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; import type { Service } from "@/domain/services/types/service"; +import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; type WorkOrdersAdvancedSearchSheetProps = { open: boolean; @@ -17,6 +18,7 @@ type WorkOrdersAdvancedSearchSheetProps = { sites: { id: string | number; name: string }[]; users: WorkOrderUser[]; services?: Service[]; + serviceQuery?: ServiceRegistryQueryState; vendorTechs: string[]; }; @@ -28,7 +30,8 @@ export function WorkOrdersAdvancedSearchSheet({ onClearAll, sites, users, - services = [], + services, + serviceQuery, vendorTechs, }: WorkOrdersAdvancedSearchSheetProps) { const { draft, setDraft, activeCount, toggleIn, setMany, handleClearAll, handleApply } = @@ -61,6 +64,7 @@ export function WorkOrdersAdvancedSearchSheet({ sites={sites} users={users} services={services} + serviceQuery={serviceQuery} vendorTechs={vendorTechs} /> diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx index 55a44859..df9a2c04 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx @@ -14,6 +14,7 @@ import { truncateServiceNotesPreview, } from "@/domain/work-orders/utils/service-notes-ui"; import type { Service } from "@/domain/services/types/service"; +import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; import { servicesForWorkOrderType } from "@/domain/services/utils/services-for-work-order-type"; import type { WOType } from "@/domain/work-orders/types/work-order-wizard"; @@ -32,6 +33,7 @@ type PMTypeCellProps = { services?: Service[]; serviceId?: string | number | null; workOrderType?: WOType | ""; + serviceQuery?: ServiceRegistryQueryState; }; export function PMTypeCell({ @@ -49,6 +51,7 @@ export function PMTypeCell({ services, serviceId, workOrderType = "", + serviceQuery, }: PMTypeCellProps) { const [anchorEl, setAnchorEl] = useState(null); const [search, setSearch] = useState(""); @@ -62,6 +65,17 @@ export function PMTypeCell({ : serviceNames; const linkedService = services?.find((service) => String(service.id) === String(serviceId)); const displayValue = !viewOnly && linkedService?.isActive ? linkedService.name : value; + const serviceRegistryManaged = services !== undefined; + const serviceChoicesUnavailable = + serviceRegistryManaged && + (serviceQuery?.isLoading === true || + (serviceQuery?.isError === true && serviceQuery.hasData === false)); + const serviceCatalogEmpty = + serviceRegistryManaged && + (serviceQuery?.hasData ?? true) && + !serviceQuery?.isLoading && + !serviceQuery?.isError && + serviceNames.length === 0; const extras = extraServices ?? []; const hasDetails = hasServiceNotesDetails(serviceNotes, extras, avetaRequired); @@ -181,6 +195,13 @@ export function PMTypeCell({ showAddDetails={Boolean(onSaveDetails)} onSearchChange={setSearch} onClose={closePopover} + registryManaged={serviceRegistryManaged} + loading={serviceQuery?.isLoading === true && !serviceQuery.hasData} + fetching={serviceQuery?.isFetching === true && serviceQuery.hasData} + error={serviceQuery?.isError === true} + empty={serviceCatalogEmpty} + selectionDisabled={serviceChoicesUnavailable} + onRetry={serviceQuery?.retry} onPick={(pm) => { const picked = assignableServices?.find((service) => service.name === pm); onSave(pm, picked?.id); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx index 8f7d75a6..dc32a4c5 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx @@ -1,5 +1,6 @@ import { Check, Plus } from "lucide-react"; import { Popover } from "@mui/material"; +import { QueryErrorPanel } from "@/components/ui/query-error-panel"; import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons"; type PmTypePickerPopoverProps = { @@ -12,6 +13,13 @@ type PmTypePickerPopoverProps = { onClose: () => void; onPick: (pm: string) => void; onAddDetails: () => void; + registryManaged?: boolean; + loading?: boolean; + fetching?: boolean; + error?: boolean; + empty?: boolean; + selectionDisabled?: boolean; + onRetry?: () => void; }; export function PmTypePickerPopover({ @@ -24,6 +32,13 @@ export function PmTypePickerPopover({ onClose, onPick, onAddDetails, + registryManaged = false, + loading = false, + fetching = false, + error = false, + empty = false, + selectionDisabled = false, + onRetry, }: PmTypePickerPopoverProps) { return ( onSearchChange(e.target.value)} placeholder="Search service…" + disabled={selectionDisabled} className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none" />
+ {loading && ( +
+ Loading services… +
+ )} + {Boolean(fetching && !error) && ( +
+ Updating services… +
+ )} + {error && ( + + )} + {Boolean(registryManaged && empty) && ( +
+ No services are available for this work-order type. +
+ )} {showAddDetails && ( ))} + {Boolean(!loading && !error && !empty && filtered.length === 0) && ( +
No matches
+ )}
); diff --git a/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts b/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts index 6e5e0bf7..53271e82 100644 --- a/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts +++ b/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts @@ -24,10 +24,11 @@ export function saveExistingRowType( type: WOType, severity: WorkOrderSeverity | null, serviceHistory: Service[] = [], + serviceHistoryReady = true, ) { - const selectedService = serviceHistory.find( - (service) => String(service.id) === String(row.serviceId), - ); + const selectedService = serviceHistoryReady + ? serviceHistory.find((service) => String(service.id) === String(row.serviceId)) + : undefined; const clearService = row.serviceId != null && selectedService != null && diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx index 3d8f07a8..58e1340b 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx @@ -9,6 +9,7 @@ import type { WorkOrderTableRow, } from "@/domain/work-orders/types/work-order-table-row"; import type { Service } from "@/domain/services/types/service"; +import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; import { isServiceEligibleForWorkOrderType } from "@/domain/services/utils/services-for-work-order-type"; import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch"; import { AssignedToCell } from "./cells/assigned-to-cell"; @@ -41,6 +42,7 @@ type WoTableInlineRowCellsProps = { vendors: VendorDropdownItem[]; pmTypes: string[]; services?: Service[]; + serviceQuery?: ServiceRegistryQueryState; allRows: { id: string | number; woNumber: string }[]; siteCellRef: RefObject; woCellRef: RefObject; @@ -246,6 +248,7 @@ export function WoTableInlineRowCells({ value={draft.pm} pmTypes={pmTypes} services={services} + serviceQuery={serviceQuery} serviceId={draft.serviceId} workOrderType={draft.type} serviceNotes={draft.serviceNotes} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx index c37b37e1..441d7740 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx @@ -12,6 +12,7 @@ import type { WorkOrderTableRow, } from "@/domain/work-orders/types/work-order-table-row"; import type { Service } from "@/domain/services/types/service"; +import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; import { UNASSIGNED_DAY_KEY, UNSCHEDULED_DAY_KEY, @@ -35,6 +36,7 @@ type WoTableInlineRowProps = { vendors: VendorDropdownItem[]; pmTypes: string[]; services?: Service[]; + serviceQuery?: ServiceRegistryQueryState; allRows: { id: string | number; woNumber: string }[]; saving?: boolean; onChange: (draft: WorkOrderTableDraft) => void; @@ -65,6 +67,7 @@ export function WoTableInlineRow({ vendors, pmTypes, services = [], + serviceQuery, allRows, saving = false, onChange, @@ -140,6 +143,7 @@ export function WoTableInlineRow({ vendors={vendors} pmTypes={pmTypes} services={services} + serviceQuery={serviceQuery} allRows={allRows} siteCellRef={siteCellRef} woCellRef={woCellRef} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx index 73227bc5..0a2ca545 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx @@ -26,6 +26,7 @@ type WoTableRowCellsProps = { sites: LocationOption[]; services?: Service[]; serviceHistory?: Service[]; + serviceHistoryReady?: boolean; sitePlace?: { city: string; state: string }; search?: string; forceScheduleHighlight?: boolean; @@ -43,7 +44,8 @@ export function WoTableRowCells({ users, sites, services = [], - serviceHistory = [], + serviceHistory, + serviceHistoryReady = true, sitePlace, search, forceScheduleHighlight, @@ -130,7 +132,8 @@ export function WoTableRowCells({ row, t, sev, - serviceHistory.length ? serviceHistory : services, + serviceHistory ?? services, + serviceHistoryReady, ) } /> diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx index ec670821..8a97ca7f 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx @@ -14,6 +14,7 @@ import { applyVendorTableSave } from "@/domain/work-orders/utils/work-order-feed import { effectiveCompany } from "@/domain/work-orders/utils/effective-completion-snapshot"; import type { WoTableRowHandlers } from "./wo-table-row"; import type { Service } from "@/domain/services/types/service"; +import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch"; type WoTableRowServiceCellsProps = { @@ -21,6 +22,7 @@ type WoTableRowServiceCellsProps = { vendors: VendorDropdownItem[]; pmTypes: string[]; services?: Service[]; + serviceQuery?: ServiceRegistryQueryState; search?: string; mutationCellStyle?: CSSProperties; viewOnly?: boolean; @@ -34,6 +36,7 @@ export function WoTableRowServiceCells({ vendors, pmTypes, services = [], + serviceQuery, search, mutationCellStyle, viewOnly = false, @@ -54,6 +57,7 @@ export function WoTableRowServiceCells({ value={row.pm} pmTypes={pmTypes} services={services} + serviceQuery={serviceQuery} serviceId={row.serviceId} workOrderType={row.type} q={search} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx index b7d8365a..50d0ce73 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx @@ -24,6 +24,7 @@ import { resolveWoTableRowStyle, } from "./wo-table-row-style"; import type { Service } from "@/domain/services/types/service"; +import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; export type WoTableRowHandlers = { onRowClick: (row: WorkOrderTableRow) => void; @@ -62,6 +63,8 @@ type WoTableRowProps = { pmTypes: string[]; services?: Service[]; serviceHistory?: Service[]; + serviceQuery?: ServiceRegistryQueryState; + serviceHistoryReady?: boolean; search?: string; forceScheduleHighlight?: boolean; forceDueDateHighlight?: boolean; @@ -106,6 +109,8 @@ export function WoTableRow({ pmTypes, services = [], serviceHistory = [], + serviceQuery, + serviceHistoryReady = true, search, forceScheduleHighlight, forceDueDateHighlight, @@ -204,6 +209,7 @@ export function WoTableRow({ sites={sites} services={services} serviceHistory={serviceHistory} + serviceHistoryReady={serviceHistoryReady} sitePlace={sitePlace} search={search} forceScheduleHighlight={forceScheduleHighlight} @@ -218,6 +224,7 @@ export function WoTableRow({ row={row} vendors={vendors} services={services} + serviceQuery={serviceQuery} pmTypes={pmTypes} search={search} mutationCellStyle={coreLockStyle} diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx index aeca77b4..063f193b 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -22,6 +22,7 @@ import { WoTableRow } from "./wo-table-row"; import { WoTableEmptyState, WoTableShell } from "./wo-table-shell"; import { WO_TABLE_MIN_WIDTH } from "./wo-table-cols"; import type { Service } from "@/domain/services/types/service"; +import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; type WoTableProps = { tableData: ReturnTypeOfUseWorkOrdersTableData; @@ -31,6 +32,8 @@ type WoTableProps = { pmTypes: string[]; services?: Service[]; serviceHistory?: Service[]; + serviceQuery?: ServiceRegistryQueryState; + serviceHistoryReady?: boolean; weekMonday: string; onNavigateToDay: (dateIso: string) => void; onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void; @@ -98,6 +101,8 @@ export function WoTable({ pmTypes, services = [], serviceHistory = [], + serviceQuery, + serviceHistoryReady = true, weekMonday, onNavigateToDay, onOpenSlideOver, @@ -148,6 +153,8 @@ export function WoTable({ vendors={vendors} services={services} serviceHistory={serviceHistory} + serviceQuery={serviceQuery} + serviceHistoryReady={serviceHistoryReady} pmTypes={pmTypes} search={tableData.search} forceScheduleHighlight={forceScheduleHighlight} @@ -173,6 +180,7 @@ export function WoTable({ )} vendors={vendors} services={services} + serviceQuery={serviceQuery} pmTypes={pmTypes} allRows={tableData.rows} saving={saving} diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx index dd541536..9794353b 100644 --- a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx @@ -33,6 +33,9 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps pmTypes, services, serviceHistory, + servicePickerState, + serviceHistoryState, + serviceHistoryReadyForTypeChange, vendorTechs, } = page; @@ -55,6 +58,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps sites={locations} users={users} services={serviceHistory} + serviceQuery={serviceHistoryState} vendorTechs={vendorTechs} /> @@ -70,6 +74,8 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps pmTypes={pmTypes} services={services} serviceHistory={serviceHistory} + serviceQuery={servicePickerState} + serviceHistoryReady={serviceHistoryReadyForTypeChange} allOrders={tableData.rows} onSave={(id, patch, options) => page.patchField(id, patch, options)} onOpenCompletionDoc={(row) => openCompletionDoc(row, page.setDocRow)} diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts index e322964e..1cbc0b2d 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts @@ -13,6 +13,7 @@ import { useActiveServices, useAllServices, } from "@/domain/services/use-cases/use-services-for-work-order-type"; +import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; import { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete"; import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link"; import { @@ -95,8 +96,29 @@ export function useWorkOrdersListPage() { const { data: locations = [] } = useWorkOrderLocations(); const { data: vendors = [] } = useWorkOrderVendors(); const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem"); - const { data: services = [] } = useActiveServices(); - const { data: serviceHistory = [] } = useAllServices(); + const activeServicesQuery = useActiveServices(); + const serviceHistoryQuery = useAllServices(); + const services = activeServicesQuery.data ?? []; + const serviceHistory = serviceHistoryQuery.data ?? []; + const servicePickerState: ServiceRegistryQueryState = { + isLoading: activeServicesQuery.isPending && activeServicesQuery.data === undefined, + isError: activeServicesQuery.isError, + isFetching: activeServicesQuery.isFetching, + hasData: activeServicesQuery.data !== undefined, + retry: () => void activeServicesQuery.refetch(), + }; + const serviceHistoryState: ServiceRegistryQueryState = { + isLoading: serviceHistoryQuery.isPending && serviceHistoryQuery.data === undefined, + isError: serviceHistoryQuery.isError, + isFetching: serviceHistoryQuery.isFetching, + hasData: serviceHistoryQuery.data !== undefined, + retry: () => void serviceHistoryQuery.refetch(), + }; + const serviceHistoryReadyForTypeChange = + serviceHistoryState.hasData && + !serviceHistoryState.isLoading && + !serviceHistoryState.isError && + !serviceHistoryState.isFetching; const pmTypes = useMemo( () => problemOptions.map((option) => option.value).filter(Boolean), @@ -203,6 +225,9 @@ export function useWorkOrdersListPage() { pmTypes, services, serviceHistory, + servicePickerState, + serviceHistoryState, + serviceHistoryReadyForTypeChange, vendorTechs, activeSlideOverRow, activeDocRow, diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 8b697e8e..3ef8567a 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -112,6 +112,8 @@ export default function WorkOrdersListPage() { pmTypes={page.pmTypes} services={page.services} serviceHistory={page.serviceHistory} + serviceQuery={page.servicePickerState} + serviceHistoryReady={page.serviceHistoryReadyForTypeChange} weekMonday={filters.weekMonday} onNavigateToDay={filters.navigateToDay} onOpenSlideOver={page.handleOpenSlideOver} diff --git a/src/domain/services/types/service-registry-query-state.ts b/src/domain/services/types/service-registry-query-state.ts new file mode 100644 index 00000000..a209bbd2 --- /dev/null +++ b/src/domain/services/types/service-registry-query-state.ts @@ -0,0 +1,7 @@ +export type ServiceRegistryQueryState = { + isLoading: boolean; + isError: boolean; + isFetching: boolean; + hasData: boolean; + retry: () => void; +}; diff --git a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx index a9fe6360..19678c65 100644 --- a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx +++ b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx @@ -6,6 +6,99 @@ import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identit import { renderWithProviders } from "@/test/test-utils"; describe("Aveta Service & Notes", () => { + it("shows loading and preserves the current value while registry services are pending", () => { + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Saved service/i })); + + expect(screen.getByRole("status", { name: /loading services/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Leak" })).not.toBeInTheDocument(); + }); + + it("offers a working retry and no legacy choices after registry failure", () => { + const onRetryServices = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Saved service/i })); + + expect(screen.getByText(/service options couldn't load/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Leak" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(onRetryServices).toHaveBeenCalledOnce(); + }); + + it("shows a successful empty registry without legacy choices", () => { + renderWithProviders(); + + fireEvent.click(screen.getByRole("button", { name: /Select/i })); + + expect(screen.getByText(/no services are available/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Leak" })).not.toBeInTheDocument(); + }); + + it("keeps loaded options available while the registry refreshes", () => { + const onSave = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Old snapshot/i })); + fireEvent.click(screen.getByRole("button", { name: "QA Test Service" })); + + expect(onSave).toHaveBeenCalledWith("QA Test Service", 701); + }); + it("shows eligible registry services and saves the selected service identity", () => { const onSave = vi.fn(); renderWithProviders( diff --git a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts index 11809966..4ec3a799 100644 --- a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts +++ b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts @@ -135,9 +135,26 @@ describe("saveExistingRowType", () => { it("preserves a service when the registry history has not loaded yet", () => { const onPatch = vi.fn(); + const inactiveService: Service = { + id: 701, + name: "Retired Service", + trade: "Electrical", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: false, + supportedWorkOrderTypes: ["PM"], + }; - saveExistingRowType(onPatch, { ...row, serviceId: 701 }, "Reactive", null, []); + saveExistingRowType( + onPatch, + { ...row, serviceId: 701 }, + "Emergency", + null, + [inactiveService], + false, + ); - expect(onPatch).toHaveBeenCalledWith(7, { type: "Reactive", severity: null }); + expect(onPatch).toHaveBeenCalledWith(7, { type: "Emergency", severity: null }); }); }); diff --git a/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx index 8457d0ac..7746cfad 100644 --- a/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx @@ -297,6 +297,58 @@ describe("SlideOverInfoTabEditView additional contacts", () => { ]); }); + it("does not clear a saved service while registry history is unresolved", () => { + const baseline = baseRow({ type: "PM", pm: "Historical service", serviceId: 703 }); + const onSave = vi.fn(); + const pmOnlyService = { + id: 703, + name: "Historical service", + trade: "Electrical", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: false, + supportedWorkOrderTypes: ["PM" as const], + }; + + function SlideOverSaveHarness() { + const [draft, setDraft] = useState(baseline); + return ( + <> + setDraft((current) => ({ ...current, ...patch }))} + /> + + + ); + } + + renderWithProviders(, { withAuth: false }); + fireEvent.click(screen.getByRole("button", { name: "Change type to Emergency" })); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + + expect(onSave).toHaveBeenCalledWith([ + { field: "severity", value: "2" }, + { field: "workOrderType", value: "Emergency" }, + ]); + }); + it("passes additionalContacts into SiteCell and persists them on save", () => { const onDraftChange = vi.fn(); const contacts = [{ name: "Existing", phone: "555-0000" }]; 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 index 9097d2cc..5f763f0e 100644 --- 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 @@ -11,6 +11,22 @@ import { renderWithProviders } from "@/test/test-utils"; const setAdvOpen = vi.fn(); const applyAdvancedFilters = vi.fn(); const clearAdvancedFilters = vi.fn(); +const serviceQueries = vi.hoisted(() => ({ + active: { + data: undefined as unknown, + isPending: false as boolean, + isError: false as boolean, + isFetching: false as boolean, + refetch: vi.fn(), + }, + all: { + data: undefined as unknown, + isPending: false as boolean, + isError: false as boolean, + isFetching: false as boolean, + refetch: vi.fn(), + }, +})); let advOpen = false; let advApplied: typeof EMPTY_ADVANCED_FILTERS | null = null; @@ -120,44 +136,8 @@ vi.mock("@/domain/services/use-cases/use-services-for-work-order-type", async (i >(); return { ...actual, - useActiveServices: () => ({ - data: [ - { - id: 701, - name: "QA Test Service", - trade: "Electrical", - iconKey: "wrench", - requiresCompletionDocument: false, - completionDocTemplate: null, - isActive: true, - supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"], - }, - ], - }), - useAllServices: () => ({ - data: [ - { - id: 701, - name: "QA Test Service", - trade: "Electrical", - iconKey: "wrench", - requiresCompletionDocument: false, - completionDocTemplate: null, - isActive: true, - supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"], - }, - { - id: 702, - name: "Retired Service", - trade: "Electrical", - iconKey: "wrench", - requiresCompletionDocument: false, - completionDocTemplate: null, - isActive: false, - supportedWorkOrderTypes: ["PM"], - }, - ], - }), + useActiveServices: () => serviceQueries.active, + useAllServices: () => serviceQueries.all, }; }); @@ -177,6 +157,48 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { beforeEach(() => { advOpen = false; advApplied = null; + serviceQueries.active.data = [ + { + id: 701, + name: "QA Test Service", + trade: "Electrical", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: true, + supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"], + }, + ]; + serviceQueries.active.isPending = false; + serviceQueries.active.isError = false; + serviceQueries.active.isFetching = false; + serviceQueries.active.refetch.mockReset(); + serviceQueries.all.data = [ + { + id: 701, + name: "QA Test Service", + trade: "Electrical", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: true, + supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"], + }, + { + id: 702, + name: "Retired Service", + trade: "Electrical", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: false, + supportedWorkOrderTypes: ["PM"], + }, + ]; + serviceQueries.all.isPending = false; + serviceQueries.all.isError = false; + serviceQueries.all.isFetching = false; + serviceQueries.all.refetch.mockReset(); setAdvOpen.mockClear(); applyAdvancedFilters.mockClear(); clearAdvancedFilters.mockClear(); @@ -200,6 +222,89 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { expect(within(sheet).queryByText("Leak")).not.toBeInTheDocument(); }); + it("shows loading instead of legacy values while registry history is pending", () => { + serviceQueries.all.data = undefined; + serviceQueries.all.isPending = true; + serviceQueries.all.isFetching = true; + const view = renderWithProviders(, { + route: "/workorders", + withAuth: false, + }); + + fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" })); + advOpen = true; + view.rerender(); + const sheet = screen.getByRole("dialog", { name: "Advanced Filters" }); + fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i })); + + expect(within(sheet).getByRole("status", { name: /loading services/i })).toBeInTheDocument(); + expect(within(sheet).queryByText("Leak")).not.toBeInTheDocument(); + expect(within(sheet).getByPlaceholderText("Search service…")).toBeDisabled(); + }); + + it("shows a retryable registry error and no legacy service filters on failure", () => { + serviceQueries.all.data = undefined; + serviceQueries.all.isError = true; + advApplied = { + ...EMPTY_ADVANCED_FILTERS, + pmTypes: new Set(["Previously selected service"]), + }; + serviceQueries.all.refetch.mockImplementation(() => { + serviceQueries.all.data = [ + { + id: 701, + name: "QA Test Service", + trade: "Electrical", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: true, + supportedWorkOrderTypes: ["PM"], + }, + ]; + serviceQueries.all.isError = false; + serviceQueries.all.isPending = false; + serviceQueries.all.isFetching = false; + }); + const view = renderWithProviders(, { + route: "/workorders", + withAuth: false, + }); + + fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" })); + advOpen = true; + view.rerender(); + const sheet = screen.getByRole("dialog", { name: "Advanced Filters" }); + fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i })); + + expect(within(sheet).getByText(/service filters couldn't load/i)).toBeInTheDocument(); + expect(within(sheet).queryByText("Leak")).not.toBeInTheDocument(); + expect(within(sheet).getByText("Previously selected service")).toBeInTheDocument(); + expect(within(sheet).getByPlaceholderText("Search service…")).toBeDisabled(); + fireEvent.click(within(sheet).getByRole("button", { name: "Retry" })); + expect(serviceQueries.all.refetch).toHaveBeenCalledOnce(); + view.rerender(); + expect(within(sheet).getByText("QA Test Service")).toBeInTheDocument(); + expect(within(sheet).getByText("Previously selected service")).toBeInTheDocument(); + }); + + it("shows a successful empty registry as empty instead of legacy choices", () => { + serviceQueries.all.data = []; + const view = renderWithProviders(, { + route: "/workorders", + withAuth: false, + }); + + fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" })); + advOpen = true; + view.rerender(); + const sheet = screen.getByRole("dialog", { name: "Advanced Filters" }); + fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i })); + + expect(within(sheet).getByText(/no services are available/i)).toBeInTheDocument(); + expect(within(sheet).queryByText("Leak")).not.toBeInTheDocument(); + }); + it("opens the sheet from Advanced Filters, applies, then clears", () => { const view = renderWithProviders(, { route: "/workorders", From ffbf60d16a7e1bfb2966134a18bf3bc955ef3bcb Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 18:24:24 -0300 Subject: [PATCH 08/15] fix(workorders): complete service query wiring --- .../advanced-search/adv-filter-sections.tsx | 28 ++++++++----- .../table/cells/pm-type-picker-popover.tsx | 12 +++--- .../list/table/wo-table-inline-row-cells.tsx | 1 + .../workorders/aveta-service-notes.test.tsx | 1 + .../wo-table-inline-schedule-range.test.tsx | 42 +++++++++++++++++++ ...ork-orders-advanced-search-wiring.test.tsx | 3 ++ 6 files changed, 71 insertions(+), 16 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx index 7fb5f6fd..ebc227ea 100644 --- a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx @@ -163,21 +163,27 @@ export function AdvancedSearchFilterSections({ {Boolean(serviceQuery?.isLoading && !serviceQuery.hasData) && ( - - Loading services… - +
+ + Loading services… + +
)} {Boolean(serviceQuery?.isFetching && serviceQuery.hasData && !serviceQuery.isError) && ( - - Updating services… - +
+ + Updating services… + +
)} {Boolean(serviceQuery?.isError) && ( - +
+ +
)} {Boolean( serviceRegistryManaged && diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx index dc32a4c5..e1572842 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx @@ -80,11 +80,13 @@ export function PmTypePickerPopover({
)} {error && ( - +
+ +
)} {Boolean(registryManaged && empty) && (
diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx index 58e1340b..319333ac 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx @@ -194,6 +194,7 @@ export function WoTableInlineRowCells({ vendors, pmTypes, services = [], + serviceQuery, allRows, siteCellRef, woCellRef, diff --git a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx index 19678c65..feca7415 100644 --- a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx +++ b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx @@ -49,6 +49,7 @@ describe("Aveta Service & Notes", () => { fireEvent.click(screen.getByRole("button", { name: /Saved service/i })); + expect(screen.getByRole("alert", { name: /service options unavailable/i })).toBeInTheDocument(); expect(screen.getByText(/service options couldn't load/i)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Leak" })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Retry" })); diff --git a/src/test/app/(protected)/workorders/wo-table-inline-schedule-range.test.tsx b/src/test/app/(protected)/workorders/wo-table-inline-schedule-range.test.tsx index e1cc7139..cb366c95 100644 --- a/src/test/app/(protected)/workorders/wo-table-inline-schedule-range.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-inline-schedule-range.test.tsx @@ -12,6 +12,48 @@ function currentMonthIso(day: number): string { } describe("WoTableInlineRowCells — Schedule On range", () => { + it("keeps registry failure and retry in the inline Add WO service picker", async () => { + const retry = vi.fn(); + renderWithProviders( + + + + + + +
, + ); + + fireEvent.click(screen.getByRole("button", { name: "Select …" })); + + expect(screen.getByRole("alert", { name: /service options unavailable/i })).toBeVisible(); + expect(screen.queryByRole("button", { name: "Leak" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(retry).toHaveBeenCalledOnce(); + }); + it("reopens a company-only inline assignment without inferring its anchor as technician", async () => { renderWithProviders( 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 index 5f763f0e..4daf50ad 100644 --- 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 @@ -277,6 +277,9 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => { const sheet = screen.getByRole("dialog", { name: "Advanced Filters" }); fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i })); + expect( + within(sheet).getByRole("alert", { name: /service filters unavailable/i }), + ).toBeInTheDocument(); expect(within(sheet).getByText(/service filters couldn't load/i)).toBeInTheDocument(); expect(within(sheet).queryByText("Leak")).not.toBeInTheDocument(); expect(within(sheet).getByText("Previously selected service")).toBeInTheDocument(); From d40d4eaffba4779123ed14ff676e07e11f71b2d8 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 18:43:00 -0300 Subject: [PATCH 09/15] refactor(workorders): meet maintainability gate --- .../tabs/slide-over-info-tab-edit-view.tsx | 69 ++-- .../detail/work-order-slide-over-tabs.tsx | 111 ++++-- .../advanced-search/adv-filter-sections.tsx | 324 ++++++++++----- .../table/build-inline-draft-create-input.ts | 41 +- .../list/table/cells/pm-type-cell.tsx | 376 +++++++++++------- .../list/table/save-existing-row-type.ts | 23 +- .../list/table/wo-table-row-cells.tsx | 12 +- .../_components/list/table/wo-table.tsx | 183 ++++++--- .../_hooks/use-work-order-service-registry.ts | 39 ++ .../_hooks/use-work-orders-list-page.ts | 36 +- .../workorders/save-existing-row-type.test.ts | 46 ++- 11 files changed, 821 insertions(+), 439 deletions(-) create mode 100644 src/app/(protected)/workorders/_hooks/use-work-order-service-registry.ts diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index 59abae96..1347384f 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -44,6 +44,45 @@ type SlideOverInfoTabEditViewProps = { onRequestComplete?: () => void; }; +type SlideOverServiceFieldProps = Pick< + SlideOverInfoTabEditViewProps, + "draft" | "pmTypes" | "services" | "serviceQuery" | "onDraftChange" +>; + +function SlideOverServiceField({ + draft, + pmTypes, + services = [], + serviceQuery, + onDraftChange, +}: SlideOverServiceFieldProps) { + return ( + + onDraftChange(id != null ? { pm, serviceId: id } : { pm })} + onSaveDetails={(details) => + onDraftChange({ + ...serviceIdentityPatch(draft.serviceId, draft.pm, details.pm, details.serviceId), + serviceNotes: details.serviceNotes, + extraServices: details.extraServices, + avetaRequired: details.avetaRequired, + }) + } + /> + + ); +} + function SlideOverScheduleOnField({ draft, onDraftChange, @@ -216,29 +255,13 @@ export function SlideOverInfoTabEditView({ onSave={(apptTime) => onDraftChange({ apptTime })} /> - - onDraftChange(id != null ? { pm, serviceId: id } : { pm })} - onSaveDetails={(details) => - onDraftChange({ - ...serviceIdentityPatch(draft.serviceId, draft.pm, details.pm, details.serviceId), - serviceNotes: details.serviceNotes, - extraServices: details.extraServices, - avetaRequired: details.avetaRequired, - }) - } - /> - + void; }; -export function WorkOrderSlideOverTabs({ +type SecondarySlideOverTabProps = Pick< + WorkOrderSlideOverTabsProps, + "tab" | "state" | "users" | "commentHighlight" | "onOpenCompletionDoc" +>; + +function SecondarySlideOverTabs({ tab, state, - saving, users, - sites, - vendors, - pmTypes, - services = [], - serviceHistory, - serviceQuery, - serviceHistoryReady = true, - allOrders, commentHighlight, - onDuplicateFound, onOpenCompletionDoc, - onRequestComplete, -}: WorkOrderSlideOverTabsProps) { +}: SecondarySlideOverTabProps) { const hasError = Boolean(state.error) && !state.detailForRow; const showContent = Boolean(state.detailForRow || !state.isLoading); const { infoSource, activeDraft } = state; return ( - - {tab === "info" && infoSource != null && activeDraft != null && ( - - )} - + <> {tab === "comments" && ( )} - {tab === "completion" && state.infoSource != null && ( + {tab === "completion" && infoSource != null && ( { - state.uploadMediaRequests(requests); - }} + onUploadMedia={(requests) => state.uploadMediaRequests(requests)} onCategorizeMedia={state.categorizeMedia} onRemoveMedia={state.removeMedia} onRetryMedia={state.retryUpload} @@ -175,14 +145,67 @@ export function WorkOrderSlideOverTabs({ )} isReadOnly={state.isExtrasReadOnly} pending={state.mediaPending} - onUpload={(files) => { - void state.uploadExtraFiles(files); - }} + onUpload={(files) => void state.uploadExtraFiles(files)} onRemove={state.removeMedia} onCategorize={state.categorizeMedia} /> )} + + ); +} + +export function WorkOrderSlideOverTabs({ + tab, + state, + saving, + users, + sites, + vendors, + pmTypes, + services = [], + serviceHistory, + serviceQuery, + serviceHistoryReady = true, + allOrders, + commentHighlight, + onDuplicateFound, + onOpenCompletionDoc, + onRequestComplete, +}: WorkOrderSlideOverTabsProps) { + const { infoSource, activeDraft } = state; + + return ( + + {tab === "info" && infoSource != null && activeDraft != null && ( + + )} + + ); } diff --git a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx index ebc227ea..1d470409 100644 --- a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx @@ -34,6 +34,205 @@ import { QueryErrorPanel } from "@/components/ui/query-error-panel"; import { Text } from "@/components/ui/text"; import { WIZARD_STATUS_DOTS } from "@/domain/work-orders/types/work-order-wizard"; +type FilterActions = Pick; + +function AdvSelectAllRow({ + setMany, + keyName, + values, + selectedCount, +}: { + setMany: AdvancedSearchFilterSectionsProps["setMany"]; + keyName: AdvancedFilterSetKey; + values: string[]; + selectedCount: number; +}) { + return ( +
+ 0} + onClick={() => setMany(keyName, values, selectedCount === 0)} + /> +
+ ); +} + +function AdvancedStatusSection({ + draft, + setDraft, + toggleIn, + setMany, +}: FilterActions & Pick) { + return ( + + +
+ {ALL_WIZARD_STATUSES.map((status) => ( + toggleIn("statuses", status)} + /> + ))} + setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))} + /> +
+
+ ); +} + +type AdvancedServiceSectionProps = FilterActions & + Pick; + +function buildServiceFilterOptions( + draft: WorkOrderAdvancedFilters, + services: Service[] | undefined, + pmTypes: string[] | undefined, +) { + const serviceRegistryManaged = services !== undefined; + const serviceList = services ?? []; + const options = serviceRegistryManaged + ? [ + ...serviceList.map((service) => ({ value: service.name, label: service.name })), + ...[...draft.pmTypes] + .filter((name) => !serviceList.some((service) => service.name === name)) + .map((name) => ({ value: name, label: name })), + ] + : (pmTypes ?? []).map((name) => ({ value: name, label: name })); + return { serviceRegistryManaged, serviceList, options }; +} + +function AdvancedServiceRegistryFeedback({ + query, + managed, + services, +}: { + query?: ServiceRegistryQueryState; + managed: boolean; + services: Service[]; +}) { + const showEmpty = + managed && + query?.hasData === true && + !query.isLoading && + !query.isError && + services.length === 0; + + return ( + <> + {Boolean(query?.isLoading && !query.hasData) && ( +
+ + Loading services… + +
+ )} + {Boolean(query?.isFetching && query.hasData && !query.isError) && ( +
+ + Updating services… + +
+ )} + {Boolean(query?.isError) && ( +
+ +
+ )} + {showEmpty && ( + + No services are available. + + )} + + ); +} + +function AdvancedServiceSection({ + draft, + toggleIn, + setMany, + services, + serviceQuery, + pmTypes, +}: AdvancedServiceSectionProps) { + const { serviceRegistryManaged, serviceList, options } = buildServiceFilterOptions( + draft, + services, + pmTypes, + ); + const unavailable = + serviceRegistryManaged && + (serviceQuery?.isLoading === true || + (serviceQuery?.isError === true && serviceQuery.hasData === false)); + + return ( + + + toggleIn("pmTypes", value)} + onBulkToggle={(values, select) => setMany("pmTypes", values, select)} + placeholder="Search service…" + disabled={unavailable} + renderLeft={(name) => ( + + )} + /> + + ); +} + +function AdvancedCompletionDocSection({ draft, toggleIn, setMany }: FilterActions) { + return ( + + +
+ {COMPLETION_DOC_OPTIONS.map((doc) => ( + toggleIn("docs", doc)} + /> + ))} +
+
+ ); +} + type AdvancedSearchFilterSectionsProps = { draft: WorkOrderAdvancedFilters; setDraft: Dispatch>; @@ -59,29 +258,6 @@ export function AdvancedSearchFilterSections({ pmTypes = [], vendorTechs, }: AdvancedSearchFilterSectionsProps) { - const selectAllRow = (key: AdvancedFilterSetKey, all: string[], selectedSize: number) => ( -
- 0} - onClick={() => setMany(key, all, selectedSize === 0)} - /> -
- ); - const serviceRegistryManaged = services !== undefined; - const serviceList = services ?? []; - const serviceOptions = serviceRegistryManaged - ? [ - ...serviceList.map((service) => ({ value: service.name, label: service.name })), - ...[...draft.pmTypes] - .filter((name) => !serviceList.some((service) => service.name === name)) - .map((name) => ({ value: name, label: name })), - ] - : pmTypes.map((name) => ({ value: name, label: name })); - const serviceChoicesUnavailable = - serviceRegistryManaged && - (serviceQuery?.isLoading === true || - (serviceQuery?.isError === true && serviceQuery.hasData === false)); - return (
@@ -131,83 +307,23 @@ export function AdvancedSearchFilterSections({ /> - - {selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)} -
- {ALL_WIZARD_STATUSES.map((status) => ( - toggleIn("statuses", status)} - /> - ))} - setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))} - /> -
-
+ - - {Boolean(serviceQuery?.isLoading && !serviceQuery.hasData) && ( -
- - Loading services… - -
- )} - {Boolean(serviceQuery?.isFetching && serviceQuery.hasData && !serviceQuery.isError) && ( -
- - Updating services… - -
- )} - {Boolean(serviceQuery?.isError) && ( -
- -
- )} - {Boolean( - serviceRegistryManaged && - serviceQuery?.hasData && - !serviceQuery.isLoading && - !serviceQuery.isError && - serviceList.length === 0, - ) && ( - - No services are available. - - )} - toggleIn("pmTypes", value)} - onBulkToggle={(values, select) => setMany("pmTypes", values, select)} - placeholder="Search service…" - disabled={serviceChoicesUnavailable} - renderLeft={(name) => ( - - )} - /> -
+ - - {selectAllRow("docs", [...COMPLETION_DOC_OPTIONS], draft.docs.size)} -
- {COMPLETION_DOC_OPTIONS.map((doc) => ( - toggleIn("docs", doc)} - /> - ))} -
-
+ String(site.id) === String(draft.locationId)); + const locationName = draft.location || draft.site || location?.name || ""; + return { + locationName, + siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }), + }; +} + +function resolveInlineDraftSchedule(draft: WorkOrderTableDraft) { + const hasWeekTarget = Boolean(draft.targetWeek?.trim()); + const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn)); + return { + scheduleWeekOnly, + status: resolveCreationStatus({ + status: draft.status || "Incomplete", + scheduledOn: draft.scheduledOn, + scheduleWeekOnly, + }), + }; +} + export function buildInlineDraftCreateInput( draft: WorkOrderTableDraft, users: WorkOrderUser[], sites: LocationOption[], ): WorkOrderWizardDraft { const assignee = users.find((u) => String(u.id) === draft.dispatcherId); - const location = sites.find((s) => String(s.id) === String(draft.locationId)); - const locationName = draft.location || draft.site || location?.name || ""; - - const hasWeekTarget = Boolean(draft.targetWeek?.trim()); - const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn)); + const location = resolveInlineDraftLocation(draft, sites); + const schedule = resolveInlineDraftSchedule(draft); return { locationId: String(draft.locationId || ""), - locationName, - siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }), + locationName: location.locationName, + siteCode: location.siteCode, woNumber: draft.woNumber, type: draft.type || "PM", severity: draft.severity, @@ -39,16 +58,12 @@ export function buildInlineDraftCreateInput( vendorNotes: "", apptStart: draft.apptStart, apptEnd: draft.apptEnd, - status: resolveCreationStatus({ - status: draft.status || "Incomplete", - scheduledOn: draft.scheduledOn, - scheduleWeekOnly, - }), + status: schedule.status, pocName: draft.pocName, pocPhone: draft.pocPhone, pocNotes: draft.pocNotes, additionalContacts: draft.additionalContacts ?? [], - scheduleWeekOnly, + scheduleWeekOnly: schedule.scheduleWeekOnly, targetWeek: draft.targetWeek || "", scheduleEnd: draft.scheduleEnd || "", schedMode: draft.schedMode, diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx index df9a2c04..61241964 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx @@ -36,183 +36,277 @@ type PMTypeCellProps = { serviceQuery?: ServiceRegistryQueryState; }; -export function PMTypeCell({ - value, - pmTypes, - q, - onSave, - serviceNotes = "", - extraServices = [], - avetaRequired = false, - avetaAttached = false, - woNumber, - onSaveDetails, - viewOnly = false, - services, - serviceId, - workOrderType = "", - serviceQuery, -}: PMTypeCellProps) { - const [anchorEl, setAnchorEl] = useState(null); - const [search, setSearch] = useState(""); - const [modalOpen, setModalOpen] = useState(false); +type PMTypeCellModel = { + assignableServices?: Service[]; + displayValue: string; + filtered: string[]; + registryManaged: boolean; + choicesUnavailable: boolean; + catalogEmpty: boolean; + hasDetails: boolean; + notePreview: string; +}; - const assignableServices = - services == null ? undefined : servicesForWorkOrderType(services, workOrderType); - const serviceNames = assignableServices?.map((service) => service.name) ?? pmTypes; - const filtered = search - ? serviceNames.filter((p) => p.toLowerCase().includes(search.toLowerCase())) - : serviceNames; - const linkedService = services?.find((service) => String(service.id) === String(serviceId)); - const displayValue = !viewOnly && linkedService?.isActive ? linkedService.name : value; - const serviceRegistryManaged = services !== undefined; - const serviceChoicesUnavailable = - serviceRegistryManaged && - (serviceQuery?.isLoading === true || - (serviceQuery?.isError === true && serviceQuery.hasData === false)); - const serviceCatalogEmpty = - serviceRegistryManaged && - (serviceQuery?.hasData ?? true) && - !serviceQuery?.isLoading && - !serviceQuery?.isError && - serviceNames.length === 0; +function getAssignableServices(props: PMTypeCellProps) { + return props.services == null + ? undefined + : servicesForWorkOrderType(props.services, props.workOrderType ?? ""); +} - const extras = extraServices ?? []; - const hasDetails = hasServiceNotesDetails(serviceNotes, extras, avetaRequired); - const notePreview = serviceNotes?.trim() ? truncateServiceNotesPreview(serviceNotes) : ""; +function getCellDisplayValue(props: PMTypeCellProps) { + const linkedService = props.services?.find( + (service) => String(service.id) === String(props.serviceId), + ); + return !props.viewOnly && linkedService?.isActive ? linkedService.name : props.value; +} - const closePopover = () => { - setAnchorEl(null); - setSearch(""); +function getServiceChoicesUnavailable(props: PMTypeCellProps, registryManaged: boolean) { + const query = props.serviceQuery; + return ( + registryManaged && + (query?.isLoading === true || (query?.isError === true && query.hasData === false)) + ); +} + +function isServiceCatalogEmpty( + props: PMTypeCellProps, + registryManaged: boolean, + services: Service[] | undefined, +) { + const query = props.serviceQuery; + return ( + registryManaged && + (query?.hasData ?? true) && + !query?.isLoading && + !query?.isError && + (services?.length ?? props.pmTypes.length) === 0 + ); +} + +function resolvePMTypeCellModel(props: PMTypeCellProps, search: string): PMTypeCellModel { + const assignableServices = getAssignableServices(props); + const serviceNames = assignableServices?.map((service) => service.name) ?? props.pmTypes; + const registryManaged = props.services !== undefined; + + return { + assignableServices, + displayValue: getCellDisplayValue(props), + filtered: search + ? serviceNames.filter((name) => name.toLowerCase().includes(search.toLowerCase())) + : serviceNames, + registryManaged, + choicesUnavailable: getServiceChoicesUnavailable(props, registryManaged), + catalogEmpty: isServiceCatalogEmpty(props, registryManaged, assignableServices), + hasDetails: hasServiceNotesDetails( + props.serviceNotes ?? "", + props.extraServices ?? [], + props.avetaRequired ?? false, + ), + notePreview: props.serviceNotes?.trim() ? truncateServiceNotesPreview(props.serviceNotes) : "", }; +} - const modal = - onSaveDetails != null || viewOnly ? ( - undefined)} +function PMTypeCellDialog({ + props, + model, + open, + onOpenChange, +}: { + props: PMTypeCellProps; + model: PMTypeCellModel; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + if (!props.viewOnly && props.onSaveDetails == null) return null; + + return ( + undefined)} + /> + ); +} + +function PMTypeCellReadOnly({ + props, + model, + onOpenDetails, +}: { + props: PMTypeCellProps; + model: PMTypeCellModel; + onOpenDetails: () => void; +}) { + return ( + - {modal} - - ); - } + + + ) : ( + — + )} + {Boolean(props.avetaRequired) && } + + ); +} - if (hasDetails && onSaveDetails) { - return ( - <> - setModalOpen(true)} - /> - {modal} - - ); - } +function PMTypeCellDetails({ + props, + model, + onOpenDetails, +}: { + props: PMTypeCellProps; + model: PMTypeCellModel; + onOpenDetails: () => void; +}) { + return ( + + ); +} + +function PMTypeCellPicker({ + props, + model, + anchorEl, + search, + onOpenPicker, + onSearchChange, + onClose, + onOpenDetails, +}: { + props: PMTypeCellProps; + model: PMTypeCellModel; + anchorEl: HTMLElement | null; + search: string; + onOpenPicker: (anchor: HTMLElement) => void; + onSearchChange: (search: string) => void; + onClose: () => void; + onOpenDetails: () => void; +}) { + const onPick = (name: string) => { + const service = model.assignableServices?.find((candidate) => candidate.name === name); + props.onSave(name, service?.id); + onClose(); + }; return ( <> - { - const picked = assignableServices?.find((service) => service.name === pm); - onSave(pm, picked?.id); - closePopover(); - }} + filtered={model.filtered} + value={model.displayValue} + showAddDetails={Boolean(props.onSaveDetails)} + onSearchChange={onSearchChange} + onClose={onClose} + registryManaged={model.registryManaged} + loading={props.serviceQuery?.isLoading === true && !props.serviceQuery.hasData} + fetching={props.serviceQuery?.isFetching === true && props.serviceQuery.hasData} + error={props.serviceQuery?.isError === true} + empty={model.catalogEmpty} + selectionDisabled={model.choicesUnavailable} + onRetry={props.serviceQuery?.retry} + onPick={onPick} onAddDetails={() => { - closePopover(); - setModalOpen(true); + onClose(); + onOpenDetails(); }} /> - {modal} + + ); +} + +export function PMTypeCell(props: PMTypeCellProps) { + const [anchorEl, setAnchorEl] = useState(null); + const [search, setSearch] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const model = resolvePMTypeCellModel(props, search); + const closePicker = () => { + setAnchorEl(null); + setSearch(""); + }; + const openDetails = () => setModalOpen(true); + const content = props.viewOnly ? ( + + ) : model.hasDetails && props.onSaveDetails ? ( + + ) : ( + + ); + + return ( + <> + {content} + ); } diff --git a/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts b/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts index 53271e82..ef6cf584 100644 --- a/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts +++ b/src/app/(protected)/workorders/_components/list/table/save-existing-row-type.ts @@ -18,14 +18,23 @@ type PersistPatchFn = ( options?: { onSuccess?: () => void }, ) => void; -export function saveExistingRowType( - onPatch: PersistPatchFn, - row: Pick, - type: WOType, - severity: WorkOrderSeverity | null, - serviceHistory: Service[] = [], +type SaveExistingRowTypeOptions = { + onPatch: PersistPatchFn; + row: Pick; + type: WOType; + severity: WorkOrderSeverity | null; + serviceHistory?: Service[]; + serviceHistoryReady?: boolean; +}; + +export function saveExistingRowType({ + onPatch, + row, + type, + severity, + serviceHistory = [], serviceHistoryReady = true, -) { +}: SaveExistingRowTypeOptions) { const selectedService = serviceHistoryReady ? serviceHistory.find((service) => String(service.id) === String(row.serviceId)) : undefined; diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx index 0a2ca545..b04eba58 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-cells.tsx @@ -127,14 +127,14 @@ export function WoTableRowCells({ q={search} viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)} onSave={(t, sev) => - saveExistingRowType( - handlers.onPatch, + saveExistingRowType({ + onPatch: handlers.onPatch, row, - t, - sev, - serviceHistory ?? services, + type: t, + severity: sev, + serviceHistory: serviceHistory ?? services, serviceHistoryReady, - ) + }) } /> diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx index 063f193b..4de3a8ee 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -93,6 +93,112 @@ function WoTablePager({ tableData }: { tableData: ReturnTypeOfUseWorkOrdersTable ); } +type WoTableRowRenderProps = { + row: WorkOrderTableRow; + allRows: WorkOrderTableRow[]; + users: WorkOrderUser[]; + sites: LocationOption[]; + sitePlace?: { city: string; state: string }; + vendors: VendorDropdownItem[]; + services: Service[]; + serviceHistory: Service[]; + serviceQuery?: ServiceRegistryQueryState; + serviceHistoryReady: boolean; + pmTypes: string[]; + search: string; + forceScheduleHighlight: boolean; + forceDueDateHighlight: boolean; + isSelected: boolean; + isFlashing: boolean; + dragViz: ReturnTypeOfUseWorkOrdersTableData["dragViz"]; + handlers: ReturnType; +}; + +function WoTableRenderedRow(props: WoTableRowRenderProps) { + return ; +} + +type WoTableInlineRenderProps = { + tableData: ReturnTypeOfUseWorkOrdersTableData; + dayKey: string; + users: WorkOrderUser[]; + sites: LocationOption[]; + sitePlace?: { city: string; state: string }; + vendors: VendorDropdownItem[]; + services: Service[]; + serviceQuery?: ServiceRegistryQueryState; + pmTypes: string[]; + saving: boolean; + handleInlineSave: () => void; + onDuplicateFound: (row: WorkOrderTableRow) => void; +}; + +function WoTableRenderedInlineRow({ + tableData, + dayKey, + users, + sites, + sitePlace, + vendors, + services, + serviceQuery, + pmTypes, + saving, + handleInlineSave, + onDuplicateFound, +}: WoTableInlineRenderProps) { + if (tableData.draftDayKey !== dayKey) return null; + return ( + + ); +} + +function WoTableEmptyResult({ + tableData, + onOpenAdvancedFilters, + onClearSearch, +}: { + tableData: ReturnTypeOfUseWorkOrdersTableData; + onOpenAdvancedFilters?: () => void; + onClearSearch?: () => void; +}) { + if (tableData.advActive && tableData.scopeTotalCount === 0) { + return ( + + ); + } + if (!tableData.advActive && tableData.noSearchResults) { + return ( + + ); + } + return null; +} + export function WoTable({ tableData, users, @@ -141,7 +247,7 @@ export function WoTable({ }); const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => ( - ); - const renderInlineRow = (dayKey: string) => - tableData.draftDayKey === dayKey && ( - - ); + const renderInlineRow = (dayKey: string) => ( + + ); if (tableData.isLoading) { return ( @@ -197,29 +299,14 @@ export function WoTable({ ); } - const emptyAdv = tableData.advActive && tableData.scopeTotalCount === 0; - - if (emptyAdv) { - return ( - - ); - } - - const showSearchEmpty = !tableData.advActive && tableData.noSearchResults; - - if (showSearchEmpty) { - return ( - - ); - } + const emptyResult = ( + + ); + if (emptyResult != null) return emptyResult; return ( <> diff --git a/src/app/(protected)/workorders/_hooks/use-work-order-service-registry.ts b/src/app/(protected)/workorders/_hooks/use-work-order-service-registry.ts new file mode 100644 index 00000000..a294ae7b --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-work-order-service-registry.ts @@ -0,0 +1,39 @@ +import type { UseQueryResult } from "@tanstack/react-query"; +import type { Service } from "@/domain/services/types/service"; +import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; +import { + useActiveServices, + useAllServices, +} from "@/domain/services/use-cases/use-services-for-work-order-type"; + +function toServiceRegistryQueryState( + query: UseQueryResult, +): ServiceRegistryQueryState { + return { + isLoading: query.isPending && query.data === undefined, + isError: query.isError, + isFetching: query.isFetching, + hasData: query.data !== undefined, + retry: () => void query.refetch(), + }; +} + +export function useWorkOrderServiceRegistry() { + const activeQuery = useActiveServices(); + const historyQuery = useAllServices(); + const servicePickerState = toServiceRegistryQueryState(activeQuery); + const serviceHistoryState = toServiceRegistryQueryState(historyQuery); + const serviceHistoryReadyForTypeChange = + serviceHistoryState.hasData && + !serviceHistoryState.isLoading && + !serviceHistoryState.isError && + !serviceHistoryState.isFetching; + + return { + services: activeQuery.data ?? [], + serviceHistory: historyQuery.data ?? [], + servicePickerState, + serviceHistoryState, + serviceHistoryReadyForTypeChange, + }; +} diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts index 1cbc0b2d..87987653 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts @@ -9,11 +9,6 @@ import { } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; -import { - useActiveServices, - useAllServices, -} from "@/domain/services/use-cases/use-services-for-work-order-type"; -import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; import { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete"; import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link"; import { @@ -23,6 +18,7 @@ import { import { guardDialogClose } from "@/lib/dialog-guard"; import { useWorkOrdersListFilters } from "./use-work-orders-list-filters"; import { useWorkOrdersTableData } from "./use-work-orders-table-data"; +import { useWorkOrderServiceRegistry } from "./use-work-order-service-registry"; function useSlideOverOpenState() { const [slideOverRow, setSlideOverRow] = useState(null); @@ -96,29 +92,7 @@ export function useWorkOrdersListPage() { const { data: locations = [] } = useWorkOrderLocations(); const { data: vendors = [] } = useWorkOrderVendors(); const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem"); - const activeServicesQuery = useActiveServices(); - const serviceHistoryQuery = useAllServices(); - const services = activeServicesQuery.data ?? []; - const serviceHistory = serviceHistoryQuery.data ?? []; - const servicePickerState: ServiceRegistryQueryState = { - isLoading: activeServicesQuery.isPending && activeServicesQuery.data === undefined, - isError: activeServicesQuery.isError, - isFetching: activeServicesQuery.isFetching, - hasData: activeServicesQuery.data !== undefined, - retry: () => void activeServicesQuery.refetch(), - }; - const serviceHistoryState: ServiceRegistryQueryState = { - isLoading: serviceHistoryQuery.isPending && serviceHistoryQuery.data === undefined, - isError: serviceHistoryQuery.isError, - isFetching: serviceHistoryQuery.isFetching, - hasData: serviceHistoryQuery.data !== undefined, - retry: () => void serviceHistoryQuery.refetch(), - }; - const serviceHistoryReadyForTypeChange = - serviceHistoryState.hasData && - !serviceHistoryState.isLoading && - !serviceHistoryState.isError && - !serviceHistoryState.isFetching; + const serviceRegistry = useWorkOrderServiceRegistry(); const pmTypes = useMemo( () => problemOptions.map((option) => option.value).filter(Boolean), @@ -223,11 +197,7 @@ export function useWorkOrdersListPage() { locations, vendors, pmTypes, - services, - serviceHistory, - servicePickerState, - serviceHistoryState, - serviceHistoryReadyForTypeChange, + ...serviceRegistry, vendorTechs, activeSlideOverRow, activeDocRow, diff --git a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts index 4ec3a799..5f199fa9 100644 --- a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts +++ b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts @@ -19,7 +19,7 @@ describe("saveExistingRowType", () => { it("warns when severity newly enters SEV 1 only after persist onSuccess", () => { const onPatch = vi.fn(); - saveExistingRowType(onPatch, row, "Emergency", 1); + saveExistingRowType({ onPatch, row, type: "Emergency", severity: 1 }); expect(toastWarn).not.toHaveBeenCalled(); expect(onPatch).toHaveBeenCalledWith( @@ -36,7 +36,7 @@ describe("saveExistingRowType", () => { it("does not warn when staying on a critical severity", () => { const onPatch = vi.fn(); - saveExistingRowType(onPatch, { ...row, severity: 1 }, "Emergency", 1); + saveExistingRowType({ onPatch, row: { ...row, severity: 1 }, type: "Emergency", severity: 1 }); expect(toastWarn).not.toHaveBeenCalled(); expect(onPatch).toHaveBeenCalledWith(7, { type: "Emergency", severity: 1 }); @@ -46,7 +46,7 @@ describe("saveExistingRowType", () => { it("does not warn when moving between SEV 3–5", () => { const onPatch = vi.fn(); - saveExistingRowType(onPatch, { ...row, severity: 5 }, "Reactive", 3); + saveExistingRowType({ onPatch, row: { ...row, severity: 5 }, type: "Reactive", severity: 3 }); expect(toastWarn).not.toHaveBeenCalled(); expect(onPatch).toHaveBeenCalledWith(7, { type: "Reactive", severity: 3 }); @@ -56,7 +56,7 @@ describe("saveExistingRowType", () => { it("does not warn when clearing severity on a PM correction", () => { const onPatch = vi.fn(); - saveExistingRowType(onPatch, { ...row, severity: 2 }, "PM", null); + saveExistingRowType({ onPatch, row: { ...row, severity: 2 }, type: "PM", severity: null }); expect(toastWarn).not.toHaveBeenCalled(); expect(onPatch).toHaveBeenCalledWith(7, { type: "PM", severity: null }); @@ -78,7 +78,13 @@ describe("saveExistingRowType", () => { }, ]; - saveExistingRowType(onPatch, { ...row, serviceId: 701 }, "Emergency", null, services); + saveExistingRowType({ + onPatch, + row: { ...row, serviceId: 701 }, + type: "Emergency", + severity: null, + serviceHistory: services, + }); expect(onPatch).toHaveBeenCalledWith(7, { type: "Emergency", @@ -103,7 +109,13 @@ describe("saveExistingRowType", () => { }, ]; - saveExistingRowType(onPatch, { ...row, serviceId: 702 }, "Reactive", null, services); + saveExistingRowType({ + onPatch, + row: { ...row, serviceId: 702 }, + type: "Reactive", + severity: null, + serviceHistory: services, + }); expect(onPatch).toHaveBeenCalledWith(7, { type: "Reactive", severity: null }); }); @@ -123,7 +135,13 @@ describe("saveExistingRowType", () => { }, ]; - saveExistingRowType(onPatch, { ...row, serviceId: 701 }, "Emergency", null, services); + saveExistingRowType({ + onPatch, + row: { ...row, serviceId: 701 }, + type: "Emergency", + severity: null, + serviceHistory: services, + }); expect(onPatch).toHaveBeenCalledWith(7, { type: "Emergency", @@ -146,14 +164,14 @@ describe("saveExistingRowType", () => { supportedWorkOrderTypes: ["PM"], }; - saveExistingRowType( + saveExistingRowType({ onPatch, - { ...row, serviceId: 701 }, - "Emergency", - null, - [inactiveService], - false, - ); + row: { ...row, serviceId: 701 }, + type: "Emergency", + severity: null, + serviceHistory: [inactiveService], + serviceHistoryReady: false, + }); expect(onPatch).toHaveBeenCalledWith(7, { type: "Emergency", severity: null }); }); From 48fc415d54edaf871902866c78ce9d6bffd6ee9b Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 19:00:55 -0300 Subject: [PATCH 10/15] fix(workorders): guard service-linked type changes --- .../tabs/slide-over-info-tab-edit-view.tsx | 69 +++++-- .../detail/tabs/slide-over-info-tab.tsx | 3 + .../detail/work-order-slide-over-tabs.tsx | 3 + .../detail/work-order-slide-over.tsx | 3 + .../list/table/cells/pm-type-cell.tsx | 1 + .../list/table/cells/service-notes-dialog.tsx | 20 ++ .../table/cells/service-registry-status.tsx | 48 +++++ .../list/table/cells/type-cell.tsx | 33 ++- .../list/table/save-existing-row-type.ts | 6 +- .../list/table/wo-table-row-cells.tsx | 91 ++++++-- .../_components/list/table/wo-table-row.tsx | 3 + .../_components/list/table/wo-table.tsx | 4 + .../list/work-orders-list-page-panels.tsx | 1 + .../wizard/wizard-service-notes-fields.tsx | 4 + .../_hooks/use-work-order-service-registry.ts | 6 +- src/app/(protected)/workorders/index.tsx | 1 + .../workorders/aveta-service-notes.test.tsx | 195 ++++++++++++++++++ .../workorders/save-existing-row-type.test.ts | 16 +- ...tab-edit-view-additional-contacts.test.tsx | 5 +- .../use-work-order-service-registry.test.tsx | 49 +++++ 20 files changed, 504 insertions(+), 57 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/service-registry-status.tsx create mode 100644 src/test/app/(protected)/workorders/use-work-order-service-registry.test.tsx diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index 1347384f..1a99666c 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -37,6 +37,7 @@ type SlideOverInfoTabEditViewProps = { services?: Service[]; serviceHistory?: Service[]; serviceQuery?: ServiceRegistryQueryState; + serviceHistoryQuery?: ServiceRegistryQueryState; serviceHistoryReady?: boolean; allOrders: WorkOrderTableRow[]; onDraftChange: (patch: Partial) => void; @@ -83,6 +84,50 @@ function SlideOverServiceField({ ); } +type SlideOverTypeFieldProps = Pick< + SlideOverInfoTabEditViewProps, + | "draft" + | "services" + | "serviceHistory" + | "serviceHistoryReady" + | "serviceHistoryQuery" + | "onDraftChange" +>; + +function SlideOverTypeField({ + draft, + services = [], + serviceHistory, + serviceHistoryReady = true, + serviceHistoryQuery, + onDraftChange, +}: SlideOverTypeFieldProps) { + const typeServices = serviceHistory ?? services; + const linkedService = typeServices.find( + (service) => String(service.id) === String(draft.serviceId), + ); + const registryUnavailable = draft.serviceId != null && (!serviceHistoryReady || !linkedService); + + return ( + { + if (registryUnavailable) return; + onDraftChange( + draft.serviceId != null && + linkedService != null && + !serviceSupportsWorkOrderType(linkedService, type) + ? { type, severity, serviceId: null, pm: "" } + : { type, severity }, + ); + }} + /> + ); +} + function SlideOverScheduleOnField({ draft, onDraftChange, @@ -164,6 +209,7 @@ export function SlideOverInfoTabEditView({ services = [], serviceHistory, serviceQuery, + serviceHistoryQuery, serviceHistoryReady = true, allOrders, onDraftChange, @@ -213,22 +259,13 @@ export function SlideOverInfoTabEditView({ /> - { - const typeServices = serviceHistory ?? services; - const selectedService = serviceHistoryReady - ? typeServices.find((service) => String(service.id) === String(draft.serviceId)) - : undefined; - onDraftChange( - draft.serviceId != null && - selectedService != null && - !serviceSupportsWorkOrderType(selectedService, type) - ? { type, severity, serviceId: null, pm: "" } - : { type, severity }, - ); - }} + diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx index 377f1603..d8f1e049 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab.tsx @@ -22,6 +22,7 @@ type SlideOverInfoTabProps = { services?: Service[]; serviceHistory?: Service[]; serviceQuery?: ServiceRegistryQueryState; + serviceHistoryQuery?: ServiceRegistryQueryState; serviceHistoryReady?: boolean; allOrders: WorkOrderTableRow[]; onDraftChange: (patch: Partial) => void; @@ -42,6 +43,7 @@ export function SlideOverInfoTab({ services = [], serviceHistory, serviceQuery, + serviceHistoryQuery, serviceHistoryReady = true, allOrders, onDraftChange, @@ -63,6 +65,7 @@ export function SlideOverInfoTab({ services={services} serviceHistory={serviceHistory} serviceQuery={serviceQuery} + serviceHistoryQuery={serviceHistoryQuery} serviceHistoryReady={serviceHistoryReady} allOrders={allOrders} onDraftChange={onDraftChange} diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index ec5e611b..e742b0f7 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -30,6 +30,7 @@ type WorkOrderSlideOverTabsProps = { services?: Service[]; serviceHistory?: Service[]; serviceQuery?: ServiceRegistryQueryState; + serviceHistoryQuery?: ServiceRegistryQueryState; serviceHistoryReady?: boolean; allOrders: WorkOrderTableRow[]; commentHighlight?: CommentHighlight | null; @@ -166,6 +167,7 @@ export function WorkOrderSlideOverTabs({ services = [], serviceHistory, serviceQuery, + serviceHistoryQuery, serviceHistoryReady = true, allOrders, commentHighlight, @@ -191,6 +193,7 @@ export function WorkOrderSlideOverTabs({ services={services} serviceHistory={serviceHistory} serviceQuery={serviceQuery} + serviceHistoryQuery={serviceHistoryQuery} serviceHistoryReady={serviceHistoryReady} allOrders={allOrders} onDraftChange={state.handleDraftChange} diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx index 8a3a97f2..b080915a 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx @@ -33,6 +33,7 @@ type WorkOrderSlideOverProps = { services?: Service[]; serviceHistory?: Service[]; serviceQuery?: ServiceRegistryQueryState; + serviceHistoryQuery?: ServiceRegistryQueryState; serviceHistoryReady?: boolean; allOrders: WorkOrderTableRow[]; onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void; @@ -59,6 +60,7 @@ export function WorkOrderSlideOver({ services = [], serviceHistory, serviceQuery, + serviceHistoryQuery, serviceHistoryReady = true, allOrders, onSave, @@ -114,6 +116,7 @@ export function WorkOrderSlideOver({ services={services} serviceHistory={serviceHistory} serviceQuery={serviceQuery} + serviceHistoryQuery={serviceHistoryQuery} serviceHistoryReady={serviceHistoryReady} allOrders={allOrders} commentHighlight={commentHighlight} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx index 61241964..4581bc96 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx @@ -131,6 +131,7 @@ function PMTypeCellDialog({ pmTypes={props.pmTypes} services={model.assignableServices} serviceId={props.serviceId} + serviceQuery={props.serviceQuery} viewOnly={props.viewOnly ?? false} onSave={props.onSaveDetails ?? (() => undefined)} /> diff --git a/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx index 737c729e..a9ed0377 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx @@ -5,6 +5,8 @@ import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_componen import { Button } from "@/components/ui/button"; import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; import type { Service } from "@/domain/services/types/service"; +import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; +import { ServiceRegistryStatus } from "./service-registry-status"; export type ServiceNotesDetails = { pm: string; @@ -25,6 +27,7 @@ type ServiceNotesDialogProps = { pmTypes: string[]; services?: Service[]; serviceId?: string | number | null; + serviceQuery?: ServiceRegistryQueryState; viewOnly?: boolean; onSave: (details: ServiceNotesDetails) => void; }; @@ -40,6 +43,7 @@ export function ServiceNotesDialog({ pmTypes, services, serviceId, + serviceQuery, viewOnly = false, onSave, }: ServiceNotesDialogProps) { @@ -71,11 +75,21 @@ export function ServiceNotesDialog({ ] : pmTypes.map((value) => ({ id: value, value })); const saveServiceId = () => { + if (serviceOptionsUnavailable) return serviceId ?? undefined; const selectedId = services?.find((service) => service.name === sel[0])?.id; if (selectedId != null) return selectedId; if (sel[0] === pm) return serviceId ?? undefined; return serviceId == null ? undefined : null; }; + const registryManaged = services !== undefined; + const serviceOptionsUnavailable = + registryManaged && serviceQuery != null && !serviceQuery.hasData; + const serviceOptionsEmpty = + registryManaged && + serviceQuery?.hasData === true && + !serviceQuery.isLoading && + !serviceQuery.isError && + services.length === 0; return (
+ diff --git a/src/app/(protected)/workorders/_components/list/table/cells/service-registry-status.tsx b/src/app/(protected)/workorders/_components/list/table/cells/service-registry-status.tsx new file mode 100644 index 00000000..0d5506ad --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/service-registry-status.tsx @@ -0,0 +1,48 @@ +import { QueryErrorPanel } from "@/components/ui/query-error-panel"; +import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; + +type ServiceRegistryStatusProps = { + query?: ServiceRegistryQueryState; + unavailable?: boolean; + empty?: boolean; + emptyMessage?: string; +}; + +export function ServiceRegistryStatus({ + query, + unavailable = false, + empty = false, + emptyMessage = "No services are available.", +}: ServiceRegistryStatusProps) { + if (query?.isLoading && !query.hasData) { + return
Loading services…
; + } + if (query?.isError) { + return ( +
+ +
+ ); + } + if (query?.isFetching && query.hasData) { + return
Updating services…
; + } + if (empty && query?.hasData) { + return
{emptyMessage}
; + } + if (unavailable) { + return ( +
+ Service history is unavailable. Retry before changing the work-order type. + +
+ ); + } + return null; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/type-cell.tsx index 949722d6..63c056ec 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/type-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/type-cell.tsx @@ -13,12 +13,16 @@ import { HoverChevron } from "./hover-chevron"; import { SeverityBadge } from "./severity-badge"; import { TypeSeverityModal } from "./type-severity-modal"; import { WoTableTypeLabel } from "./wo-table-type-label"; +import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; +import { ServiceRegistryStatus } from "./service-registry-status"; type TypeCellProps = { value: WOType | ""; severity?: WorkOrderSeverity | null; q?: string; viewOnly?: boolean; + registryUnavailable?: boolean; + serviceHistoryQuery?: ServiceRegistryQueryState; onSave: (type: WOType, severity: WorkOrderSeverity | null) => void; }; @@ -29,7 +33,15 @@ function TypeCellValue({ value, q }: { value: WOType | ""; q?: string }) { return Select type of WO; } -export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }: TypeCellProps) { +export function TypeCell({ + value, + severity = null, + q, + viewOnly = false, + registryUnavailable = false, + serviceHistoryQuery, + onSave, +}: TypeCellProps) { const [anchorEl, setAnchorEl] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [modalSeed, setModalSeed] = useState(value); @@ -65,6 +77,7 @@ export function TypeCell({ value, severity = null, q, viewOnly = false, onSave } <>
+ ); +} + export function WoTableRowCells({ row, allRows, @@ -46,6 +104,7 @@ export function WoTableRowCells({ services = [], serviceHistory, serviceHistoryReady = true, + serviceHistoryQuery, sitePlace, search, forceScheduleHighlight, @@ -116,28 +175,16 @@ export function WoTableRowCells({ onDuplicateFound={handlers.onDuplicateFound} /> - +
event.stopPropagation()} + > + + saveExistingRowType({ + onPatch: handlers.onPatch, + row, + type, + severity, + serviceHistory: typeServices, + serviceHistoryReady, + }) + } + /> + e.stopPropagation()} - > - - saveExistingRowType({ - onPatch: handlers.onPatch, - row, - type: t, - severity: sev, - serviceHistory: serviceHistory ?? services, - serviceHistoryReady, - }) - } - /> - void; @@ -103,6 +104,7 @@ type WoTableRowRenderProps = { services: Service[]; serviceHistory: Service[]; serviceQuery?: ServiceRegistryQueryState; + serviceHistoryQuery?: ServiceRegistryQueryState; serviceHistoryReady: boolean; pmTypes: string[]; search: string; @@ -208,6 +210,7 @@ export function WoTable({ services = [], serviceHistory = [], serviceQuery, + serviceHistoryQuery, serviceHistoryReady = true, weekMonday, onNavigateToDay, @@ -260,6 +263,7 @@ export function WoTable({ services={services} serviceHistory={serviceHistory} serviceQuery={serviceQuery} + serviceHistoryQuery={serviceHistoryQuery} serviceHistoryReady={serviceHistoryReady} pmTypes={pmTypes} search={tableData.search} diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx index 9794353b..86ddcf88 100644 --- a/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-page-panels.tsx @@ -75,6 +75,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps services={services} serviceHistory={serviceHistory} serviceQuery={servicePickerState} + serviceHistoryQuery={serviceHistoryState} serviceHistoryReady={serviceHistoryReadyForTypeChange} allOrders={tableData.rows} onSave={(id, patch, options) => page.patchField(id, patch, options)} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields.tsx index 1f0dafc2..9ba59a4f 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields.tsx @@ -17,6 +17,7 @@ type WizardServiceNotesFieldsProps = { setSearch: (v: string) => void; avetaRequired?: boolean; onAvetaRequiredChange?: (value: boolean) => void; + serviceSelectionDisabled?: boolean; }; function SvcSectionLabel({ children }: { children: ReactNode }) { @@ -48,6 +49,7 @@ export function WizardServiceNotesFields({ setSearch, avetaRequired = false, onAvetaRequiredChange, + serviceSelectionDisabled = false, }: WizardServiceNotesFieldsProps) { const filtered = search ? services.filter((s) => s.value.toLowerCase().includes(search.toLowerCase())) @@ -61,6 +63,7 @@ export function WizardServiceNotesFields({ value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search services…" + disabled={serviceSelectionDisabled} className="mb-1.5 w-full rounded px-2 py-1.5 text-[12px] focus:outline-none focus:ring-1" style={{ border: "1px solid var(--color-border)", @@ -81,6 +84,7 @@ export function WizardServiceNotesFields({ key={String(s.id)} type="button" onClick={() => onToggle(s.value)} + disabled={serviceSelectionDisabled} className="flex w-full items-center gap-2 px-2 py-1.5 text-left transition-colors hover:bg-[#F9FAFB]" > { + it("defers linked type selection with visible loading and retry feedback", () => { + const retry = vi.fn(); + const onSave = vi.fn(); + const query = { + isLoading: false, + isError: true, + isFetching: false, + hasData: false, + retry, + }; + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("button", { name: /Type of WO, PM/i })).toBeDisabled(); + expect(screen.getByRole("alert", { name: /service options unavailable/i })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(retry).toHaveBeenCalledOnce(); + expect(onSave).not.toHaveBeenCalled(); + }); + it("shows loading and preserves the current value while registry services are pending", () => { renderWithProviders( { }), ); }); + + it("shows pending and successful-empty registry states without blocking notes", () => { + const initial = { + open: true, + onOpenChange: vi.fn(), + pm: "Saved service", + extraServices: [], + serviceNotes: "Saved notes", + pmTypes: ["Leak"], + services: [] as { + id: number; + name: string; + trade: string; + iconKey: string; + requiresCompletionDocument: boolean; + completionDocTemplate: null; + isActive: boolean; + supportedWorkOrderTypes: "PM"[]; + }[], + serviceId: 701, + onSave: vi.fn(), + }; + const view = renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("status", { name: /loading services/i })).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/search services/i)).toBeDisabled(); + expect(screen.getByPlaceholderText(/describe the specific issue/i)).toBeEnabled(); + + view.rerender( + , + ); + + expect( + screen.getByText(/no services are available for this work-order type/i), + ).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/describe the specific issue/i)).toBeEnabled(); + }); + + it.each(["picker add-details", "existing details trigger"])( + "shows registry retry and keeps notes editable from the %s entry", + (entry) => { + const onRetry = vi.fn(); + const onSaveDetails = vi.fn(); + const props = { + value: "Saved service", + pmTypes: ["Leak"], + services: [], + serviceId: 701, + serviceNotes: entry === "picker add-details" ? "" : "Original notes", + extraServices: [], + onSave: vi.fn(), + onSaveDetails, + serviceQuery: { + isLoading: false, + isError: true, + isFetching: false, + hasData: false, + retry: onRetry, + }, + }; + renderWithProviders(, { withAuth: false }); + + if (entry === "picker add-details") { + fireEvent.click(screen.getByRole("button", { name: /Saved service/i })); + fireEvent.click(screen.getByRole("button", { name: /Add service and notes/i })); + } else { + fireEvent.click(screen.getByRole("button", { name: /Saved service/i })); + } + + expect( + screen.getByRole("alert", { name: /service options unavailable/i }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + const notes = screen.getByPlaceholderText(/describe the specific issue/i); + expect(notes).toBeEnabled(); + fireEvent.change(notes, { target: { value: "Updated notes" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(onSaveDetails).toHaveBeenCalledWith( + expect.objectContaining({ + pm: "Saved service", + serviceId: 701, + serviceNotes: "Updated notes", + }), + ); + expect(onRetry).toHaveBeenCalledOnce(); + }, + ); + + it("allows registry selection after retry resolves and saves the service ID", () => { + const onSave = vi.fn(); + function RetryHarness() { + const [resolved, setResolved] = useState(false); + return ( + setResolved(true), + }} + onSave={onSave} + /> + ); + } + + renderWithProviders(, { withAuth: false }); + expect(screen.getByRole("alert", { name: /service options unavailable/i })).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/search services/i)).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(screen.getByPlaceholderText(/search services/i)).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: /Saved servicePrimary/i })); + fireEvent.click(screen.getByRole("button", { name: /QA Test Service/i })); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ pm: "QA Test Service", serviceId: 702 }), + ); + }); }); diff --git a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts index 5f199fa9..63574348 100644 --- a/src/test/app/(protected)/workorders/save-existing-row-type.test.ts +++ b/src/test/app/(protected)/workorders/save-existing-row-type.test.ts @@ -151,7 +151,7 @@ describe("saveExistingRowType", () => { }); }); - it("preserves a service when the registry history has not loaded yet", () => { + it("defers a linked service type change when history has not loaded", () => { const onPatch = vi.fn(); const inactiveService: Service = { id: 701, @@ -173,6 +173,20 @@ describe("saveExistingRowType", () => { serviceHistoryReady: false, }); + expect(onPatch).not.toHaveBeenCalled(); + }); + + it("allows type changes for unlinked legacy rows while history is unavailable", () => { + const onPatch = vi.fn(); + + saveExistingRowType({ + onPatch, + row: { ...row, serviceId: null }, + type: "Emergency", + severity: null, + serviceHistoryReady: false, + }); + expect(onPatch).toHaveBeenCalledWith(7, { type: "Emergency", severity: null }); }); }); diff --git a/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx index 7746cfad..d7cec1fb 100644 --- a/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-info-tab-edit-view-additional-contacts.test.tsx @@ -343,10 +343,7 @@ describe("SlideOverInfoTabEditView additional contacts", () => { fireEvent.click(screen.getByRole("button", { name: "Change type to Emergency" })); fireEvent.click(screen.getByRole("button", { name: "Save changes" })); - expect(onSave).toHaveBeenCalledWith([ - { field: "severity", value: "2" }, - { field: "workOrderType", value: "Emergency" }, - ]); + expect(onSave).toHaveBeenCalledWith([]); }); it("passes additionalContacts into SiteCell and persists them on save", () => { diff --git a/src/test/app/(protected)/workorders/use-work-order-service-registry.test.tsx b/src/test/app/(protected)/workorders/use-work-order-service-registry.test.tsx new file mode 100644 index 00000000..03ef7c57 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-work-order-service-registry.test.tsx @@ -0,0 +1,49 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const queryFixtures = vi.hoisted(() => ({ active: null as unknown, history: null as unknown })); + +vi.mock("@/domain/services/use-cases/use-services-for-work-order-type", () => ({ + useActiveServices: () => queryFixtures.active, + useAllServices: () => queryFixtures.history, +})); + +import { useWorkOrderServiceRegistry } from "@/app/(protected)/workorders/_hooks/use-work-order-service-registry"; + +function query(data: unknown, isFetching: boolean) { + return { + data, + isPending: data === undefined, + isError: false, + isFetching, + refetch: vi.fn(), + }; +} + +describe("useWorkOrderServiceRegistry", () => { + beforeEach(() => { + queryFixtures.active = query([], false); + queryFixtures.history = query([], false); + }); + + it("uses cached service history while a background refresh is in progress", () => { + const cached = [{ id: 701, name: "Cached historical service" }]; + queryFixtures.history = query(cached, true); + + const { result } = renderHook(() => useWorkOrderServiceRegistry()); + + expect(result.current.serviceHistory).toEqual(cached); + expect(result.current.serviceHistoryReadyForTypeChange).toBe(true); + }); + + it("does not treat initial pending or failed queries without data as ready", () => { + queryFixtures.history = { ...query(undefined, true), isPending: true }; + const pending = renderHook(() => useWorkOrderServiceRegistry()); + expect(pending.result.current.serviceHistoryReadyForTypeChange).toBe(false); + pending.unmount(); + + queryFixtures.history = { ...query(undefined, false), isError: true, isPending: false }; + const failed = renderHook(() => useWorkOrderServiceRegistry()); + expect(failed.result.current.serviceHistoryReadyForTypeChange).toBe(false); + }); +}); From 3b58bceb3dc06a480503653f8fbc04e6568e13ea Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 19:22:33 -0300 Subject: [PATCH 11/15] fix(workorders): preserve table and service drafts --- .../list/table/cells/service-notes-dialog.tsx | 105 +++++++++-- .../_components/list/table/wo-table.tsx | 16 +- .../workorders/aveta-service-notes.test.tsx | 166 +++++++++++++++++- ...work-orders-list-page-cancel-toast.test.ts | 22 +++ ...rk-orders-list-page-completion-doc.test.ts | 22 +++ .../wo-table-adv-search-pagination.test.tsx | 22 +++ 6 files changed, 328 insertions(+), 25 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx index a9ed0377..0705ac47 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Wrench } from "lucide-react"; import { Dialog, DialogContent, DialogTitle } from "@mui/material"; import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields"; @@ -32,6 +32,85 @@ type ServiceNotesDialogProps = { onSave: (details: ServiceNotesDetails) => void; }; +type DialogTarget = { + key: string; + pm: string; + serviceId?: string | number | null; +}; + +function getDialogTarget( + woNumber: string | undefined, + serviceId: string | number | null | undefined, + pm: string, +) { + const key = woNumber ? `wo:${woNumber}` : serviceId != null ? `service:${serviceId}` : `pm:${pm}`; + return { key, pm, serviceId }; +} + +function selectedServices(pm: string, extraServices: string[]) { + return [pm, ...extraServices].filter( + (value, index, values) => !!value && values.indexOf(value) === index, + ); +} + +function useServiceNotesDraft({ + open, + target, + pm, + serviceId, + extraServices, + serviceNotes, + avetaRequired, +}: { + open: boolean; + target: DialogTarget; + pm: string; + serviceId: string | number | null | undefined; + extraServices: string[]; + serviceNotes: string; + avetaRequired: boolean; +}) { + const [sel, setSel] = useState([]); + const [note, setNote] = useState(""); + const [aveta, setAveta] = useState(false); + const [search, setSearch] = useState(""); + const lastTarget = useRef(null); + const { key: targetKey, pm: targetPm, serviceId: targetServiceId } = target; + + useEffect(() => { + if (!open) { + lastTarget.current = null; + return; + } + const previous = lastTarget.current; + if (previous == null || previous.key !== targetKey) { + setSel(selectedServices(pm, extraServices)); + setNote(serviceNotes ?? ""); + setAveta(Boolean(avetaRequired)); + setSearch(""); + } else if ( + previous.serviceId != null && + String(previous.serviceId) === String(serviceId) && + previous.pm !== pm + ) { + setSel((current) => (current[0] === previous.pm ? [pm, ...current.slice(1)] : current)); + } + lastTarget.current = { key: targetKey, pm: targetPm, serviceId: targetServiceId }; + }, [ + open, + targetKey, + targetPm, + targetServiceId, + pm, + serviceId, + extraServices, + serviceNotes, + avetaRequired, + ]); + + return { sel, setSel, note, setNote, aveta, setAveta, search, setSearch }; +} + export function ServiceNotesDialog({ open, onOpenChange, @@ -47,20 +126,16 @@ export function ServiceNotesDialog({ viewOnly = false, onSave, }: ServiceNotesDialogProps) { - const [sel, setSel] = useState([]); - const [note, setNote] = useState(""); - const [aveta, setAveta] = useState(false); - const [search, setSearch] = useState(""); - - useEffect(() => { - if (!open) { - return; - } - setSel([pm, ...(extraServices ?? [])].filter((p, i, a) => !!p && a.indexOf(p) === i)); - setNote(serviceNotes ?? ""); - setAveta(Boolean(avetaRequired)); - setSearch(""); - }, [open, pm, extraServices, serviceNotes, avetaRequired]); + const target = getDialogTarget(woNumber, serviceId, pm); + const { sel, setSel, note, setNote, aveta, setAveta, search, setSearch } = useServiceNotesDraft({ + open, + target, + pm, + serviceId, + extraServices, + serviceNotes, + avetaRequired, + }); const toggle = (value: string) => { setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value])); diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx index fd692c6c..9a77af28 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -171,7 +171,7 @@ function WoTableRenderedInlineRow({ ); } -function WoTableEmptyResult({ +function getWoTableEmptyResult({ tableData, onOpenAdvancedFilters, onClearSearch, @@ -303,14 +303,12 @@ export function WoTable({ ); } - const emptyResult = ( - - ); - if (emptyResult != null) return emptyResult; + const emptyResult = getWoTableEmptyResult({ + tableData, + onOpenAdvancedFilters, + onClearSearch, + }); + if (emptyResult) return emptyResult; return ( <> diff --git a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx index ef8ef567..86b66331 100644 --- a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx +++ b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx @@ -1,12 +1,73 @@ import { useState } from "react"; -import { fireEvent, screen } from "@testing-library/react"; +import { fireEvent, screen, waitForElementToBeRemoved } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell"; import { ServiceNotesDialog } from "@/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog"; +import type { ServiceNotesDetails } from "@/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog"; import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell"; import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch"; +import type { Service } from "@/domain/services/types/service"; import { renderWithProviders } from "@/test/test-utils"; +function serviceDefinition(id: number, name: string): Service { + return { + id, + name, + trade: "Electrical", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: true, + supportedWorkOrderTypes: ["PM"], + }; +} + +function ServiceRegistryRefreshHarness({ + withDetails, + onSaved, +}: { + withDetails: boolean; + onSaved: (details: ServiceNotesDetails) => void; +}) { + const [renamed, setRenamed] = useState(false); + const [saved, setSaved] = useState({ + pm: "Original service", + serviceId: 701, + extraServices: [], + serviceNotes: withDetails ? "Existing notes" : "", + avetaRequired: false, + }); + const primaryName = renamed ? "Renamed service" : "Original service"; + + return ( + <> + setRenamed(true), + }} + onSave={vi.fn()} + onSaveDetails={(details) => { + onSaved(details); + setSaved(details); + }} + /> + + ); +} + describe("Aveta Service & Notes", () => { it("defers linked type selection with visible loading and retry feedback", () => { const retry = vi.fn(); @@ -495,4 +556,107 @@ describe("Aveta Service & Notes", () => { expect.objectContaining({ pm: "QA Test Service", serviceId: 702 }), ); }); + + it.each([ + ["picker Add service and notes", false], + ["direct details trigger", true], + ])("retains the draft and linked ID across a rename from the %s", async (_entry, withDetails) => { + const onSaved = vi.fn(); + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /Original service/i })); + if (!withDetails) { + fireEvent.click(screen.getByRole("button", { name: /Add service and notes/i })); + } + const notes = screen.getByPlaceholderText(/describe the specific issue/i); + fireEvent.change(notes, { target: { value: "Typed while registry refreshed" } }); + fireEvent.click(screen.getByRole("checkbox", { name: /Requires Aveta documentation/i })); + fireEvent.click(screen.getByRole("button", { name: "Extra service" })); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + expect(notes).toHaveValue("Typed while registry refreshed"); + expect(screen.getByRole("checkbox", { name: /Requires Aveta documentation/i })).toBeChecked(); + expect(screen.getByRole("button", { name: /Renamed servicePrimary/i })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(onSaved).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + pm: "Renamed service", + serviceId: 701, + extraServices: ["Extra service"], + serviceNotes: "Typed while registry refreshed", + avetaRequired: true, + }), + ); + + await waitForElementToBeRemoved(() => screen.queryByRole("dialog")); + fireEvent.click(screen.getByRole("button", { name: /Renamed service/i })); + expect(screen.getByPlaceholderText(/describe the specific issue/i)).toHaveValue( + "Typed while registry refreshed", + ); + expect(screen.getByRole("checkbox", { name: /Requires Aveta documentation/i })).toBeChecked(); + expect(screen.getByText("Primary").closest("button")).toHaveTextContent("Renamed service"); + expect(screen.getByRole("button", { name: "Extra service" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + expect(onSaved).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + serviceId: 701, + extraServices: ["Extra service"], + serviceNotes: "Typed while registry refreshed", + avetaRequired: true, + }), + ); + }); + + it("preserves a deliberate primary selection during linked-service rename", () => { + const onSave = vi.fn(); + const common = { + open: true, + onOpenChange: vi.fn(), + extraServices: [], + serviceNotes: "Saved notes", + avetaRequired: false, + pmTypes: [], + services: [ + serviceDefinition(701, "Original service"), + serviceDefinition(702, "Other service"), + ], + serviceId: 701, + serviceQuery: { + isLoading: false, + isError: false, + isFetching: false, + hasData: true, + retry: vi.fn(), + }, + onSave, + }; + const view = renderWithProviders(, { + withAuth: false, + }); + + fireEvent.click(screen.getByRole("button", { name: /Original servicePrimary/i })); + fireEvent.click(screen.getByRole("button", { name: "Other service" })); + view.rerender( + , + ); + expect(screen.getByRole("button", { name: /Other servicePrimary/i })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ pm: "Other service", serviceId: 702 }), + ); + }); }); diff --git a/src/test/app/(protected)/workorders/use-work-orders-list-page-cancel-toast.test.ts b/src/test/app/(protected)/workorders/use-work-orders-list-page-cancel-toast.test.ts index 73007eea..f9b11d28 100644 --- a/src/test/app/(protected)/workorders/use-work-orders-list-page-cancel-toast.test.ts +++ b/src/test/app/(protected)/workorders/use-work-orders-list-page-cancel-toast.test.ts @@ -52,6 +52,28 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => }), })); +vi.mock("@/app/(protected)/workorders/_hooks/use-work-order-service-registry", () => ({ + useWorkOrderServiceRegistry: () => ({ + services: [], + serviceHistory: [], + servicePickerState: { + isLoading: false, + isError: false, + isFetching: false, + hasData: true, + retry: vi.fn(), + }, + serviceHistoryState: { + isLoading: false, + isError: false, + isFetching: false, + hasData: true, + retry: vi.fn(), + }, + serviceHistoryReadyForTypeChange: true, + }), +})); + vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({ useWorkOrderTableMutations: () => ({ patchField, diff --git a/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts b/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts index 60c004c7..7e2de77c 100644 --- a/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts +++ b/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts @@ -51,6 +51,28 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => }), })); +vi.mock("@/app/(protected)/workorders/_hooks/use-work-order-service-registry", () => ({ + useWorkOrderServiceRegistry: () => ({ + services: [], + serviceHistory: [], + servicePickerState: { + isLoading: false, + isError: false, + isFetching: false, + hasData: true, + retry: vi.fn(), + }, + serviceHistoryState: { + isLoading: false, + isError: false, + isFetching: false, + hasData: true, + retry: vi.fn(), + }, + serviceHistoryReadyForTypeChange: true, + }), +})); + vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({ useWorkOrderTableMutations: () => ({ patchField, diff --git a/src/test/app/(protected)/workorders/wo-table-adv-search-pagination.test.tsx b/src/test/app/(protected)/workorders/wo-table-adv-search-pagination.test.tsx index 496fa5d5..b9fd3c11 100644 --- a/src/test/app/(protected)/workorders/wo-table-adv-search-pagination.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-adv-search-pagination.test.tsx @@ -128,6 +128,28 @@ function tableData( } describe("WoTable advanced-search pagination", () => { + it("renders the nonempty table body and pager", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("WO-0")).toBeInTheDocument(); + expect(screen.getByText("RESULTS · 1778 items")).toBeInTheDocument(); + expect(screen.getByText("Page 1 of 36")).toBeInTheDocument(); + }); + it("pages the flat Unassigned search without a pin or a 1778-row dump", () => { const goToNextDayPage = vi.fn(); const data = tableData({ goToNextDayPage }); From 7090f6fb040ad0b2f75e6cae567a79198b2a6f2a Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 19:29:17 -0300 Subject: [PATCH 12/15] style(workorders): format registry status component --- .../list/table/cells/service-registry-status.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/(protected)/workorders/_components/list/table/cells/service-registry-status.tsx b/src/app/(protected)/workorders/_components/list/table/cells/service-registry-status.tsx index 0d5506ad..973442ca 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/service-registry-status.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/service-registry-status.tsx @@ -15,7 +15,11 @@ export function ServiceRegistryStatus({ emptyMessage = "No services are available.", }: ServiceRegistryStatusProps) { if (query?.isLoading && !query.hasData) { - return
Loading services…
; + return ( +
+ Loading services… +
+ ); } if (query?.isError) { return ( @@ -29,7 +33,11 @@ export function ServiceRegistryStatus({ ); } if (query?.isFetching && query.hasData) { - return
Updating services…
; + return ( +
+ Updating services… +
+ ); } if (empty && query?.hasData) { return
{emptyMessage}
; From 5ee2f6f374a6f8fb8116346edc971c24520864f2 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 19:43:06 -0300 Subject: [PATCH 13/15] test(workorders): seed service registry in browser fixtures --- e2e/work-orders/advanced-filters.spec.ts | 17 ++++++++++ e2e/work-orders/work-orders.visual.spec.ts | 39 ++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/e2e/work-orders/advanced-filters.spec.ts b/e2e/work-orders/advanced-filters.spec.ts index ef4b55f1..cbda41ad 100644 --- a/e2e/work-orders/advanced-filters.spec.ts +++ b/e2e/work-orders/advanced-filters.spec.ts @@ -12,6 +12,19 @@ const TEST_USER = { id: "1", }; +const SERVICE_CATALOG = [ + { + id: 1, + name: "HVAC", + trade: "HVAC", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: true, + supportedWorkOrderTypes: ["PM"], + }, +]; + async function fulfillJson(route: Route, body: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); } @@ -39,6 +52,9 @@ async function mockWorkOrderApis(page: Page, searchUrls: string[]) { if (pathname.includes("/workorders/board") || pathname.includes("DropdownOptions")) { return route.fallback(); } + if (pathname === "/api/services") { + return fulfillJson(route, { data: SERVICE_CATALOG }); + } return fulfillJson(route, { data: [] }); }); await page.route("**/api/**/DropdownOptions/ByCategory/**", (route) => { @@ -136,6 +152,7 @@ test.describe("Work Orders advanced filters", () => { await sheet.getByRole("button", { name: /Indicators/i }).click(); await sheet.getByRole("button", { name: /^Add-On$/i }).click(); await sheet.getByRole("button", { name: /^Service/i }).click(); + await expect(sheet.getByText("HVAC", { exact: true })).toBeVisible(); await sheet.getByText("HVAC").click(); await sheet.getByRole("button", { name: /Completion doc/i }).click(); await sheet.getByRole("button", { name: /^Pending$/i }).click(); diff --git a/e2e/work-orders/work-orders.visual.spec.ts b/e2e/work-orders/work-orders.visual.spec.ts index 17f8d49b..46517d49 100644 --- a/e2e/work-orders/work-orders.visual.spec.ts +++ b/e2e/work-orders/work-orders.visual.spec.ts @@ -37,6 +37,29 @@ const VENDORS = [ }, ]; +const SERVICES = [ + { + id: 1, + name: "HVAC", + trade: "HVAC", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: true, + supportedWorkOrderTypes: ["PM"], + }, + { + id: 2, + name: "Plumbing", + trade: "Plumbing", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: true, + supportedWorkOrderTypes: ["PM"], + }, +]; + const scheduledRow = { id: 1, woNumber: "WO-501", @@ -150,6 +173,11 @@ async function mockWorkOrderApi(page: Page, mode: "default" | "empty" | "error" return; } + if (pathname === "/api/services") { + await fulfillJson(route, { data: SERVICES }); + return; + } + if (pathname.includes("/DropdownOptions/ByCategory/Problem")) { await fulfillJson(route, { data: [ @@ -192,7 +220,18 @@ async function mockWorkOrderApi(page: Page, mode: "default" | "empty" | "error" async function openWorkOrderPage(page: Page, mode: "default" | "empty" | "error" = "default") { await mockWorkOrderApi(page, mode); + const registryResponses = Promise.all([ + page.waitForResponse((response) => { + const url = new URL(response.url()); + return url.pathname === "/api/services" && url.searchParams.get("isActive") === "true"; + }), + page.waitForResponse((response) => { + const url = new URL(response.url()); + return url.pathname === "/api/services" && !url.searchParams.has("isActive"); + }), + ]); await page.goto("/workorders"); + await registryResponses; await page.evaluate(() => document.fonts.ready); await page.addStyleTag({ content: ` From bb8d56d3345b0993b1b0b8dbf08b10e580c8da7a Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 30 Sep 2026 20:33:39 -0300 Subject: [PATCH 14/15] fix(workorders): preserve service identity and recover missing links --- .../tabs/slide-over-info-tab-edit-view.tsx | 8 +- .../list/table/cells/service-notes-dialog.tsx | 292 +++++++++++++++--- .../list/table/cells/type-cell.tsx | 8 + .../list/table/save-existing-row-type.ts | 5 +- .../list/table/wo-table-row-cells.tsx | 8 +- .../wizard/wizard-service-notes-fields.tsx | 4 +- .../workorders/aveta-service-notes.test.tsx | 70 +++++ .../workorders/save-existing-row-type.test.ts | 19 ++ ...led-missing-service-type-recovery.test.tsx | 159 ++++++++++ 9 files changed, 511 insertions(+), 62 deletions(-) create mode 100644 src/test/app/(protected)/workorders/settled-missing-service-type-recovery.test.tsx diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index 1a99666c..631d75f4 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -106,20 +106,22 @@ function SlideOverTypeField({ const linkedService = typeServices.find( (service) => String(service.id) === String(draft.serviceId), ); - const registryUnavailable = draft.serviceId != null && (!serviceHistoryReady || !linkedService); + const registryUnavailable = draft.serviceId != null && !serviceHistoryReady; + const linkedServiceMissing = + draft.serviceId != null && serviceHistoryReady && linkedService == null; return ( { if (registryUnavailable) return; onDraftChange( draft.serviceId != null && - linkedService != null && - !serviceSupportsWorkOrderType(linkedService, type) + (linkedService == null || !serviceSupportsWorkOrderType(linkedService, type)) ? { type, severity, serviceId: null, pm: "" } : { type, severity }, ); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx index 0705ac47..edd1cf79 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type SetStateAction } from "react"; import { Wrench } from "lucide-react"; import { Dialog, DialogContent, DialogTitle } from "@mui/material"; import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields"; @@ -16,6 +16,75 @@ export type ServiceNotesDetails = { serviceId?: Service["id"] | null; }; +type ServiceNotesSelectionFieldsProps = { + pm: string; + extraServices: string[]; + pmTypes: string[]; + services?: Service[]; + serviceQuery?: ServiceRegistryQueryState; + serviceOptionsUnavailable: boolean; + serviceOptionsEmpty: boolean; + sel: string[]; + onToggle: (value: string, optionId?: string | number) => void; + note: string; + setNote: (value: string) => void; + search: string; + setSearch: (value: string) => void; + aveta: boolean; + setAveta: (value: boolean) => void; + viewOnly: boolean; +}; + +function ServiceNotesSelectionFields({ + pm, + extraServices, + pmTypes, + services, + serviceQuery, + serviceOptionsUnavailable, + serviceOptionsEmpty, + sel, + onToggle, + note, + setNote, + search, + setSearch, + aveta, + setAveta, + viewOnly, +}: ServiceNotesSelectionFieldsProps) { + const serviceOptions = services + ? [ + ...services.map((service) => ({ id: service.id, value: service.name })), + ...[pm, ...extraServices] + .filter((value) => value && !services.some((service) => service.name === value)) + .map((value) => ({ id: `historical:${value}`, value })), + ] + : pmTypes.map((value) => ({ id: value, value })); + + return ( +
+ + +
+ ); +} + type ServiceNotesDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; @@ -53,6 +122,96 @@ function selectedServices(pm: string, extraServices: string[]) { ); } +function createServiceSelectionToggle({ + services, + selected, + primaryServiceId, + serviceIdsByName, + setSelected, + setPrimaryServiceId, + setServiceIdsByName, +}: { + services?: Service[]; + selected: string[]; + primaryServiceId?: string | number | null; + serviceIdsByName: Record; + setSelected: (value: string[]) => void; + setPrimaryServiceId: (value: string | number | null | undefined) => void; + setServiceIdsByName: (value: SetStateAction>) => void; +}) { + return (value: string, optionId?: string | number) => { + const option = services?.find((service) => String(service.id) === String(optionId)); + const optionServiceId = option?.id; + const replacesSameLabel = + selected[0] === value && + optionServiceId != null && + String(optionServiceId) !== String(primaryServiceId); + if (replacesSameLabel) { + setPrimaryServiceId(optionServiceId); + setServiceIdsByName((current) => ({ ...current, [value]: optionServiceId })); + return; + } + if (selected.includes(value)) { + const next = selected.filter((item) => item !== value); + setSelected(next); + if (selected[0] === value) { + const nextName = next[0]; + const nextService = services?.find((service) => service.name === nextName); + setPrimaryServiceId( + nextName == null ? undefined : (serviceIdsByName[nextName] ?? nextService?.id), + ); + } + return; + } + setSelected([...selected, value]); + if (optionServiceId != null) { + setServiceIdsByName((current) => ({ ...current, [value]: optionServiceId })); + if (selected.length === 0) setPrimaryServiceId(optionServiceId); + } + }; +} + +function resolveSelectedServiceId({ + unavailable, + selected, + currentPm, + savedServiceId, + selectedServiceId, +}: { + unavailable: boolean; + selected: string[]; + currentPm: string; + savedServiceId?: string | number | null; + selectedServiceId?: string | number | null; +}) { + if (unavailable) return savedServiceId ?? undefined; + if ( + selected[0] === currentPm && + String(savedServiceId ?? "") === String(selectedServiceId ?? "") + ) { + return savedServiceId ?? undefined; + } + if (selected[0] != null && selectedServiceId != null) return selectedServiceId; + if (selected[0] === currentPm && savedServiceId != null) return savedServiceId; + return savedServiceId == null ? undefined : null; +} + +function getServiceOptionState( + services: Service[] | undefined, + query: ServiceRegistryQueryState | undefined, +) { + const managed = services !== undefined; + return { + unavailable: managed && query != null && !query.hasData, + empty: + managed && + query?.hasData === true && + !query.isLoading && + !query.isError && + services.length === 0, + }; +} + function useServiceNotesDraft({ open, target, @@ -61,6 +220,7 @@ function useServiceNotesDraft({ extraServices, serviceNotes, avetaRequired, + services, }: { open: boolean; target: DialogTarget; @@ -69,10 +229,13 @@ function useServiceNotesDraft({ extraServices: string[]; serviceNotes: string; avetaRequired: boolean; + services?: Service[]; }) { const [sel, setSel] = useState([]); const [note, setNote] = useState(""); const [aveta, setAveta] = useState(false); + const [primaryServiceId, setPrimaryServiceId] = useState(); + const [serviceIdsByName, setServiceIdsByName] = useState>({}); const [search, setSearch] = useState(""); const lastTarget = useRef(null); const { key: targetKey, pm: targetPm, serviceId: targetServiceId } = target; @@ -85,6 +248,16 @@ function useServiceNotesDraft({ const previous = lastTarget.current; if (previous == null || previous.key !== targetKey) { setSel(selectedServices(pm, extraServices)); + setPrimaryServiceId(serviceId); + setServiceIdsByName({ + ...(serviceId != null ? { [pm]: serviceId } : {}), + ...Object.fromEntries( + extraServices.flatMap((name) => { + const service = services?.find((item) => item.name === name); + return service == null ? [] : [[name, service.id]]; + }), + ), + }); setNote(serviceNotes ?? ""); setAveta(Boolean(avetaRequired)); setSearch(""); @@ -94,6 +267,7 @@ function useServiceNotesDraft({ previous.pm !== pm ) { setSel((current) => (current[0] === previous.pm ? [pm, ...current.slice(1)] : current)); + setServiceIdsByName((current) => ({ ...current, [pm]: serviceId! })); } lastTarget.current = { key: targetKey, pm: targetPm, serviceId: targetServiceId }; }, [ @@ -104,11 +278,25 @@ function useServiceNotesDraft({ pm, serviceId, extraServices, + services, serviceNotes, avetaRequired, ]); - return { sel, setSel, note, setNote, aveta, setAveta, search, setSearch }; + return { + sel, + setSel, + note, + setNote, + aveta, + setAveta, + search, + setSearch, + primaryServiceId, + setPrimaryServiceId, + serviceIdsByName, + setServiceIdsByName, + }; } export function ServiceNotesDialog({ @@ -127,7 +315,20 @@ export function ServiceNotesDialog({ onSave, }: ServiceNotesDialogProps) { const target = getDialogTarget(woNumber, serviceId, pm); - const { sel, setSel, note, setNote, aveta, setAveta, search, setSearch } = useServiceNotesDraft({ + const { + sel, + setSel, + note, + setNote, + aveta, + setAveta, + search, + setSearch, + primaryServiceId, + setPrimaryServiceId, + serviceIdsByName, + setServiceIdsByName, + } = useServiceNotesDraft({ open, target, pm, @@ -135,37 +336,27 @@ export function ServiceNotesDialog({ extraServices, serviceNotes, avetaRequired, + services, }); - const toggle = (value: string) => { - setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value])); - }; - - const serviceOptions = services - ? [ - ...services.map((service) => ({ id: service.id, value: service.name })), - ...[pm, ...extraServices] - .filter((value) => value && !services.some((service) => service.name === value)) - .map((value) => ({ id: `historical:${value}`, value })), - ] - : pmTypes.map((value) => ({ id: value, value })); - const saveServiceId = () => { - if (serviceOptionsUnavailable) return serviceId ?? undefined; - const selectedId = services?.find((service) => service.name === sel[0])?.id; - if (selectedId != null) return selectedId; - if (sel[0] === pm) return serviceId ?? undefined; - return serviceId == null ? undefined : null; - }; - const registryManaged = services !== undefined; - const serviceOptionsUnavailable = - registryManaged && serviceQuery != null && !serviceQuery.hasData; - const serviceOptionsEmpty = - registryManaged && - serviceQuery?.hasData === true && - !serviceQuery.isLoading && - !serviceQuery.isError && - services.length === 0; - + const toggle = createServiceSelectionToggle({ + services, + selected: sel, + primaryServiceId, + serviceIdsByName, + setSelected: setSel, + setPrimaryServiceId, + setServiceIdsByName, + }); + const optionState = getServiceOptionState(services, serviceQuery); + const saveServiceId = () => + resolveSelectedServiceId({ + unavailable: optionState.unavailable, + selected: sel, + currentPm: pm, + savedServiceId: serviceId, + selectedServiceId: primaryServiceId, + }); return ( e.stopPropagation()} style={{ paddingTop: 8 }} > -
- - -
+
{viewOnly ? (
saveExistingRowType({ diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields.tsx index 9ba59a4f..e20543a6 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields.tsx @@ -10,7 +10,7 @@ import { type WizardServiceNotesFieldsProps = { services: { id: string | number; value: string }[]; sel: string[]; - onToggle: (service: string) => void; + onToggle: (service: string, serviceId?: string | number) => void; note: string; setNote: (v: string) => void; search: string; @@ -83,7 +83,7 @@ export function WizardServiceNotesFields({