style: WIP BatchBoard and train schedule

This commit is contained in:
Nathnael
2026-06-22 06:38:05 +00:00
parent 3dfb8d99ba
commit e6101c5514
3 changed files with 189 additions and 214 deletions

View File

@@ -1,12 +1,13 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
ActionIcon,
Alert, Alert,
Box, Box,
Button, Button,
Card, Card,
Container,
Group, Group,
Menu,
Paper, Paper,
RingProgress, RingProgress,
Select, Select,
@@ -21,7 +22,9 @@ import {
ArrowRight, ArrowRight,
CalendarClock, CalendarClock,
CalendarDays, CalendarDays,
Eye,
Inbox, Inbox,
MoreHorizontal,
Package, Package,
Ruler, Ruler,
Train, Train,
@@ -31,7 +34,7 @@ import {
import type { ColumnDef } from "@edr/ui-common"; import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import FleetToolbar from "@/components/fleet/FleetToolbar"; import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { import {
@@ -40,7 +43,7 @@ import {
totalBookingCount, totalBookingCount,
WindowStatusPill, WindowStatusPill,
} from "@/components/trainScheduling/batchVisuals"; } from "@/components/trainScheduling/batchVisuals";
import { RouteCorridor, StatTile } from "@/components/trainScheduling/scheduleVisuals"; import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand"; import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand";
import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling"; import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling";
@@ -365,7 +368,7 @@ function CardSkeleton() {
export default function BatchBoardPage() { export default function BatchBoardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { data, isLoading, isFetching, refetch } = useBatchBoard(); const { data, isLoading, isError, isFetching, refetch } = useBatchBoard();
const { viewMode, setViewMode } = useFleetViewMode("batch-board"); const { viewMode, setViewMode } = useFleetViewMode("batch-board");
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -549,72 +552,69 @@ export default function BatchBoardPage() {
header: "", header: "",
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` }, meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => ( cell: ({ row }) => (
<Group justify="flex-end" wrap="nowrap"> <Group justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<Button <Menu position="bottom-end" withinPortal shadow="md" width={180}>
variant="light" <Menu.Target>
color="green" <ActionIcon variant="subtle" color="gray" aria-label="Row actions">
size="compact-sm" <MoreHorizontal size={16} />
rightSection={<ArrowRight size={14} />} </ActionIcon>
onClick={() => </Menu.Target>
navigate(`/dashboard/operations/batch-board/${row.original.scheduleId}`) <Menu.Dropdown>
} <Menu.Item
> leftSection={<Eye size={15} />}
View windows onClick={() =>
</Button> navigate(`/dashboard/operations/batch-board/${row.original.scheduleId}`)
}
>
View windows
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group> </Group>
), ),
}, },
]; ];
}, [navigate]); }, [navigate]);
const tableStatus = isLoading ? "loading" : "success"; const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
return ( return (
<Container fluid py="lg" px="xl"> <PageContainer>
<Breadcrumbs items={[{ label: "Operations" }, { label: "Batch board" }]} /> <PageHeader
title="Batch Board"
subtitle="Active schedules and their booking windows."
action={
<Button variant="default" loading={isFetching} onClick={() => void refetch()}>
Refresh
</Button>
}
/>
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="md" mt="md"> <KpiStrip
<StatTile loading={isLoading}
icon={Train} items={[
label="Active schedules" {
value={isLoading ? "…" : schedules.length} label: "Active schedules",
hint="on the board right now" value: schedules.length,
accent="#F2A516" hint: "on the board right now",
graph="area" icon: Train,
graphAccent="gold" },
/> {
<StatTile label: "Open windows",
icon={CalendarDays} value: summary.openWindows,
label="Open windows" hint: "accepting bookings",
value={isLoading ? "…" : summary.openWindows} icon: CalendarDays,
hint="accepting bookings" },
accent="#FB8C2E" {
graph="line" label: "Bookings in play",
graphAccent="orange" value: summary.totalBookings,
/> hint: `${summary.totalWagons} wagons allocated`,
<StatTile icon: Package,
icon={Package} },
label="Bookings in play" ]}
value={isLoading ? "…" : summary.totalBookings} />
hint={`${summary.totalWagons} wagons allocated`}
accent="#F2A516"
graph="ring"
graphAccent="gold"
graphPct={
schedules.length
? Math.min(100, Math.round((summary.openWindows / schedules.length) * 100))
: 0
}
/>
</SimpleGrid>
<Card <Card p={0}>
radius="lg"
padding={0}
withBorder
mt="lg"
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Stack gap={0}> <Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%"> <Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar <FleetToolbar
@@ -647,6 +647,17 @@ export default function BatchBoardPage() {
columns={columns} columns={columns}
data={paged} data={paged}
status={tableStatus} status={tableStatus}
onRowClick={(schedule) =>
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
}
error={
isError
? {
message: "Failed to load the batch board.",
onRetry: () => void refetch(),
}
: undefined
}
emptyMessage="No active schedules" emptyMessage="No active schedules"
pagination={{ pagination={{
pageIndex: pagination.pageIndex, pageIndex: pagination.pageIndex,
@@ -688,7 +699,6 @@ export default function BatchBoardPage() {
borderRadius: 20, borderRadius: 20,
background: "white", background: "white",
border: "1px solid var(--mantine-color-gray-2)", 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)" /> <Inbox size={28} color="var(--mantine-color-gray-5)" />
@@ -711,18 +721,6 @@ export default function BatchBoardPage() {
)} )}
</Stack> </Stack>
</Card> </Card>
</PageContainer>
<Group justify="flex-end" mt="md">
<Button
variant="subtle"
color="gray"
size="xs"
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
</Group>
</Container>
); );
} }

View File

@@ -3,10 +3,12 @@ import { useNavigate } from "react-router-dom";
import { isAxiosError } from "axios"; import { isAxiosError } from "axios";
import type { ColumnDef } from "@edr/ui-common"; import type { ColumnDef } from "@edr/ui-common";
import { import {
ActionIcon,
Box, Box,
Button, Button,
Card, Card,
Group, Group,
Menu,
Modal, Modal,
Select, Select,
SimpleGrid, SimpleGrid,
@@ -15,16 +17,25 @@ import {
TextInput, TextInput,
ThemeIcon, ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { ArrowRight, CalendarClock, Navigation, Send, Train, Weight } from "lucide-react"; import {
ArrowRight,
Ban,
CalendarClock,
Eye,
MoreHorizontal,
Navigation,
Send,
Train,
Weight,
} from "lucide-react";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import FleetToolbar from "@/components/fleet/FleetToolbar"; import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { import {
RouteCorridor, RouteCorridor,
StatTile,
StatusPill, StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals"; } from "@/components/trainScheduling/scheduleVisuals";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
@@ -253,61 +264,65 @@ export default function TrainScheduleV2ListPage() {
id: "actions", id: "actions",
header: "Actions", header: "Actions",
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` }, meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => ( cell: ({ row }) => {
<Group gap={6} justify="flex-end" wrap="nowrap"> const schedule = row.original;
<Button return (
variant="light" <Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
color="green" <Menu position="bottom-end" withinPortal shadow="md" width={190}>
size="compact-sm" <Menu.Target>
rightSection={<ArrowRight size={14} />} <ActionIcon variant="subtle" color="gray" aria-label="Row actions">
onClick={() => <MoreHorizontal size={16} />
navigate(`/dashboard/operations/train-scheduling-v2/${row.original.id}`) </ActionIcon>
} </Menu.Target>
> <Menu.Dropdown>
Open <Menu.Item
</Button> leftSection={<Eye size={15} />}
{["DISPATCHED", "ARRIVED"].includes(row.original.status) ? ( onClick={() =>
<Button navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
variant="light" }
color="teal" >
size="compact-sm" View detail
leftSection={<Navigation size={14} />} </Menu.Item>
onClick={() => {["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
navigate( <Menu.Item
`/dashboard/operations/train-scheduling-v2/${row.original.id}/track`, leftSection={<Navigation size={15} />}
) onClick={() =>
} navigate(
> `/dashboard/operations/train-scheduling-v2/${schedule.id}/track`,
Track )
</Button> }
) : null} >
{["DRAFT", "SCHEDULED"].includes(row.original.status) ? ( Track
<Button </Menu.Item>
variant="subtle" ) : null}
color="red" {["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
size="compact-sm" <Menu.Item
loading={cancel.isPending} color="red"
onClick={async () => { leftSection={<Ban size={15} />}
try { onClick={async () => {
await cancel.mutateAsync({ try {
id: row.original.id, await cancel.mutateAsync({
freightType: row.original.freightType ?? "CONTAINER", id: schedule.id,
}); freightType: schedule.freightType ?? "CONTAINER",
toast({ title: "Schedule cancelled" }); });
} catch (err) { toast({ title: "Schedule cancelled" });
toast({ } catch (err) {
title: "Cancel failed", toast({
description: parseError(err, "Could not cancel"), title: "Cancel failed",
variant: "destructive", description: parseError(err, "Could not cancel"),
}); variant: "destructive",
} });
}} }
> }}
Cancel >
</Button> Cancel schedule
) : null} </Menu.Item>
</Group> ) : null}
), </Menu.Dropdown>
</Menu>
</Group>
);
},
}, },
]; ];
}, [navigate, cancel.isPending, cancel, toast]); }, [navigate, cancel.isPending, cancel, toast]);
@@ -340,65 +355,33 @@ export default function TrainScheduleV2ListPage() {
: "success"; : "success";
return ( return (
<Stack gap="lg"> <PageContainer>
<Group justify="flex-end"> <PageHeader
<Button title="Train Schedules"
size="md" subtitle="Operational train scheduling with full allocation workflow."
radius="lg" action={
color="green" <Button leftSection={<Train size={18} />} onClick={() => setCreateOpen(true)}>
leftSection={<Train size={18} />} New schedule
onClick={() => setCreateOpen(true)} </Button>
> }
New schedule />
</Button>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md"> <KpiStrip
<StatTile items={[
icon={Train} { label: "Total trains", value: stats.total, icon: Train },
label="Total trains" { label: "Scheduled", value: stats.scheduled, icon: CalendarClock },
value={stats.total} { label: "Dispatched", value: stats.dispatched, icon: Send },
accent="#F2A516" { label: "Planned load", value: `${Math.round(stats.weight)}T`, icon: Weight },
graph="area" ]}
graphAccent="gold" />
/>
<StatTile
icon={CalendarClock}
label="Scheduled"
value={stats.scheduled}
accent="#FB8C2E"
graph="line"
graphAccent="orange"
graphPct={stats.total ? Math.round((stats.scheduled / stats.total) * 100) : 0}
/>
<StatTile
icon={Send}
label="Dispatched"
value={stats.dispatched}
accent="#F2A516"
graph="ring"
graphAccent="gold"
graphPct={stats.total ? Math.round((stats.dispatched / stats.total) * 100) : 0}
/>
<StatTile
icon={Weight}
label="Planned load"
value={`${Math.round(stats.weight)}T`}
accent="#FB8C2E"
graph="area"
graphAccent="orange"
/>
</SimpleGrid>
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}> <Card p={0}>
<Stack gap={0}> <Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%"> <Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar <FleetToolbar
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="Search schedules…" searchPlaceholder="Search schedules…"
addLabel="Create schedule"
onAdd={() => setCreateOpen(true)}
viewMode={viewMode} viewMode={viewMode}
onViewModeChange={setViewMode} onViewModeChange={setViewMode}
filters={ filters={
@@ -442,6 +425,17 @@ export default function TrainScheduleV2ListPage() {
columns={columns} columns={columns}
data={paged} data={paged}
status={tableStatus} status={tableStatus}
onRowClick={(schedule) =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
}
error={
schedulesQuery.isError
? {
message: "Failed to load train schedules.",
onRetry: () => void schedulesQuery.refetch(),
}
: undefined
}
emptyMessage="No train schedules found" emptyMessage="No train schedules found"
pagination={{ pagination={{
pageIndex: pagination.pageIndex, pageIndex: pagination.pageIndex,
@@ -554,13 +548,13 @@ export default function TrainScheduleV2ListPage() {
<Button variant="default" onClick={() => setCreateOpen(false)}> <Button variant="default" onClick={() => setCreateOpen(false)}>
Cancel Cancel
</Button> </Button>
<Button color="green" loading={create.isPending} onClick={handleCreate}> <Button loading={create.isPending} onClick={handleCreate}>
Create Create
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>
</Stack> </PageContainer>
); );
} }
@@ -613,31 +607,15 @@ function ScheduleCard({
const canTrack = ["DISPATCHED", "ARRIVED"].includes(schedule.status); const canTrack = ["DISPATCHED", "ARRIVED"].includes(schedule.status);
return ( return (
<Card <Card
radius="lg"
padding={0} padding={0}
withBorder withBorder
onClick={onOpen} onClick={onOpen}
style={{ className="cursor-pointer overflow-hidden transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
cursor: "pointer",
overflow: "hidden",
borderColor: "var(--mantine-color-gray-2)",
transition: "box-shadow 150ms ease, transform 150ms ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = scheduleBrand.shadowSm;
e.currentTarget.style.transform = "translateY(-2px)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = "";
e.currentTarget.style.transform = "";
}}
> >
{/* accent strip */}
<Box style={{ height: 4, background: "linear-gradient(90deg, #FBD171, #F2A516)" }} />
<Stack gap="sm" p="md"> <Stack gap="sm" p="md">
<Group justify="space-between" align="flex-start" wrap="nowrap"> <Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}> <Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={38} radius="md" variant="light" color="#F2A516"> <ThemeIcon size={38} radius="md" variant="light" color="edr-green">
<Train size={18} /> <Train size={18} />
</ThemeIcon> </ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}> <Stack gap={0} style={{ minWidth: 0 }}>

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, Card, Group, NumberInput, Stack, Text, Title } from "@mantine/core"; import { Button, Card, Group, NumberInput, Stack } from "@mantine/core";
import { PageContainer, PageHeader } from "@/components/page";
import { trainSchedulingService } from "@/services/trainScheduling.service"; import { trainSchedulingService } from "@/services/trainScheduling.service";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import type { TrainSchedulingGlobalRules } from "@/types/trainScheduling"; import type { TrainSchedulingGlobalRules } from "@/types/trainScheduling";
@@ -44,15 +45,13 @@ export default function TrainSchedulingGlobalRulesPage() {
}; };
return ( return (
<Stack gap="lg" maw={720}> <PageContainer>
<Stack gap={4}> <PageHeader
<Title order={3}>Train scheduling rules</Title> title="Train scheduling rules"
<Text size="sm" c="dimmed"> subtitle="Global limits applied when previewing and assigning bookings to trains."
Global limits applied when previewing and assigning bookings to trains. />
</Text>
</Stack>
<Card radius="xl" padding="lg" withBorder> <Card maw={720}>
<Stack gap="md"> <Stack gap="md">
<NumberInput <NumberInput
label="Max train length (m)" label="Max train length (m)"
@@ -110,12 +109,12 @@ export default function TrainSchedulingGlobalRulesPage() {
disabled={loading} disabled={loading}
/> />
<Group justify="flex-end"> <Group justify="flex-end">
<Button color="teal" loading={saving} disabled={loading} onClick={() => void handleSave()}> <Button loading={saving} disabled={loading} onClick={() => void handleSave()}>
Save rules Save rules
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Card> </Card>
</Stack> </PageContainer>
); );
} }