This commit is contained in:
Marshal
2026-06-12 12:59:01 +00:00
parent db39e77e90
commit 6b79348074
6 changed files with 166 additions and 399 deletions

View File

@@ -1,11 +1,10 @@
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,
@@ -55,76 +54,45 @@ export function BookingRequestsHeader({
const val = (n?: number) => (loading ? "—" : (n ?? 0));
return (
<Paper
radius="xl"
p="xl"
style={{
position: "relative",
overflow: "hidden",
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
}}
>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon size={56} radius="lg" variant="light" color="green">
<Inbox size={28} />
</ThemeIcon>
<Stack gap={4}>
<Text size="xs" fw={700} c="green.7" tt="uppercase" style={{ letterSpacing: 1 }}>
Operations
</Text>
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
Booking Requests
</Title>
<Text size="sm" c="dimmed" maw={520}>
Track every booking from submission through approval, payment, and
dispatch prioritize what needs action.
</Text>
</Stack>
</Group>
<Group gap="sm">
<Button color="green" radius="lg" leftSection={<Plus size={18} />} onClick={onCreate}>
Create booking
</Button>
<Button
variant="default"
radius="lg"
leftSection={<RefreshCw size={16} />}
loading={isFetching}
onClick={onRefresh}
>
Refresh
</Button>
</Group>
</Group>
<Stack gap="lg">
<Group justify="flex-end" gap="sm">
<Button color="green" radius="lg" leftSection={<Plus size={18} />} onClick={onCreate}>
Create booking
</Button>
<Button
variant="default"
radius="lg"
leftSection={<RefreshCw size={16} />}
loading={isFetching}
onClick={onRefresh}
>
Refresh
</Button>
</Group>
<Group grow gap="md" align="stretch" wrap="wrap">
<HeroStat icon={LayoutList} label="In queue" value={val(metrics?.inQueue)} hint="Matching current filter" />
<HeroStat
icon={Clock}
label="Needs action"
value={val(metrics?.needsAction)}
hint="Submitted or pending"
ratio={metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0}
ratioColor="#f59e0b"
/>
<HeroStat
icon={AlertTriangle}
label="Urgent"
value={val(metrics?.urgent)}
hint="High priority score"
ratio={metrics?.inQueue ? (metrics.urgent ?? 0) / metrics.inQueue : 0}
ratioColor="#ef4444"
/>
<HeroStat icon={CheckCircle2} label="Completed" value={val(tabs?.completed)} hint="Fully executed" />
</Group>
<Group grow gap="md" align="stretch" wrap="wrap">
<HeroStat icon={LayoutList} label="In queue" value={val(metrics?.inQueue)} hint="Matching current filter" />
<HeroStat
icon={Clock}
label="Needs action"
value={val(metrics?.needsAction)}
hint="Submitted or pending"
ratio={metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0}
ratioColor="#f59e0b"
/>
<HeroStat
icon={AlertTriangle}
label="Urgent"
value={val(metrics?.urgent)}
hint="High priority score"
ratio={metrics?.inQueue ? (metrics.urgent ?? 0) / metrics.inQueue : 0}
ratioColor="#ef4444"
/>
<HeroStat icon={CheckCircle2} label="Completed" value={val(tabs?.completed)} hint="Fully executed" />
</Group>
{tabs ? <PipelineBar tabs={tabs} /> : null}
</Stack>
</Paper>
{tabs ? <PipelineBar tabs={tabs} /> : null}
</Stack>
);
}

View File

@@ -1,14 +1,5 @@
import {
ActionIcon,
Box,
Group,
SegmentedControl,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { Activity, RefreshCw } from "lucide-react";
import { ActionIcon, Group, SegmentedControl, Text } from "@mantine/core";
import { RefreshCw } from "lucide-react";
import type { OverviewRange } from "@/types/overview";
@@ -29,40 +20,6 @@ function formatRelativeTime(iso: string | undefined) {
return new Date(iso).toLocaleString();
}
/** Decorative line-art locomotive + rails — faint brand tint on the right. */
function TrainArtwork() {
return (
<Box
aria-hidden
style={{
position: "absolute",
right: -10,
bottom: -8,
width: 360,
height: 200,
opacity: 0.06,
pointerEvents: "none",
color: "var(--mantine-color-green-7)",
}}
>
<svg viewBox="0 0 360 200" fill="none" width="100%" height="100%">
<path d="M0 168 H360" stroke="currentColor" strokeWidth="2" strokeDasharray="2 10" strokeLinecap="round" />
<path d="M0 180 H360" stroke="currentColor" strokeWidth="2" />
<path d="M70 60 H250 a14 14 0 0 1 14 14 V150 H56 V94 a34 34 0 0 1 14-28 Z" stroke="currentColor" strokeWidth="3" />
<rect x="84" y="80" width="40" height="30" rx="6" stroke="currentColor" strokeWidth="3" />
<rect x="140" y="80" width="44" height="30" rx="6" stroke="currentColor" strokeWidth="3" />
<rect x="200" y="80" width="44" height="30" rx="6" stroke="currentColor" strokeWidth="3" />
<path d="M56 130 H264" stroke="currentColor" strokeWidth="3" />
<circle cx="96" cy="150" r="16" stroke="currentColor" strokeWidth="3" />
<circle cx="150" cy="150" r="16" stroke="currentColor" strokeWidth="3" />
<circle cx="214" cy="150" r="16" stroke="currentColor" strokeWidth="3" />
<path d="M264 120 H300 a8 8 0 0 1 8 8 V150 H300" stroke="currentColor" strokeWidth="3" />
<path d="M56 100 l-28 -10 M56 112 l-30 0 M56 124 l-28 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</Box>
);
}
interface OverviewPageHeaderProps {
range: OverviewRange;
onRangeChange: (range: OverviewRange) => void;
@@ -79,59 +36,31 @@ export function OverviewPageHeader({
isRefreshing,
}: OverviewPageHeaderProps) {
return (
<Box
style={{
position: "relative",
overflow: "hidden",
borderRadius: 20,
padding: "26px 28px",
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
}}
>
<TrainArtwork />
<Group justify="space-between" align="flex-end" wrap="wrap" gap="lg" style={{ position: "relative" }}>
<Stack gap={6} style={{ minWidth: 0 }}>
<Group gap={8} align="center">
<ThemeIcon size={28} radius="md" variant="light" color="green">
<Activity size={16} />
</ThemeIcon>
<Text size="xs" fw={700} c="green.7" tt="uppercase" style={{ letterSpacing: 1.2 }}>
Freight Backoffice · Live
</Text>
</Group>
<Title order={1} style={{ letterSpacing: "-0.03em", fontSize: 34, lineHeight: 1.1, color: "#0f172a" }}>
Operations Overview
</Title>
<Text size="sm" c="dimmed">
Real-time freight performance · updated {formatRelativeTime(generatedAt)}
</Text>
</Stack>
<Group gap="sm" style={{ position: "relative" }}>
<SegmentedControl
value={range}
onChange={(value) => onRangeChange(value as OverviewRange)}
data={RANGE_OPTIONS}
size="sm"
radius="lg"
color="green"
/>
<ActionIcon
variant="light"
color="green"
size="lg"
radius="lg"
aria-label="Refresh dashboard"
onClick={onRefresh}
loading={isRefreshing}
>
<RefreshCw size={18} />
</ActionIcon>
</Group>
<Group justify="space-between" align="center" wrap="wrap" gap="md">
<Text size="sm" c="dimmed">
Updated {formatRelativeTime(generatedAt)}
</Text>
<Group gap="sm">
<SegmentedControl
value={range}
onChange={(value) => onRangeChange(value as OverviewRange)}
data={RANGE_OPTIONS}
size="sm"
radius="lg"
color="green"
/>
<ActionIcon
variant="light"
color="green"
size="lg"
radius="lg"
aria-label="Refresh dashboard"
onClick={onRefresh}
loading={isRefreshing}
>
<RefreshCw size={18} />
</ActionIcon>
</Group>
</Box>
</Group>
);
}

View File

@@ -21,7 +21,6 @@ import {
Textarea,
TextInput,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
@@ -380,47 +379,16 @@ export default function NewBookingPage() {
]}
/>
{/* Hero */}
<Paper
radius="xl"
p="xl"
mt="md"
style={{
position: "relative",
overflow: "hidden",
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
}}
>
<Group justify="space-between" align="flex-start" wrap="wrap" style={{ position: "relative" }}>
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon size={54} radius="lg" variant="light" color="green">
<Package size={26} />
</ThemeIcon>
<Stack gap={2}>
<Text size="xs" fw={700} c="green.7" tt="uppercase" style={{ letterSpacing: 1 }}>
New booking · Staff
</Text>
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
Create freight booking
</Title>
<Text size="sm" c="dimmed" maw={520}>
Capture cargo details, container lines, and routing saved as a draft and pushed
into the approval workflow.
</Text>
</Stack>
</Group>
<Button
variant="default"
radius="lg"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/booking-requests")}
>
Back to list
</Button>
</Group>
</Paper>
<Group justify="flex-end" mt="md">
<Button
variant="default"
radius="lg"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/booking-requests")}
>
Back to list
</Button>
</Group>
<Grid gutter="lg" mt="lg">
{/* LEFT — form */}

View File

@@ -13,14 +13,12 @@ import {
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import {
AlertTriangle,
ArrowRight,
CalendarDays,
Inbox,
LayoutGrid,
Package,
RefreshCw,
Ruler,
@@ -323,82 +321,38 @@ export default function BatchBoardPage() {
<Container fluid py="lg" px="xl">
<Breadcrumbs items={[{ label: "Operations" }, { label: "Batch board" }]} />
{/* Hero — clean white */}
<Paper
radius="xl"
p="xl"
mt="md"
withBorder
style={{
borderColor: "var(--mantine-color-gray-2)",
background: "white",
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
}}
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon size={56} radius="lg" variant="light" color="green">
<LayoutGrid size={28} />
</ThemeIcon>
<Stack gap={2}>
<Group gap={8}>
<Text
size="xs"
fw={700}
c="green.7"
tt="uppercase"
style={{ letterSpacing: 1.2 }}
>
Allocation · Live
</Text>
<Box
w={7}
h={7}
className="bb-pulse-dot"
style={{ borderRadius: 999, background: FREIGHT_BRAND }}
/>
</Group>
<Title order={2} fw={800} style={{ color: "#0f172a", letterSpacing: 0.2 }}>
Batch board
</Title>
<Text size="sm" c="dimmed" maw={580}>
Active schedules open a card to see EAT 3-hour batch windows, bookings,
and live wagon allocation.
</Text>
</Stack>
</Group>
<Button
variant="default"
radius="lg"
leftSection={<RefreshCw size={16} />}
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
</Group>
<Group justify="flex-end" mt="md">
<Button
variant="default"
radius="lg"
leftSection={<RefreshCw size={16} />}
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
</Group>
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="md" mt="lg">
<StatTile
icon={Train}
label="Active schedules"
value={isLoading ? "…" : schedules.length}
hint="on the board right now"
/>
<StatTile
icon={CalendarDays}
label="Open windows"
value={isLoading ? "…" : summary.openWindows}
hint="accepting bookings"
/>
<StatTile
icon={Package}
label="Bookings in play"
value={isLoading ? "…" : summary.totalBookings}
hint={`${summary.totalWagons} wagons allocated`}
/>
</SimpleGrid>
</Paper>
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="md" mt="md">
<StatTile
icon={Train}
label="Active schedules"
value={isLoading ? "…" : schedules.length}
hint="on the board right now"
/>
<StatTile
icon={CalendarDays}
label="Open windows"
value={isLoading ? "…" : summary.openWindows}
hint="accepting bookings"
/>
<StatTile
icon={Package}
label="Bookings in play"
value={isLoading ? "…" : summary.totalBookings}
hint={`${summary.totalWagons} wagons allocated`}
/>
</SimpleGrid>
{isLoading ? (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" mt="lg">

View File

@@ -33,7 +33,6 @@ import {
PlayCircle,
RefreshCw,
Ruler,
Train,
TrainFront,
Weight,
XCircle,
@@ -540,69 +539,50 @@ export default function BatchScheduleDetailPage() {
]}
/>
{/* Hero — clean white */}
<Paper
radius="xl"
p="xl"
mt="md"
withBorder
style={{
borderColor: "var(--mantine-color-gray-2)",
background: "white",
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
}}
>
<Stack gap="lg">
<Stack gap="lg" mt="md">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="flex-start" wrap="nowrap" style={{ minWidth: 0 }}>
<Button
variant="subtle"
color="gray"
radius="md"
px="sm"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/operations/batch-board")}
>
Back
</Button>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={52} radius="md" variant="light" color="green">
<Train size={26} />
</ThemeIcon>
<Stack gap={7} style={{ minWidth: 0 }}>
<Group gap="sm" wrap="wrap">
<Title order={2} fw={800} style={{ color: "#0f172a", letterSpacing: 0.2 }}>
{data.trainNumber ?? data.routeName ?? "Schedule"}
</Title>
<WindowStatusPill status={data.bookingWindowStatus} />
<HeroChip>{data.status}</HeroChip>
</Group>
<RouteCorridor origin={data.origin} destination={data.destination} />
<Group gap={6} wrap="wrap">
<HeroChip icon={<CalendarDays size={12} />}>
{data.scheduleDate
? new Intl.DateTimeFormat("en-GB", {
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).format(new Date(data.scheduleDate)) + " EAT"
: "No date"}
</HeroChip>
{data.locomotive ? (
<HeroChip icon={<TrainFront size={12} />}>
Loco {data.locomotive.code} · {fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
{data.locomotive.maxTrainLengthMeters} m
</HeroChip>
) : null}
</Group>
</Stack>
<Stack gap={9} style={{ minWidth: 0 }}>
<Group gap="sm" wrap="wrap" align="center">
<Button
variant="subtle"
color="gray"
radius="md"
px="sm"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/operations/batch-board")}
>
Back
</Button>
<Title order={2} fw={800} style={{ color: "#0f172a", letterSpacing: 0.2 }}>
{data.trainNumber ?? data.routeName ?? "Schedule"}
</Title>
<WindowStatusPill status={data.bookingWindowStatus} />
<HeroChip>{data.status}</HeroChip>
</Group>
</Group>
<RouteCorridor origin={data.origin} destination={data.destination} />
<Group gap={6} wrap="wrap">
<HeroChip icon={<CalendarDays size={12} />}>
{data.scheduleDate
? new Intl.DateTimeFormat("en-GB", {
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).format(new Date(data.scheduleDate)) + " EAT"
: "No date"}
</HeroChip>
{data.locomotive ? (
<HeroChip icon={<TrainFront size={12} />}>
Loco {data.locomotive.code} · {fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
{data.locomotive.maxTrainLengthMeters} m
</HeroChip>
) : null}
</Group>
</Stack>
<Group gap="sm">
<Button
@@ -677,8 +657,7 @@ export default function BatchScheduleDetailPage() {
sub={`${data.counts.allocated} allocated · ${data.counts.expired} expired`}
/>
</SimpleGrid>
</Stack>
</Paper>
</Stack>
{/* Booking pipeline */}
<Paper

View File

@@ -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,24 @@ export default function TrainScheduleV2ListPage() {
return (
<Stack gap="lg">
{/* Hero banner */}
<Paper
radius="xl"
p="xl"
style={{
position: "relative",
overflow: "hidden",
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
}}
>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon size={56} radius="lg" variant="light" color="green">
<Train size={28} />
</ThemeIcon>
<Stack gap={4}>
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
Train Schedules
</Title>
<Text size="sm" c="dimmed" maw={520}>
Plan departures, allocate bookings, and dispatch trains across
every corridor.
</Text>
</Stack>
</Group>
<Button
size="md"
radius="lg"
color="green"
leftSection={<Train size={18} />}
onClick={() => setCreateOpen(true)}
>
New schedule
</Button>
</Group>
<Group justify="flex-end">
<Button
size="md"
radius="lg"
color="green"
leftSection={<Train size={18} />}
onClick={() => setCreateOpen(true)}
>
New schedule
</Button>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatTile icon={Train} label="Total trains" value={stats.total} />
<StatTile icon={CalendarClock} label="Scheduled" value={stats.scheduled} />
<StatTile icon={Send} label="Dispatched" value={stats.dispatched} />
<StatTile icon={Weight} label="Planned load" value={`${Math.round(stats.weight)}T`} />
</SimpleGrid>
</Stack>
</Paper>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatTile icon={Train} label="Total trains" value={stats.total} />
<StatTile icon={CalendarClock} label="Scheduled" value={stats.scheduled} />
<StatTile icon={Send} label="Dispatched" value={stats.dispatched} />
<StatTile icon={Weight} label="Planned load" value={`${Math.round(stats.weight)}T`} />
</SimpleGrid>
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>