fix(workorders): order service reassignment around type updates

This commit is contained in:
Alexandre Brandizzi 2026-09-30 17:47:27 -03:00
parent e35bd2983e
commit 690820f0b9
17 changed files with 161 additions and 14 deletions

View file

@ -34,6 +34,7 @@ type SlideOverInfoTabEditViewProps = {
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
serviceHistory?: Service[];
allOrders: WorkOrderTableRow[];
onDraftChange: (patch: Partial<WorkOrderTableRow>) => 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(

View file

@ -19,6 +19,7 @@ type SlideOverInfoTabProps = {
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
serviceHistory?: Service[];
allOrders: WorkOrderTableRow[];
onDraftChange: (patch: Partial<WorkOrderTableRow>) => 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}

View file

@ -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}

View file

@ -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}

View file

@ -23,13 +23,14 @@ export function saveExistingRowType(
row: Pick<WorkOrderTableRow, "id" | "woNumber" | "severity" | "serviceId">,
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)) {

View file

@ -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,
)
}
/>
</td>
<td

View file

@ -61,6 +61,7 @@ type WoTableRowProps = {
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
serviceHistory?: Service[];
search?: string;
forceScheduleHighlight?: boolean;
forceDueDateHighlight?: boolean;
@ -104,6 +105,7 @@ export function WoTableRow({
vendors,
pmTypes,
services = [],
serviceHistory = [],
search,
forceScheduleHighlight,
forceDueDateHighlight,
@ -201,6 +203,7 @@ export function WoTableRow({
users={users}
sites={sites}
services={services}
serviceHistory={serviceHistory}
sitePlace={sitePlace}
search={search}
forceScheduleHighlight={forceScheduleHighlight}

View file

@ -30,6 +30,7 @@ type WoTableProps = {
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
serviceHistory?: Service[];
weekMonday: string;
onNavigateToDay: (dateIso: string) => 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}

View file

@ -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)}

View file

@ -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}

View file

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

View file

@ -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<WorkOrderTableRow, "serviceId" | "type">,
): 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 {

View file

@ -83,12 +83,12 @@ async function applyBoardFieldOps(
patch: WorkOrderTablePatch,
getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined,
): Promise<WorkOrderTableRow> {
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.");
}

View file

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

View file

@ -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 }))}
/>

View file

@ -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({

View file

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