mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
165 lines
4.9 KiB
TypeScript
165 lines
4.9 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { AlertCircle } from "lucide-react";
|
|
import { Alert, Skeleton, Stack, Text } from "@mantine/core";
|
|
|
|
import { humanize } from "@/lib/format";
|
|
import { overviewChartColors } from "@/components/overview/overview.styles";
|
|
import type {
|
|
IOverviewDashboard,
|
|
IOverviewLabelCount,
|
|
IOverviewMatrixCell,
|
|
IOverviewStatusCount,
|
|
OverviewRange,
|
|
} from "@/types/overview";
|
|
|
|
/** Every role layout takes the same summary payload + the selected range. */
|
|
export interface RoleOverviewProps {
|
|
data: IOverviewDashboard;
|
|
range: OverviewRange;
|
|
}
|
|
|
|
/** Uppercase section eyebrow — matches the WarehouseDashboardPage convention. */
|
|
export function SectionTitle({ children }: { children: string }) {
|
|
return (
|
|
<Text fw={700} fz="sm" tt="uppercase" c="edr-muted" style={{ letterSpacing: 0.4 }}>
|
|
{children}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
/** One page band: eyebrow + content, with a staggered entrance by index. */
|
|
export function Band({
|
|
index,
|
|
title,
|
|
children,
|
|
}: {
|
|
index: number;
|
|
title: string;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<Stack gap="sm" className="ov-band" style={{ animationDelay: `${index * 70}ms` }}>
|
|
<SectionTitle>{title}</SectionTitle>
|
|
{children}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
/** Minimal shape of the react-query result a band consumes. */
|
|
interface BandQuery<T> {
|
|
data?: T;
|
|
isLoading: boolean;
|
|
isError: boolean;
|
|
}
|
|
|
|
/**
|
|
* A band fed by one of the per-domain overview endpoints. Loading shows a
|
|
* skeleton, a failure degrades to an inline notice — a role dashboard stitches
|
|
* several endpoints together and one 403 (a role without that domain's
|
|
* permission) must not blank the whole page.
|
|
*/
|
|
export function AsyncBand<T>({
|
|
index,
|
|
title,
|
|
query,
|
|
children,
|
|
}: {
|
|
index: number;
|
|
title: string;
|
|
query: BandQuery<T>;
|
|
children: (data: T) => ReactNode;
|
|
}) {
|
|
return (
|
|
<Band index={index} title={title}>
|
|
{query.isLoading ? (
|
|
<Skeleton height={300} radius="lg" />
|
|
) : query.isError || !query.data ? (
|
|
<Alert
|
|
icon={<AlertCircle size={16} />}
|
|
color="gray"
|
|
variant="light"
|
|
title={`${title} unavailable`}
|
|
>
|
|
This section could not be loaded for your account.
|
|
</Alert>
|
|
) : (
|
|
children(query.data)
|
|
)}
|
|
</Band>
|
|
);
|
|
}
|
|
|
|
/** Fixed direction colors (CVD-validated pair + violet): color follows the entity. */
|
|
export const DIRECTION_SERIES = [
|
|
{ key: "exportCount", label: "Export", color: "#D98A0B" },
|
|
{ key: "importCount", label: "Import", color: "#0369a1" },
|
|
{ key: "domesticCount", label: "Domestic", color: "#7c3aed" },
|
|
];
|
|
|
|
export function formatDayLabel(date: string) {
|
|
return new Date(`${date}T00:00:00`).toLocaleDateString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
export function sumCounts(items: IOverviewStatusCount[] = []) {
|
|
return items.reduce((sum, item) => sum + item.count, 0);
|
|
}
|
|
|
|
export function countByStatus(items: IOverviewStatusCount[] = [], ...statuses: string[]) {
|
|
return items
|
|
.filter((item) => statuses.includes(item.status))
|
|
.reduce((sum, item) => sum + item.count, 0);
|
|
}
|
|
|
|
/** Status breakdown → donut slices; statuses are enum keys, so humanize them. */
|
|
export function toDonut(items: IOverviewStatusCount[] = []) {
|
|
return items.map((item) => ({ name: humanize(item.status), value: item.count }));
|
|
}
|
|
|
|
/** Label breakdown → donut slices. Labels are names (yards, sizes) — left verbatim. */
|
|
export function labelsToDonut(items: IOverviewLabelCount[] = []) {
|
|
return items.map((item) => ({ name: item.label, value: item.count }));
|
|
}
|
|
|
|
export function labelsToBars(items: IOverviewLabelCount[] = []) {
|
|
return items.map((item) => ({ label: item.label, value: item.count }));
|
|
}
|
|
|
|
/**
|
|
* Matrix cells → the row/series shape OverviewStackedBarChart wants: one row
|
|
* per `group`, one series per distinct `series` value, colors fixed by the
|
|
* series' position so a status keeps its color across charts.
|
|
*/
|
|
export function pivotMatrix(cells: IOverviewMatrixCell[] = []) {
|
|
const seriesKeys = [...new Set(cells.map((cell) => cell.series))].sort();
|
|
const groups = [...new Set(cells.map((cell) => cell.group))];
|
|
|
|
const rows = groups.map((group) => {
|
|
const row: Record<string, string | number> = { group };
|
|
for (const key of seriesKeys) row[key] = 0;
|
|
for (const cell of cells) {
|
|
if (cell.group === group) row[cell.series] = cell.count;
|
|
}
|
|
return row;
|
|
});
|
|
|
|
const series = seriesKeys.map((key, index) => ({
|
|
key,
|
|
label: humanize(key),
|
|
color: overviewChartColors.pipeline[index % overviewChartColors.pipeline.length],
|
|
}));
|
|
|
|
return { rows, series };
|
|
}
|
|
|
|
/** Same, for breakdowns whose labels are enum values (ONE_TIME, BULK, …). */
|
|
export function enumLabelsToDonut(items: IOverviewLabelCount[] = []) {
|
|
return items.map((item) => ({ name: humanize(item.label), value: item.count }));
|
|
}
|
|
|
|
export function enumLabelsToBars(items: IOverviewLabelCount[] = []) {
|
|
return items.map((item) => ({ label: humanize(item.label), value: item.count }));
|
|
}
|