mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #144 from Tria-plc/freight_feature/schedule
backoffice ui fix
This commit is contained in:
@@ -1,16 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
|
||||
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Inbox,
|
||||
LayoutList,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
|
||||
import { MiniRing, MiniSparkline } from "@/components/common/MiniGraph";
|
||||
import { ACCENT_CHIP_BG } from "@/components/overview/OverviewKpiCard";
|
||||
import {
|
||||
overviewAccentGradients,
|
||||
type OverviewAccent,
|
||||
} from "@/components/overview/overview.styles";
|
||||
import type {
|
||||
BookingListSummaryMetrics,
|
||||
BookingListSummaryTabs,
|
||||
@@ -55,173 +60,128 @@ export function BookingRequestsHeader({
|
||||
const val = (n?: number) => (loading ? "—" : (n ?? 0));
|
||||
|
||||
return (
|
||||
<Paper
|
||||
radius="xl"
|
||||
p="xl"
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={56} radius="lg" variant="light" color="green">
|
||||
<Inbox size={28} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" fw={700} c="green.7" tt="uppercase" style={{ letterSpacing: 1 }}>
|
||||
Operations
|
||||
</Text>
|
||||
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
||||
Booking Requests
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" maw={520}>
|
||||
Track every booking from submission through approval, payment, and
|
||||
dispatch — prioritize what needs action.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Button color="green" radius="lg" leftSection={<Plus size={18} />} onClick={onCreate}>
|
||||
Create booking
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="lg"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Stack gap="lg">
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button color="green" radius="lg" leftSection={<Plus size={18} />} onClick={onCreate}>
|
||||
Create booking
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="lg"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group grow gap="md" align="stretch" wrap="wrap">
|
||||
<HeroStat icon={LayoutList} label="In queue" value={val(metrics?.inQueue)} hint="Matching current filter" />
|
||||
<HeroStat
|
||||
icon={Clock}
|
||||
label="Needs action"
|
||||
value={val(metrics?.needsAction)}
|
||||
hint="Submitted or pending"
|
||||
ratio={metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0}
|
||||
ratioColor="#f59e0b"
|
||||
/>
|
||||
<HeroStat
|
||||
icon={AlertTriangle}
|
||||
label="Urgent"
|
||||
value={val(metrics?.urgent)}
|
||||
hint="High priority score"
|
||||
ratio={metrics?.inQueue ? (metrics.urgent ?? 0) / metrics.inQueue : 0}
|
||||
ratioColor="#ef4444"
|
||||
/>
|
||||
<HeroStat icon={CheckCircle2} label="Completed" value={val(tabs?.completed)} hint="Fully executed" />
|
||||
</Group>
|
||||
|
||||
{tabs ? <PipelineBar tabs={tabs} /> : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact ring gauge with the stat icon at its center. */
|
||||
function MiniDonut({
|
||||
pct,
|
||||
color,
|
||||
children,
|
||||
size = 52,
|
||||
stroke = 5,
|
||||
}: {
|
||||
pct?: number | null;
|
||||
color: string;
|
||||
children: ReactNode;
|
||||
size?: number;
|
||||
stroke?: number;
|
||||
}) {
|
||||
const radius = (size - stroke) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const clamped = pct != null ? Math.min(100, Math.max(0, Math.round(pct))) : null;
|
||||
const dash = clamped != null ? (clamped / 100) * circumference : 0;
|
||||
|
||||
return (
|
||||
<Box style={{ position: "relative", width: size, height: size, flexShrink: 0 }}>
|
||||
<svg width={size} height={size} style={{ transform: "rotate(-90deg)", display: "block" }}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--mantine-color-gray-2)"
|
||||
strokeWidth={stroke}
|
||||
<Group grow gap="md" align="stretch" wrap="wrap">
|
||||
<HeroStat
|
||||
icon={LayoutList}
|
||||
label="In queue"
|
||||
value={val(metrics?.inQueue)}
|
||||
hint="Matching current filter"
|
||||
accent="gold"
|
||||
variant="area"
|
||||
/>
|
||||
{clamped != null ? (
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${dash} ${circumference}`}
|
||||
style={{ transition: "stroke-dasharray 400ms ease" }}
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
<HeroStat
|
||||
icon={Clock}
|
||||
label="Needs action"
|
||||
value={val(metrics?.needsAction)}
|
||||
hint="Submitted or pending"
|
||||
ratio={metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0}
|
||||
accent="orange"
|
||||
/>
|
||||
<HeroStat
|
||||
icon={AlertTriangle}
|
||||
label="Urgent"
|
||||
value={val(metrics?.urgent)}
|
||||
hint="High priority score"
|
||||
ratio={metrics?.inQueue ? (metrics.urgent ?? 0) / metrics.inQueue : 0}
|
||||
accent="rose"
|
||||
/>
|
||||
<HeroStat
|
||||
icon={CheckCircle2}
|
||||
label="Completed"
|
||||
value={val(tabs?.completed)}
|
||||
hint="Fully executed"
|
||||
accent="gold"
|
||||
variant="line"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* {tabs ? <PipelineBar tabs={tabs} /> : null} */}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* KPI card matching the overview style: icon chip + value + label, with a mini
|
||||
* graph at the bottom. Ratio cards show a ring; the rest show an area/line trend.
|
||||
*/
|
||||
function HeroStat({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
ratio,
|
||||
ratioColor = "var(--mantine-color-green-6)",
|
||||
accent = "emerald",
|
||||
variant = "area",
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: string;
|
||||
ratio?: number;
|
||||
ratioColor?: string;
|
||||
accent?: OverviewAccent;
|
||||
variant?: "area" | "line";
|
||||
}) {
|
||||
const [, accentDeep] = overviewAccentGradients[accent] ?? overviewAccentGradients.default;
|
||||
const chipBg = ACCENT_CHIP_BG[accent] ?? ACCENT_CHIP_BG.default;
|
||||
const pct = ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null;
|
||||
|
||||
return (
|
||||
<Paper p="md" radius="lg" style={{ flex: "1 1 180px", minWidth: 160, ...CARD_STYLE }}>
|
||||
<Group gap="md" wrap="nowrap" align="center">
|
||||
<MiniDonut pct={pct} color={ratioColor}>
|
||||
<Icon size={19} />
|
||||
</MiniDonut>
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.4 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={700} size="28px" lh={1} style={{ color: "#0f172a" }}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{pct != null ? `${pct}% of queue` : hint}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack gap={8}>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 11,
|
||||
background: chipBg,
|
||||
color: accentDeep,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={2} />
|
||||
</Box>
|
||||
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text fw={800} size="24px" lh={1.05} style={{ color: "#0f172a" }} truncate>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} c="dimmed" truncate>
|
||||
{label}
|
||||
{hint ? ` · ${pct != null ? `${pct}% of queue` : hint}` : ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
{pct != null ? (
|
||||
<MiniRing pct={pct} accent={accent} size={44} stroke={5}>
|
||||
<Text size="10px" fw={800} style={{ color: accentDeep }}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</MiniRing>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{pct == null ? (
|
||||
<MiniSparkline variant={variant} accent={accent} baseline={0.5} seed={label} height={22} />
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function BookingContainersCard({ containers }: BookingContainersCardProps
|
||||
<Table.Td>{container.quantity}</Table.Td>
|
||||
<Table.Td>{container.vgmPerUnitTons} t</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600} c="green.7" size="sm">
|
||||
<Text fw={600} size="sm" style={{ color: "#B26C09" }}>
|
||||
{(container.quantity * container.vgmPerUnitTons).toFixed(2)} t
|
||||
</Text>
|
||||
</Table.Td>
|
||||
|
||||
@@ -21,10 +21,10 @@ export function BookingPaymentCard({
|
||||
Total Amount
|
||||
</Text>
|
||||
<Group align="flex-end" gap="xs">
|
||||
<Title order={1} fw={700} c="green.9" style={{ letterSpacing: "-1px" }}>
|
||||
<Title order={1} fw={700} style={{ letterSpacing: "-1px", color: "#8A5304" }}>
|
||||
{totalAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</Title>
|
||||
<Text fw={600} c="green.7" mb={6}>
|
||||
<Text fw={600} mb={6} style={{ color: "#B26C09" }}>
|
||||
{currency}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -83,7 +83,7 @@ export function BookingRequestHero({
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
|
||||
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="xs" c="green.7" fw={700} tt="uppercase" style={{ letterSpacing: 1 }}>
|
||||
<Text size="xs" fw={700} tt="uppercase" style={{ letterSpacing: 1, color: "#B26C09" }}>
|
||||
Booking reference
|
||||
</Text>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
|
||||
@@ -25,7 +25,7 @@ export function BookingReviewNotesCard({ notes }: BookingReviewNotesCardProps) {
|
||||
<Stack gap="md">
|
||||
{notes.map((note) => (
|
||||
<Group key={note.id} align="flex-start" gap="md" wrap="nowrap">
|
||||
<ThemeIcon size={34} radius="xl" variant="light" color="green">
|
||||
<ThemeIcon size={34} radius="xl" variant="light" color="#F2A516">
|
||||
<FileText size={16} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { FREIGHT_BRAND } from "@/theme/freight-brand";
|
||||
|
||||
/** Single brand accent. Minimal design uses solid green sparingly, no gradients. */
|
||||
export const BRAND_GREEN = FREIGHT_BRAND;
|
||||
/** Single brand accent (gold). Used sparingly for icons/highlights, no gradients. */
|
||||
export const BRAND_GREEN = "#F2A516";
|
||||
|
||||
/** Centralised style tokens for the booking detail page + cards. */
|
||||
export const detailStyles = {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
import {
|
||||
overviewAccentGradients,
|
||||
type OverviewAccent,
|
||||
} from "@/components/overview/overview.styles";
|
||||
|
||||
/**
|
||||
* Tiny inline-SVG mini graphs shared by every KPI / stat card across the app.
|
||||
* No charting dependency — crisp and cheap to render in long card grids.
|
||||
*
|
||||
* - `MiniSparkline` (variant "area" | "line") draws a smooth decorative trend.
|
||||
* - `MiniRing` draws a circular percentage gauge.
|
||||
*
|
||||
* The sparkline series is deterministic (seeded from a string) so a given card
|
||||
* always renders the same shape — purely decorative, not a real time-series.
|
||||
*/
|
||||
|
||||
/** Deterministic, gently-rising series seeded by a string (purely decorative). */
|
||||
export function sparkHeights(seed: string, baseline: number, count = 9): number[] {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < seed.length; i += 1) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619) >>> 0;
|
||||
}
|
||||
const out: number[] = [];
|
||||
let v = 0.35 + (baseline > 0 ? Math.min(0.35, baseline * 0.35) : 0.15);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
h = (Math.imul(h, 1103515245) + 12345) >>> 0;
|
||||
const delta = ((h % 1000) / 1000 - 0.42) * 0.28;
|
||||
v = Math.min(1, Math.max(0.22, v + delta));
|
||||
out.push(v);
|
||||
}
|
||||
// Nudge the final point up so the series reads as an upward trend.
|
||||
out[out.length - 1] = Math.min(1, out[out.length - 1] + 0.15);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build a smooth (Catmull-Rom → bezier) path string through y-points in [0,1]. */
|
||||
function smoothPath(values: number[], width: number, height: number, pad = 2): string {
|
||||
const n = values.length;
|
||||
if (n === 0) return "";
|
||||
const innerW = width - pad * 2;
|
||||
const innerH = height - pad * 2;
|
||||
const pts = values.map((v, i) => ({
|
||||
x: pad + (n === 1 ? 0 : (i / (n - 1)) * innerW),
|
||||
y: pad + (1 - v) * innerH,
|
||||
}));
|
||||
if (n === 1) return `M ${pts[0].x} ${pts[0].y}`;
|
||||
let d = `M ${pts[0].x} ${pts[0].y}`;
|
||||
for (let i = 0; i < n - 1; i += 1) {
|
||||
const p0 = pts[i - 1] ?? pts[i];
|
||||
const p1 = pts[i];
|
||||
const p2 = pts[i + 1];
|
||||
const p3 = pts[i + 2] ?? p2;
|
||||
const c1x = p1.x + (p2.x - p0.x) / 6;
|
||||
const c1y = p1.y + (p2.y - p0.y) / 6;
|
||||
const c2x = p2.x - (p3.x - p1.x) / 6;
|
||||
const c2y = p2.y - (p3.y - p1.y) / 6;
|
||||
d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${p2.x} ${p2.y}`;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
export type SparklineVariant = "area" | "line";
|
||||
|
||||
/**
|
||||
* Smooth decorative trend. `area` fills under the curve with an accent gradient;
|
||||
* `line` is a clean stroke with a highlighted dot at the latest point.
|
||||
*/
|
||||
export function MiniSparkline({
|
||||
variant,
|
||||
accent = "default",
|
||||
seed,
|
||||
baseline = 0,
|
||||
height = 38,
|
||||
}: {
|
||||
variant: SparklineVariant;
|
||||
accent?: OverviewAccent;
|
||||
seed: string;
|
||||
baseline?: number;
|
||||
height?: number;
|
||||
}) {
|
||||
const [light, deep] = overviewAccentGradients[accent] ?? overviewAccentGradients.default;
|
||||
const values = sparkHeights(seed, baseline);
|
||||
const width = 120;
|
||||
const pad = 3;
|
||||
const line = smoothPath(values, width, height, pad);
|
||||
const last = values[values.length - 1];
|
||||
const lastX = pad + ((values.length - 1) / (values.length - 1 || 1)) * (width - pad * 2);
|
||||
const lastY = pad + (1 - last) * (height - pad * 2);
|
||||
// Unique gradient id per seed+accent so multiple cards don't collide.
|
||||
const gid = `spark-${variant}-${accent}-${seed.replace(/[^a-zA-Z0-9]/g, "")}`;
|
||||
|
||||
return (
|
||||
<Box style={{ width: "100%" }}>
|
||||
<svg
|
||||
width="100%"
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
style={{ display: "block", overflow: "visible" }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={deep} stopOpacity={variant === "area" ? 0.32 : 0} />
|
||||
<stop offset="100%" stopColor={light} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{variant === "area" ? (
|
||||
<path
|
||||
d={`${line} L ${width - pad} ${height - pad} L ${pad} ${height - pad} Z`}
|
||||
fill={`url(#${gid})`}
|
||||
stroke="none"
|
||||
/>
|
||||
) : null}
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={deep}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{variant === "line" ? (
|
||||
<circle cx={lastX} cy={lastY} r={3} fill={deep} stroke="white" strokeWidth={1.5} />
|
||||
) : null}
|
||||
</svg>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Circular percentage gauge with an accent stroke. */
|
||||
export function MiniRing({
|
||||
pct,
|
||||
accent = "default",
|
||||
size = 52,
|
||||
stroke = 5,
|
||||
children,
|
||||
}: {
|
||||
pct: number | null | undefined;
|
||||
accent?: OverviewAccent;
|
||||
size?: number;
|
||||
stroke?: number;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const [, deep] = overviewAccentGradients[accent] ?? overviewAccentGradients.default;
|
||||
const radius = (size - stroke) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const clamped = pct != null ? Math.min(100, Math.max(0, Math.round(pct))) : null;
|
||||
const dash = clamped != null ? (clamped / 100) * circumference : 0;
|
||||
|
||||
return (
|
||||
<Box style={{ position: "relative", width: size, height: size, flexShrink: 0 }}>
|
||||
<svg width={size} height={size} style={{ transform: "rotate(-90deg)", display: "block" }}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--mantine-color-gray-2)"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
{clamped != null ? (
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={deep}
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${dash} ${circumference}`}
|
||||
style={{ transition: "stroke-dasharray 400ms ease" }}
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: deep,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -78,8 +78,8 @@ const FreightDashboardHeader = ({
|
||||
<header className="fdh-root">
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<span className="fdh-eyebrow">
|
||||
<span className="fdh-eyebrow-dot" />
|
||||
Freight Backoffice
|
||||
{/* <span className="fdh-eyebrow-dot" />
|
||||
Freight Backoffice */}
|
||||
</span>
|
||||
<Text
|
||||
fw={700}
|
||||
@@ -88,9 +88,9 @@ const FreightDashboardHeader = ({
|
||||
>
|
||||
{pageMeta.title}
|
||||
</Text>
|
||||
<Text size="sm" truncate style={{ color: "#94a3b8", lineHeight: 1.35 }}>
|
||||
{/* <Text size="sm" truncate style={{ color: "#94a3b8", lineHeight: 1.35 }}>
|
||||
{pageMeta.subtitle}
|
||||
</Text>
|
||||
</Text> */}
|
||||
</Stack>
|
||||
|
||||
<Group gap={10} wrap="nowrap">
|
||||
|
||||
@@ -79,11 +79,11 @@ const FreightDashboardLayout = ({
|
||||
height: "100dvh",
|
||||
overflow: "hidden",
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
padding: "8px",
|
||||
padding: "0px",
|
||||
fontFamily: "'Outfit', var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
<Box style={{ display: "flex", height: "100%", minHeight: 0, width: "100%", gap: "8px" }}>
|
||||
<Box style={{ display: "flex", height: "100%", minHeight: 0, width: "100%", gap: "5px" }}>
|
||||
<FreightSidebar
|
||||
sections={sidebarSections}
|
||||
activeHref={activeHref}
|
||||
|
||||
@@ -21,15 +21,15 @@ export function OverviewBookingTrendChart({ data }: { data: IOverviewTrendPoint[
|
||||
const hasData = data.some((point) => point.count > 0);
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
|
||||
<Stack gap="md" h="100%">
|
||||
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
|
||||
<Stack gap="sm" h="100%">
|
||||
<Text fw={600}>Booking trend</Text>
|
||||
{!hasData ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No bookings in this period
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="bookingTrendFill" x1="0" y1="0" x2="0" y2="1">
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
import { Paper, Stack, Text } from "@mantine/core";
|
||||
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { overviewChartColors } from "./overview.styles";
|
||||
|
||||
@@ -27,42 +27,100 @@ export function OverviewDonutChart({
|
||||
}: OverviewDonutChartProps) {
|
||||
const filtered = data.filter((item) => item.value > 0);
|
||||
const hasData = filtered.length > 0;
|
||||
const total = filtered.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 300 }}>
|
||||
<Stack gap="md" h="100%">
|
||||
<Text fw={600}>{title}</Text>
|
||||
<Paper p="md" radius="lg" withBorder h="100%">
|
||||
<Stack gap="sm" h="100%">
|
||||
<Text fw={600} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
{!hasData ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
{emptyMessage}
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={filtered}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={55}
|
||||
outerRadius={90}
|
||||
paddingAngle={2}
|
||||
<Group gap="md" wrap="nowrap" align="center" style={{ flex: 1 }}>
|
||||
{/* Compact donut with the total in its center */}
|
||||
<Box style={{ position: "relative", width: 132, height: 132, flexShrink: 0 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={filtered}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={42}
|
||||
outerRadius={62}
|
||||
paddingAngle={2}
|
||||
stroke="none"
|
||||
>
|
||||
{filtered.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.name}
|
||||
fill={
|
||||
overviewChartColors.pipeline[
|
||||
index % overviewChartColors.pipeline.length
|
||||
]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value) => [value, "Count"]} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{filtered.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.name}
|
||||
fill={
|
||||
overviewChartColors.pipeline[
|
||||
index % overviewChartColors.pipeline.length
|
||||
]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value) => [value, "Count"]} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<Text fw={800} size="xl" lh={1} style={{ color: "#0f172a" }}>
|
||||
{total}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed" fw={600} tt="uppercase" style={{ letterSpacing: 0.4 }}>
|
||||
Total
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Legend fills the space — color, name, count and share */}
|
||||
<Stack gap={6} style={{ flex: 1, minWidth: 0 }}>
|
||||
{filtered.map((entry, index) => {
|
||||
const color =
|
||||
overviewChartColors.pipeline[index % overviewChartColors.pipeline.length];
|
||||
const pct = total > 0 ? Math.round((entry.value / total) * 100) : 0;
|
||||
return (
|
||||
<Group key={entry.name} gap={8} wrap="nowrap" justify="space-between">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
w={9}
|
||||
h={9}
|
||||
style={{ borderRadius: 3, background: color, flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{entry.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<Text size="xs" fw={700} c="dark.5">
|
||||
{entry.value}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ width: 34, textAlign: "right" }}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -36,8 +36,8 @@ export function OverviewHorizontalBarChart({
|
||||
const hasData = chartData.length > 0;
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 300 }}>
|
||||
<Stack gap="md" h="100%">
|
||||
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 240 }}>
|
||||
<Stack gap="sm" h="100%">
|
||||
<Text fw={600}>{title}</Text>
|
||||
{!hasData ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Card, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { MiniRing, MiniSparkline } from "@/components/common/MiniGraph";
|
||||
import {
|
||||
overviewAccentGradients,
|
||||
type OverviewAccent,
|
||||
} from "./overview.styles";
|
||||
|
||||
/** Mini-graph type rendered at the bottom of a KPI card. */
|
||||
export type KpiGraphVariant = "area" | "line" | "ring";
|
||||
|
||||
/** Pale chip background per accent — matches the Pencil "tinted icon chip" look. */
|
||||
const ACCENT_CHIP_BG: Record<string, string> = {
|
||||
export const ACCENT_CHIP_BG: Record<string, string> = {
|
||||
default: "#F1F5F4",
|
||||
emerald: "#E7F8F2",
|
||||
amber: "#FEF9C3",
|
||||
rose: "#FFE4E6",
|
||||
sky: "#E0F2FE",
|
||||
violet: "#EDE9FE",
|
||||
gold: "#FEF1D5",
|
||||
orange: "#FEEAD7",
|
||||
};
|
||||
|
||||
export interface OverviewKpiItem {
|
||||
@@ -22,67 +28,10 @@ export interface OverviewKpiItem {
|
||||
hint?: string;
|
||||
icon: LucideIcon;
|
||||
accent?: keyof typeof ACCENT_CHIP_BG;
|
||||
/** Share 0..1 used as a baseline for the decorative trend sparkline. */
|
||||
/** Share 0..1 used as a baseline for the decorative trend + ring gauge. */
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
/** Deterministic, gently-rising series seeded by the KPI label (purely decorative). */
|
||||
function sparkHeights(seed: string, baseline: number, count = 9): number[] {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < seed.length; i += 1) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619) >>> 0;
|
||||
}
|
||||
const out: number[] = [];
|
||||
let v = 0.35 + (baseline > 0 ? Math.min(0.35, baseline * 0.35) : 0.15);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
h = (Math.imul(h, 1103515245) + 12345) >>> 0;
|
||||
const delta = ((h % 1000) / 1000 - 0.42) * 0.28;
|
||||
v = Math.min(1, Math.max(0.22, v + delta));
|
||||
out.push(v);
|
||||
}
|
||||
// Nudge the final bar up so the series reads as an upward trend.
|
||||
out[out.length - 1] = Math.min(1, out[out.length - 1] + 0.15);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Compact bar sparkline; last bar highlighted in the accent colour. */
|
||||
function KpiSparkline({
|
||||
accent,
|
||||
baseline,
|
||||
seed,
|
||||
}: {
|
||||
accent: OverviewAccent;
|
||||
baseline: number;
|
||||
seed: string;
|
||||
}) {
|
||||
const [light, deep] = overviewAccentGradients[accent] ?? overviewAccentGradients.default;
|
||||
const bars = sparkHeights(seed, baseline);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
gap: 4,
|
||||
height: 38,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{bars.map((value, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: Math.round(8 + value * 30),
|
||||
borderRadius: 3,
|
||||
background: index === bars.length - 1 ? deep : light,
|
||||
opacity: index === bars.length - 1 ? 1 : 0.55,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
/** Mini-graph type; defaults to "area" when not provided. */
|
||||
variant?: KpiGraphVariant;
|
||||
}
|
||||
|
||||
export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
|
||||
@@ -91,10 +40,11 @@ export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
|
||||
const accentKey = accent as OverviewAccent;
|
||||
const [, accentDeep] = overviewAccentGradients[accentKey] ?? overviewAccentGradients.default;
|
||||
const chipBg = ACCENT_CHIP_BG[accent] ?? ACCENT_CHIP_BG.default;
|
||||
const variant = item.variant ?? "area";
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="lg"
|
||||
p="md"
|
||||
radius={16}
|
||||
withBorder
|
||||
style={{
|
||||
@@ -113,15 +63,15 @@ export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
}}
|
||||
>
|
||||
<Stack gap={14}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Stack gap={8}>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 38,
|
||||
height: 38,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 11,
|
||||
background: chipBg,
|
||||
color: accentDeep,
|
||||
@@ -130,25 +80,38 @@ export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
|
||||
>
|
||||
<Icon size={20} strokeWidth={2} />
|
||||
</div>
|
||||
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="24px" fw={800} style={{ lineHeight: 1.05, letterSpacing: "-0.02em" }} truncate>
|
||||
{item.value}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} c="dimmed" truncate>
|
||||
{item.label}
|
||||
{item.hint ? ` · ${item.hint}` : ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
{variant === "ring" ? (
|
||||
<MiniRing
|
||||
pct={Math.round((item.progress ?? 0) * 100)}
|
||||
accent={accentKey}
|
||||
size={44}
|
||||
stroke={5}
|
||||
>
|
||||
<Text size="10px" fw={800} style={{ color: accentDeep }}>
|
||||
{Math.round((item.progress ?? 0) * 100)}%
|
||||
</Text>
|
||||
</MiniRing>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<Stack gap={3} style={{ minWidth: 0 }}>
|
||||
<Text size="30px" fw={800} style={{ lineHeight: 1, letterSpacing: "-0.02em" }} truncate>
|
||||
{item.value}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600} c="dimmed" truncate>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.hint && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
· {item.hint}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<KpiSparkline accent={accentKey} baseline={item.progress ?? 0} seed={item.label} />
|
||||
{variant !== "ring" ? (
|
||||
<MiniSparkline
|
||||
variant={variant}
|
||||
accent={accentKey}
|
||||
baseline={item.progress ?? 0}
|
||||
seed={item.label}
|
||||
height={22}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { Group, Paper, Text } from "@mantine/core";
|
||||
|
||||
import { OverviewKpiCard, type OverviewKpiItem } from "./OverviewKpiCard";
|
||||
import {
|
||||
OverviewKpiCard,
|
||||
type KpiGraphVariant,
|
||||
type OverviewKpiItem,
|
||||
} from "./OverviewKpiCard";
|
||||
|
||||
/** Rotate mini-graph types per card so each strip reads as a lively mix. */
|
||||
const VARIANT_CYCLE: KpiGraphVariant[] = ["area", "line", "ring"];
|
||||
|
||||
/** Cohesive accent rotation — gold-forward with an orange and neutral break. */
|
||||
const ACCENT_CYCLE: NonNullable<OverviewKpiItem["accent"]>[] = [
|
||||
"gold",
|
||||
"orange",
|
||||
"default",
|
||||
];
|
||||
|
||||
interface OverviewKpiStripProps {
|
||||
title?: string;
|
||||
@@ -33,11 +47,13 @@ export function OverviewKpiStrip({ title, items }: OverviewKpiStripProps) {
|
||||
</Text>
|
||||
)}
|
||||
<Group gap="md" align="stretch" wrap="wrap">
|
||||
{items.map((item) => (
|
||||
{items.map((item, index) => (
|
||||
<OverviewKpiCard
|
||||
key={item.label}
|
||||
item={{
|
||||
...item,
|
||||
accent: ACCENT_CYCLE[index % ACCENT_CYCLE.length],
|
||||
variant: item.variant ?? VARIANT_CYCLE[index % VARIANT_CYCLE.length],
|
||||
progress:
|
||||
item.progress ?? (max > 0 ? toNumber(item.value) / max : 0),
|
||||
}}
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Activity, RefreshCw } from "lucide-react";
|
||||
import { ActionIcon, Group, SegmentedControl, Text } from "@mantine/core";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
import type { OverviewRange } from "@/types/overview";
|
||||
|
||||
@@ -29,40 +20,6 @@ function formatRelativeTime(iso: string | undefined) {
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
/** Decorative line-art locomotive + rails — faint brand tint on the right. */
|
||||
function TrainArtwork() {
|
||||
return (
|
||||
<Box
|
||||
aria-hidden
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: -10,
|
||||
bottom: -8,
|
||||
width: 360,
|
||||
height: 200,
|
||||
opacity: 0.06,
|
||||
pointerEvents: "none",
|
||||
color: "var(--mantine-color-green-7)",
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 360 200" fill="none" width="100%" height="100%">
|
||||
<path d="M0 168 H360" stroke="currentColor" strokeWidth="2" strokeDasharray="2 10" strokeLinecap="round" />
|
||||
<path d="M0 180 H360" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M70 60 H250 a14 14 0 0 1 14 14 V150 H56 V94 a34 34 0 0 1 14-28 Z" stroke="currentColor" strokeWidth="3" />
|
||||
<rect x="84" y="80" width="40" height="30" rx="6" stroke="currentColor" strokeWidth="3" />
|
||||
<rect x="140" y="80" width="44" height="30" rx="6" stroke="currentColor" strokeWidth="3" />
|
||||
<rect x="200" y="80" width="44" height="30" rx="6" stroke="currentColor" strokeWidth="3" />
|
||||
<path d="M56 130 H264" stroke="currentColor" strokeWidth="3" />
|
||||
<circle cx="96" cy="150" r="16" stroke="currentColor" strokeWidth="3" />
|
||||
<circle cx="150" cy="150" r="16" stroke="currentColor" strokeWidth="3" />
|
||||
<circle cx="214" cy="150" r="16" stroke="currentColor" strokeWidth="3" />
|
||||
<path d="M264 120 H300 a8 8 0 0 1 8 8 V150 H300" stroke="currentColor" strokeWidth="3" />
|
||||
<path d="M56 100 l-28 -10 M56 112 l-30 0 M56 124 l-28 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewPageHeaderProps {
|
||||
range: OverviewRange;
|
||||
onRangeChange: (range: OverviewRange) => void;
|
||||
@@ -79,59 +36,31 @@ export function OverviewPageHeader({
|
||||
isRefreshing,
|
||||
}: OverviewPageHeaderProps) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
borderRadius: 20,
|
||||
padding: "26px 28px",
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
|
||||
}}
|
||||
>
|
||||
<TrainArtwork />
|
||||
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="lg" style={{ position: "relative" }}>
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<Group gap={8} align="center">
|
||||
<ThemeIcon size={28} radius="md" variant="light" color="green">
|
||||
<Activity size={16} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" fw={700} c="green.7" tt="uppercase" style={{ letterSpacing: 1.2 }}>
|
||||
Freight Backoffice · Live
|
||||
</Text>
|
||||
</Group>
|
||||
<Title order={1} style={{ letterSpacing: "-0.03em", fontSize: 34, lineHeight: 1.1, color: "#0f172a" }}>
|
||||
Operations Overview
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Real-time freight performance · updated {formatRelativeTime(generatedAt)}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Group gap="sm" style={{ position: "relative" }}>
|
||||
<SegmentedControl
|
||||
value={range}
|
||||
onChange={(value) => onRangeChange(value as OverviewRange)}
|
||||
data={RANGE_OPTIONS}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
color="green"
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="green"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
aria-label="Refresh dashboard"
|
||||
onClick={onRefresh}
|
||||
loading={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Updated {formatRelativeTime(generatedAt)}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<SegmentedControl
|
||||
value={range}
|
||||
onChange={(value) => onRangeChange(value as OverviewRange)}
|
||||
data={RANGE_OPTIONS}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
color="green"
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="green"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
aria-label="Refresh dashboard"
|
||||
onClick={onRefresh}
|
||||
loading={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,15 +30,15 @@ export function OverviewPaymentChart({ data }: { data: IOverviewPaymentTrendPoin
|
||||
const hasData = data.some((point) => point.amountEtb > 0 || point.amountUsd > 0);
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
|
||||
<Stack gap="md" h="100%">
|
||||
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
|
||||
<Stack gap="sm" h="100%">
|
||||
<Text fw={600}>Payment trend</Text>
|
||||
{!hasData ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No successful payments in this period
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
|
||||
@@ -26,15 +26,15 @@ export function OverviewStatusChart({ data }: { data: IOverviewPipelineCount[] }
|
||||
const hasData = chartData.some((item) => item.count > 0);
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
|
||||
<Stack gap="md" h="100%">
|
||||
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
|
||||
<Stack gap="sm" h="100%">
|
||||
<Text fw={600}>Pipeline by stage</Text>
|
||||
{!hasData ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No bookings in pipeline
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 24 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
export const overviewChartColors = {
|
||||
primary: freightBrand.primary,
|
||||
primaryLight: freightBrand.primaryLight,
|
||||
primaryDark: freightBrand.primaryDark,
|
||||
primary: "#F2A516",
|
||||
primaryLight: "#FBD171",
|
||||
primaryDark: "#D98A0B",
|
||||
muted: freightBrand.mutedBg,
|
||||
etb: freightBrand.primary,
|
||||
etb: "#F2A516",
|
||||
usd: "#0369a1",
|
||||
/** Vibrant, well-separated categorical palette for charts. */
|
||||
/** Vibrant, well-separated categorical palette for charts (gold-forward). */
|
||||
pipeline: [
|
||||
"#1B9E7A", // brand green
|
||||
"#F2A516", // gold (brand accent)
|
||||
"#0ea5e9", // sky
|
||||
"#8b5cf6", // violet
|
||||
"#f59e0b", // amber
|
||||
"#FB8C2E", // orange
|
||||
"#14b8a6", // teal
|
||||
"#ec4899", // pink
|
||||
"#f43f5e", // rose
|
||||
@@ -22,6 +22,10 @@ export const overviewChartColors = {
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** Brand gold + complementary orange used as the primary KPI accents. */
|
||||
export const ACCENT_GOLD = "#F2A516";
|
||||
export const ACCENT_ORANGE = "#FB8C2E";
|
||||
|
||||
/** Two-stop gradients keyed by KPI accent — used for radial gauges + accent bars. */
|
||||
export const overviewAccentGradients = {
|
||||
default: ["#94a3b8", "#475569"],
|
||||
@@ -30,6 +34,8 @@ export const overviewAccentGradients = {
|
||||
rose: ["#fb7185", "#e11d48"],
|
||||
sky: ["#38bdf8", "#0284c7"],
|
||||
violet: ["#a78bfa", "#7c3aed"],
|
||||
gold: ["#FBD171", ACCENT_GOLD],
|
||||
orange: ["#FDBA74", ACCENT_ORANGE],
|
||||
} as const;
|
||||
|
||||
export type OverviewAccent = keyof typeof overviewAccentGradients;
|
||||
|
||||
@@ -98,15 +98,15 @@ export function OverviewBillingTabPanel({ data }: OverviewBillingTabPanelProps)
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 300 }}>
|
||||
<Stack gap="md">
|
||||
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600}>Payments by method</Text>
|
||||
{methodChartData.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No payment methods recorded
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<BarChart data={methodChartData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} stroke="#94a3b8" />
|
||||
|
||||
@@ -48,15 +48,15 @@ export function OverviewCustomersTabPanel({ data }: OverviewCustomersTabPanelPro
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
|
||||
<Stack gap="md">
|
||||
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600}>Customer growth</Text>
|
||||
{!hasGrowth ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No new customers in this period
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<AreaChart data={data.customerGrowthTrend}>
|
||||
<defs>
|
||||
<linearGradient id="customerGrowthFill" x1="0" y1="0" x2="0" y2="1">
|
||||
|
||||
@@ -47,15 +47,15 @@ export function OverviewStaffTabPanel({ data }: OverviewStaffTabPanelProps) {
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
|
||||
<Stack gap="md">
|
||||
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600}>Employee onboarding trend</Text>
|
||||
{!hasGrowth ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No new employees in this period
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<AreaChart data={data.employeeGrowthTrend}>
|
||||
<defs>
|
||||
<linearGradient id="employeeGrowthFill" x1="0" y1="0" x2="0" y2="1">
|
||||
|
||||
@@ -93,12 +93,14 @@ function Wheels({ count = 2, dark = false }: { count?: number; dark?: boolean })
|
||||
<Box
|
||||
key={i}
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
width: 13,
|
||||
height: 13,
|
||||
borderRadius: "50%",
|
||||
background: dark ? "#0f291b" : "var(--mantine-color-gray-7)",
|
||||
background: dark
|
||||
? "radial-gradient(circle at 35% 35%, #2c4a3a, #0f291b)"
|
||||
: "radial-gradient(circle at 35% 35%, var(--mantine-color-gray-5), var(--mantine-color-gray-8))",
|
||||
border: "2px solid var(--mantine-color-gray-4)",
|
||||
boxShadow: "inset 0 0 0 2px rgba(255,255,255,0.25)",
|
||||
boxShadow: "inset 0 0 0 2px rgba(255,255,255,0.3), 0 1px 2px rgba(0,0,0,0.2)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -108,7 +110,7 @@ function Wheels({ count = 2, dark = false }: { count?: number; dark?: boolean })
|
||||
|
||||
function Coupler() {
|
||||
return (
|
||||
<Box style={{ width: 16, height: 74, display: "flex", alignItems: "center", flexShrink: 0 }}>
|
||||
<Box style={{ width: 16, height: 78, display: "flex", alignItems: "center", flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: "100%",
|
||||
@@ -116,6 +118,7 @@ function Coupler() {
|
||||
borderRadius: 3,
|
||||
background:
|
||||
"linear-gradient(90deg, var(--mantine-color-gray-4), var(--mantine-color-gray-6), var(--mantine-color-gray-4))",
|
||||
boxShadow: "0 1px 1px rgba(0,0,0,0.15)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -142,62 +145,109 @@ function LocomotiveCar({
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
height: 74,
|
||||
borderRadius: "16px 22px 10px 10px",
|
||||
background: freightBrand.gradient,
|
||||
boxShadow: freightBrand.shadowSm,
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
height: 78,
|
||||
borderRadius: "14px 26px 10px 10px",
|
||||
background: `linear-gradient(160deg, ${freightBrand.primaryLight} 0%, ${freightBrand.primary} 45%, ${freightBrand.primaryDark} 100%)`,
|
||||
boxShadow: `${freightBrand.shadowSm}, inset 0 1px 0 rgba(255,255,255,0.25)`,
|
||||
border: "1px solid rgba(0,0,0,0.1)",
|
||||
overflow: "hidden",
|
||||
padding: "8px 10px",
|
||||
padding: "10px 10px 8px",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{/* cab windows */}
|
||||
{/* roofline */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 10,
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 14,
|
||||
height: 5,
|
||||
background: "rgba(0,0,0,0.22)",
|
||||
borderRadius: "14px 0 0 0",
|
||||
}}
|
||||
>
|
||||
<Box style={{ width: 14, height: 12, borderRadius: 3, background: "rgba(255,255,255,0.85)" }} />
|
||||
<Box style={{ width: 14, height: 12, borderRadius: 3, background: "rgba(255,255,255,0.6)" }} />
|
||||
/>
|
||||
{/* roof vents */}
|
||||
<Box style={{ position: "absolute", top: 1, left: 16, display: "flex", gap: 5 }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Box
|
||||
key={i}
|
||||
style={{
|
||||
width: 10,
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
background: "rgba(255,255,255,0.35)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
{/* cab windows */}
|
||||
<Box style={{ position: "absolute", top: 10, right: 10, display: "flex", gap: 4 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 15,
|
||||
height: 13,
|
||||
borderRadius: "3px 6px 3px 3px",
|
||||
background: "linear-gradient(135deg, #E8FBFF 0%, #9ED9E8 100%)",
|
||||
border: "1px solid rgba(0,0,0,0.15)",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
width: 12,
|
||||
height: 13,
|
||||
borderRadius: 3,
|
||||
background: "linear-gradient(135deg, rgba(232,251,255,0.8), rgba(158,217,232,0.6))",
|
||||
border: "1px solid rgba(0,0,0,0.12)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{/* headlight */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 10,
|
||||
right: 6,
|
||||
width: 7,
|
||||
height: 7,
|
||||
bottom: 14,
|
||||
right: 5,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: "#fde68a",
|
||||
boxShadow: "0 0 8px 2px rgba(253,230,138,0.8)",
|
||||
boxShadow: "0 0 10px 3px rgba(253,230,138,0.9)",
|
||||
}}
|
||||
/>
|
||||
{/* hazard stripe on the nose */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 6,
|
||||
background:
|
||||
"repeating-linear-gradient(45deg, #fbbf24 0 7px, #1f2937 7px 14px)",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
/>
|
||||
<Group gap={6} wrap="nowrap" align="center">
|
||||
<TrainFront size={18} />
|
||||
<Text size="sm" fw={800} style={{ letterSpacing: 0.3 }}>
|
||||
<Text size="sm" fw={800} style={{ letterSpacing: 0.4 }}>
|
||||
{code}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="9px" mt={2} style={{ opacity: 0.85 }} lineClamp={1}>
|
||||
<Text size="9px" mt={1} style={{ opacity: 0.85 }} lineClamp={1}>
|
||||
{name ?? "Locomotive"}
|
||||
</Text>
|
||||
{maxPullWeightTons ? (
|
||||
<Group gap={3} wrap="nowrap" mt={4} style={{ opacity: 0.95 }}>
|
||||
<Group gap={3} wrap="nowrap" mt={3} style={{ opacity: 0.95 }}>
|
||||
<Gauge size={10} />
|
||||
<Text size="9px" fw={600}>
|
||||
<Text size="9px" fw={700}>
|
||||
{maxPullWeightTons}T pull
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Box>
|
||||
<Wheels count={3} dark />
|
||||
<Text size="9px" ta="center" c="dimmed" mt={2} fw={700}>
|
||||
<Text size="9px" ta="center" c="dimmed" mt={2} fw={700} style={{ letterSpacing: 1 }}>
|
||||
HEAD
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -205,6 +255,15 @@ function LocomotiveCar({
|
||||
);
|
||||
}
|
||||
|
||||
const CONTAINER_GRADIENTS = [
|
||||
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
|
||||
"linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))",
|
||||
];
|
||||
const CONTAINER_BORDERS = [
|
||||
"var(--mantine-color-cyan-8)",
|
||||
"var(--mantine-color-blue-8)",
|
||||
];
|
||||
|
||||
function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
const utilization =
|
||||
wagon.capacityTons > 0
|
||||
@@ -219,7 +278,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : ""
|
||||
}${
|
||||
wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : ""
|
||||
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}`;
|
||||
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)`;
|
||||
|
||||
// container blocks: one per container number (cap visual at 2 = TEU per wagon)
|
||||
const blocks = wagon.containerNumbers.slice(0, 2);
|
||||
@@ -230,13 +289,15 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
height: 74,
|
||||
height: 78,
|
||||
borderRadius: 12,
|
||||
background: wagon.isEmpty ? "var(--mantine-color-gray-0)" : "white",
|
||||
background: wagon.isEmpty
|
||||
? "var(--mantine-color-gray-0)"
|
||||
: "linear-gradient(180deg, white, var(--mantine-color-gray-0))",
|
||||
border: wagon.isEmpty
|
||||
? "1.5px dashed var(--mantine-color-gray-4)"
|
||||
: "1px solid var(--mantine-color-gray-3)",
|
||||
boxShadow: wagon.isEmpty ? "none" : "0 2px 8px rgba(15,41,27,0.06)",
|
||||
boxShadow: wagon.isEmpty ? "none" : "0 3px 10px rgba(15,41,27,0.08)",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -257,13 +318,13 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
#{wagon.sequenceNo}
|
||||
</Text>
|
||||
{wagon.isEmpty ? (
|
||||
<Text size="9px" c="dimmed" fw={600}>
|
||||
<Text size="9px" c="dimmed" fw={700} style={{ letterSpacing: 0.5 }}>
|
||||
EMPTY
|
||||
</Text>
|
||||
) : (
|
||||
<Group gap={3} wrap="nowrap">
|
||||
{wagon.isBulk ? <Fuel size={11} color={accentVar} /> : <ContainerIcon size={11} color={accentVar} />}
|
||||
<Text size="9px" fw={700} c={`${accent}.7`}>
|
||||
<Text size="9px" fw={700} c={`${accent}.7`} style={{ letterSpacing: 0.4 }}>
|
||||
{wagon.isBulk ? "BULK" : "CONT"}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -271,7 +332,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
</Group>
|
||||
|
||||
{/* body */}
|
||||
<Box style={{ flex: 1, padding: "4px 8px 6px", display: "flex", alignItems: "center" }}>
|
||||
<Box style={{ flex: 1, padding: "4px 8px 4px", display: "flex", alignItems: "center" }}>
|
||||
{wagon.isEmpty ? (
|
||||
<Group gap={4} justify="center" style={{ width: "100%" }}>
|
||||
<BoxIcon size={14} color="var(--mantine-color-gray-4)" />
|
||||
@@ -296,11 +357,13 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: `${utilization}%`,
|
||||
background: "linear-gradient(90deg, var(--mantine-color-orange-5), var(--mantine-color-orange-3))",
|
||||
background:
|
||||
"linear-gradient(90deg, var(--mantine-color-orange-6), var(--mantine-color-orange-4))",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.35)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Text size="9px" c="dimmed" ta="center">
|
||||
<Text size="9px" c="dimmed" ta="center" fw={600}>
|
||||
{wagon.assignedWeightTons}/{wagon.capacityTons}T
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -312,17 +375,30 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 26,
|
||||
height: 28,
|
||||
borderRadius: 5,
|
||||
background: "linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
|
||||
border: "1px solid var(--mantine-color-cyan-8)",
|
||||
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
|
||||
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3), 0 1px 2px rgba(0,0,0,0.15)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 3px",
|
||||
}}
|
||||
>
|
||||
<Text size="8px" fw={700} c="white" truncate>
|
||||
{/* corrugation lines */}
|
||||
<Box
|
||||
style={{
|
||||
width: "80%",
|
||||
height: 2,
|
||||
marginBottom: 2,
|
||||
background:
|
||||
"repeating-linear-gradient(90deg, rgba(255,255,255,0.4) 0 3px, transparent 3px 6px)",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
||||
{cn}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -331,6 +407,22 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* utilization hairline */}
|
||||
{!wagon.isEmpty && !wagon.isBulk ? (
|
||||
<Box style={{ height: 3, background: "var(--mantine-color-gray-1)" }}>
|
||||
<Box
|
||||
style={{
|
||||
width: `${utilization}%`,
|
||||
height: "100%",
|
||||
background:
|
||||
utilization >= 100
|
||||
? "var(--mantine-color-red-5)"
|
||||
: `var(--mantine-color-${accent}-5)`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* footer */}
|
||||
<Box
|
||||
style={{
|
||||
@@ -339,9 +431,16 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
background: wagon.isEmpty ? "transparent" : "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
<Text size="8px" c="dimmed" truncate>
|
||||
{wagon.physicalWagonNumber ?? wagon.wagonTypeCode ?? "Wagon"}
|
||||
</Text>
|
||||
<Group justify="space-between" wrap="nowrap" gap={4}>
|
||||
<Text size="8px" c="dimmed" fw={600} truncate>
|
||||
{wagon.physicalWagonNumber ?? wagon.wagonTypeCode ?? "Wagon"}
|
||||
</Text>
|
||||
{!wagon.isEmpty ? (
|
||||
<Text size="8px" c="gray.6" fw={700} style={{ whiteSpace: "nowrap" }}>
|
||||
{wagon.assignedWeightTons}T
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
<Wheels count={2} />
|
||||
@@ -350,6 +449,56 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Railway track: two rails over evenly-spaced sleepers. */
|
||||
function TrackBed() {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 4,
|
||||
right: 4,
|
||||
bottom: 4,
|
||||
height: 10,
|
||||
}}
|
||||
>
|
||||
{/* sleepers */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background:
|
||||
"repeating-linear-gradient(90deg, var(--mantine-color-gray-4) 0 5px, transparent 5px 20px)",
|
||||
opacity: 0.6,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
{/* rails */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 1,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
background: "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 1,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
background: "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrainCompositionDiagram({
|
||||
locomotive,
|
||||
wagons,
|
||||
@@ -404,8 +553,7 @@ export function TrainCompositionDiagram({
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-gray-2)",
|
||||
background:
|
||||
"linear-gradient(180deg, var(--mantine-color-gray-0) 0%, white 40%)",
|
||||
background: "white",
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
@@ -417,17 +565,20 @@ export function TrainCompositionDiagram({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 10,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 11,
|
||||
background: freightBrand.gradient,
|
||||
boxShadow: freightBrand.shadowSm,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<TrainFront size={20} />
|
||||
<TrainFront size={21} />
|
||||
</Box>
|
||||
<Stack gap={0}>
|
||||
<Text fw={700}>Train composition</Text>
|
||||
<Text fw={800} style={{ letterSpacing: 0.2 }}>
|
||||
Train composition
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{trainNumber ? `${trainNumber} · ` : ""}
|
||||
{stats.total} wagons · {stats.assigned} loaded · {stats.empty} empty
|
||||
@@ -436,35 +587,58 @@ export function TrainCompositionDiagram({
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
<LegendDot color="cyan" label="Container" />
|
||||
<LegendDot color="orange" label="Bulk" />
|
||||
<LegendDot color="gray" label="Empty" />
|
||||
<Group gap="md">
|
||||
<Group
|
||||
gap={6}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
borderRadius: 999,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={700} c="dark.4">
|
||||
{stats.totalWeight}T
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
of {stats.totalCapacity}T capacity
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<LegendDot color="cyan" label="Container" />
|
||||
<LegendDot color="orange" label="Bulk" />
|
||||
<LegendDot color="gray" label="Empty" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Locomotive pull gauge */}
|
||||
{stats.pullUtil != null ? (
|
||||
<Box
|
||||
p="xs"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: "var(--mantine-color-green-0)",
|
||||
borderRadius: 12,
|
||||
background: "linear-gradient(135deg, var(--mantine-color-green-0), #F2FBF7)",
|
||||
border: "1px solid var(--mantine-color-green-1)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" fw={600} c="green.8">
|
||||
Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T
|
||||
</Text>
|
||||
<Text size="xs" fw={700} c={stats.pullUtil > 95 ? "red.7" : "green.7"}>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Gauge size={14} color={freightBrand.primary} />
|
||||
<Text size="xs" fw={700} c="green.8">
|
||||
Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "green.7"}>
|
||||
{stats.pullUtil}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={stats.pullUtil}
|
||||
size="sm"
|
||||
size="md"
|
||||
radius="xl"
|
||||
striped={stats.pullUtil > 95}
|
||||
animated={stats.pullUtil > 95}
|
||||
color={stats.pullUtil > 95 ? "red" : stats.pullUtil > 80 ? "yellow" : "green"}
|
||||
/>
|
||||
</Box>
|
||||
@@ -480,20 +654,8 @@ export function TrainCompositionDiagram({
|
||||
const turnSide: "left" | "right" = rowIndex % 2 === 0 ? "right" : "left";
|
||||
return (
|
||||
<Box key={rowIndex}>
|
||||
<Box style={{ position: "relative", paddingBottom: 6 }}>
|
||||
{/* rail under the row */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 4,
|
||||
right: 4,
|
||||
bottom: 8,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
background:
|
||||
"repeating-linear-gradient(90deg, var(--mantine-color-gray-5) 0 10px, var(--mantine-color-gray-3) 10px 16px)",
|
||||
}}
|
||||
/>
|
||||
<Box style={{ position: "relative", paddingBottom: 10 }}>
|
||||
<TrackBed />
|
||||
<Group
|
||||
gap={0}
|
||||
wrap="nowrap"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Batch board / batch detail shared visuals */
|
||||
|
||||
@keyframes bbPulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(27, 158, 122, 0.5);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(27, 158, 122, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(27, 158, 122, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.bb-pulse-dot {
|
||||
animation: bbPulse 2.2s ease-out infinite;
|
||||
}
|
||||
|
||||
.bb-card {
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.bb-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 18px 40px -16px rgba(13, 60, 44, 0.26);
|
||||
border-color: var(--mantine-color-green-3);
|
||||
}
|
||||
|
||||
.bb-card .bb-arrow {
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.bb-card:hover .bb-arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.bb-window-accordion .mantine-Accordion-item {
|
||||
border: 1px solid var(--mantine-color-gray-2);
|
||||
border-radius: 14px;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.bb-window-accordion .mantine-Accordion-item[data-active] {
|
||||
border-color: var(--mantine-color-green-3);
|
||||
box-shadow: 0 8px 22px -14px rgba(13, 60, 44, 0.2);
|
||||
}
|
||||
|
||||
.bb-window-accordion .mantine-Accordion-control:hover {
|
||||
background: var(--mantine-color-green-0);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Group, Progress, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import "./batchVisuals.css";
|
||||
|
||||
/**
|
||||
* Shared visual building blocks for the batch board surfaces (list + detail).
|
||||
* Everything keys off the same booking-pipeline color language so the two
|
||||
* pages read as one clean, professional product on a white surface.
|
||||
*/
|
||||
|
||||
export type BatchCounts = {
|
||||
allocated: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
pendingContract: number;
|
||||
expired: number;
|
||||
};
|
||||
|
||||
export const PIPELINE_STAGES: ReadonlyArray<{
|
||||
key: keyof BatchCounts;
|
||||
label: string;
|
||||
color: string;
|
||||
hint: string;
|
||||
}> = [
|
||||
{
|
||||
key: "allocated",
|
||||
label: "Allocated",
|
||||
color: "green",
|
||||
hint: "Assigned to wagons on the train",
|
||||
},
|
||||
{
|
||||
key: "selectedForBatch",
|
||||
label: "Selected",
|
||||
color: "orange",
|
||||
hint: "Picked by batch — customer notified to pay",
|
||||
},
|
||||
{
|
||||
key: "ready",
|
||||
label: "Ready",
|
||||
color: "teal",
|
||||
hint: "Contract signed — waiting for batch pick",
|
||||
},
|
||||
{
|
||||
key: "waiting",
|
||||
label: "Waiting",
|
||||
color: "blue",
|
||||
hint: "Paid — waiting for a slot",
|
||||
},
|
||||
{
|
||||
key: "pendingContract",
|
||||
label: "Pending contract",
|
||||
color: "gray",
|
||||
hint: "Contract not signed yet",
|
||||
},
|
||||
{
|
||||
key: "expired",
|
||||
label: "Expired",
|
||||
color: "red",
|
||||
hint: "Payment deadline missed",
|
||||
},
|
||||
];
|
||||
|
||||
export function totalBookingCount(counts: BatchCounts): number {
|
||||
return PIPELINE_STAGES.reduce((sum, stage) => sum + counts[stage.key], 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacked booking-pipeline bar: one colored segment per batch state, with an
|
||||
* optional dot legend underneath. Reads as a single glanceable funnel.
|
||||
*/
|
||||
export function BookingPipeline({
|
||||
counts,
|
||||
size = 10,
|
||||
showLegend = true,
|
||||
}: {
|
||||
counts: BatchCounts;
|
||||
size?: number;
|
||||
showLegend?: boolean;
|
||||
}) {
|
||||
const total = totalBookingCount(counts);
|
||||
const stages = PIPELINE_STAGES.filter((s) => counts[s.key] > 0);
|
||||
|
||||
if (!total) {
|
||||
return (
|
||||
<Box>
|
||||
<Progress value={0} size={size} radius="xl" />
|
||||
<Text size="xs" c="dimmed" mt={6}>
|
||||
No bookings yet
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Progress.Root size={size} radius="xl">
|
||||
{stages.map((stage) => (
|
||||
<Tooltip
|
||||
key={stage.key}
|
||||
label={`${counts[stage.key]} ${stage.label.toLowerCase()} — ${stage.hint}`}
|
||||
withArrow
|
||||
>
|
||||
<Progress.Section
|
||||
value={(counts[stage.key] / total) * 100}
|
||||
color={stage.color}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Progress.Root>
|
||||
{showLegend ? (
|
||||
<Group gap={12} mt={9} wrap="wrap">
|
||||
{stages.map((stage) => (
|
||||
<Group key={stage.key} gap={5} wrap="nowrap">
|
||||
<Box
|
||||
w={8}
|
||||
h={8}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${stage.color}-6)`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Text component="span" size="xs" fw={700} c="dark.4">
|
||||
{counts[stage.key]}
|
||||
</Text>{" "}
|
||||
{stage.label.toLowerCase()}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const WINDOW_META: Record<string, { color: string; label: string; pulse: boolean }> = {
|
||||
OPEN: { color: "green", label: "Window open", pulse: true },
|
||||
FULL: { color: "orange", label: "Full", pulse: false },
|
||||
CLOSED: { color: "gray", label: "Closed", pulse: false },
|
||||
};
|
||||
|
||||
/**
|
||||
* Booking-window status pill with a status dot (pulsing while OPEN), styled for
|
||||
* a clean white surface.
|
||||
*/
|
||||
export function WindowStatusPill({ status }: { status: string }) {
|
||||
const meta = WINDOW_META[status] ?? { color: "gray", label: status, pulse: false };
|
||||
return (
|
||||
<Group
|
||||
gap={6}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
padding: "4px 11px",
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${meta.color}-0)`,
|
||||
border: `1px solid var(--mantine-color-${meta.color}-2)`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
w={7}
|
||||
h={7}
|
||||
className={meta.pulse ? "bb-pulse-dot" : undefined}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
background: `var(--mantine-color-${meta.color}-6)`,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
c={`${meta.color}.8`}
|
||||
style={{ letterSpacing: 0.3, lineHeight: 1, whiteSpace: "nowrap" }}
|
||||
>
|
||||
{meta.label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Small neutral info chip used in the page headers (date, loco, status, …).
|
||||
* Clean light surface that reads well on white.
|
||||
*/
|
||||
export function HeroChip({
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
gap={6}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
padding: "4px 10px",
|
||||
borderRadius: 8,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<Text size="xs" fw={600} c="gray.7" style={{ whiteSpace: "nowrap" }}>
|
||||
{children}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,13 @@ 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
|
||||
@@ -95,6 +100,9 @@ export function StatTile({
|
||||
hint,
|
||||
onDark = false,
|
||||
accent = freightBrand.primary,
|
||||
graph = "none",
|
||||
graphAccent = "emerald",
|
||||
graphPct,
|
||||
}: {
|
||||
icon?: LucideIcon;
|
||||
label: string;
|
||||
@@ -102,7 +110,15 @@ export function StatTile({
|
||||
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"
|
||||
@@ -120,50 +136,64 @@ export function StatTile({
|
||||
}
|
||||
}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{Icon ? (
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 36,
|
||||
height: 36,
|
||||
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={2} style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 0.4 }}
|
||||
c={onDark ? "rgba(255,255,255,0.75)" : "dimmed"}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
fw={700}
|
||||
size="lg"
|
||||
lh={1.1}
|
||||
c={onDark ? "white" : undefined}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
{hint ? (
|
||||
<Text size="xs" c={onDark ? "rgba(255,255,255,0.7)" : "dimmed"}>
|
||||
{hint}
|
||||
</Text>
|
||||
<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>
|
||||
</Group>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
@@ -380,47 +379,16 @@ export default function NewBookingPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Hero */}
|
||||
<Paper
|
||||
radius="xl"
|
||||
p="xl"
|
||||
mt="md"
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" style={{ position: "relative" }}>
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={54} radius="lg" variant="light" color="green">
|
||||
<Package size={26} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" fw={700} c="green.7" tt="uppercase" style={{ letterSpacing: 1 }}>
|
||||
New booking · Staff
|
||||
</Text>
|
||||
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
||||
Create freight booking
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" maw={520}>
|
||||
Capture cargo details, container lines, and routing — saved as a draft and pushed
|
||||
into the approval workflow.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="lg"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/booking-requests")}
|
||||
>
|
||||
Back to list
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="lg"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/booking-requests")}
|
||||
>
|
||||
Back to list
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Grid gutter="lg" mt="lg">
|
||||
{/* LEFT — form */}
|
||||
|
||||
@@ -1,30 +1,41 @@
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
RingProgress,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
LayoutGrid,
|
||||
CalendarDays,
|
||||
Inbox,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Ruler,
|
||||
Train,
|
||||
TrainFront,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import {
|
||||
BookingPipeline,
|
||||
HeroChip,
|
||||
totalBookingCount,
|
||||
WindowStatusPill,
|
||||
} from "@/components/trainScheduling/batchVisuals";
|
||||
import { RouteCorridor, StatTile } from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand";
|
||||
import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import type { BatchBoardSchedule } from "@/types/trainScheduling";
|
||||
|
||||
@@ -34,187 +45,227 @@ const fmtTons = (n: number) =>
|
||||
const fmtMeters = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
|
||||
|
||||
const fmtScheduleDate = (iso: string | null) =>
|
||||
iso
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(new Date(iso)) + " EAT"
|
||||
: "No date";
|
||||
|
||||
/** Capacity ring color: gold normally, red once over capacity. */
|
||||
function ringColor(pct: number) {
|
||||
if (pct >= 100) return "#fa5252";
|
||||
return "#F2A516";
|
||||
}
|
||||
|
||||
/** Capacity ring that keeps the explicit allocated/max numbers underneath. */
|
||||
function CapacityRing({
|
||||
pct,
|
||||
label,
|
||||
current,
|
||||
max,
|
||||
}: {
|
||||
pct: number;
|
||||
label: string;
|
||||
current: string;
|
||||
max: string;
|
||||
}) {
|
||||
const clamped = Math.min(100, Math.max(0, pct));
|
||||
const color = ringColor(pct);
|
||||
return (
|
||||
<Stack gap={4} align="center" style={{ flex: 1 }}>
|
||||
<RingProgress
|
||||
size={104}
|
||||
thickness={9}
|
||||
roundCaps
|
||||
sections={[{ value: clamped, color }]}
|
||||
rootColor="var(--mantine-color-gray-1)"
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text ta="center" size="lg" fw={800} lh={1} style={{ color }}>
|
||||
{Math.round(pct)}%
|
||||
</Text>
|
||||
<Text ta="center" size="9px" c="dimmed" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Stack gap={0} align="center">
|
||||
<Text size="xs" fw={700} c="dark.4">
|
||||
{current}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
of {max}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
const navigate = useNavigate();
|
||||
const { capacity, counts, locomotive } = schedule;
|
||||
|
||||
const lengthPct =
|
||||
capacity.maxLengthMeters && capacity.maxLengthMeters > 0
|
||||
? (capacity.allocatedLengthMeters / capacity.maxLengthMeters) * 100
|
||||
: 0;
|
||||
: null;
|
||||
const weightPct =
|
||||
capacity.maxWeightTons && capacity.maxWeightTons > 0
|
||||
? (capacity.usedWeightTons / capacity.maxWeightTons) * 100
|
||||
: 0;
|
||||
: null;
|
||||
|
||||
const windowColor =
|
||||
schedule.bookingWindowStatus === "OPEN"
|
||||
? "green"
|
||||
: schedule.bookingWindowStatus === "FULL"
|
||||
? "orange"
|
||||
: "gray";
|
||||
|
||||
const totalBookings =
|
||||
counts.allocated +
|
||||
counts.selectedForBatch +
|
||||
counts.ready +
|
||||
counts.waiting +
|
||||
counts.pendingContract +
|
||||
counts.expired;
|
||||
const totalBookings = totalBookingCount(counts);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
className="bb-card"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-gray-2)",
|
||||
background: "white",
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
|
||||
}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
height: 3,
|
||||
background:
|
||||
"linear-gradient(90deg, var(--mantine-color-green-5), var(--mantine-color-teal-7))",
|
||||
}}
|
||||
/>
|
||||
<Stack gap="sm" p="md">
|
||||
<Stack gap="md" p="lg" style={{ flex: 1 }}>
|
||||
{/* header */}
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon size={38} radius="md" variant="light" color="green">
|
||||
<Train size={19} />
|
||||
<ThemeIcon size={44} radius="md" variant="light" color="#F2A516">
|
||||
<Train size={22} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={700} size="sm" truncate>
|
||||
<Text fw={800} size="md" truncate style={{ letterSpacing: 0.2 }}>
|
||||
{schedule.trainNumber ?? schedule.routeName ?? "Schedule"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{schedule.origin ?? "—"} → {schedule.destination ?? "—"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{schedule.scheduleDate
|
||||
? new Date(schedule.scheduleDate).toLocaleString()
|
||||
: "No date"}
|
||||
<Text
|
||||
size="10px"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c="dimmed"
|
||||
style={{ letterSpacing: 0.8 }}
|
||||
>
|
||||
Freight schedule · {schedule.status}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Stack gap={4} align="flex-end">
|
||||
<Badge variant="light" color={windowColor} radius="sm">
|
||||
{schedule.bookingWindowStatus}
|
||||
</Badge>
|
||||
<Badge variant="outline" color="gray" radius="sm">
|
||||
{schedule.status}
|
||||
</Badge>
|
||||
</Stack>
|
||||
<WindowStatusPill status={schedule.bookingWindowStatus} />
|
||||
</Group>
|
||||
|
||||
{locomotive ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Loco {locomotive.code} · max {fmtTons(locomotive.maxPullWeightTons)} ·{" "}
|
||||
{locomotive.maxTrainLengthMeters} m
|
||||
</Text>
|
||||
) : (
|
||||
<Badge variant="light" color="red" radius="sm">
|
||||
No locomotive — won't allocate
|
||||
</Badge>
|
||||
)}
|
||||
<RouteCorridor
|
||||
origin={schedule.origin}
|
||||
destination={schedule.destination}
|
||||
variant="compact"
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Group justify="space-between" mb={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Allocated wagons
|
||||
</Text>
|
||||
<Text size="xs" fw={600}>
|
||||
{capacity.allocatedWagons}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
{capacity.maxLengthMeters ? (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={2}>
|
||||
<Group gap={4}>
|
||||
<Ruler size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
Train length
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" fw={600}>
|
||||
{fmtMeters(capacity.allocatedLengthMeters)}/{fmtMeters(capacity.maxLengthMeters)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={lengthPct}
|
||||
color={lengthPct >= 100 ? "orange" : "blue"}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
{capacity.maxWeightTons ? (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={2}>
|
||||
<Group gap={4}>
|
||||
<Weight size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
Weight
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" fw={600}>
|
||||
{fmtTons(capacity.usedWeightTons)}/{fmtTons(capacity.maxWeightTons)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={weightPct}
|
||||
color={weightPct >= 100 ? "red" : "teal"}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Group gap={6}>
|
||||
<Tooltip label="Allocated to the train">
|
||||
<Badge variant="light" color="green" radius="sm">
|
||||
{counts.allocated} allocated
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Tooltip label="Picked by batch — customer notified to pay">
|
||||
<Badge variant="light" color="orange" radius="sm">
|
||||
{counts.selectedForBatch} selected
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Tooltip label="Contract signed — waiting for batch pick">
|
||||
<Badge variant="light" color="teal" radius="sm">
|
||||
{counts.ready} ready
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Tooltip label="Paid, waiting for a slot">
|
||||
<Badge variant="light" color="blue" radius="sm">
|
||||
{counts.waiting} waiting
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
{counts.expired ? (
|
||||
<Badge variant="light" color="red" radius="sm">
|
||||
{counts.expired} expired
|
||||
</Badge>
|
||||
<Group gap={6} wrap="wrap">
|
||||
<HeroChip icon={<CalendarDays size={12} />}>
|
||||
{fmtScheduleDate(schedule.scheduleDate)}
|
||||
</HeroChip>
|
||||
{locomotive ? (
|
||||
<HeroChip icon={<TrainFront size={12} />}>
|
||||
{locomotive.code} · {fmtTons(locomotive.maxPullWeightTons)}
|
||||
</HeroChip>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{totalBookings
|
||||
? `${totalBookings} booking${totalBookings === 1 ? "" : "s"} · click for batch windows`
|
||||
: "No bookings yet · click to open"}
|
||||
</Text>
|
||||
{!locomotive ? (
|
||||
<Alert
|
||||
color="red"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={15} />}
|
||||
py={6}
|
||||
styles={{ message: { fontSize: 12 } }}
|
||||
>
|
||||
No locomotive assigned — wagon allocation cannot run.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* capacity: weight + length rings + wagons (numbers preserved) */}
|
||||
<Box
|
||||
py="sm"
|
||||
px="xs"
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-1)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-around" align="center" wrap="nowrap" gap="xs">
|
||||
{weightPct != null ? (
|
||||
<CapacityRing
|
||||
pct={weightPct}
|
||||
label="WEIGHT"
|
||||
current={fmtTons(capacity.usedWeightTons)}
|
||||
max={fmtTons(capacity.maxWeightTons ?? 0)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Stack gap={0} align="center" style={{ flex: 1 }}>
|
||||
<ThemeIcon size={34} radius="md" variant="light" color="gray">
|
||||
<Package size={17} />
|
||||
</ThemeIcon>
|
||||
<Text fw={800} size="26px" c="dark.5" lh={1.1} mt={6}>
|
||||
{capacity.allocatedWagons}
|
||||
</Text>
|
||||
<Text size="9px" fw={700} c="gray.6" tt="uppercase" style={{ letterSpacing: 0.5 }}>
|
||||
Wagons
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
allocated
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{lengthPct != null ? (
|
||||
<CapacityRing
|
||||
pct={lengthPct}
|
||||
label="LENGTH"
|
||||
current={fmtMeters(capacity.allocatedLengthMeters)}
|
||||
max={fmtMeters(capacity.maxLengthMeters ?? 0)}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* booking pipeline */}
|
||||
<Box>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Package size={13} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="xs" fw={700} c="gray.7" tt="uppercase" style={{ letterSpacing: 0.4 }}>
|
||||
Booking pipeline
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" fw={700} c="dark.4">
|
||||
{totalBookings} booking{totalBookings === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Group>
|
||||
<BookingPipeline counts={counts} />
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* CTA */}
|
||||
<Box px="lg" pb="lg">
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
fullWidth
|
||||
radius="md"
|
||||
size="compact-sm"
|
||||
rightSection={<ArrowRight size={15} />}
|
||||
variant="gradient"
|
||||
gradient={{ from: FREIGHT_BRAND, to: FREIGHT_BRAND_DARK, deg: 135 }}
|
||||
rightSection={<ArrowRight size={16} className="bb-arrow" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`);
|
||||
@@ -222,6 +273,25 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
>
|
||||
View batch windows
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function CardSkeleton() {
|
||||
return (
|
||||
<Paper radius="lg" withBorder style={{ overflow: "hidden" }}>
|
||||
<Stack gap="md" p="lg">
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<Skeleton height={44} width={44} radius="md" />
|
||||
<Skeleton height={32} width={120} />
|
||||
</Group>
|
||||
<Skeleton height={22} width={90} radius="xl" />
|
||||
</Group>
|
||||
<Skeleton height={120} radius="md" />
|
||||
<Skeleton height={10} radius="xl" />
|
||||
<Skeleton height={36} radius="md" />
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
@@ -231,62 +301,99 @@ export default function BatchBoardPage() {
|
||||
const { data, isLoading, isFetching, refetch } = useBatchBoard();
|
||||
const schedules = data ?? [];
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const openWindows = schedules.filter((s) => s.bookingWindowStatus === "OPEN").length;
|
||||
const totalBookings = schedules.reduce((sum, s) => sum + totalBookingCount(s.counts), 0);
|
||||
const totalWagons = schedules.reduce((sum, s) => sum + s.capacity.allocatedWagons, 0);
|
||||
return { openWindows, totalBookings, totalWagons };
|
||||
}, [schedules]);
|
||||
|
||||
return (
|
||||
<Container size="xl" py="lg">
|
||||
<Container fluid py="lg" px="xl">
|
||||
<Breadcrumbs items={[{ label: "Operations" }, { label: "Batch board" }]} />
|
||||
|
||||
<Paper
|
||||
radius="xl"
|
||||
p="xl"
|
||||
mt="md"
|
||||
style={{
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={54} radius="lg" variant="light" color="green">
|
||||
<LayoutGrid size={26} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" fw={700} c="green.7" tt="uppercase" style={{ letterSpacing: 1 }}>
|
||||
Allocation · Live
|
||||
</Text>
|
||||
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
||||
Batch board
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" maw={560}>
|
||||
Active schedules — click a card to see EAT 3-hour batch windows, bookings, and
|
||||
wagon allocation status.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="lg"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="lg"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="md" mt="md">
|
||||
<StatTile
|
||||
icon={Train}
|
||||
label="Active schedules"
|
||||
value={isLoading ? "…" : schedules.length}
|
||||
hint="on the board right now"
|
||||
accent="#F2A516"
|
||||
graph="area"
|
||||
graphAccent="gold"
|
||||
/>
|
||||
<StatTile
|
||||
icon={CalendarDays}
|
||||
label="Open windows"
|
||||
value={isLoading ? "…" : summary.openWindows}
|
||||
hint="accepting bookings"
|
||||
accent="#FB8C2E"
|
||||
graph="line"
|
||||
graphAccent="orange"
|
||||
/>
|
||||
<StatTile
|
||||
icon={Package}
|
||||
label="Bookings in play"
|
||||
value={isLoading ? "…" : summary.totalBookings}
|
||||
hint={`${summary.totalWagons} wagons allocated`}
|
||||
accent="#F2A516"
|
||||
graph="ring"
|
||||
graphAccent="gold"
|
||||
graphPct={
|
||||
schedules.length
|
||||
? Math.min(100, Math.round((summary.openWindows / schedules.length) * 100))
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader color="green" />
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" mt="lg">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</SimpleGrid>
|
||||
) : schedules.length === 0 ? (
|
||||
<Paper radius="lg" withBorder p="xl" mt="lg" bg="gray.0">
|
||||
<Text ta="center" c="dimmed">
|
||||
No active schedules to show.
|
||||
</Text>
|
||||
<Paper radius="lg" withBorder p={48} mt="lg" bg="gray.0">
|
||||
<Stack align="center" gap="sm">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 20,
|
||||
background: "white",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 4px 12px rgba(15,23,42,0.06)",
|
||||
}}
|
||||
>
|
||||
<Inbox size={28} color="var(--mantine-color-gray-5)" />
|
||||
</Box>
|
||||
<Text fw={700} c="gray.7">
|
||||
No active schedules
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={380}>
|
||||
Schedules with an open booking window appear here as cards. Create or activate
|
||||
a schedule to get started.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg" mt="lg">
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" mt="lg">
|
||||
{schedules.map((s) => (
|
||||
<ScheduleCard key={s.scheduleId} schedule={s} />
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Accordion,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
@@ -22,20 +22,34 @@ import {
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Boxes,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FileSignature,
|
||||
Hourglass,
|
||||
Layers,
|
||||
Package,
|
||||
PlayCircle,
|
||||
RefreshCw,
|
||||
Train,
|
||||
Weight,
|
||||
Ruler,
|
||||
TrainFront,
|
||||
Weight,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import {
|
||||
BookingPipeline,
|
||||
HeroChip,
|
||||
totalBookingCount,
|
||||
WindowStatusPill,
|
||||
} from "@/components/trainScheduling/batchVisuals";
|
||||
import { MiniRing, MiniSparkline } from "@/components/common/MiniGraph";
|
||||
import type { OverviewAccent } from "@/components/overview/overview.styles";
|
||||
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
||||
import {
|
||||
useBatchBoardDetail,
|
||||
useRunAllocation,
|
||||
@@ -89,6 +103,15 @@ const fmtDateTime = (iso: string | null) =>
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
const initials = (name: string) =>
|
||||
name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase() || "?";
|
||||
|
||||
function StateBadge({ state }: { state: BatchBoardBookingState }) {
|
||||
const meta = STATE_META[state];
|
||||
const Icon = meta.icon;
|
||||
@@ -132,73 +155,166 @@ function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
const th = (label: string) => (
|
||||
<Table.Th
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
color: "var(--mantine-color-gray-6)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Table.Th>
|
||||
);
|
||||
|
||||
return (
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Reference</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Contract signed</Table.Th>
|
||||
<Table.Th>Selected for batch</Table.Th>
|
||||
<Table.Th>Capacity</Table.Th>
|
||||
<Table.Th>Batch state</Table.Th>
|
||||
<Table.Th>Wagon allocation</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{bookings.map((b) => (
|
||||
<Table.Tr key={b.id}>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{b.reference}
|
||||
</Text>
|
||||
{b.isGovernment ? (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
Gov
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{b.company}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{fmtDateTime(b.fullyExecutedAt)} EAT</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{b.selectedForBatchAt ? (
|
||||
<>
|
||||
<Text size="sm">{fmtDateTime(b.selectedForBatchAt)} EAT</Text>
|
||||
{b.paymentDeadline ? (
|
||||
<Text size="xs" c="orange">
|
||||
Pay by {fmtDateTime(b.paymentDeadline)} EAT
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{b.wagons}w · {fmtTons(b.weightTons)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StateBadge state={b.state} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<AllocationBadge status={b.allocationStatus} issue={b.allocationIssue} />
|
||||
</Table.Td>
|
||||
<Box
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderRadius: 12,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<Table highlightOnHover verticalSpacing="sm" horizontalSpacing="md" miw={760}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{th("Reference")}
|
||||
{th("Customer")}
|
||||
{th("Contract signed")}
|
||||
{th("Selected for batch")}
|
||||
{th("Capacity")}
|
||||
{th("Batch state")}
|
||||
{th("Wagon allocation")}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{bookings.map((b) => (
|
||||
<Table.Tr key={b.id}>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={700} c="dark.5">
|
||||
{b.reference}
|
||||
</Text>
|
||||
{b.isGovernment ? (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
Gov
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 8,
|
||||
flexShrink: 0,
|
||||
background: "#FEF1D5",
|
||||
border: "1px solid #FBD171",
|
||||
}}
|
||||
>
|
||||
<Text size="10px" fw={800} style={{ color: "#B26C09" }}>
|
||||
{initials(b.company)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text size="sm" c="gray.7" truncate>
|
||||
{b.company}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{fmtDateTime(b.fullyExecutedAt)} EAT
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{b.selectedForBatchAt ? (
|
||||
<>
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{fmtDateTime(b.selectedForBatchAt)} EAT
|
||||
</Text>
|
||||
{b.paymentDeadline ? (
|
||||
<Text size="xs" c="orange.7" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||
Pay by {fmtDateTime(b.paymentDeadline)} EAT
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge variant="default" radius="sm" size="sm">
|
||||
{b.wagons}w
|
||||
</Badge>
|
||||
<Badge variant="default" radius="sm" size="sm">
|
||||
{fmtTons(b.weightTons)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StateBadge state={b.state} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<AllocationBadge status={b.allocationStatus} issue={b.allocationIssue} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) {
|
||||
const chips: Array<{ value: number; color: string; label: string }> = [
|
||||
{ value: counts.allocated, color: "green", label: "allocated" },
|
||||
{ value: counts.selectedForBatch, color: "orange", label: "selected" },
|
||||
{ value: counts.ready, color: "teal", label: "ready" },
|
||||
{ value: counts.waiting, color: "blue", label: "waiting" },
|
||||
{ value: counts.expired, color: "red", label: "expired" },
|
||||
].filter((c) => c.value > 0);
|
||||
|
||||
return (
|
||||
<Group gap={5} wrap="nowrap">
|
||||
{chips.map((c) => (
|
||||
<Tooltip key={c.label} label={`${c.value} ${c.label}`} withArrow>
|
||||
<Group
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${c.color}-0)`,
|
||||
border: `1px solid var(--mantine-color-${c.color}-2)`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
w={6}
|
||||
h={6}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${c.color}-6)`,
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" fw={700} c={`${c.color}.8`}>
|
||||
{c.value}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -211,19 +327,45 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
|
||||
return (
|
||||
<Accordion.Item value={window.key}>
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md">
|
||||
<Text fw={600} size="sm">
|
||||
{window.label}
|
||||
</Text>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md" gap="sm">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
flexShrink: 0,
|
||||
background: total ? "#FEF1D5" : "var(--mantine-color-gray-0)",
|
||||
border: total ? "1px solid #FBD171" : "1px solid var(--mantine-color-gray-2)",
|
||||
color: total ? "#B26C09" : "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
>
|
||||
<Clock size={16} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={700} size="sm" truncate>
|
||||
{window.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{total ? `${total} booking${total === 1 ? "" : "s"}` : "Empty window"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{hasIssues ? (
|
||||
<Badge variant="light" color="red" size="sm">
|
||||
<Badge
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
leftSection={<AlertTriangle size={10} />}
|
||||
>
|
||||
Issues
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="outline" color="gray" size="sm">
|
||||
{total} booking{total === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
<WindowCountChips counts={window.counts} />
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
@@ -234,6 +376,66 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Light stat card matching the overview look — keeps the explicit numbers.
|
||||
* Capacity cards (pct set) show a ring; count cards show an area/line trend.
|
||||
*/
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
pct,
|
||||
variant = "area",
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
sub?: string;
|
||||
pct?: number | null;
|
||||
variant?: "area" | "line";
|
||||
}) {
|
||||
const ringAccent: OverviewAccent =
|
||||
pct == null ? "gold" : pct >= 100 ? "rose" : pct >= 85 ? "orange" : "gold";
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="md"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)", background: "white" }}
|
||||
>
|
||||
<Stack gap={8}>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<ThemeIcon size={38} radius="md" variant="light" color="#F2A516">
|
||||
<Icon size={19} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text fw={800} size="lg" lh={1.05} c="dark.5" style={{ whiteSpace: "nowrap" }}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} c="dimmed" truncate>
|
||||
{label}
|
||||
{sub ? ` · ${sub}` : ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
{pct != null ? (
|
||||
<MiniRing pct={pct} accent={ringAccent} size={42} stroke={5}>
|
||||
<Text size="9px" fw={800} c="dark.4">
|
||||
{Math.round(pct)}%
|
||||
</Text>
|
||||
</MiniRing>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{pct == null ? (
|
||||
<MiniSparkline variant={variant} accent="gold" baseline={0.5} seed={label} height={20} />
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BatchScheduleDetailPage() {
|
||||
const { scheduleId } = useParams<{ scheduleId: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -287,7 +489,7 @@ export default function BatchScheduleDetailPage() {
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Container size="xl" py="lg">
|
||||
<Container fluid py="lg" px="xl">
|
||||
<Group justify="center" py="xl">
|
||||
<Loader color="green" />
|
||||
</Group>
|
||||
@@ -298,14 +500,16 @@ export default function BatchScheduleDetailPage() {
|
||||
const lengthPct =
|
||||
data.capacity.maxLengthMeters && data.capacity.maxLengthMeters > 0
|
||||
? (data.capacity.allocatedLengthMeters / data.capacity.maxLengthMeters) * 100
|
||||
: 0;
|
||||
: null;
|
||||
const weightPct =
|
||||
data.capacity.maxWeightTons && data.capacity.maxWeightTons > 0
|
||||
? (data.capacity.usedWeightTons / data.capacity.maxWeightTons) * 100
|
||||
: 0;
|
||||
: null;
|
||||
|
||||
const totalBookings = totalBookingCount(data.counts);
|
||||
|
||||
return (
|
||||
<Container size="xl" py="lg">
|
||||
<Container fluid py="lg" px="xl">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Operations" },
|
||||
@@ -314,165 +518,157 @@ export default function BatchScheduleDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Paper radius="xl" p="xl" mt="md" withBorder>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/operations/batch-board")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Stack gap={4}>
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" variant="light" color="green">
|
||||
<Train size={22} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>
|
||||
{data.trainNumber ?? data.routeName ?? "Schedule"}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{data.origin ?? "—"} → {data.destination ?? "—"} ·{" "}
|
||||
{data.scheduleDate
|
||||
? new Date(data.scheduleDate).toLocaleString()
|
||||
: "No date"}
|
||||
</Text>
|
||||
</div>
|
||||
<Stack gap="lg" mt="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Stack gap={9} style={{ minWidth: 0 }}>
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
px="sm"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/operations/batch-board")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Title order={2} fw={800} style={{ color: "#0f172a", letterSpacing: 0.2 }}>
|
||||
{data.trainNumber ?? data.routeName ?? "Schedule"}
|
||||
</Title>
|
||||
<WindowStatusPill status={data.bookingWindowStatus} />
|
||||
<HeroChip>{data.status}</HeroChip>
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<Badge variant="light" color="green">
|
||||
{data.bookingWindowStatus}
|
||||
</Badge>
|
||||
<Badge variant="outline" color="gray">
|
||||
{data.status}
|
||||
</Badge>
|
||||
<RouteCorridor origin={data.origin} destination={data.destination} />
|
||||
<Group gap={6} wrap="wrap">
|
||||
<HeroChip icon={<CalendarDays size={12} />}>
|
||||
{data.scheduleDate
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(new Date(data.scheduleDate)) + " EAT"
|
||||
: "No date"}
|
||||
</HeroChip>
|
||||
{data.locomotive ? (
|
||||
<HeroChip icon={<TrainFront size={12} />}>
|
||||
Loco {data.locomotive.code} · {fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
|
||||
{data.locomotive.maxTrainLengthMeters} m
|
||||
</HeroChip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
leftSection={<PlayCircle size={16} />}
|
||||
loading={runAllocation.isPending}
|
||||
onClick={handleRunAllocation}
|
||||
>
|
||||
Run allocation
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Layers size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${data.scheduleId}`)
|
||||
}
|
||||
>
|
||||
Open schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{data.locomotive ? (
|
||||
<Text size="sm" c="dimmed" mt="md">
|
||||
Loco {data.locomotive.code} · max {fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
|
||||
{data.locomotive.maxTrainLengthMeters} m
|
||||
</Text>
|
||||
) : (
|
||||
<Alert color="red" mt="md" icon={<AlertTriangle size={16} />}>
|
||||
No locomotive assigned — wagon allocation cannot run.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="md" mt="md">
|
||||
<Box>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="sm" c="dimmed">
|
||||
Allocated wagons
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{data.capacity.allocatedWagons}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
leftSection={<PlayCircle size={16} />}
|
||||
loading={runAllocation.isPending}
|
||||
onClick={handleRunAllocation}
|
||||
>
|
||||
Run allocation
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
radius="md"
|
||||
leftSection={<Layers size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${data.scheduleId}`)
|
||||
}
|
||||
>
|
||||
Open schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
{data.capacity.maxLengthMeters ? (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Group gap={4}>
|
||||
<Ruler size={14} />
|
||||
<Text size="sm" c="dimmed">
|
||||
Train length
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600}>
|
||||
{fmtMeters(data.capacity.allocatedLengthMeters)}/
|
||||
{fmtMeters(data.capacity.maxLengthMeters)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={lengthPct}
|
||||
color={lengthPct >= 100 ? "orange" : "blue"}
|
||||
radius="xl"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
{data.capacity.maxWeightTons ? (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Group gap={4}>
|
||||
<Weight size={14} />
|
||||
<Text size="sm" c="dimmed">
|
||||
Weight
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600}>
|
||||
{fmtTons(data.capacity.usedWeightTons)}/{fmtTons(data.capacity.maxWeightTons)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={weightPct}
|
||||
color={weightPct >= 100 ? "red" : "teal"}
|
||||
radius="xl"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</SimpleGrid>
|
||||
</Group>
|
||||
|
||||
<Group gap={6} mt="md">
|
||||
<Badge variant="light" color="green">
|
||||
{data.counts.allocated} allocated
|
||||
</Badge>
|
||||
<Badge variant="light" color="orange">
|
||||
{data.counts.selectedForBatch} selected
|
||||
</Badge>
|
||||
<Badge variant="light" color="teal">
|
||||
{data.counts.ready} ready
|
||||
</Badge>
|
||||
<Badge variant="light" color="blue">
|
||||
{data.counts.waiting} waiting
|
||||
</Badge>
|
||||
<Badge variant="light" color="gray">
|
||||
{data.counts.pendingContract} pending contract
|
||||
</Badge>
|
||||
{data.counts.expired ? (
|
||||
<Badge variant="light" color="red">
|
||||
{data.counts.expired} expired
|
||||
</Badge>
|
||||
{!data.locomotive ? (
|
||||
<Alert color="red" radius="md" icon={<AlertTriangle size={16} />}>
|
||||
No locomotive assigned — wagon allocation cannot run.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||||
<StatCard
|
||||
icon={Boxes}
|
||||
label="Allocated wagons"
|
||||
value={data.capacity.allocatedWagons}
|
||||
sub="on this train"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Ruler}
|
||||
label="Train length"
|
||||
value={
|
||||
data.capacity.maxLengthMeters
|
||||
? `${fmtMeters(data.capacity.allocatedLengthMeters)} / ${fmtMeters(data.capacity.maxLengthMeters)}`
|
||||
: fmtMeters(data.capacity.allocatedLengthMeters)
|
||||
}
|
||||
pct={lengthPct}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Weight}
|
||||
label="Weight"
|
||||
value={
|
||||
data.capacity.maxWeightTons
|
||||
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
|
||||
: fmtTons(data.capacity.usedWeightTons)
|
||||
}
|
||||
pct={weightPct}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Package}
|
||||
label="Bookings"
|
||||
value={totalBookings}
|
||||
sub={`${data.counts.allocated} allocated · ${data.counts.expired} expired`}
|
||||
variant="line"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
{/* Booking pipeline */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
p="lg"
|
||||
mt="lg"
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<ThemeIcon size={32} radius="md" variant="light" color="#F2A516">
|
||||
<Package size={16} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Booking pipeline</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={700} c="dark.4">
|
||||
{totalBookings} booking{totalBookings === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Group>
|
||||
<BookingPipeline counts={data.counts} size={14} />
|
||||
</Paper>
|
||||
|
||||
{data.allocationViolations.length ? (
|
||||
<Alert color="red" mt="md" icon={<AlertTriangle size={16} />} title="Allocation constraints">
|
||||
<Alert
|
||||
color="red"
|
||||
mt="lg"
|
||||
radius="lg"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Allocation constraints"
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{data.allocationViolations.map((v) => (
|
||||
<Text key={v} size="sm">
|
||||
@@ -483,26 +679,80 @@ export default function BatchScheduleDetailPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Paper radius="lg" withBorder p="lg" mt="lg">
|
||||
<Title order={4} mb="md">
|
||||
Batch windows (EAT)
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Bookings are grouped by contract signing time (<code>fullyExecutedAt</code>). Expand a
|
||||
window to see bookings and wagon allocation issues.
|
||||
</Text>
|
||||
{/* Batch windows */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
p="lg"
|
||||
mt="lg"
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group gap="sm" mb={4} wrap="nowrap" align="flex-start">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 10,
|
||||
background: "linear-gradient(135deg, #FBD171, #F2A516)",
|
||||
color: "white",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Clock size={19} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Title order={4}>Batch windows (EAT)</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Bookings grouped by contract signing time in 3-hour windows — expand one to
|
||||
see bookings and wagon allocation issues.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Accordion multiple defaultValue={defaultOpen} variant="separated">
|
||||
<Accordion
|
||||
multiple
|
||||
defaultValue={defaultOpen}
|
||||
variant="separated"
|
||||
radius="md"
|
||||
mt="md"
|
||||
className="bb-window-accordion"
|
||||
>
|
||||
{data.windows.map((window) => (
|
||||
<WindowAccordionItem key={window.key} window={window} />
|
||||
))}
|
||||
{data.pendingContract.bookings.length ? (
|
||||
<Accordion.Item value="pending-contract">
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md">
|
||||
<Text fw={600} size="sm">
|
||||
Pending contract
|
||||
</Text>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md" gap="sm">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
flexShrink: 0,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
color: "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
>
|
||||
<FileSignature size={16} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fw={700} size="sm">
|
||||
Pending contract
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Contract not signed yet — not in any window
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Badge variant="outline" color="gray" size="sm">
|
||||
{data.pendingContract.bookings.length} booking
|
||||
{data.pendingContract.bookings.length === 1 ? "" : "s"}
|
||||
@@ -517,11 +767,9 @@ export default function BatchScheduleDetailPage() {
|
||||
</Accordion>
|
||||
</Paper>
|
||||
|
||||
{/* Train composition */}
|
||||
{hasAssignedWagons && scheduleDetailQuery.data ? (
|
||||
<Paper radius="lg" withBorder p="lg" mt="lg">
|
||||
<Title order={4} mb="md">
|
||||
Train composition
|
||||
</Title>
|
||||
<Box mt="lg">
|
||||
<TrainCompositionDiagram
|
||||
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
|
||||
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
|
||||
@@ -529,8 +777,8 @@ export default function BatchScheduleDetailPage() {
|
||||
trainNumber={scheduleDetailQuery.data.trainNumber}
|
||||
totalLengthMeters={scheduleDetailQuery.data.trainSet?.totalLengthMeters}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
) : null}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,7 +622,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
}}
|
||||
>
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon size={44} radius="md" variant="light" color="green">
|
||||
<ThemeIcon size={44} radius="md" variant="light" color="#F2A516">
|
||||
<CheckCircle2 size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
@@ -722,7 +722,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon size={56} radius="lg" variant="light" color="green">
|
||||
<ThemeIcon size={56} radius="lg" variant="light" color="#F2A516">
|
||||
<Train size={28} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={6}>
|
||||
@@ -731,7 +731,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
{schedule.route?.name ?? "Train schedule"}
|
||||
</Title>
|
||||
{schedule.trainNumber ? (
|
||||
<Badge variant="light" color="green" radius="sm" style={{ fontWeight: 600 }}>
|
||||
<Badge variant="light" color="#F2A516" radius="sm" style={{ fontWeight: 600 }}>
|
||||
{schedule.trainNumber}
|
||||
</Badge>
|
||||
) : null}
|
||||
@@ -791,11 +791,17 @@ export default function TrainScheduleV2DetailPage() {
|
||||
? "Import-ready"
|
||||
: undefined
|
||||
}
|
||||
accent="#F2A516"
|
||||
graph="area"
|
||||
graphAccent="gold"
|
||||
/>
|
||||
<StatTile
|
||||
icon={Package}
|
||||
label="Bookings"
|
||||
value={schedule.bookings?.length ?? 0}
|
||||
accent="#FB8C2E"
|
||||
graph="line"
|
||||
graphAccent="orange"
|
||||
/>
|
||||
<StatTile
|
||||
icon={Weight}
|
||||
@@ -803,6 +809,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
value={`${schedule.trainSet?.wagonCount ?? displayWagonPlan.length} · ${
|
||||
schedule.trainSet?.totalWeightTons ?? 0
|
||||
}T`}
|
||||
accent="#F2A516"
|
||||
graph="area"
|
||||
graphAccent="gold"
|
||||
/>
|
||||
<StatTile
|
||||
icon={CalendarClock}
|
||||
@@ -815,6 +824,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
accent="#FB8C2E"
|
||||
graph="line"
|
||||
graphAccent="orange"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
|
||||
@@ -8,14 +8,12 @@ import {
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { ArrowRight, CalendarClock, Navigation, Send, Train, Weight } from "lucide-react";
|
||||
|
||||
@@ -345,53 +343,54 @@ export default function TrainScheduleV2ListPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Hero banner */}
|
||||
<Paper
|
||||
radius="xl"
|
||||
p="xl"
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={56} radius="lg" variant="light" color="green">
|
||||
<Train size={28} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4}>
|
||||
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
||||
Train Schedules
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" maw={520}>
|
||||
Plan departures, allocate bookings, and dispatch trains across
|
||||
every corridor.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Button
|
||||
size="md"
|
||||
radius="lg"
|
||||
color="green"
|
||||
leftSection={<Train size={18} />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
New schedule
|
||||
</Button>
|
||||
</Group>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
size="md"
|
||||
radius="lg"
|
||||
color="green"
|
||||
leftSection={<Train size={18} />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
New schedule
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<StatTile icon={Train} label="Total trains" value={stats.total} />
|
||||
<StatTile icon={CalendarClock} label="Scheduled" value={stats.scheduled} />
|
||||
<StatTile icon={Send} label="Dispatched" value={stats.dispatched} />
|
||||
<StatTile icon={Weight} label="Planned load" value={`${Math.round(stats.weight)}T`} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<StatTile
|
||||
icon={Train}
|
||||
label="Total trains"
|
||||
value={stats.total}
|
||||
accent="#F2A516"
|
||||
graph="area"
|
||||
graphAccent="gold"
|
||||
/>
|
||||
<StatTile
|
||||
icon={CalendarClock}
|
||||
label="Scheduled"
|
||||
value={stats.scheduled}
|
||||
accent="#FB8C2E"
|
||||
graph="line"
|
||||
graphAccent="orange"
|
||||
graphPct={stats.total ? Math.round((stats.scheduled / stats.total) * 100) : 0}
|
||||
/>
|
||||
<StatTile
|
||||
icon={Send}
|
||||
label="Dispatched"
|
||||
value={stats.dispatched}
|
||||
accent="#F2A516"
|
||||
graph="ring"
|
||||
graphAccent="gold"
|
||||
graphPct={stats.total ? Math.round((stats.dispatched / stats.total) * 100) : 0}
|
||||
/>
|
||||
<StatTile
|
||||
icon={Weight}
|
||||
label="Planned load"
|
||||
value={`${Math.round(stats.weight)}T`}
|
||||
accent="#FB8C2E"
|
||||
graph="area"
|
||||
graphAccent="orange"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap={0}>
|
||||
@@ -638,11 +637,11 @@ function ScheduleCard({
|
||||
}}
|
||||
>
|
||||
{/* accent strip */}
|
||||
<Box style={{ height: 4, background: scheduleBrand.heroGradient }} />
|
||||
<Box style={{ height: 4, background: "linear-gradient(90deg, #FBD171, #F2A516)" }} />
|
||||
<Stack gap="sm" p="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon size={38} radius="md" variant="light" color="green">
|
||||
<ThemeIcon size={38} radius="md" variant="light" color="#F2A516">
|
||||
<Train size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
|
||||
@@ -108,3 +108,4 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
//
|
||||
@@ -5,7 +5,7 @@ import { ActionIcon, Button, Skeleton, Stack, Text, TextInput } from "@mantine/c
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
calcWagons,
|
||||
calcWagons,
|
||||
type BookingFormValues,
|
||||
type RouteDirection,
|
||||
} from "./schema";
|
||||
|
||||
Reference in New Issue
Block a user