diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx
index 3d6eeb398..ca4fca5a5 100644
--- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx
@@ -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 (
-
-
-
-
-
-
-
-
-
- Operations
-
-
- Booking Requests
-
-
- Track every booking from submission through approval, payment, and
- dispatch — prioritize what needs action.
-
-
-
-
- } onClick={onCreate}>
- Create booking
-
- }
- loading={isFetching}
- onClick={onRefresh}
- >
- Refresh
-
-
-
+
+
+ } onClick={onCreate}>
+ Create booking
+
+ }
+ loading={isFetching}
+ onClick={onRefresh}
+ >
+ Refresh
+
+
-
-
-
-
-
-
-
- {tabs ? : null}
-
-
- );
-}
-
-/** 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 (
-
-
-
- {children}
-
-
+
+
+
+
+
+ {/* {tabs ? : null} */}
+
);
}
+/**
+ * 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 (
-
-
-
-
-
-
- {label}
-
-
- {value}
-
-
- {pct != null ? `${pct}% of queue` : hint}
-
-
-
+
+
+
+
+
+
+
+ {value}
+
+
+ {label}
+ {hint ? ` · ${pct != null ? `${pct}% of queue` : hint}` : ""}
+
+
+ {pct != null ? (
+
+
+ {pct}%
+
+
+ ) : null}
+
+
+ {pct == null ? (
+
+ ) : null}
+
);
}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContainersCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContainersCard.tsx
index 2ed87baf3..3048b705b 100644
--- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContainersCard.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContainersCard.tsx
@@ -42,7 +42,7 @@ export function BookingContainersCard({ containers }: BookingContainersCardProps
{container.quantity}
{container.vgmPerUnitTons} t
-
+
{(container.quantity * container.vgmPerUnitTons).toFixed(2)} t
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingPaymentCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingPaymentCard.tsx
index 90440dfae..7d0535bd4 100644
--- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingPaymentCard.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingPaymentCard.tsx
@@ -21,10 +21,10 @@ export function BookingPaymentCard({
Total Amount
-
+
{totalAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
-
+
{currency}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx
index e396ec4c2..94648a76a 100644
--- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx
@@ -83,7 +83,7 @@ export function BookingRequestHero({
-
+
Booking reference
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingReviewNotesCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingReviewNotesCard.tsx
index 218f0c7c5..87e9097fa 100644
--- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingReviewNotesCard.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingReviewNotesCard.tsx
@@ -25,7 +25,7 @@ export function BookingReviewNotesCard({ notes }: BookingReviewNotesCardProps) {
{notes.map((note) => (
-
+
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts
index 4a6f79661..e12b7220b 100644
--- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts
@@ -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 = {
diff --git a/apps/edr-freight-web/backoffice/src/components/common/MiniGraph.tsx b/apps/edr-freight-web/backoffice/src/components/common/MiniGraph.tsx
new file mode 100644
index 000000000..0aa322702
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/common/MiniGraph.tsx
@@ -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 (
+
+
+
+ );
+}
+
+/** 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 (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx
index 14336e70a..cc6454f60 100644
--- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx
@@ -78,8 +78,8 @@ const FreightDashboardHeader = ({
-
- Freight Backoffice
+ {/*
+ Freight Backoffice */}
{pageMeta.title}
-
+ {/*
{pageMeta.subtitle}
-
+ */}
diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx
index 8417065cf..a8e0b0aa6 100644
--- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx
@@ -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)",
}}
>
-
+
point.count > 0);
return (
-
-
+
+
Booking trend
{!hasData ? (
No bookings in this period
) : (
-
+
diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewDonutChart.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewDonutChart.tsx
index 494b3d653..6b333d6ea 100644
--- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewDonutChart.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewDonutChart.tsx
@@ -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 (
-
-
- {title}
+
+
+
+ {title}
+
{!hasData ? (
-
+
{emptyMessage}
) : (
-
-
-
+ {/* Compact donut with the total in its center */}
+
+
+
+
+ {filtered.map((entry, index) => (
+ |
+ ))}
+
+ [value, "Count"]} />
+
+
+
- {filtered.map((entry, index) => (
- |
- ))}
-
- [value, "Count"]} />
-
-
+
+ {total}
+
+
+ Total
+
+
+
+
+ {/* Legend fills the space — color, name, count and share */}
+
+ {filtered.map((entry, index) => {
+ const color =
+ overviewChartColors.pipeline[index % overviewChartColors.pipeline.length];
+ const pct = total > 0 ? Math.round((entry.value / total) * 100) : 0;
+ return (
+
+
+
+
+ {entry.name}
+
+
+
+
+ {entry.value}
+
+
+ {pct}%
+
+
+
+ );
+ })}
+
+
)}
diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewHorizontalBarChart.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewHorizontalBarChart.tsx
index e5b5d3f8e..2de3eb20b 100644
--- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewHorizontalBarChart.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewHorizontalBarChart.tsx
@@ -36,8 +36,8 @@ export function OverviewHorizontalBarChart({
const hasData = chartData.length > 0;
return (
-
-
+
+
{title}
{!hasData ? (
diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiCard.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiCard.tsx
index 43108ffd7..f986f622e 100644
--- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiCard.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiCard.tsx
@@ -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 = {
+export const ACCENT_CHIP_BG: Record = {
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 (
-
- {bars.map((value, index) => (
-
- ))}
-
- );
+ /** 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 (
-
-
+
+
+
+
+ {item.value}
+
+
+ {item.label}
+ {item.hint ? ` · ${item.hint}` : ""}
+
+
+ {variant === "ring" ? (
+
+
+ {Math.round((item.progress ?? 0) * 100)}%
+
+
+ ) : null}
-
-
- {item.value}
-
-
-
- {item.label}
-
- {item.hint && (
-
- · {item.hint}
-
- )}
-
-
-
-
+ {variant !== "ring" ? (
+
+ ) : null}
);
diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx
index 79132cd64..04740585b 100644
--- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx
@@ -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[] = [
+ "gold",
+ "orange",
+ "default",
+];
interface OverviewKpiStripProps {
title?: string;
@@ -33,11 +47,13 @@ export function OverviewKpiStrip({ title, items }: OverviewKpiStripProps) {
)}
- {items.map((item) => (
+ {items.map((item, index) => (
0 ? toNumber(item.value) / max : 0),
}}
diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx
index dbea71656..3c2e79f75 100644
--- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx
@@ -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 (
-
-
-
- );
-}
-
interface OverviewPageHeaderProps {
range: OverviewRange;
onRangeChange: (range: OverviewRange) => void;
@@ -79,59 +36,31 @@ export function OverviewPageHeader({
isRefreshing,
}: OverviewPageHeaderProps) {
return (
-
-
-
-
-
-
-
-
-
-
- Freight Backoffice · Live
-
-
-
- Operations Overview
-
-
- Real-time freight performance · updated {formatRelativeTime(generatedAt)}
-
-
-
-
- onRangeChange(value as OverviewRange)}
- data={RANGE_OPTIONS}
- size="sm"
- radius="lg"
- color="green"
- />
-
-
-
-
+
+
+ Updated {formatRelativeTime(generatedAt)}
+
+
+ onRangeChange(value as OverviewRange)}
+ data={RANGE_OPTIONS}
+ size="sm"
+ radius="lg"
+ color="green"
+ />
+
+
+
-
+
);
}
diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx
index 58b90d02a..f1a74ac4e 100644
--- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx
@@ -30,15 +30,15 @@ export function OverviewPaymentChart({ data }: { data: IOverviewPaymentTrendPoin
const hasData = data.some((point) => point.amountEtb > 0 || point.amountUsd > 0);
return (
-
-
+
+
Payment trend
{!hasData ? (
No successful payments in this period
) : (
-
+
item.count > 0);
return (
-
-
+
+
Pipeline by stage
{!hasData ? (
No bookings in pipeline
) : (
-
+
-
-
+
+
Payments by method
{methodChartData.length === 0 ? (
No payment methods recorded
) : (
-
+
diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewCustomersTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewCustomersTabPanel.tsx
index 3788dfed6..dea4c9921 100644
--- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewCustomersTabPanel.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewCustomersTabPanel.tsx
@@ -48,15 +48,15 @@ export function OverviewCustomersTabPanel({ data }: OverviewCustomersTabPanelPro
-
-
+
+
Customer growth
{!hasGrowth ? (
No new customers in this period
) : (
-
+
diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewStaffTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewStaffTabPanel.tsx
index 27f0b1271..1293cba95 100644
--- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewStaffTabPanel.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewStaffTabPanel.tsx
@@ -47,15 +47,15 @@ export function OverviewStaffTabPanel({ data }: OverviewStaffTabPanelProps) {
-
-
+
+
Employee onboarding trend
{!hasGrowth ? (
No new employees in this period
) : (
-
+
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx
index c9a526f6b..233ab81ac 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx
@@ -93,12 +93,14 @@ function Wheels({ count = 2, dark = false }: { count?: number; dark?: boolean })
))}
@@ -108,7 +110,7 @@ function Wheels({ count = 2, dark = false }: { count?: number; dark?: boolean })
function Coupler() {
return (
-
+
@@ -142,62 +145,109 @@ function LocomotiveCar({
- {/* cab windows */}
+ {/* roofline */}
-
-
+ />
+ {/* roof vents */}
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ {/* cab windows */}
+
+
+
{/* headlight */}
+ {/* hazard stripe on the nose */}
+
-
+
{code}
-
+
{name ?? "Locomotive"}
{maxPullWeightTons ? (
-
+
-
+
{maxPullWeightTons}T pull
) : null}
-
+
HEAD
@@ -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 }) {
{wagon.isEmpty ? (
-
+
EMPTY
) : (
{wagon.isBulk ? : }
-
+
{wagon.isBulk ? "BULK" : "CONT"}
@@ -271,7 +332,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
{/* body */}
-
+
{wagon.isEmpty ? (
@@ -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)",
}}
/>
-
+
{wagon.assignedWeightTons}/{wagon.capacityTons}T
@@ -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",
}}
>
-
+ {/* corrugation lines */}
+
+
{cn}
@@ -331,6 +407,22 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
)}
+ {/* utilization hairline */}
+ {!wagon.isEmpty && !wagon.isBulk ? (
+
+ = 100
+ ? "var(--mantine-color-red-5)"
+ : `var(--mantine-color-${accent}-5)`,
+ }}
+ />
+
+ ) : null}
+
{/* footer */}
-
- {wagon.physicalWagonNumber ?? wagon.wagonTypeCode ?? "Wagon"}
-
+
+
+ {wagon.physicalWagonNumber ?? wagon.wagonTypeCode ?? "Wagon"}
+
+ {!wagon.isEmpty ? (
+
+ {wagon.assignedWeightTons}T
+
+ ) : null}
+
@@ -350,6 +449,56 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
);
}
+/** Railway track: two rails over evenly-spaced sleepers. */
+function TrackBed() {
+ return (
+
+ {/* sleepers */}
+
+ {/* rails */}
+
+
+
+ );
+}
+
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",
}}
>
@@ -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",
}}
>
-
+
- Train composition
+
+ Train composition
+
{trainNumber ? `${trainNumber} · ` : ""}
{stats.total} wagons · {stats.assigned} loaded · {stats.empty} empty
@@ -436,35 +587,58 @@ export function TrainCompositionDiagram({
-
-
-
-
+
+
+
+ {stats.totalWeight}T
+
+
+ of {stats.totalCapacity}T capacity
+
+
+
+
+
+
+
{/* Locomotive pull gauge */}
{stats.pullUtil != null ? (
-
-
- Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T
-
- 95 ? "red.7" : "green.7"}>
+
+
+
+
+ Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T
+
+
+ 95 ? "red.7" : "green.7"}>
{stats.pullUtil}%
@@ -480,20 +654,8 @@ export function TrainCompositionDiagram({
const turnSide: "left" | "right" = rowIndex % 2 === 0 ? "right" : "left";
return (
-
- {/* rail under the row */}
-
+
+
= [
+ {
+ 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 (
+
+
+
+ No bookings yet
+
+
+ );
+ }
+
+ return (
+
+
+ {stages.map((stage) => (
+
+
+
+ ))}
+
+ {showLegend ? (
+
+ {stages.map((stage) => (
+
+
+
+
+ {counts[stage.key]}
+ {" "}
+ {stage.label.toLowerCase()}
+
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+const WINDOW_META: Record = {
+ 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 (
+
+
+
+ {meta.label}
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ {icon}
+
+ {children}
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/scheduleVisuals.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/scheduleVisuals.tsx
index 1079d299c..ff7417a94 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/scheduleVisuals.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/scheduleVisuals.tsx
@@ -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 (
-
- {Icon ? (
-
-
-
- ) : null}
-
-
- {label}
-
-
- {value}
-
- {hint ? (
-
- {hint}
-
+
+
+ {Icon ? (
+
+
+
) : null}
-
-
+
+
+ {value}
+
+
+ {label}
+ {hint ? · {hint} : null}
+
+
+ {showGraph && graph === "ring" ? (
+
+
+ {Math.round(graphPct ?? 0)}%
+
+
+ ) : null}
+
+
+ {showGraph && graph !== "ring" ? (
+
+ ) : null}
+
);
}
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx
index 04c6a421d..38be6dd31 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx
@@ -21,7 +21,6 @@ import {
Textarea,
TextInput,
ThemeIcon,
- Title,
Tooltip,
} from "@mantine/core";
import {
@@ -380,47 +379,16 @@ export default function NewBookingPage() {
]}
/>
- {/* Hero */}
-
-
-
-
-
-
-
-
- New booking · Staff
-
-
- Create freight booking
-
-
- Capture cargo details, container lines, and routing — saved as a draft and pushed
- into the approval workflow.
-
-
-
- }
- onClick={() => navigate("/dashboard/booking-requests")}
- >
- Back to list
-
-
-
+
+ }
+ onClick={() => navigate("/dashboard/booking-requests")}
+ >
+ Back to list
+
+
{/* LEFT — form */}
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx
index 224c1c5ba..0dec57af5 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx
@@ -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 (
+
+
+
+ {Math.round(pct)}%
+
+
+ {label}
+
+
+ }
+ />
+
+
+ {current}
+
+
+ of {max}
+
+
+
+ );
+}
+
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 (
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
}
>
-
-
+
+ {/* header */}
-
-
+
+
-
+
{schedule.trainNumber ?? schedule.routeName ?? "Schedule"}
-
- {schedule.origin ?? "—"} → {schedule.destination ?? "—"}
-
-
- {schedule.scheduleDate
- ? new Date(schedule.scheduleDate).toLocaleString()
- : "No date"}
+
+ Freight schedule · {schedule.status}
-
-
- {schedule.bookingWindowStatus}
-
-
- {schedule.status}
-
-
+
- {locomotive ? (
-
- Loco {locomotive.code} · max {fmtTons(locomotive.maxPullWeightTons)} ·{" "}
- {locomotive.maxTrainLengthMeters} m
-
- ) : (
-
- No locomotive — won't allocate
-
- )}
+
-
-
-
- Allocated wagons
-
-
- {capacity.allocatedWagons}
-
-
-
- {capacity.maxLengthMeters ? (
-
-
-
-
-
- Train length
-
-
-
- {fmtMeters(capacity.allocatedLengthMeters)}/{fmtMeters(capacity.maxLengthMeters)}
-
-
-
- ) : null}
- {capacity.maxWeightTons ? (
-
-
-
-
-
- Weight
-
-
-
- {fmtTons(capacity.usedWeightTons)}/{fmtTons(capacity.maxWeightTons)}
-
-
-
- ) : null}
-
-
-
-
- {counts.allocated} allocated
-
-
-
-
- {counts.selectedForBatch} selected
-
-
-
-
- {counts.ready} ready
-
-
-
-
- {counts.waiting} waiting
-
-
- {counts.expired ? (
-
- {counts.expired} expired
-
+
+ }>
+ {fmtScheduleDate(schedule.scheduleDate)}
+
+ {locomotive ? (
+ }>
+ {locomotive.code} · {fmtTons(locomotive.maxPullWeightTons)}
+
) : null}
-
- {totalBookings
- ? `${totalBookings} booking${totalBookings === 1 ? "" : "s"} · click for batch windows`
- : "No bookings yet · click to open"}
-
+ {!locomotive ? (
+ }
+ py={6}
+ styles={{ message: { fontSize: 12 } }}
+ >
+ No locomotive assigned — wagon allocation cannot run.
+
+ ) : null}
+ {/* capacity: weight + length rings + wagons (numbers preserved) */}
+
+
+ {weightPct != null ? (
+
+ ) : null}
+
+
+
+
+
+
+ {capacity.allocatedWagons}
+
+
+ Wagons
+
+
+ allocated
+
+
+
+ {lengthPct != null ? (
+
+ ) : null}
+
+
+
+ {/* booking pipeline */}
+
+
+
+
+
+ Booking pipeline
+
+
+
+ {totalBookings} booking{totalBookings === 1 ? "" : "s"}
+
+
+
+
+
+
+ {/* CTA */}
+
}
+ variant="gradient"
+ gradient={{ from: FREIGHT_BRAND, to: FREIGHT_BRAND_DARK, deg: 135 }}
+ rightSection={}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`);
@@ -222,6 +273,25 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
>
View batch windows
+
+
+ );
+}
+
+function CardSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
);
@@ -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 (
-
+
-
-
-
-
-
-
-
-
- Allocation · Live
-
-
- Batch board
-
-
- Active schedules — click a card to see EAT 3-hour batch windows, bookings, and
- wagon allocation status.
-
-
-
- }
- loading={isFetching}
- onClick={() => void refetch()}
- >
- Refresh
-
-
-
+
+ }
+ loading={isFetching}
+ onClick={() => void refetch()}
+ >
+ Refresh
+
+
+
+
+
+
+
+
{isLoading ? (
-
-
-
+
+
+
+
+
) : schedules.length === 0 ? (
-
-
- No active schedules to show.
-
+
+
+
+
+
+
+ No active schedules
+
+
+ Schedules with an open booking window appear here as cards. Create or activate
+ a schedule to get started.
+
+
) : (
-
+
{schedules.map((s) => (
))}
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx
index 2097bca9b..91849ee3e 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx
@@ -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) => (
+
+ {label}
+
+ );
+
return (
-
-
-
- Reference
- Customer
- Contract signed
- Selected for batch
- Capacity
- Batch state
- Wagon allocation
-
-
-
- {bookings.map((b) => (
-
-
-
-
- {b.reference}
-
- {b.isGovernment ? (
-
- Gov
-
- ) : null}
-
-
-
-
- {b.company}
-
-
-
- {fmtDateTime(b.fullyExecutedAt)} EAT
-
-
- {b.selectedForBatchAt ? (
- <>
- {fmtDateTime(b.selectedForBatchAt)} EAT
- {b.paymentDeadline ? (
-
- Pay by {fmtDateTime(b.paymentDeadline)} EAT
-
- ) : null}
- >
- ) : (
-
- —
-
- )}
-
-
-
- {b.wagons}w · {fmtTons(b.weightTons)}
-
-
-
-
-
-
-
-
+
+
+
+
+ {th("Reference")}
+ {th("Customer")}
+ {th("Contract signed")}
+ {th("Selected for batch")}
+ {th("Capacity")}
+ {th("Batch state")}
+ {th("Wagon allocation")}
- ))}
-
-
+
+
+ {bookings.map((b) => (
+
+
+
+
+ {b.reference}
+
+ {b.isGovernment ? (
+
+ Gov
+
+ ) : null}
+
+
+
+
+
+
+ {initials(b.company)}
+
+
+
+ {b.company}
+
+
+
+
+
+ {fmtDateTime(b.fullyExecutedAt)} EAT
+
+
+
+ {b.selectedForBatchAt ? (
+ <>
+
+ {fmtDateTime(b.selectedForBatchAt)} EAT
+
+ {b.paymentDeadline ? (
+
+ Pay by {fmtDateTime(b.paymentDeadline)} EAT
+
+ ) : null}
+ >
+ ) : (
+
+ —
+
+ )}
+
+
+
+
+ {b.wagons}w
+
+
+ {fmtTons(b.weightTons)}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ );
+}
+
+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 (
+
+ {chips.map((c) => (
+
+
+
+
+ {c.value}
+
+
+
+ ))}
+
);
}
@@ -211,19 +327,45 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
return (
-
-
- {window.label}
-
+
+
+
+
+
+
+
+ {window.label}
+
+
+ {total ? `${total} booking${total === 1 ? "" : "s"}` : "Empty window"}
+
+
+
{hasIssues ? (
-
+ }
+ >
Issues
) : null}
-
- {total} booking{total === 1 ? "" : "s"}
-
+
@@ -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 (
+
+
+
+
+
+
+
+
+ {value}
+
+
+ {label}
+ {sub ? ` · ${sub}` : ""}
+
+
+ {pct != null ? (
+
+
+ {Math.round(pct)}%
+
+
+ ) : null}
+
+
+ {pct == null ? (
+
+ ) : null}
+
+
+ );
+}
+
export default function BatchScheduleDetailPage() {
const { scheduleId } = useParams<{ scheduleId: string }>();
const navigate = useNavigate();
@@ -287,7 +489,7 @@ export default function BatchScheduleDetailPage() {
if (isLoading || !data) {
return (
-
+
@@ -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 (
-
+
-
-
-
- }
- onClick={() => navigate("/dashboard/operations/batch-board")}
- >
- Back
-
-
-
-
-
-
-
-
- {data.trainNumber ?? data.routeName ?? "Schedule"}
-
-
- {data.origin ?? "—"} → {data.destination ?? "—"} ·{" "}
- {data.scheduleDate
- ? new Date(data.scheduleDate).toLocaleString()
- : "No date"}
-
-
+
+
+
+
+ }
+ onClick={() => navigate("/dashboard/operations/batch-board")}
+ >
+ Back
+
+
+ {data.trainNumber ?? data.routeName ?? "Schedule"}
+
+
+ {data.status}
-
-
- {data.bookingWindowStatus}
-
-
- {data.status}
-
+
+
+ }>
+ {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"}
+
+ {data.locomotive ? (
+ }>
+ Loco {data.locomotive.code} · {fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
+ {data.locomotive.maxTrainLengthMeters} m
+
+ ) : null}
-
-
- }
- loading={isFetching}
- onClick={() => void refetch()}
- >
- Refresh
-
- }
- loading={runAllocation.isPending}
- onClick={handleRunAllocation}
- >
- Run allocation
-
- }
- onClick={() =>
- navigate(`/dashboard/operations/train-scheduling-v2/${data.scheduleId}`)
- }
- >
- Open schedule
-
-
-
-
- {data.locomotive ? (
-
- Loco {data.locomotive.code} · max {fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
- {data.locomotive.maxTrainLengthMeters} m
-
- ) : (
- }>
- No locomotive assigned — wagon allocation cannot run.
-
- )}
-
-
-
-
-
- Allocated wagons
-
-
- {data.capacity.allocatedWagons}
-
+
+ }
+ loading={isFetching}
+ onClick={() => void refetch()}
+ >
+ Refresh
+
+ }
+ loading={runAllocation.isPending}
+ onClick={handleRunAllocation}
+ >
+ Run allocation
+
+ }
+ onClick={() =>
+ navigate(`/dashboard/operations/train-scheduling-v2/${data.scheduleId}`)
+ }
+ >
+ Open schedule
+
-
- {data.capacity.maxLengthMeters ? (
-
-
-
-
-
- Train length
-
-
-
- {fmtMeters(data.capacity.allocatedLengthMeters)}/
- {fmtMeters(data.capacity.maxLengthMeters)}
-
-
-
- ) : null}
- {data.capacity.maxWeightTons ? (
-
-
-
-
-
- Weight
-
-
-
- {fmtTons(data.capacity.usedWeightTons)}/{fmtTons(data.capacity.maxWeightTons)}
-
-
-
- ) : null}
-
+
-
-
- {data.counts.allocated} allocated
-
-
- {data.counts.selectedForBatch} selected
-
-
- {data.counts.ready} ready
-
-
- {data.counts.waiting} waiting
-
-
- {data.counts.pendingContract} pending contract
-
- {data.counts.expired ? (
-
- {data.counts.expired} expired
-
+ {!data.locomotive ? (
+ }>
+ No locomotive assigned — wagon allocation cannot run.
+
) : null}
+
+
+
+
+
+
+
+
+
+ {/* Booking pipeline */}
+
+
+
+
+
+
+ Booking pipeline
+
+
+ {totalBookings} booking{totalBookings === 1 ? "" : "s"}
+
+
{data.allocationViolations.length ? (
- } title="Allocation constraints">
+ }
+ title="Allocation constraints"
+ >
{data.allocationViolations.map((v) => (
@@ -483,26 +679,80 @@ export default function BatchScheduleDetailPage() {
) : null}
-
-
- Batch windows (EAT)
-
-
- Bookings are grouped by contract signing time (fullyExecutedAt). Expand a
- window to see bookings and wagon allocation issues.
-
+ {/* Batch windows */}
+
+
+
+
+
+
+ Batch windows (EAT)
+
+ Bookings grouped by contract signing time in 3-hour windows — expand one to
+ see bookings and wagon allocation issues.
+
+
+
-
+
{data.windows.map((window) => (
))}
{data.pendingContract.bookings.length ? (
-
-
- Pending contract
-
+
+
+
+
+
+
+
+ Pending contract
+
+
+ Contract not signed yet — not in any window
+
+
+
{data.pendingContract.bookings.length} booking
{data.pendingContract.bookings.length === 1 ? "" : "s"}
@@ -517,11 +767,9 @@ export default function BatchScheduleDetailPage() {
+ {/* Train composition */}
{hasAssignedWagons && scheduleDetailQuery.data ? (
-
-
- Train composition
-
+
-
+
) : null}
);
-}
\ No newline at end of file
+}
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
index ac9d85b76..0543ae0ba 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
@@ -622,7 +622,7 @@ export default function TrainScheduleV2DetailPage() {
}}
>
-
+
@@ -722,7 +722,7 @@ export default function TrainScheduleV2DetailPage() {
-
+
@@ -731,7 +731,7 @@ export default function TrainScheduleV2DetailPage() {
{schedule.route?.name ?? "Train schedule"}
{schedule.trainNumber ? (
-
+
{schedule.trainNumber}
) : null}
@@ -791,11 +791,17 @@ export default function TrainScheduleV2DetailPage() {
? "Import-ready"
: undefined
}
+ accent="#F2A516"
+ graph="area"
+ graphAccent="gold"
/>
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx
index f7dbf8a70..52930b86b 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx
@@ -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 (
- {/* Hero banner */}
-
-
-
-
-
-
-
-
-
- Train Schedules
-
-
- Plan departures, allocate bookings, and dispatch trains across
- every corridor.
-
-
-
- }
- onClick={() => setCreateOpen(true)}
- >
- New schedule
-
-
+
+ }
+ onClick={() => setCreateOpen(true)}
+ >
+ New schedule
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
@@ -638,11 +637,11 @@ function ScheduleCard({
}}
>
{/* accent strip */}
-
+
-
+
diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
index 2083fcd37..755f85b45 100644
--- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
@@ -108,3 +108,4 @@ export default function SettingsPage() {
);
}
+//
\ No newline at end of file
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
index 5f2b428f5..9b0549959 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
@@ -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";