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 ( {meta.label ?? status} ); } /** * 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 ( {Icon ? ( ) : null} {value} {label} {hint ? · {hint} : null} {showGraph && graph === "ring" ? ( {Math.round(graphPct ?? 0)}% ) : null} {showGraph && graph !== "ring" ? ( ) : null} ); } /** * 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 ( {origin ?? "—"} {destination ?? "—"} ); } /** 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 ( {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 ( {seg.from.label} {used} {cap ? `/${cap}` : ""} wagons {full ? " · full" : ""} {cap ? ( <> ) : ( )} {seg.cargo} cargo {seg.intercity > 0 ? ( {" "} · {seg.intercity} intercity ) : null} {seg.grossTons > 0 ? ( maxGrossTons ? "red.7" : "dimmed" } style={{ whiteSpace: "nowrap" }} > {seg.grossTons} {maxGrossTons != null ? ` / ${maxGrossTons}` : ""} T gross ) : null} {i === segments.length - 1 ? ( {seg.to.label} ) : null} ); })} ); }