shoc-frontend-new/src/domain/work-orders/utils/board-query-params.ts
Codex Review Integration 1271d3c11e feat(dashboard): dispatcher scope picker, status distribution, region drilldown, perf counts
- SH-336: add the dispatcher-scope picker (All / My WOs / individual) for
  Scheduler/Admin via a new viewAllDispatchersOnDashboard role gate, a static
  'My WOs' label for Dispatcher, and hide the dispatcher tables (and skip their
  queries) when the viewer cannot see all dispatchers. Scope drives dispatcherId
  on stats, workload, performance, and regions (SH-347 contract).
- SH-352: add the Status Distribution module with its empty state and status
  drill-down.
- SH-347: show Assigned/Completed counts and colour the completion rate
  (green >=90, amber 70-89, red <70) on Dispatcher Performance.
- SH-348: drill a region bar into the board region filter; hold the
  unconfirmed 'Unmapped/Other' bucket off the chart.
- SH-349: label Vendor Insights as all-time, company-wide.
- Make the drilldown-filters effect fire only on URL change; add a deterministic
  clock to the status-drilldown test; add the dashboard visual regression spec
  and baselines.
2026-09-17 02:56:31 -03:00

271 lines
8.8 KiB
TypeScript

import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import {
EMPTY_ADVANCED_FILTERS,
type WorkOrderAdvancedFilters,
} from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
import {
BOARD_DISPATCHER_UNASSIGNED,
DATE_RANGE_TO_PRESET,
DOC_STATUS_API,
LIFECYCLE_STATUS_API,
NON_TERMINAL_LIFECYCLE_STATUSES,
type WorkOrderBoardQueryParams,
type WorkOrderBoardSearchQueryParams,
} from "@/domain/work-orders/types/work-order-board";
import { mapWizardTypeToApi } from "@/domain/work-orders/mappers/work-order-board-mapper";
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
import type { CompletionDocFilter } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard";
export const BOARD_SEARCH_MAX_PAGE_SIZE = 200;
/** Wide Custom window so applied advanced search is not pinned to ThisWeek. */
export const ADVANCED_SEARCH_ALL_WEEKS_FROM = "2000-01-01";
export const ADVANCED_SEARCH_ALL_WEEKS_TO = "2099-12-31";
/**
* Wide Custom window for SH-165. GET /board/search defaults omitted
* `datePreset` to ThisWeek (`WorkOrderSearchDateRangeResolver`), which would
* hide scheduled-unassigned WOs outside the current week.
*/
export const UNASSIGNED_QUEUE_DATE_FROM = "1970-01-01";
export const UNASSIGNED_QUEUE_DATE_TO = "2099-12-31";
function toBoardDispatcherId(id: string): string {
if (id === ASSIGNEE_FILTER_UNASSIGNED || id === BOARD_DISPATCHER_UNASSIGNED) {
return BOARD_DISPATCHER_UNASSIGNED;
}
return id;
}
function mapDispatchers(ids: Iterable<string>): string[] | undefined {
const list = [...ids].map(toBoardDispatcherId);
return list.length > 0 ? list : 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") {
overdue = true;
continue;
}
const api = mapWizardTypeToApi(type as WOType);
if (api != null) {
list.push(api);
}
}
return {
types: list.length > 0 ? list : undefined,
overdue: overdue || undefined,
};
}
function mapStatuses(statuses: Iterable<WizardWOStatus | string>): number[] | undefined {
const list: number[] = [];
for (const status of statuses) {
const value = LIFECYCLE_STATUS_API[status as WizardWOStatus];
if (typeof value === "number") {
list.push(value);
} else if (status === "Canceled" || status === "Cancelled") {
list.push(LIFECYCLE_STATUS_API.Canceled);
}
}
return list.length > 0 ? list : undefined;
}
function mapDocStatuses(docs: Iterable<CompletionDocFilter>): number[] | undefined {
const list: number[] = [];
for (const doc of docs) {
if (doc === "Yes") list.push(DOC_STATUS_API.Yes);
else if (doc === "No") list.push(DOC_STATUS_API.No);
else if (doc === "NN") list.push(DOC_STATUS_API.NotNeeded);
}
return list.length > 0 ? list : undefined;
}
function resolveVendorIds(
vendorTechs: Iterable<string>,
vendorIdsByCompanyName?: Map<string, number>,
): number[] | undefined {
if (!vendorIdsByCompanyName) return undefined;
const vendorIds: number[] = [];
for (const name of vendorTechs) {
const id = vendorIdsByCompanyName.get(name);
if (typeof id === "number") {
vendorIds.push(id);
}
}
return vendorIds.length > 0 ? vendorIds : undefined;
}
function mapSearchFacetParams(
filters: WorkOrderAdvancedFilters,
): Pick<
WorkOrderBoardSearchQueryParams,
| "severities"
| "rescheduled"
| "carriedOver"
| "addOn"
| "avetaOnly"
| "flagColors"
| "internalOnly"
| "hasUplift"
| "upliftStatuses"
> {
return {
severities: filters.severities?.size ? [...filters.severities] : undefined,
rescheduled: filters.rescheduled || undefined,
carriedOver: filters.carriedOver || undefined,
addOn: filters.addOn || undefined,
avetaOnly: filters.avetaOnly || undefined,
flagColors: filters.flagColors?.size ? [...filters.flagColors] : undefined,
internalOnly: filters.internalOnly || undefined,
hasUplift: filters.hasUplift || undefined,
upliftStatuses:
filters.hasUplift && filters.upliftStatuses?.size ? [...filters.upliftStatuses] : undefined,
};
}
/** Build GET /board query from weekly filter bar. */
export function toBoardQueryParams(
bar: WorkOrderBarFilters,
search: string,
): WorkOrderBoardQueryParams {
const [, weekTo] = weekRange(bar.weekMonday);
const typeFilters = bar.types.size > 0 ? mapWoTypeFilters(bar.types) : {};
return {
weekStart: bar.weekMonday,
weekEnd: weekTo,
dispatchers: mapDispatchers(bar.dispatcherIds),
types: typeFilters.types,
overdue: typeFilters.overdue,
search: search.trim().length >= 2 ? search.trim() : undefined,
};
}
function resolveAdvancedSearchDate(filters: WorkOrderAdvancedFilters): {
datePreset?: WorkOrderBoardSearchQueryParams["datePreset"];
dateFrom?: string;
dateTo?: string;
} {
// SH-338: server-side unscheduledOnly owns the date-range exemption; the
// client must not fake a Custom window and sends no date params.
if (filters.unscheduledOnly) {
return {};
}
if (filters.dateRange === EMPTY_ADVANCED_FILTERS.dateRange) {
return {
datePreset: "Custom",
dateFrom: ADVANCED_SEARCH_ALL_WEEKS_FROM,
dateTo: ADVANCED_SEARCH_ALL_WEEKS_TO,
};
}
const datePreset = DATE_RANGE_TO_PRESET[filters.dateRange];
if (datePreset === "Custom") {
return {
datePreset,
dateFrom: filters.customFrom || undefined,
dateTo: filters.customTo || undefined,
};
}
return { datePreset };
}
/** Build GET /board/search query from advanced filters (+ optional bar/search). */
export function toBoardSearchQueryParams(input: {
filters: WorkOrderAdvancedFilters;
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortDir?: string;
/** Optional company-name → vendorId resolution. */
vendorIdsByCompanyName?: Map<string, number>;
}): WorkOrderBoardSearchQueryParams {
const { filters } = input;
const date = resolveAdvancedSearchDate(filters);
const pageSize = Math.min(
input.pageSize ?? BOARD_SEARCH_MAX_PAGE_SIZE,
BOARD_SEARCH_MAX_PAGE_SIZE,
);
const typeFilters = mapWoTypeFilters(filters.types);
const trimmedSearch = input.search?.trim();
const search = trimmedSearch && trimmedSearch.length >= 2 ? trimmedSearch : undefined;
return {
page: input.page ?? 0,
pageSize,
search,
datePreset: date.datePreset,
dateFrom: date.dateFrom,
dateTo: date.dateTo,
sites: filters.sites.size > 0 ? [...filters.sites] : undefined,
types: typeFilters.types,
overdue: typeFilters.overdue,
dispatchers: mapDispatchers(filters.dispatchers),
regions: filters.regions.size > 0 ? [...filters.regions] : undefined,
statuses: mapStatuses(filters.statuses),
pmTypes: filters.pmTypes.size > 0 ? [...filters.pmTypes] : undefined,
vendorIds: resolveVendorIds(filters.vendorTechs, input.vendorIdsByCompanyName),
docStatuses: mapDocStatuses(filters.docs),
unscheduledOnly: filters.unscheduledOnly || undefined,
...mapSearchFacetParams(filters),
sortBy: input.sortBy || "scheduledDate",
sortDir: input.sortDir === "desc" ? "desc" : "asc",
};
}
/** ky/URLSearchParams-friendly object; arrays become repeated keys. */
export function buildBoardSearchParamsRecord(
params: WorkOrderBoardQueryParams | WorkOrderBoardSearchQueryParams,
): Record<string, string | number | boolean | (string | number)[]> {
const out: Record<string, string | number | boolean | (string | number)[]> = {};
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === "") {
continue;
}
if (Array.isArray(value)) {
if (value.length === 0) continue;
out[key] = value;
continue;
}
out[key] = value as string | number | boolean;
}
return out;
}
export function woTypeFilterIsOverdue(type: string): boolean {
return type === "Overdue";
}
/** Week-independent GET /board/search params for the pinned Unassigned queue. */
export function toUnassignedQueueQueryParams(
page = 0,
pageSize = BOARD_SEARCH_MAX_PAGE_SIZE,
): WorkOrderBoardSearchQueryParams {
return {
page,
pageSize: Math.min(pageSize || BOARD_SEARCH_MAX_PAGE_SIZE, BOARD_SEARCH_MAX_PAGE_SIZE),
dispatchers: [BOARD_DISPATCHER_UNASSIGNED],
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
datePreset: "Custom",
dateFrom: UNASSIGNED_QUEUE_DATE_FROM,
dateTo: UNASSIGNED_QUEUE_DATE_TO,
};
}