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. - - - - - - - - + + + + + - - - - - - - - {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 ( - - - + - {clamped != null ? ( - - ) : null} - - - {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 - + <Title order={1} fw={700} style={{ letterSpacing: "-1px", color: "#8A5304" }}> {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 ( + + + + + + + + + {variant === "area" ? ( + + ) : null} + + {variant === "line" ? ( + + ) : null} + + + ); +} + +/** 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 ( + + + + {clamped != null ? ( + + ) : null} + + + {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}% 95} + animated={stats.pullUtil > 95} color={stats.pullUtil > 95 ? "red" : stats.pullUtil > 80 ? "yellow" : "green"} /> @@ -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. - - - - - - + + + {/* 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)} - - - = 100 ? "orange" : "blue"} - radius="xl" - size="sm" - /> - - ) : null} - {capacity.maxWeightTons ? ( - - - - - - Weight - - - - {fmtTons(capacity.usedWeightTons)}/{fmtTons(capacity.maxWeightTons)} - - - = 100 ? "red" : "teal"} - radius="xl" - size="sm" - /> - - ) : 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 */} + + + + ); +} + +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. - - - - - - + + + + + + + + + {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 ( - + - - - - - - - - - -
- - {data.trainNumber ?? data.routeName ?? "Schedule"} - - - {data.origin ?? "—"} → {data.destination ?? "—"} ·{" "} - {data.scheduleDate - ? new Date(data.scheduleDate).toLocaleString() - : "No date"} - -
+ + + + + + + {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} - - - - - - -
- - {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} - + + + + - - {data.capacity.maxLengthMeters ? ( - - - - - - Train length - - - - {fmtMeters(data.capacity.allocatedLengthMeters)}/ - {fmtMeters(data.capacity.maxLengthMeters)} - - - = 100 ? "orange" : "blue"} - radius="xl" - /> - - ) : null} - {data.capacity.maxWeightTons ? ( - - - - - - Weight - - - - {fmtTons(data.capacity.usedWeightTons)}/{fmtTons(data.capacity.maxWeightTons)} - - - = 100 ? "red" : "teal"} - radius="xl" - /> - - ) : 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. - - - - - + + + - - - - - - - - + + + + + + @@ -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";