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[] { if (!Array.isArray(value)) { return []; } return value.filter( (item): item is Record => typeof item === "object" && item !== null && !Array.isArray(item), ); } function readField(data: Record, 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 { return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : {}; } function readCollection(value: unknown, key: string): Record[] { 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 = {}; 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 = { 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 = {}; 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( 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): 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) .map(([status, count]) => ({ status: normalizeBreakdownStatus(status), count: toNumber(count), })) .filter((row) => row.status !== ""); } return []; } function mapDispatcherIdentity(raw: Record): { 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 => { const data = await apiGet( API_PATHS.dashboard.stats, scopedSearchParams(range, dispatcherId), ); return mapDashboardStats(handleApiResponse(data)); }, getWorkload: async ( range?: DashboardRangeParams, page = 1, dispatcherId?: string, ): Promise> => { const data = await apiGet( API_PATHS.dashboard.workload, dispatcherPageSearchParams(range, page, dispatcherId), ); const response = handleApiResponse(data); const items = mapDispatcherWorkload(response); return { items, ...readDispatcherPageMeta(response, page, items.length) }; }, getPerformance: async ( range?: DashboardRangeParams, page = 1, dispatcherId?: string, ): Promise> => { const data = await apiGet( API_PATHS.dashboard.performance, dispatcherPageSearchParams(range, page, dispatcherId), ); const response = handleApiResponse(data); const items = mapDispatcherPerformance(response); return { items, ...readDispatcherPageMeta(response, page, items.length) }; }, getRegions: async ( range?: DashboardRangeParams, dispatcherId?: string, ): Promise => { const data = await apiGet( API_PATHS.dashboard.regions, scopedSearchParams(range, dispatcherId), ); return mapRegions(handleApiResponse(data)); }, getVendorInsights: async (): Promise => { const data = await apiGet(API_PATHS.dashboard.vendorInsights); return mapVendorInsights(handleApiResponse(data)); }, getTrend: async (params?: DashboardTrendParams): Promise => { const data = await apiGet(API_PATHS.dashboard.trend, trendSearchParams(params)); return mapDashboardTrend(handleApiResponse(data)); }, };