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));