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: ` 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..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 @@ -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 linkedService = services.find((service) => String(service.id) === String(source.serviceId)); + const name = linkedService?.isActive ? linkedService.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..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 @@ -16,6 +16,10 @@ 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 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"; import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; import { DocBadge } from "./slide-over-doc-badge"; @@ -30,12 +34,102 @@ type SlideOverInfoTabEditViewProps = { sites: LocationOption[]; vendors: VendorDropdownItem[]; pmTypes: string[]; + services?: Service[]; + serviceHistory?: Service[]; + serviceQuery?: ServiceRegistryQueryState; + serviceHistoryQuery?: ServiceRegistryQueryState; + serviceHistoryReady?: boolean; allOrders: WorkOrderTableRow[]; onDraftChange: (patch: Partial) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; 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, + }) + } + /> + + ); +} + +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; + const linkedServiceMissing = + draft.serviceId != null && serviceHistoryReady && linkedService == null; + + return ( + { + if (registryUnavailable) return; + onDraftChange( + draft.serviceId != null && + (linkedService == null || !serviceSupportsWorkOrderType(linkedService, type)) + ? { type, severity, serviceId: null, pm: "" } + : { type, severity }, + ); + }} + /> + ); +} + function SlideOverScheduleOnField({ draft, onDraftChange, @@ -114,6 +208,11 @@ export function SlideOverInfoTabEditView({ sites, vendors, pmTypes, + services = [], + serviceHistory, + serviceQuery, + serviceHistoryQuery, + serviceHistoryReady = true, allOrders, onDraftChange, onDuplicateFound, @@ -162,10 +261,13 @@ export function SlideOverInfoTabEditView({ /> - onDraftChange({ type, severity })} + @@ -192,25 +294,13 @@ export function SlideOverInfoTabEditView({ onSave={(apptTime) => onDraftChange({ apptTime })} /> - - onDraftChange({ pm })} - onSaveDetails={(details) => - onDraftChange({ - pm: details.pm, - serviceNotes: details.serviceNotes, - extraServices: details.extraServices, - avetaRequired: details.avetaRequired, - }) - } - /> - + ) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; @@ -33,6 +40,11 @@ export function SlideOverInfoTab({ sites, vendors, pmTypes, + services = [], + serviceHistory, + serviceQuery, + serviceHistoryQuery, + serviceHistoryReady = true, allOrders, onDraftChange, onDuplicateFound, @@ -50,6 +62,11 @@ export function SlideOverInfoTab({ sites={sites} vendors={vendors} pmTypes={pmTypes} + services={services} + serviceHistory={serviceHistory} + serviceQuery={serviceQuery} + serviceHistoryQuery={serviceHistoryQuery} + serviceHistoryReady={serviceHistoryReady} allOrders={allOrders} onDraftChange={onDraftChange} onDuplicateFound={onDuplicateFound} @@ -67,6 +84,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..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 @@ -12,6 +12,8 @@ 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 { 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"; @@ -25,6 +27,11 @@ type WorkOrderSlideOverTabsProps = { sites: LocationOption[]; vendors: VendorDropdownItem[]; pmTypes: string[]; + services?: Service[]; + serviceHistory?: Service[]; + serviceQuery?: ServiceRegistryQueryState; + serviceHistoryQuery?: ServiceRegistryQueryState; + serviceHistoryReady?: boolean; allOrders: WorkOrderTableRow[]; commentHighlight?: CommentHighlight | null; onDuplicateFound?: (existing: WorkOrderTableRow) => void; @@ -32,44 +39,24 @@ type WorkOrderSlideOverTabsProps = { onRequestComplete?: () => void; }; -export function WorkOrderSlideOverTabs({ +type SecondarySlideOverTabProps = Pick< + WorkOrderSlideOverTabsProps, + "tab" | "state" | "users" | "commentHighlight" | "onOpenCompletionDoc" +>; + +function SecondarySlideOverTabs({ tab, state, - saving, users, - sites, - vendors, - pmTypes, - 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} @@ -161,14 +146,69 @@ 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, + serviceHistoryQuery, + 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/detail/work-order-slide-over.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx index 762d5aa4..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 @@ -12,6 +12,8 @@ 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 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"; @@ -28,6 +30,11 @@ type WorkOrderSlideOverProps = { sites: LocationOption[]; vendors: VendorDropdownItem[]; pmTypes: string[]; + services?: Service[]; + serviceHistory?: Service[]; + serviceQuery?: ServiceRegistryQueryState; + serviceHistoryQuery?: ServiceRegistryQueryState; + serviceHistoryReady?: boolean; allOrders: WorkOrderTableRow[]; onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void; onOpenCompletionDoc: (row: WorkOrderTableRow) => void; @@ -50,6 +57,11 @@ export function WorkOrderSlideOver({ sites, vendors, pmTypes, + services = [], + serviceHistory, + serviceQuery, + serviceHistoryQuery, + serviceHistoryReady = true, allOrders, onSave, onOpenCompletionDoc, @@ -101,6 +113,11 @@ export function WorkOrderSlideOver({ sites={sites} vendors={vendors} pmTypes={pmTypes} + services={services} + serviceHistory={serviceHistory} + serviceQuery={serviceQuery} + serviceHistoryQuery={serviceHistoryQuery} + 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 148aeee9..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 @@ -28,8 +28,211 @@ 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"; +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>; @@ -37,7 +240,9 @@ type AdvancedSearchFilterSectionsProps = { setMany: (key: AdvancedFilterSetKey, values: string[], select: boolean) => void; sites: { id: string | number; name: string }[]; users: WorkOrderUser[]; - pmTypes: string[]; + services?: Service[]; + serviceQuery?: ServiceRegistryQueryState; + pmTypes?: string[]; vendorTechs: string[]; }; @@ -48,18 +253,11 @@ export function AdvancedSearchFilterSections({ setMany, sites, users, - pmTypes, + services, + serviceQuery, + pmTypes = [], vendorTechs, }: AdvancedSearchFilterSectionsProps) { - const selectAllRow = (key: AdvancedFilterSetKey, all: string[], selectedSize: number) => ( -
- 0} - onClick={() => setMany(key, all, selectedSize === 0)} - /> -
- ); - return (
@@ -109,48 +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 }))} - /> -
-
+ - - ({ value: pmType, label: pmType }))} - selected={draft.pmTypes} - onToggle={(value) => toggleIn("pmTypes", value)} - onBulkToggle={(values, select) => setMany("pmTypes", values, select)} - placeholder="Search service…" - renderLeft={(pmType) => ( - - )} - /> - + - - {selectAllRow("docs", [...COMPLETION_DOC_OPTIONS], draft.docs.size)} -
- {COMPLETION_DOC_OPTIONS.map((doc) => ( - toggleIn("docs", doc)} - /> - ))} -
-
+ 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 b18415f5..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 @@ -6,6 +6,8 @@ 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"; +import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state"; type WorkOrdersAdvancedSearchSheetProps = { open: boolean; @@ -15,7 +17,8 @@ type WorkOrdersAdvancedSearchSheetProps = { onClearAll: () => void; sites: { id: string | number; name: string }[]; users: WorkOrderUser[]; - pmTypes: string[]; + services?: Service[]; + serviceQuery?: ServiceRegistryQueryState; vendorTechs: string[]; }; @@ -27,7 +30,8 @@ export function WorkOrdersAdvancedSearchSheet({ onClearAll, sites, users, - pmTypes, + services, + serviceQuery, vendorTechs, }: WorkOrdersAdvancedSearchSheetProps) { const { draft, setDraft, activeCount, toggleIn, setMany, handleClearAll, handleApply } = @@ -59,7 +63,8 @@ export function WorkOrdersAdvancedSearchSheet({ setMany={setMany} sites={sites} users={users} - pmTypes={pmTypes} + services={services} + serviceQuery={serviceQuery} 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..bebd1255 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 @@ -4,22 +4,41 @@ import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper"; import { resolveCreationStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions"; +function resolveInlineDraftLocation(draft: WorkOrderTableDraft, sites: LocationOption[]) { + const location = sites.find((site) => 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, @@ -28,7 +47,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 ?? "", @@ -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 be03e7fe..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 @@ -13,12 +13,16 @@ import { hasServiceNotesDetails, 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"; 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,155 +30,284 @@ type PMTypeCellProps = { woNumber?: string; onSaveDetails?: (details: ServiceNotesDetails) => void; viewOnly?: boolean; + services?: Service[]; + serviceId?: string | number | null; + workOrderType?: WOType | ""; + serviceQuery?: ServiceRegistryQueryState; }; -export function PMTypeCell({ - value, - pmTypes, - q, - onSave, - serviceNotes = "", - extraServices = [], - avetaRequired = false, - avetaAttached = false, - woNumber, - onSaveDetails, - viewOnly = false, -}: 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 filtered = search - ? pmTypes.filter((p) => p.toLowerCase().includes(search.toLowerCase())) - : pmTypes; +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 ( <> - { - onSave(pm); - 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/cells/pm-type-picker-popover.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover.tsx index 8f7d75a6..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 @@ -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/cells/service-notes-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx index 124357ce..c5e9d20c 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,17 +1,90 @@ -import { useEffect, 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"; 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; extraServices: string[]; serviceNotes: string; avetaRequired: boolean; + 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; @@ -21,10 +94,261 @@ type ServiceNotesDialogProps = { avetaRequired?: boolean; woNumber?: string; pmTypes: string[]; + services?: Service[]; + serviceId?: string | number | null; + serviceQuery?: ServiceRegistryQueryState; viewOnly?: boolean; 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 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 resolveRenamedPrimaryName({ + selectedName, + selectedServiceId, + serviceIdsByName, + services, + previous, + pm, + serviceId, +}: { + selectedName: string | undefined; + selectedServiceId?: string | number | null; + serviceIdsByName: Record; + services?: Service[]; + previous: DialogTarget; + pm: string; + serviceId?: string | number | null; +}) { + const chosenService = services?.find( + (service) => String(service.id) === String(selectedServiceId), + ); + const selectionTracksChosenId = + selectedName != null && + selectedServiceId != null && + String(serviceIdsByName[selectedName]) === String(selectedServiceId); + if (selectionTracksChosenId && chosenService != null) return chosenService.name; + + const savedServiceUnchanged = + previous.serviceId != null && String(previous.serviceId) === String(serviceId); + const selectedSavedId = String(selectedServiceId ?? "") === String(serviceId ?? ""); + const selectedSavedLabel = selectedName === previous.pm; + return savedServiceUnchanged && selectedSavedId && selectedSavedLabel && previous.pm !== pm + ? pm + : undefined; +} + +function useServiceNotesDraft({ + open, + target, + pm, + serviceId, + extraServices, + serviceNotes, + avetaRequired, + services, +}: { + open: boolean; + target: DialogTarget; + pm: string; + serviceId: string | number | null | undefined; + 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; + + useEffect(() => { + if (!open) { + lastTarget.current = null; + return; + } + 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(""); + } else { + const renamedPrimary = resolveRenamedPrimaryName({ + selectedName: sel[0], + selectedServiceId: primaryServiceId, + serviceIdsByName, + services, + previous, + pm, + serviceId, + }); + if (renamedPrimary != null && renamedPrimary !== sel[0]) { + setSel((current) => + current[0] === sel[0] ? [renamedPrimary, ...current.slice(1)] : current, + ); + setServiceIdsByName((current) => ({ + ...current, + [renamedPrimary]: primaryServiceId ?? serviceId!, + })); + } + } + lastTarget.current = { key: targetKey, pm: targetPm, serviceId: targetServiceId }; + }, [ + open, + targetKey, + targetPm, + targetServiceId, + pm, + serviceId, + sel, + primaryServiceId, + serviceIdsByName, + extraServices, + services, + serviceNotes, + avetaRequired, + ]); + + return { + sel, + setSel, + note, + setNote, + aveta, + setAveta, + search, + setSearch, + primaryServiceId, + setPrimaryServiceId, + serviceIdsByName, + setServiceIdsByName, + }; +} + export function ServiceNotesDialog({ open, onOpenChange, @@ -34,30 +358,55 @@ export function ServiceNotesDialog({ avetaRequired = false, woNumber, pmTypes, + services, + serviceId, + serviceQuery, 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 toggle = (value: string) => { - setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value])); - }; - - const services = pmTypes.map((value) => ({ id: value, value })); + const target = getDialogTarget(woNumber, serviceId, pm); + const { + sel, + setSel, + note, + setNote, + aveta, + setAveta, + search, + setSearch, + primaryServiceId, + setPrimaryServiceId, + serviceIdsByName, + setServiceIdsByName, + } = useServiceNotesDraft({ + open, + target, + pm, + serviceId, + extraServices, + serviceNotes, + avetaRequired, + services, + }); + 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 ? ( +
+ ); + } + 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..07bde236 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,17 @@ 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; + registryMissing?: boolean; + serviceHistoryQuery?: ServiceRegistryQueryState; onSave: (type: WOType, severity: WorkOrderSeverity | null) => void; }; @@ -29,7 +34,16 @@ 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, + registryMissing = false, + serviceHistoryQuery, + onSave, +}: TypeCellProps) { const [anchorEl, setAnchorEl] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [modalSeed, setModalSeed] = useState(value); @@ -65,6 +79,7 @@ export function TypeCell({ value, severity = null, q, viewOnly = false, onSave } <> + + + ), })); vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({ AssignedToCell: () => null, @@ -59,9 +74,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, })); @@ -121,6 +133,219 @@ 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("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(); + const inactivePmOnlyService = { + id: 701, + name: "PM only", + 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: "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" }, + ]); + }); + + 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([]); + }); + 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/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); + }); +}); 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 }); 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 3927bfaf..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 @@ -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; @@ -102,9 +118,29 @@ 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: () => serviceQueries.active, + useAllServices: () => serviceQueries.all, + }; +}); + vi.mock("@/domain/work-orders/mocks/is-wo-table-mock-enabled", () => ({ isWoTableMockEnabled: () => false, })); @@ -121,11 +157,157 @@ 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(); }); + 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("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).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(); + 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", @@ -149,7 +331,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 +400,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 +412,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..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 @@ -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" }, ]); @@ -85,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)); 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", + }); + }); +}); 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" }); 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({