mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 22:43:23 +00:00
fix(work-orders): use overdue=true and harden board patch overlays
This commit is contained in:
parent
3ea136ce3c
commit
8ae54a6173
6 changed files with 118 additions and 30 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[]");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue