Files
edr-platform/apps/edr-freight-web/backoffice/src/components/overview/layouts/layout-kit.tsx
2026-08-13 18:13:42 +00:00

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 }));
}