shoc-frontend-new/src/domain/dashboard/api/dashboard-api.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

421 lines
15 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,
DashboardStatusBucketRow,
} 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);
}
/**
* Stats, Regions, Workload, and Performance share `DashboardStatsQueryDTO`
* (backend#145): the range dates ride along when present, and `dispatcherId`
* scopes the read to a single dispatcher (SH-336's picker — All sends nothing,
* My WOs / an individual sends that id). Vendor Insights and Trend take no such
* param and stay company-wide (SH-349).
*/
function scopedSearchParams(
range?: DashboardRangeParams,
dispatcherId?: string,
): Options | undefined {
const searchParams: Record<string, string> = {};
if (range?.dateFrom && range?.dateTo) {
searchParams.dateFrom = range.dateFrom;
searchParams.dateTo = range.dateTo;
}
if (dispatcherId) {
searchParams.dispatcherId = dispatcherId;
}
return Object.keys(searchParams).length > 0 ? { searchParams } : undefined;
}
/**
* Workload and Performance are paged server-side (backend#126/#127): `page` is
* 1-based and always sent, the range dates ride along when present, and
* `dispatcherId` scopes to a single dispatcher (SH-336).
*/
function dispatcherPageSearchParams(
range: DashboardRangeParams | undefined,
page: number,
dispatcherId?: string,
): Options {
const searchParams: Record<string, string | number> = { page };
if (range?.dateFrom && range?.dateTo) {
searchParams.dateFrom = range.dateFrom;
searchParams.dateTo = range.dateTo;
}
if (dispatcherId) {
searchParams.dispatcherId = dispatcherId;
}
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 mapStatusDistribution(value: unknown): DashboardStatusBucketRow[] {
return asRecords(value)
.map((row) => ({
status: asString(
readField(row, "status") ?? readField(row, "name") ?? readField(row, "label"),
),
count: toNumber(readField(row, "count") ?? readField(row, "value")),
}))
.filter((row) => row.status !== "");
}
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")),
statusDistribution: mapStatusDistribution(readField(data, "statusDistribution")),
};
}
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")),
assignedCount: toNumber(readField(rawRow, "assignedCount") ?? readField(rawRow, "assigned")),
completedCount: toNumber(
readField(rawRow, "completedCount") ?? readField(rawRow, "completed"),
),
};
});
}
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,
dispatcherId?: string,
): Promise<DashboardStats> => {
const data = await apiGet<unknown>(
API_PATHS.dashboard.stats,
scopedSearchParams(range, dispatcherId),
);
return mapDashboardStats(handleApiResponse<unknown>(data));
},
getWorkload: async (
range?: DashboardRangeParams,
page = 1,
dispatcherId?: string,
): Promise<DispatcherPage<DispatcherWorkloadRow>> => {
const data = await apiGet<unknown>(
API_PATHS.dashboard.workload,
dispatcherPageSearchParams(range, page, dispatcherId),
);
const response = handleApiResponse<unknown>(data);
const items = mapDispatcherWorkload(response);
return { items, ...readDispatcherPageMeta(response, page, items.length) };
},
getPerformance: async (
range?: DashboardRangeParams,
page = 1,
dispatcherId?: string,
): Promise<DispatcherPage<DispatcherPerformanceRow>> => {
const data = await apiGet<unknown>(
API_PATHS.dashboard.performance,
dispatcherPageSearchParams(range, page, dispatcherId),
);
const response = handleApiResponse<unknown>(data);
const items = mapDispatcherPerformance(response);
return { items, ...readDispatcherPageMeta(response, page, items.length) };
},
getRegions: async (
range?: DashboardRangeParams,
dispatcherId?: string,
): Promise<RegionWorkOrdersRow[]> => {
const data = await apiGet<unknown>(
API_PATHS.dashboard.regions,
scopedSearchParams(range, dispatcherId),
);
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));
},
};