shoc-frontend-new/src/domain/dashboard/api/dashboard-api.ts
Codex Review Integration 0279cfac64 feat(dashboard): paginate dispatcher tables and fix Aveta drilldown
Workload and Performance page server-side at 10 rows and now return Page,
PageSize, and TotalDispatchers on dev (backend#126/#127), so wire the pager
that SH-346 and SH-347 require: getWorkload/getPerformance send a 1-based
`page` and parse the paging envelope, the hooks and query keys carry the page,
and each dispatcher table renders a 10-row pager. Page state is per-table and
resets on a range change so switching range never lands on an out-of-range
empty page that reads as a false "no dispatchers" state.

Drop `docs=No` from the Aveta Pending drilldown. `docs` filters on
completion-document status, not the Aveta clearance document the tile counts
(SH-294 / backend#138), so it narrowed to the wrong rows. The board has no
missing-Aveta-document filter, so the drilldown is now the Aveta-required work
in the two-day open window — a deliberate superset of the count.
2026-09-17 00:36:55 -03:00

371 lines
13 KiB
TypeScript

import { API_PATHS } from "@/api/api-paths";
import { apiGet } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import type { Options } from "ky";
import type { DashboardRangeParams } from "@/domain/dashboard/types/dashboard-range";
import type {
DashboardBreakdownRow,
DashboardStats,
} from "@/domain/dashboard/types/dashboard-stats";
import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance";
import {
DISPATCHER_PAGE_SIZE,
type DispatcherPage,
} from "@/domain/dashboard/types/dashboard-dispatcher-page";
import type { DashboardTrendParams } from "@/domain/dashboard/types/dashboard-trend";
import type {
DashboardTrend,
DashboardTrendGranularity,
} from "@/domain/dashboard/types/dashboard-trend";
import {
UNMAPPED_REGION_LABEL,
type RegionWorkOrdersRow,
} from "@/domain/dashboard/types/dashboard-regions";
import type { DispatcherWorkloadRow } from "@/domain/dashboard/types/dashboard-workload";
import type { VendorInsightRow } from "@/domain/dashboard/types/dashboard-vendor-insights";
import { WO_TYPES } from "@/domain/work-orders/types/work-order-wizard";
function toNumber(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
function asString(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
return "";
}
function asRecords(value: unknown): Record<string, unknown>[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter(
(item): item is Record<string, unknown> =>
typeof item === "object" && item !== null && !Array.isArray(item),
);
}
function readField(data: Record<string, unknown>, key: string): unknown {
const capitalized = key.charAt(0).toUpperCase() + key.slice(1);
const value = data[key] ?? data[capitalized];
return value === null ? undefined : value;
}
function toRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readCollection(value: unknown, key: string): Record<string, unknown>[] {
const data = toRecord(value);
const collection = key ? (readField(data, key) ?? value) : value;
return asRecords(collection);
}
function rangeSearchParams(range?: DashboardRangeParams): Options | undefined {
if (!range?.dateFrom || !range?.dateTo) {
return undefined;
}
return { searchParams: { dateFrom: range.dateFrom, dateTo: range.dateTo } };
}
/**
* Workload and Performance are paged server-side (backend#126/#127): `page` is
* 1-based and always sent, and the range dates ride along when present.
*/
function dispatcherPageSearchParams(
range: DashboardRangeParams | undefined,
page: number,
): Options {
const searchParams: Record<string, string | number> = { page };
if (range?.dateFrom && range?.dateTo) {
searchParams.dateFrom = range.dateFrom;
searchParams.dateTo = range.dateTo;
}
return { searchParams };
}
/**
* Reads the paging envelope the dispatcher endpoints return (`Page`, `PageSize`,
* `TotalDispatchers`). Falls back to the requested page, the default page size,
* and the item count for the legacy bare-array shape so older responses still
* render (with the pager hidden).
*/
function readDispatcherPageMeta(
raw: unknown,
requestedPage: number,
itemCount: number,
): { page: number; pageSize: number; totalDispatchers: number } {
const data = toRecord(raw);
const pageValue = readField(data, "page");
const pageSizeValue = readField(data, "pageSize");
const totalValue = readField(data, "totalDispatchers");
return {
page: pageValue == null ? requestedPage : toNumber(pageValue),
pageSize: pageSizeValue == null ? DISPATCHER_PAGE_SIZE : toNumber(pageSizeValue),
totalDispatchers: totalValue == null ? itemCount : toNumber(totalValue),
};
}
function trendSearchParams(params?: DashboardTrendParams): Options | undefined {
if (
!params ||
(!params.dateFrom && !params.dateTo && !params.granularity && !params.range && !params.year)
) {
return undefined;
}
const searchParams: Record<string, string | number> = {};
if (params.dateFrom) searchParams.dateFrom = params.dateFrom;
if (params.dateTo) searchParams.dateTo = params.dateTo;
if (params.granularity) searchParams.granularity = params.granularity;
if (params.range) searchParams.range = params.range;
if (params.year) searchParams.year = params.year;
return { searchParams };
}
const BREAKDOWN_TYPE_BY_LOWER = new Map<string, string>(
WO_TYPES.map((type) => [type.toLowerCase(), type]),
);
/**
* The backend breakdown object (backend#138) serializes its keys with no
* JsonPropertyName, so `PM`/`Emergency`/`Reactive`/`Overdue`/`Other` arrive
* lowercase. The card uses the row label as both display text and the
* `types` drill-down id, and `workOrderTypeDrilldownSearch` only accepts
* canonical `WOType` values — so map lowercase type keys back to their
* canonical labels. `other` is not a WOType: keep it as a labelled,
* non-drillable "Other" row. Unknown values (e.g. lifecycle labels from the
* legacy array shape) pass through unchanged.
*/
function normalizeBreakdownStatus(raw: string): string {
const trimmed = raw.trim();
const canonical = BREAKDOWN_TYPE_BY_LOWER.get(trimmed.toLowerCase());
if (canonical) return canonical;
if (trimmed.toLowerCase() === "other") return "Other";
return trimmed;
}
function mapBreakdownRow(raw: Record<string, unknown>): DashboardBreakdownRow {
const status = asString(
readField(raw, "status") ?? readField(raw, "name") ?? readField(raw, "label"),
);
return {
status: status ? normalizeBreakdownStatus(status) : UNMAPPED_REGION_LABEL,
count: toNumber(readField(raw, "count") ?? readField(raw, "value")),
};
}
function mapBreakdown(value: unknown): DashboardBreakdownRow[] {
if (Array.isArray(value)) {
return asRecords(value).map(mapBreakdownRow);
}
if (typeof value === "object" && value !== null) {
return Object.entries(value as Record<string, unknown>)
.map(([status, count]) => ({
status: normalizeBreakdownStatus(status),
count: toNumber(count),
}))
.filter((row) => row.status !== "");
}
return [];
}
function mapDispatcherIdentity(raw: Record<string, unknown>): {
dispatcherId: string;
dispatcherName: string;
} {
return {
dispatcherId: asString(
readField(raw, "dispatcherId") ?? readField(raw, "id") ?? readField(raw, "userId"),
),
dispatcherName: asString(readField(raw, "dispatcherName") ?? readField(raw, "name")),
};
}
function mapDashboardStats(raw: unknown): DashboardStats {
const data = toRecord(raw);
const average = readField(data, "averageResolutionDays");
return {
total: toNumber(readField(data, "total")),
open: toNumber(readField(data, "open")),
notDispatched: toNumber(readField(data, "notDispatched")),
completed: toNumber(readField(data, "completed")),
dueCount: toNumber(readField(data, "dueCount")),
completedDueCount: toNumber(readField(data, "completedDueCount")),
completionRate: toNumber(readField(data, "completionRate")),
averageResolutionDays: average == null ? null : toNumber(average),
scheduledTomorrow: toNumber(readField(data, "scheduledTomorrow")),
pendingUplifts: toNumber(readField(data, "pendingUplifts")),
avetaPending: toNumber(readField(data, "avetaPending")),
breakdown: mapBreakdown(readField(data, "breakdown") ?? readField(data, "workOrderBreakdown")),
};
}
function mapDispatcherWorkload(raw: unknown): DispatcherWorkloadRow[] {
return readCollection(raw, "items").map((rawRow) => {
const identity = mapDispatcherIdentity(rawRow);
return {
...identity,
openWorkOrders: toNumber(
readField(rawRow, "openWorkOrders") ??
readField(rawRow, "openCount") ??
readField(rawRow, "open"),
),
totalWorkOrders: toNumber(
readField(rawRow, "totalWorkOrders") ??
readField(rawRow, "totalCount") ??
readField(rawRow, "scheduledWorkOrders") ??
readField(rawRow, "scheduled"),
),
};
});
}
function mapDispatcherPerformance(raw: unknown): DispatcherPerformanceRow[] {
return readCollection(raw, "items").map((rawRow) => {
const identity = mapDispatcherIdentity(rawRow);
return {
...identity,
completionRate: toNumber(
readField(rawRow, "completionRate") ?? readField(rawRow, "onTimeRate"),
),
averageResolutionDays:
readField(rawRow, "averageResolutionDays") == null
? null
: toNumber(readField(rawRow, "averageResolutionDays")),
};
});
}
function mapRegions(raw: unknown): RegionWorkOrdersRow[] {
return readCollection(raw, "items").map((rawRow) => {
const region =
asString(readField(rawRow, "region") ?? readField(rawRow, "name")) || UNMAPPED_REGION_LABEL;
return {
region,
workOrderCount: toNumber(
readField(rawRow, "workOrderCount") ??
readField(rawRow, "count") ??
readField(rawRow, "total"),
),
};
});
}
function mapVendorInsights(raw: unknown): VendorInsightRow[] {
return readCollection(raw, "items").map((rawRow) => ({
vendorId: asString(
readField(rawRow, "vendorId") ??
readField(rawRow, "vendorCompanyId") ??
readField(rawRow, "id"),
),
vendorName: asString(
readField(rawRow, "vendorName") ??
readField(rawRow, "vendorCompanyName") ??
readField(rawRow, "companyName") ??
readField(rawRow, "name"),
),
completionRate: toNumber(readField(rawRow, "completionRate")),
rescheduleRate: toNumber(readField(rawRow, "rescheduleRate")),
averageResolutionDays:
readField(rawRow, "averageResolutionDays") == null
? null
: toNumber(readField(rawRow, "averageResolutionDays")),
totalJobs: toNumber(readField(rawRow, "totalJobs")),
}));
}
function mapTrendGranularity(value: unknown): DashboardTrendGranularity | null {
const granularity = asString(value).toLowerCase();
return granularity === "day" || granularity === "week" || granularity === "month"
? granularity
: null;
}
function mapDashboardTrend(raw: unknown): DashboardTrend {
const data = toRecord(raw);
const buckets = readCollection(data, "buckets");
const legacyPoints = readCollection(data, "points");
const points = (buckets.length > 0 ? buckets : legacyPoints).map((rawPoint) => ({
date: asString(readField(rawPoint, "date")),
label: asString(
readField(rawPoint, "label") ?? readField(rawPoint, "period") ?? readField(rawPoint, "date"),
),
total: toNumber(
readField(rawPoint, "total") ?? readField(rawPoint, "count") ?? readField(rawPoint, "value"),
),
open: toNumber(readField(rawPoint, "open")),
completed: toNumber(readField(rawPoint, "completed")),
canceled: toNumber(readField(rawPoint, "canceled")),
overdue: toNumber(readField(rawPoint, "overdue")),
isCurrent: readField(rawPoint, "isCurrent") === true,
}));
return { granularity: mapTrendGranularity(readField(data, "granularity")), points };
}
export const dashboardApi = {
getStats: async (range?: DashboardRangeParams): Promise<DashboardStats> => {
const data = await apiGet<unknown>(API_PATHS.dashboard.stats, rangeSearchParams(range));
return mapDashboardStats(handleApiResponse<unknown>(data));
},
getWorkload: async (
range?: DashboardRangeParams,
page = 1,
): Promise<DispatcherPage<DispatcherWorkloadRow>> => {
const data = await apiGet<unknown>(
API_PATHS.dashboard.workload,
dispatcherPageSearchParams(range, page),
);
const response = handleApiResponse<unknown>(data);
const items = mapDispatcherWorkload(response);
return { items, ...readDispatcherPageMeta(response, page, items.length) };
},
getPerformance: async (
range?: DashboardRangeParams,
page = 1,
): Promise<DispatcherPage<DispatcherPerformanceRow>> => {
const data = await apiGet<unknown>(
API_PATHS.dashboard.performance,
dispatcherPageSearchParams(range, page),
);
const response = handleApiResponse<unknown>(data);
const items = mapDispatcherPerformance(response);
return { items, ...readDispatcherPageMeta(response, page, items.length) };
},
getRegions: async (range?: DashboardRangeParams): Promise<RegionWorkOrdersRow[]> => {
const data = await apiGet<unknown>(API_PATHS.dashboard.regions, rangeSearchParams(range));
return mapRegions(handleApiResponse<unknown>(data));
},
getVendorInsights: async (): Promise<VendorInsightRow[]> => {
const data = await apiGet<unknown>(API_PATHS.dashboard.vendorInsights);
return mapVendorInsights(handleApiResponse<unknown>(data));
},
getTrend: async (params?: DashboardTrendParams): Promise<DashboardTrend> => {
const data = await apiGet<unknown>(API_PATHS.dashboard.trend, trendSearchParams(params));
return mapDashboardTrend(handleApiResponse<unknown>(data));
},
};