mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 08:32:54 +00:00
444 lines
14 KiB
TypeScript
444 lines
14 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
|
|
import type { LucideIcon } from "lucide-react";
|
|
import { MapPin } from "lucide-react";
|
|
|
|
import { MiniRing, MiniSparkline } from "@/components/common/MiniGraph";
|
|
import type { OverviewAccent } from "@/components/overview/overview.styles";
|
|
import { freightBrand } from "@/theme/freight-brand";
|
|
|
|
/** Mini-graph variants a StatTile can render at its bottom. */
|
|
export type StatTileGraph = "area" | "line" | "ring" | "none";
|
|
|
|
/**
|
|
* Shared visual building blocks for the Train Scheduling V2 surfaces.
|
|
* Everything keys off the freight brand green so the list + detail pages
|
|
* read as one cohesive, premium product.
|
|
*/
|
|
|
|
export const scheduleBrand = {
|
|
/** Deep green → emerald hero wash used across scheduling surfaces. */
|
|
heroGradient: `linear-gradient(135deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 48%, ${freightBrand.primaryLight} 120%)`,
|
|
/** Soft tinted surface for cards on white backgrounds. */
|
|
softSurface: `linear-gradient(135deg, ${freightBrand.mutedBg} 0%, #ffffff 60%, var(--mantine-color-gray-0) 100%)`,
|
|
ring: freightBrand.ring,
|
|
shadow: freightBrand.shadow,
|
|
shadowSm: freightBrand.shadowSm,
|
|
mutedBorder: freightBrand.mutedBorder,
|
|
} as const;
|
|
|
|
const STATUS_META: Record<
|
|
string,
|
|
{ color: string; dot: string; label?: string }
|
|
> = {
|
|
DRAFT: { color: "gray", dot: "var(--mantine-color-gray-5)" },
|
|
SCHEDULED: { color: "edr-green", dot: freightBrand.primary },
|
|
DISPATCHED: { color: "teal", dot: "var(--mantine-color-teal-6)" },
|
|
ARRIVED: { color: "blue", dot: "var(--mantine-color-blue-6)" },
|
|
CANCELLED: { color: "red", dot: "var(--mantine-color-red-6)" },
|
|
};
|
|
|
|
export function statusMeta(status: string) {
|
|
return STATUS_META[status] ?? STATUS_META.DRAFT;
|
|
}
|
|
|
|
/**
|
|
* Status pill with a leading status dot — clearer at a glance than a plain
|
|
* badge and consistent everywhere a schedule status appears.
|
|
*/
|
|
export function StatusPill({
|
|
status,
|
|
size = "sm",
|
|
}: {
|
|
status: string;
|
|
size?: "sm" | "md";
|
|
}) {
|
|
const meta = statusMeta(status);
|
|
const isMd = size === "md";
|
|
return (
|
|
<Group
|
|
gap={isMd ? 8 : 6}
|
|
wrap="nowrap"
|
|
style={{
|
|
display: "inline-flex",
|
|
padding: isMd ? "5px 12px" : "3px 10px",
|
|
borderRadius: 999,
|
|
background: `var(--mantine-color-${meta.color}-0)`,
|
|
border: `1px solid var(--mantine-color-${meta.color}-2)`,
|
|
}}
|
|
>
|
|
<Box
|
|
w={isMd ? 8 : 7}
|
|
h={isMd ? 8 : 7}
|
|
style={{
|
|
borderRadius: 999,
|
|
background: meta.dot,
|
|
boxShadow: `0 0 0 3px var(--mantine-color-${meta.color}-1)`,
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
<Text
|
|
size={isMd ? "sm" : "xs"}
|
|
fw={600}
|
|
c={`${meta.color}.8`}
|
|
style={{ letterSpacing: 0.2, lineHeight: 1 }}
|
|
>
|
|
{meta.label ?? status}
|
|
</Text>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Compact metric tile used in hero strips. `onDark` flips colors for use on
|
|
* the green hero gradient.
|
|
*/
|
|
export function StatTile({
|
|
icon: Icon,
|
|
label,
|
|
value,
|
|
hint,
|
|
onDark = false,
|
|
accent = freightBrand.primary,
|
|
graph = "none",
|
|
graphAccent = "emerald",
|
|
graphPct,
|
|
}: {
|
|
icon?: LucideIcon;
|
|
label: string;
|
|
value: ReactNode;
|
|
hint?: ReactNode;
|
|
onDark?: boolean;
|
|
accent?: string;
|
|
/** Optional decorative mini-graph at the bottom (ignored on dark tiles). */
|
|
graph?: StatTileGraph;
|
|
graphAccent?: OverviewAccent;
|
|
/** Percentage 0..100 for the ring variant. */
|
|
graphPct?: number | null;
|
|
}) {
|
|
// Mini-graphs only render on light tiles (the gradient reads poorly on dark).
|
|
const showGraph = graph !== "none" && !onDark;
|
|
|
|
return (
|
|
<Paper
|
|
p="md"
|
|
radius="lg"
|
|
style={
|
|
onDark
|
|
? {
|
|
background: "rgba(255,255,255,0.12)",
|
|
border: "1px solid rgba(255,255,255,0.18)",
|
|
backdropFilter: "blur(6px)",
|
|
}
|
|
: {
|
|
background: "var(--mantine-color-gray-0)",
|
|
border: "1px solid var(--mantine-color-gray-2)",
|
|
}
|
|
}
|
|
>
|
|
<Stack gap={8}>
|
|
<Group gap="sm" wrap="nowrap" align="center">
|
|
{Icon ? (
|
|
<Box
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: 38,
|
|
height: 38,
|
|
borderRadius: 10,
|
|
flexShrink: 0,
|
|
background: onDark ? "rgba(255,255,255,0.16)" : `${accent}1a`,
|
|
color: onDark ? "white" : accent,
|
|
}}
|
|
>
|
|
<Icon size={18} />
|
|
</Box>
|
|
) : null}
|
|
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
|
|
<Text
|
|
fw={800}
|
|
size="lg"
|
|
lh={1.05}
|
|
c={onDark ? "white" : "dark.5"}
|
|
style={{ whiteSpace: "nowrap" }}
|
|
>
|
|
{value}
|
|
</Text>
|
|
<Text
|
|
size="xs"
|
|
fw={600}
|
|
c={onDark ? "rgba(255,255,255,0.75)" : "dimmed"}
|
|
truncate
|
|
>
|
|
{label}
|
|
{hint ? <Text component="span" size="xs" c="dimmed"> · {hint}</Text> : null}
|
|
</Text>
|
|
</Stack>
|
|
{showGraph && graph === "ring" ? (
|
|
<MiniRing pct={graphPct ?? 0} accent={graphAccent} size={42} stroke={5}>
|
|
<Text size="9px" fw={800} c="dark.4">
|
|
{Math.round(graphPct ?? 0)}%
|
|
</Text>
|
|
</MiniRing>
|
|
) : null}
|
|
</Group>
|
|
|
|
{showGraph && graph !== "ring" ? (
|
|
<MiniSparkline
|
|
variant={graph}
|
|
accent={graphAccent}
|
|
baseline={(graphPct ?? 50) / 100}
|
|
seed={label}
|
|
height={20}
|
|
/>
|
|
) : null}
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Origin → destination corridor visual: two anchored stops joined by a rail
|
|
* line. `variant="compact"` is for dense table rows; `default` for cards.
|
|
*/
|
|
export function RouteCorridor({
|
|
origin,
|
|
destination,
|
|
variant = "default",
|
|
onDark = false,
|
|
}: {
|
|
origin?: string | null;
|
|
destination?: string | null;
|
|
variant?: "default" | "compact";
|
|
onDark?: boolean;
|
|
}) {
|
|
const compact = variant === "compact";
|
|
const dim = onDark ? "rgba(255,255,255,0.7)" : "var(--mantine-color-gray-5)";
|
|
const strong = onDark ? "white" : "var(--mantine-color-gray-8)";
|
|
const lineColor = onDark ? "rgba(255,255,255,0.4)" : "var(--mantine-color-gray-3)";
|
|
const accent = onDark ? "white" : freightBrand.primary;
|
|
|
|
return (
|
|
<Group gap={compact ? 6 : 8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
|
|
<Box
|
|
w={compact ? 7 : 9}
|
|
h={compact ? 7 : 9}
|
|
style={{
|
|
borderRadius: 999,
|
|
flexShrink: 0,
|
|
border: `2px solid ${accent}`,
|
|
background: onDark ? "transparent" : "white",
|
|
}}
|
|
/>
|
|
<Text
|
|
size={compact ? "sm" : "sm"}
|
|
fw={600}
|
|
c={strong}
|
|
style={{ whiteSpace: "nowrap" }}
|
|
>
|
|
{origin ?? "—"}
|
|
</Text>
|
|
<Box
|
|
style={{
|
|
flex: 1,
|
|
minWidth: compact ? 16 : 24,
|
|
height: 0,
|
|
borderTop: `2px dashed ${lineColor}`,
|
|
position: "relative",
|
|
}}
|
|
>
|
|
<MapPin
|
|
size={compact ? 11 : 13}
|
|
color={dim}
|
|
style={{
|
|
position: "absolute",
|
|
top: "50%",
|
|
left: "50%",
|
|
transform: "translate(-50%, -50%)",
|
|
background: onDark ? "transparent" : "white",
|
|
}}
|
|
/>
|
|
</Box>
|
|
<Text
|
|
size={compact ? "sm" : "sm"}
|
|
fw={600}
|
|
c={strong}
|
|
style={{ whiteSpace: "nowrap" }}
|
|
>
|
|
{destination ?? "—"}
|
|
</Text>
|
|
<Box
|
|
w={compact ? 7 : 9}
|
|
h={compact ? 7 : 9}
|
|
style={{
|
|
borderRadius: 999,
|
|
flexShrink: 0,
|
|
background: accent,
|
|
}}
|
|
/>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
/** Minimal booking shape the occupancy strip needs from TrainScheduleDetail. */
|
|
export type SegmentStripBooking = {
|
|
originYardId?: string | null;
|
|
destinationYardId?: string | null;
|
|
tradeDirection?: string | null;
|
|
wagonsRequired?: number | null;
|
|
/** GROSS tons (cargo + tare of the booking's wagons), as the API sends it. */
|
|
weightTons?: number | null;
|
|
};
|
|
|
|
/**
|
|
* Per-segment wagon occupancy along the corridor: which legs are full and
|
|
* which still run empty. Through cargo (unknown/off-route yards) occupies the
|
|
* whole corridor; a ride-along counts only on its own leg — this is what makes
|
|
* "export full Adama→Doraleh, intercity riding Gelan→Adama" legible at a
|
|
* glance instead of two disconnected booking lists.
|
|
*/
|
|
export function SegmentOccupancyStrip({
|
|
stops,
|
|
bookings,
|
|
maxWagons,
|
|
maxGrossTons,
|
|
}: {
|
|
stops: Array<{ yardId: string; label: string }>;
|
|
bookings: SegmentStripBooking[];
|
|
maxWagons?: number | null;
|
|
/** Loco pull ceiling incl. tolerance — per-leg gross is measured against it. */
|
|
maxGrossTons?: number | null;
|
|
}) {
|
|
if (stops.length < 2) return null;
|
|
const lastIdx = stops.length - 1;
|
|
const indexOf = new Map(stops.map((s, i) => [s.yardId, i]));
|
|
|
|
const segments = stops.slice(0, -1).map((stop, edge) => {
|
|
let cargo = 0;
|
|
let intercity = 0;
|
|
let grossTons = 0;
|
|
for (const b of bookings) {
|
|
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
|
|
const to =
|
|
(b.destinationYardId ? indexOf.get(b.destinationYardId) : undefined) ??
|
|
lastIdx;
|
|
const rides = from <= edge && edge < (to > from ? to : lastIdx);
|
|
if (!rides) continue;
|
|
const wagons = Number(b.wagonsRequired) || 1;
|
|
if (b.tradeDirection === "DOMESTIC") intercity += wagons;
|
|
else cargo += wagons;
|
|
grossTons += Number(b.weightTons) || 0;
|
|
}
|
|
return {
|
|
from: stop,
|
|
to: stops[edge + 1],
|
|
cargo,
|
|
intercity,
|
|
grossTons: Math.round(grossTons * 10) / 10,
|
|
};
|
|
});
|
|
|
|
const cap = Number(maxWagons) || null;
|
|
|
|
return (
|
|
<Group gap={0} wrap="nowrap" align="stretch" style={{ overflowX: "auto", paddingBottom: 4 }}>
|
|
{segments.map((seg, i) => {
|
|
const used = seg.cargo + seg.intercity;
|
|
const pct = cap ? Math.min(100, Math.round((used / cap) * 100)) : null;
|
|
const full = cap != null && used >= cap;
|
|
return (
|
|
<Group key={seg.from.yardId} gap={0} wrap="nowrap" align="stretch">
|
|
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
|
|
<Box
|
|
w={9}
|
|
h={9}
|
|
style={{
|
|
borderRadius: 999,
|
|
border: `2px solid ${freightBrand.primary}`,
|
|
background: i === 0 ? "white" : freightBrand.primary,
|
|
}}
|
|
/>
|
|
<Text size="xs" fw={600} style={{ whiteSpace: "nowrap" }}>
|
|
{seg.from.label}
|
|
</Text>
|
|
</Stack>
|
|
<Stack gap={3} px={10} pb={16} justify="flex-end" style={{ minWidth: 130 }}>
|
|
<Text size="xs" ta="center" fw={600} c={full ? "orange.8" : "dimmed"}>
|
|
{used}
|
|
{cap ? `/${cap}` : ""} wagons
|
|
{full ? " · full" : ""}
|
|
</Text>
|
|
<Box
|
|
style={{
|
|
height: 6,
|
|
borderRadius: 999,
|
|
background: "var(--mantine-color-gray-2)",
|
|
overflow: "hidden",
|
|
display: "flex",
|
|
}}
|
|
>
|
|
{cap ? (
|
|
<>
|
|
<Box
|
|
style={{
|
|
width: `${Math.min(100, (seg.cargo / cap) * 100)}%`,
|
|
background: freightBrand.primary,
|
|
}}
|
|
/>
|
|
<Box
|
|
style={{
|
|
width: `${Math.min(100, (seg.intercity / cap) * 100)}%`,
|
|
background: "var(--mantine-color-indigo-6)",
|
|
}}
|
|
/>
|
|
</>
|
|
) : (
|
|
<Box style={{ width: pct ? `${pct}%` : 0 }} />
|
|
)}
|
|
</Box>
|
|
<Text size="xs" ta="center" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
|
{seg.cargo} cargo
|
|
{seg.intercity > 0 ? (
|
|
<Text span size="xs" fw={700} c="indigo.7">
|
|
{" "}
|
|
· {seg.intercity} intercity
|
|
</Text>
|
|
) : null}
|
|
</Text>
|
|
{seg.grossTons > 0 ? (
|
|
<Text
|
|
size="xs"
|
|
ta="center"
|
|
fw={600}
|
|
c={
|
|
maxGrossTons != null && seg.grossTons > maxGrossTons
|
|
? "red.7"
|
|
: "dimmed"
|
|
}
|
|
style={{ whiteSpace: "nowrap" }}
|
|
>
|
|
{seg.grossTons}
|
|
{maxGrossTons != null ? ` / ${maxGrossTons}` : ""} T gross
|
|
</Text>
|
|
) : null}
|
|
</Stack>
|
|
{i === segments.length - 1 ? (
|
|
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
|
|
<Box
|
|
w={9}
|
|
h={9}
|
|
style={{ borderRadius: 999, background: freightBrand.primary }}
|
|
/>
|
|
<Text size="xs" fw={600} style={{ whiteSpace: "nowrap" }}>
|
|
{seg.to.label}
|
|
</Text>
|
|
</Stack>
|
|
) : null}
|
|
</Group>
|
|
);
|
|
})}
|
|
</Group>
|
|
);
|
|
}
|