mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
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.
371 lines
13 KiB
TypeScript
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));
|
|
},
|
|
};
|