fix(work-orders): use overdue=true and harden board patch overlays

This commit is contained in:
Arthur Bassi 2026-07-22 10:08:44 -03:00 • committed by Arthur Bassi
parent 3ea136ce3c
commit 8ae54a6173
6 changed files with 118 additions and 30 deletions

View file

@ -196,6 +196,27 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
[displayRows, selectedId],
);
const clearRowPatch = useCallback((id: string | number) => {
const key = String(id);
setPatches((prev) => {
if (!(key in prev)) {
return prev;
}
const next = { ...prev };
delete next[key];
return next;
});
}, []);
const wasFetchingRef = useRef(false);
useEffect(() => {
if (wasFetchingRef.current && !isFetching) {
// Prune optimistic overlays once the invalidated board refetch lands.
setPatches({});
}
wasFetchingRef.current = isFetching;
}, [isFetching]);
const patchRow = useCallback(
(id: string | number, patch: Partial<WorkOrderTableRow>) => {
const key = String(id);
@ -356,6 +377,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
setDragViz,
dragState,
patchRow,
clearRowPatch,
reorderRows,
endDrag,
startDayDraft,

View file

@ -31,10 +31,11 @@ export default function WorkOrdersListPage() {
const [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
const filters = useWorkOrdersListFilters();
const tableData = useWorkOrdersTableData(filters);
const { patchField } = useWorkOrderTableMutations(
tableData.patchRow,
(id) => tableData.rows.find((row) => String(row.id) === String(id)),
);
const { patchField } = useWorkOrderTableMutations({
onPatch: tableData.patchRow,
clearPatch: tableData.clearRowPatch,
getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)),
});
const { data: users = [], isLoading: usersLoading } = useWorkOrderUsers();
const { data: locations = [] } = useWorkOrderLocations();

View file

@ -192,13 +192,7 @@ export interface PagedResultApi<T> {
export type WorkOrderBoardSearchResponseApi = PagedResultApi<WorkOrderBoardRowApi>;
export type BoardDatePresetApi =
| "ThisWeek"
| "LastWeek"
| "ThisMonth"
| "Last3Months"
| "NextWeek"
| "NextMonth"
| "Custom";
"ThisWeek" | "LastWeek" | "ThisMonth" | "Last3Months" | "NextWeek" | "NextMonth" | "Custom";
export const DATE_RANGE_TO_PRESET: Record<DateRangeKey, BoardDatePresetApi> = {
"this-week": "ThisWeek",
@ -219,6 +213,8 @@ export interface WorkOrderBoardQueryParams {
dispatchers?: string[];
myWorkOrders?: boolean;
types?: number[];
/** Past-due filter; independent of WorkOrderType (Other remains types=99). */
overdue?: boolean;
search?: string;
}
@ -231,6 +227,8 @@ export interface WorkOrderBoardSearchQueryParams {
dateTo?: string;
sites?: string[];
types?: number[];
/** Past-due filter; independent of WorkOrderType (Other remains types=99). */
overdue?: boolean;
dispatchers?: string[];
statuses?: number[];
pmTypes?: string[];

View file

@ -22,10 +22,27 @@ import { queryKeys } from "@/infra/query-key/query-key";
export type { WorkOrderTablePatch };
export type WorkOrderTablePatchHandlers = {
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
clearPatch: (id: string | number) => void;
getRow?: (id: string | number) => WorkOrderTableRow | undefined;
};
export function useWorkOrderTableMutations(
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void,
getRow?: (id: string | number) => WorkOrderTableRow | undefined,
onPatchOrHandlers:
((id: string | number, patch: WorkOrderTablePatch) => void) | WorkOrderTablePatchHandlers,
getRowArg?: (id: string | number) => WorkOrderTableRow | undefined,
) {
const handlers: WorkOrderTablePatchHandlers =
typeof onPatchOrHandlers === "function"
? {
onPatch: onPatchOrHandlers,
clearPatch: () => undefined,
getRow: getRowArg,
}
: onPatchOrHandlers;
const { onPatch, clearPatch, getRow } = handlers;
const queryClient = useQueryClient();
const invalidate = () => {
@ -52,8 +69,24 @@ export function useWorkOrderTableMutations(
}
let latest = current;
let completedOps = 0;
for (const op of ops) {
latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
try {
latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
completedOps += 1;
} catch (error) {
if (completedOps > 0) {
const field = op.field;
const partial = new Error(
`Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${
error instanceof Error ? error.message : "Unknown error"
}`,
);
(partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest;
throw partial;
}
throw error;
}
}
return { kind: "board" as const, row: latest };
@ -68,12 +101,22 @@ export function useWorkOrderTableMutations(
invalidate();
},
onError: (error: Error, { id }) => {
const partialRow = (error as Error & { partialRow?: WorkOrderTableRow }).partialRow;
if (partialRow) {
onPatch(id, partialRow);
toast.error(error.message);
invalidate();
return;
}
if (error instanceof BoardPatchConflictError && error.currentState) {
onPatch(id, error.currentState);
toast.error(error.message || "Conflict — refreshed with latest data");
} else if (error instanceof BoardPatchValidationError) {
clearPatch(id);
toast.error(error.message);
} else {
clearPatch(id);
toast.error(error.message || "Failed to update work order");
}
invalidate();

View file

@ -6,7 +6,6 @@ import {
DATE_RANGE_TO_PRESET,
DOC_STATUS_API,
LIFECYCLE_STATUS_API,
WORK_ORDER_TYPE_API,
type WorkOrderBoardQueryParams,
type WorkOrderBoardSearchQueryParams,
} from "@/domain/work-orders/types/work-order-board";
@ -17,9 +16,6 @@ import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-ord
export const BOARD_SEARCH_MAX_PAGE_SIZE = 100;
/** Backend `types=99` filters by `isPastDue`, not WorkOrderType.Other. */
export const OVERDUE_TYPE_API = WORK_ORDER_TYPE_API.Other;
function toBoardDispatcherId(id: string): string {
if (id === ASSIGNEE_FILTER_UNASSIGNED || id === BOARD_DISPATCHER_UNASSIGNED) {
return BOARD_DISPATCHER_UNASSIGNED;
@ -32,14 +28,19 @@ function mapDispatchers(ids: Iterable<string>): string[] | undefined {
return list.length > 0 ? list : undefined;
}
function mapWoTypes(types: Iterable<WOType | string>): number[] | undefined {
/** Maps UI type filters to API `types` + separate `overdue` flag. Overdue is never sent as types=99. */
function mapWoTypeFilters(types: Iterable<WOType | string>): {
types?: number[];
overdue?: boolean;
} {
const list: number[] = [];
let overdue = false;
for (const type of types) {
if (type === "All") {
continue;
}
if (type === "Overdue") {
list.push(OVERDUE_TYPE_API);
overdue = true;
continue;
}
const api = mapWizardTypeToApi(type as WOType);
@ -47,7 +48,10 @@ function mapWoTypes(types: Iterable<WOType | string>): number[] | undefined {
list.push(api);
}
}
return list.length > 0 ? list : undefined;
return {
types: list.length > 0 ? list : undefined,
overdue: overdue || undefined,
};
}
function mapStatuses(statuses: Iterable<WizardWOStatus | string>): number[] | undefined {
@ -79,13 +83,14 @@ export function toBoardQueryParams(
search: string,
): WorkOrderBoardQueryParams {
const [, weekTo] = weekRange(bar.weekMonday);
const types = bar.type !== "All" ? mapWoTypes([bar.type]) : undefined;
const typeFilters = bar.type !== "All" ? mapWoTypeFilters([bar.type]) : {};
return {
weekStart: bar.weekMonday,
weekEnd: weekTo,
dispatchers: mapDispatchers(bar.dispatcherIds),
types,
types: typeFilters.types,
overdue: typeFilters.overdue,
search: search.trim().length >= 2 ? search.trim() : undefined,
};
}
@ -118,6 +123,8 @@ export function toBoardSearchQueryParams(input: {
}
}
const typeFilters = mapWoTypeFilters(filters.types);
const params: WorkOrderBoardSearchQueryParams = {
page: input.page ?? 0,
pageSize,
@ -125,7 +132,8 @@ export function toBoardSearchQueryParams(input: {
input.search?.trim() && input.search.trim().length >= 2 ? input.search.trim() : undefined,
datePreset,
sites: filters.sites.size > 0 ? [...filters.sites] : undefined,
types: mapWoTypes(filters.types),
types: typeFilters.types,
overdue: typeFilters.overdue,
dispatchers: mapDispatchers(filters.dispatchers),
statuses: mapStatuses(filters.statuses),
pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined,

View file

@ -5,7 +5,6 @@ import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board
import {
BOARD_SEARCH_MAX_PAGE_SIZE,
buildBoardSearchParamsRecord,
OVERDUE_TYPE_API,
toBoardQueryParams,
toBoardSearchQueryParams,
} from "@/domain/work-orders/utils/board-query-params";
@ -43,17 +42,19 @@ describe("toBoardQueryParams", () => {
it("maps PM type filter to API types", () => {
const params = toBoardQueryParams({ ...baseBar, type: "PM" }, "");
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]);
expect(params.overdue).toBeUndefined();
});
it("maps Overdue bar filter to types=99", () => {
it("maps Overdue bar filter to overdue=true without types", () => {
const params = toBoardQueryParams({ ...baseBar, type: "Overdue" }, "");
expect(params.types).toEqual([OVERDUE_TYPE_API]);
expect(OVERDUE_TYPE_API).toBe(99);
expect(params.overdue).toBe(true);
expect(params.types).toBeUndefined();
});
it("omits types when filter is All", () => {
const params = toBoardQueryParams(baseBar, "");
expect(params.types).toBeUndefined();
expect(params.overdue).toBeUndefined();
});
});
@ -99,14 +100,26 @@ describe("toBoardSearchQueryParams", () => {
expect(thisWeek.dateTo).toBeUndefined();
});
it("maps Overdue in advanced types to 99", () => {
it("maps Overdue-only advanced filter to overdue=true without types", () => {
const params = toBoardSearchQueryParams({
filters: {
...EMPTY_ADVANCED_FILTERS,
types: new Set(["Overdue"]),
},
});
expect(params.overdue).toBe(true);
expect(params.types).toBeUndefined();
});
it("maps Overdue with real types as OR (types + overdue)", () => {
const params = toBoardSearchQueryParams({
filters: {
...EMPTY_ADVANCED_FILTERS,
types: new Set(["PM", "Overdue"]),
},
});
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM, OVERDUE_TYPE_API]);
expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]);
expect(params.overdue).toBe(true);
});
it("resolves vendor company names to vendorIds", () => {
@ -128,6 +141,7 @@ describe("toBoardSearchQueryParams", () => {
expect(params.search).toBeUndefined();
expect(params.sites).toBeUndefined();
expect(params.types).toBeUndefined();
expect(params.overdue).toBeUndefined();
expect(params.dispatchers).toBeUndefined();
expect(params.statuses).toBeUndefined();
expect(params.pmTypes).toBeUndefined();
@ -149,10 +163,12 @@ describe("buildBoardSearchParamsRecord", () => {
const record = buildBoardSearchParamsRecord({
weekStart: "2026-07-13",
types: [2, 6],
overdue: true,
dispatchers: ["guid-a", "__unassigned__"],
search: "",
});
expect(record.types).toEqual([2, 6]);
expect(record.overdue).toBe(true);
expect(record.dispatchers).toEqual(["guid-a", "__unassigned__"]);
expect(record.search).toBeUndefined();
expect(Object.keys(record)).not.toContain("types[]");