mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 05:33:14 +00:00
fix(workorders): order service reassignment around type updates
This commit is contained in:
parent
e35bd2983e
commit
690820f0b9
17 changed files with 161 additions and 14 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }))}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue