fix batch page ui

This commit is contained in:
Marshal
2026-06-12 12:46:51 +00:00
parent da387fd310
commit db39e77e90
5 changed files with 1357 additions and 488 deletions

View File

@@ -1,30 +1,43 @@
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,
CalendarDays,
Inbox,
LayoutGrid,
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 +47,235 @@ 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";
function ringColor(pct: number) {
if (pct >= 100) return "red";
if (pct >= 85) return "orange";
return "green";
}
/** Capacity ring that keeps the explicit allocated/max numbers underneath. */
function CapacityRing({
pct,
label,
current,
max,
}: {
pct: number;
label: string;
current: string;
max: string;
}) {
const clamped = Math.min(100, Math.max(0, pct));
const color = ringColor(pct);
return (
<Stack gap={4} align="center" style={{ flex: 1 }}>
<RingProgress
size={104}
thickness={9}
roundCaps
sections={[{ value: clamped, color }]}
rootColor="var(--mantine-color-gray-1)"
label={
<Stack gap={0} align="center">
<Text ta="center" size="lg" fw={800} c={`${color}.7`} lh={1}>
{Math.round(pct)}%
</Text>
<Text ta="center" size="9px" c="dimmed" fw={600}>
{label}
</Text>
</Stack>
}
/>
<Stack gap={0} align="center">
<Text size="xs" fw={700} c="dark.4">
{current}
</Text>
<Text size="xs" c="dimmed">
of {max}
</Text>
</Stack>
</Stack>
);
}
function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
const navigate = useNavigate();
const { capacity, counts, locomotive } = schedule;
const lengthPct =
capacity.maxLengthMeters && capacity.maxLengthMeters > 0
? (capacity.allocatedLengthMeters / capacity.maxLengthMeters) * 100
: 0;
: null;
const weightPct =
capacity.maxWeightTons && capacity.maxWeightTons > 0
? (capacity.usedWeightTons / capacity.maxWeightTons) * 100
: 0;
: null;
const windowColor =
schedule.bookingWindowStatus === "OPEN"
? "green"
: schedule.bookingWindowStatus === "FULL"
? "orange"
: "gray";
const totalBookings =
counts.allocated +
counts.selectedForBatch +
counts.ready +
counts.waiting +
counts.pendingContract +
counts.expired;
const totalBookings = totalBookingCount(counts);
return (
<Paper
className="bb-card"
radius="lg"
withBorder
style={{
borderColor: "var(--mantine-color-gray-2)",
background: "white",
overflow: "hidden",
cursor: "pointer",
display: "flex",
flexDirection: "column",
}}
onClick={() =>
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
}
>
{/* thin brand accent line */}
<Box
style={{
height: 3,
background:
"linear-gradient(90deg, var(--mantine-color-green-5), var(--mantine-color-teal-7))",
height: 4,
background: `linear-gradient(90deg, ${FREIGHT_BRAND}, var(--mantine-color-teal-6))`,
}}
/>
<Stack gap="sm" p="md">
<Stack gap="md" p="lg" style={{ flex: 1 }}>
{/* header */}
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={38} radius="md" variant="light" color="green">
<Train size={19} />
<ThemeIcon size={44} radius="md" variant="light" color="green">
<Train size={22} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fw={700} size="sm" truncate>
<Text fw={800} size="md" truncate style={{ letterSpacing: 0.2 }}>
{schedule.trainNumber ?? schedule.routeName ?? "Schedule"}
</Text>
<Text size="xs" c="dimmed" truncate>
{schedule.origin ?? "—"} {schedule.destination ?? "—"}
</Text>
<Text size="xs" c="dimmed">
{schedule.scheduleDate
? new Date(schedule.scheduleDate).toLocaleString()
: "No date"}
<Text
size="10px"
fw={700}
tt="uppercase"
c="dimmed"
style={{ letterSpacing: 0.8 }}
>
Freight schedule · {schedule.status}
</Text>
</Box>
</Group>
<Stack gap={4} align="flex-end">
<Badge variant="light" color={windowColor} radius="sm">
{schedule.bookingWindowStatus}
</Badge>
<Badge variant="outline" color="gray" radius="sm">
{schedule.status}
</Badge>
</Stack>
<WindowStatusPill status={schedule.bookingWindowStatus} />
</Group>
{locomotive ? (
<Text size="xs" c="dimmed">
Loco {locomotive.code} · max {fmtTons(locomotive.maxPullWeightTons)} ·{" "}
{locomotive.maxTrainLengthMeters} m
</Text>
) : (
<Badge variant="light" color="red" radius="sm">
No locomotive won't allocate
</Badge>
)}
<RouteCorridor
origin={schedule.origin}
destination={schedule.destination}
variant="compact"
/>
<Box>
<Group justify="space-between" mb={2}>
<Text size="xs" c="dimmed">
Allocated wagons
</Text>
<Text size="xs" fw={600}>
{capacity.allocatedWagons}
</Text>
</Group>
</Box>
{capacity.maxLengthMeters ? (
<Box>
<Group justify="space-between" mb={2}>
<Group gap={4}>
<Ruler size={12} />
<Text size="xs" c="dimmed">
Train length
</Text>
</Group>
<Text size="xs" fw={600}>
{fmtMeters(capacity.allocatedLengthMeters)}/{fmtMeters(capacity.maxLengthMeters)}
</Text>
</Group>
<Progress
value={lengthPct}
color={lengthPct >= 100 ? "orange" : "blue"}
radius="xl"
size="sm"
/>
</Box>
) : null}
{capacity.maxWeightTons ? (
<Box>
<Group justify="space-between" mb={2}>
<Group gap={4}>
<Weight size={12} />
<Text size="xs" c="dimmed">
Weight
</Text>
</Group>
<Text size="xs" fw={600}>
{fmtTons(capacity.usedWeightTons)}/{fmtTons(capacity.maxWeightTons)}
</Text>
</Group>
<Progress
value={weightPct}
color={weightPct >= 100 ? "red" : "teal"}
radius="xl"
size="sm"
/>
</Box>
) : null}
<Group gap={6}>
<Tooltip label="Allocated to the train">
<Badge variant="light" color="green" radius="sm">
{counts.allocated} allocated
</Badge>
</Tooltip>
<Tooltip label="Picked by batch — customer notified to pay">
<Badge variant="light" color="orange" radius="sm">
{counts.selectedForBatch} selected
</Badge>
</Tooltip>
<Tooltip label="Contract signed — waiting for batch pick">
<Badge variant="light" color="teal" radius="sm">
{counts.ready} ready
</Badge>
</Tooltip>
<Tooltip label="Paid, waiting for a slot">
<Badge variant="light" color="blue" radius="sm">
{counts.waiting} waiting
</Badge>
</Tooltip>
{counts.expired ? (
<Badge variant="light" color="red" radius="sm">
{counts.expired} expired
</Badge>
<Group gap={6} wrap="wrap">
<HeroChip icon={<CalendarDays size={12} />}>
{fmtScheduleDate(schedule.scheduleDate)}
</HeroChip>
{locomotive ? (
<HeroChip icon={<TrainFront size={12} />}>
{locomotive.code} · {fmtTons(locomotive.maxPullWeightTons)}
</HeroChip>
) : null}
</Group>
<Text size="xs" c="dimmed" ta="center">
{totalBookings
? `${totalBookings} booking${totalBookings === 1 ? "" : "s"} · click for batch windows`
: "No bookings yet · click to open"}
</Text>
{!locomotive ? (
<Alert
color="red"
radius="md"
icon={<AlertTriangle size={15} />}
py={6}
styles={{ message: { fontSize: 12 } }}
>
No locomotive assigned wagon allocation cannot run.
</Alert>
) : null}
{/* capacity: weight + length rings + wagons (numbers preserved) */}
<Box
py="sm"
px="xs"
style={{
borderRadius: 14,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-1)",
}}
>
<Group justify="space-around" align="center" wrap="nowrap" gap="xs">
{weightPct != null ? (
<CapacityRing
pct={weightPct}
label="WEIGHT"
current={fmtTons(capacity.usedWeightTons)}
max={fmtTons(capacity.maxWeightTons ?? 0)}
/>
) : null}
<Stack gap={0} align="center" style={{ flex: 1 }}>
<ThemeIcon size={34} radius="md" variant="light" color="green">
<Package size={17} />
</ThemeIcon>
<Text fw={800} size="26px" c="dark.5" lh={1.1} mt={6}>
{capacity.allocatedWagons}
</Text>
<Text size="9px" fw={700} c="gray.6" tt="uppercase" style={{ letterSpacing: 0.5 }}>
Wagons
</Text>
<Text size="xs" c="dimmed">
allocated
</Text>
</Stack>
{lengthPct != null ? (
<CapacityRing
pct={lengthPct}
label="LENGTH"
current={fmtMeters(capacity.allocatedLengthMeters)}
max={fmtMeters(capacity.maxLengthMeters ?? 0)}
/>
) : null}
</Group>
</Box>
{/* booking pipeline */}
<Box>
<Group justify="space-between" mb={6}>
<Group gap={5} wrap="nowrap">
<Package size={13} color="var(--mantine-color-gray-6)" />
<Text size="xs" fw={700} c="gray.7" tt="uppercase" style={{ letterSpacing: 0.4 }}>
Booking pipeline
</Text>
</Group>
<Text size="xs" fw={700} c="dark.4">
{totalBookings} booking{totalBookings === 1 ? "" : "s"}
</Text>
</Group>
<BookingPipeline counts={counts} />
</Box>
</Stack>
{/* CTA */}
<Box px="lg" pb="lg">
<Button
variant="light"
color="green"
fullWidth
radius="md"
size="compact-sm"
rightSection={<ArrowRight size={15} />}
variant="gradient"
gradient={{ from: FREIGHT_BRAND, to: FREIGHT_BRAND_DARK, deg: 135 }}
rightSection={<ArrowRight size={16} className="bb-arrow" />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`);
@@ -222,6 +283,26 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
>
View batch windows
</Button>
</Box>
</Paper>
);
}
function CardSkeleton() {
return (
<Paper radius="lg" withBorder style={{ overflow: "hidden" }}>
<Skeleton height={4} radius={0} />
<Stack gap="md" p="lg">
<Group justify="space-between">
<Group gap="sm">
<Skeleton height={44} width={44} radius="md" />
<Skeleton height={32} width={120} />
</Group>
<Skeleton height={22} width={90} radius="xl" />
</Group>
<Skeleton height={120} radius="md" />
<Skeleton height={10} radius="xl" />
<Skeleton height={36} radius="md" />
</Stack>
</Paper>
);
@@ -231,35 +312,58 @@ export default function BatchBoardPage() {
const { data, isLoading, isFetching, refetch } = useBatchBoard();
const schedules = data ?? [];
const summary = useMemo(() => {
const openWindows = schedules.filter((s) => s.bookingWindowStatus === "OPEN").length;
const totalBookings = schedules.reduce((sum, s) => sum + totalBookingCount(s.counts), 0);
const totalWagons = schedules.reduce((sum, s) => sum + s.capacity.allocatedWagons, 0);
return { openWindows, totalBookings, totalWagons };
}, [schedules]);
return (
<Container size="xl" py="lg">
<Container fluid py="lg" px="xl">
<Breadcrumbs items={[{ label: "Operations" }, { label: "Batch board" }]} />
{/* Hero — clean white */}
<Paper
radius="xl"
p="xl"
mt="md"
withBorder
style={{
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
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">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon size={54} radius="lg" variant="light" color="green">
<LayoutGrid size={26} />
<ThemeIcon size={56} radius="lg" variant="light" color="green">
<LayoutGrid size={28} />
</ThemeIcon>
<Stack gap={2}>
<Text size="xs" fw={700} c="green.7" tt="uppercase" style={{ letterSpacing: 1 }}>
Allocation · Live
</Text>
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
<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={560}>
Active schedules click a card to see EAT 3-hour batch windows, bookings, and
wagon allocation status.
<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>
@@ -273,20 +377,64 @@ export default function BatchBoardPage() {
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>
{isLoading ? (
<Group justify="center" py="xl">
<Loader color="green" />
</Group>
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" mt="lg">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</SimpleGrid>
) : schedules.length === 0 ? (
<Paper radius="lg" withBorder p="xl" mt="lg" bg="gray.0">
<Text ta="center" c="dimmed">
No active schedules to show.
</Text>
<Paper radius="lg" withBorder p={48} mt="lg" bg="gray.0">
<Stack align="center" gap="sm">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 64,
height: 64,
borderRadius: 20,
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 4px 12px rgba(15,23,42,0.06)",
}}
>
<Inbox size={28} color="var(--mantine-color-gray-5)" />
</Box>
<Text fw={700} c="gray.7">
No active schedules
</Text>
<Text size="sm" c="dimmed" ta="center" maw={380}>
Schedules with an open booking window appear here as cards. Create or activate
a schedule to get started.
</Text>
</Stack>
</Paper>
) : (
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg" mt="lg">
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" mt="lg">
{schedules.map((s) => (
<ScheduleCard key={s.scheduleId} schedule={s} />
))}