mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 02:28:18 +00:00
fix batch page ui
This commit is contained in:
@@ -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} />
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user