Merge branch 'freight_feature/payments' of github.com:Tria-plc/edr-platform into freight_feature/payments

This commit is contained in:
marshal
2026-06-15 15:02:25 +03:00
2 changed files with 180 additions and 113 deletions

View File

@@ -16,7 +16,7 @@ import {
Group, Group,
Loader, Loader,
Paper, Paper,
SimpleGrid, Progress,
Stack, Stack,
Text, Text,
ThemeIcon, ThemeIcon,
@@ -25,12 +25,8 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack"; import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
import { import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals";
RouteCorridor, import { freightBrand } from "@/theme/freight-brand";
StatTile,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
import { useTrainTrack, useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling"; import { useTrainTrack, useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
@@ -54,6 +50,33 @@ function formatDateTime(iso?: string | null) {
}); });
} }
/** Compact icon + label + value cell used in the header meta strip. */
function MetaStat({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={32} radius="md" variant="light" color="green">
{icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="10px" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.5 }}>
{label}
</Text>
<Text size="sm" fw={700} c="dark.5" truncate>
{value}
</Text>
</Stack>
</Group>
);
}
export default function TrainScheduleTrackPage() { export default function TrainScheduleTrackPage() {
const { scheduleId } = useParams<{ scheduleId: string }>(); const { scheduleId } = useParams<{ scheduleId: string }>();
const { toast } = useToast(); const { toast } = useToast();
@@ -63,7 +86,7 @@ export default function TrainScheduleTrackPage() {
if (trackQuery.isLoading) { if (trackQuery.isLoading) {
return ( return (
<Group justify="center" py="xl"> <Group justify="center" py="xl">
<Loader size="sm" /> <Loader size="sm" color="green" />
</Group> </Group>
); );
} }
@@ -80,7 +103,9 @@ export default function TrainScheduleTrackPage() {
const canLog = track.status === "DISPATCHED"; const canLog = track.status === "DISPATCHED";
const totalStations = track.stations.length; const totalStations = track.stations.length;
const reached = Math.min(track.currentSequenceNo + 1, totalStations); const reached = Math.min(track.currentSequenceNo + 1, totalStations);
const progressLabel = `${reached} / ${totalStations}`; const progressPct = totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0;
const clampedPct = Math.min(100, Math.max(0, progressPct));
const currentStation = track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—";
const handleLog = (sequenceNo: number) => { const handleLog = (sequenceNo: number) => {
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo; const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
@@ -105,7 +130,7 @@ export default function TrainScheduleTrackPage() {
}; };
return ( return (
<Stack gap="lg"> <Stack gap="md" px={{ base: "xs", sm: 0 }} py="md" maw={1080} mx="auto" w="100%">
<Button <Button
component={Link} component={Link}
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`} to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
@@ -118,89 +143,111 @@ export default function TrainScheduleTrackPage() {
Back to schedule Back to schedule
</Button> </Button>
{/* Hero */} {/* Header */}
<Paper <Paper radius="lg" withBorder p="lg" style={{ borderColor: "var(--mantine-color-gray-2)" }}>
radius="xl" <Stack gap="lg">
p="xl" <Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
style={{ <Group gap="md" align="flex-start" wrap="nowrap" style={{ minWidth: 0 }}>
position: "relative", <Box
overflow: "hidden", style={{
background: scheduleBrand.heroGradient, width: 48,
boxShadow: scheduleBrand.shadow, height: 48,
}} borderRadius: 12,
> display: "flex",
<Box alignItems: "center",
style={{ justifyContent: "center",
position: "absolute", background: freightBrand.gradient,
top: -90, color: "white",
right: -50, flexShrink: 0,
width: 280, }}
height: 280, >
borderRadius: "50%", <Navigation size={24} />
background: "rgba(255,255,255,0.10)", </Box>
pointerEvents: "none", <Stack gap={6} style={{ minWidth: 0 }}>
}}
/>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={56} radius="lg" variant="white" style={{ color: "var(--mantine-color-green-7)" }}>
<Navigation size={28} />
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center" wrap="wrap"> <Group gap="sm" align="center" wrap="wrap">
<Title order={2} c="white" fw={700}> <Title order={3} fw={800}>
Track train Train tracking
</Title> </Title>
{track.trainNumber ? ( {track.trainNumber ? (
<Badge variant="white" c="green.8" radius="sm" style={{ fontWeight: 600 }}> <Badge variant="light" color="green" radius="sm">
{track.trainNumber} {track.trainNumber}
</Badge> </Badge>
) : null} ) : null}
{track.direction ? ( {track.direction ? (
<Badge variant="white" c="green.8" radius="sm"> <Badge variant="light" color="gray" radius="sm">
{track.direction} {track.direction}
</Badge> </Badge>
) : null} ) : null}
</Group> </Group>
<Box maw={340}> <Box maw={360}>
<RouteCorridor onDark origin={track.origin} destination={track.destination} /> <RouteCorridor origin={track.origin} destination={track.destination} variant="compact" />
</Box> </Box>
<StatusPill status={track.status} />
</Stack> </Stack>
</Group> </Group>
<StatusPill status={track.status} />
</Group> </Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md"> {/* Journey progress */}
<StatTile onDark icon={Train} label="Progress" value={progressLabel} hint="stations reached" /> <Box>
<StatTile onDark icon={MapPin} label="Current" value={track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—"} /> <Group justify="space-between" mb={6}>
<StatTile onDark icon={CalendarClock} label="Departed" value={formatDateTime(track.actualDepartureAt)} /> <Text size="xs" fw={700} c="gray.7" tt="uppercase" style={{ letterSpacing: 0.4 }}>
<StatTile onDark icon={Flag} label="Arrived" value={formatDateTime(track.actualArrivalAt)} /> Journey progress
</SimpleGrid> </Text>
<Text size="xs" fw={700} c="green.8">
{reached} / {totalStations} stations · {Math.round(clampedPct)}%
</Text>
</Group>
<Progress
value={clampedPct}
size="lg"
radius="xl"
color="green"
striped={track.status === "DISPATCHED"}
animated={track.status === "DISPATCHED"}
/>
</Box>
{/* Meta strip */}
<Group justify="space-between" wrap="wrap" gap="lg">
<MetaStat icon={<MapPin size={16} />} label="Current" value={currentStation} />
<MetaStat
icon={<CalendarClock size={16} />}
label="Departed"
value={formatDateTime(track.actualDepartureAt)}
/>
<MetaStat
icon={<Flag size={16} />}
label="Arrived"
value={formatDateTime(track.actualArrivalAt)}
/>
<MetaStat
icon={<Train size={16} />}
label="Stations"
value={`${reached} of ${totalStations}`}
/>
</Group>
</Stack> </Stack>
</Paper> </Paper>
{/* Corridor */} {/* Corridor */}
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}> <Paper radius="lg" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="lg"> <Stack gap="md">
<Group justify="space-between" align="center" wrap="nowrap"> <Group gap="sm" align="center" wrap="nowrap">
<Group gap="md" align="center" wrap="nowrap"> <ThemeIcon size={34} radius="md" variant="light" color="green">
<ThemeIcon size={44} radius="md" variant="gradient" gradient={{ from: "green", to: "teal", deg: 135 }}> <Navigation size={17} />
<Navigation size={22} /> </ThemeIcon>
</ThemeIcon> <Stack gap={0}>
<Stack gap={2}> <Text fw={800} size="sm">
<Title order={4} fw={700}> Route corridor
Route corridor </Text>
</Title> <Text size="xs" c="dimmed">
<Text size="sm" c="dimmed"> {canLog
{canLog ? "Log the train passing each station; the final station marks arrival."
? "Log the train passing each station; the final station marks arrival." : track.status === "ARRIVED"
: track.status === "ARRIVED" ? "This train has arrived at its destination."
? "This train has arrived at its destination." : "Tracking becomes available once the train is dispatched."}
: "Tracking becomes available once the train is dispatched."} </Text>
</Text> </Stack>
</Stack>
</Group>
</Group> </Group>
<RouteCorridorTrack <RouteCorridorTrack
@@ -216,47 +263,68 @@ export default function TrainScheduleTrackPage() {
</Stack> </Stack>
</Paper> </Paper>
{/* Timeline */} {/* Checkpoint log */}
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}> <Paper radius="lg" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="md"> <Group gap="sm" align="center" wrap="nowrap" mb="md">
<Title order={5} fw={700}> <ThemeIcon size={34} radius="md" variant="light" color="green">
Checkpoint log <CheckCircle2 size={17} />
</Title> </ThemeIcon>
{track.checkpoints.length === 0 ? ( <Stack gap={0}>
<Text size="sm" c="dimmed"> <Text fw={800} size="sm">
No checkpoints logged yet. Checkpoint log
</Text> </Text>
) : ( <Text size="xs" c="dimmed">
<Timeline active={track.checkpoints.length} bulletSize={22} lineWidth={2} color="green"> {track.checkpoints.length} event{track.checkpoints.length === 1 ? "" : "s"} recorded
{track.checkpoints.map((cp) => ( </Text>
<Timeline.Item </Stack>
key={cp.id} </Group>
bullet={cp.kind === "ARRIVED" ? <CheckCircle2 size={13} /> : <MapPin size={12} />}
title={ {track.checkpoints.length === 0 ? (
<Group gap="sm"> <Stack align="center" gap="xs" py="xl">
<Text fw={600} size="sm"> <ThemeIcon size={44} radius="xl" variant="light" color="gray">
{cp.label ?? `Station ${cp.sequenceNo}`} <MapPin size={20} />
</Text> </ThemeIcon>
<Badge <Text size="sm" fw={600} c="gray.7">
size="xs" No checkpoints yet
radius="sm" </Text>
variant="light" <Text size="xs" c="dimmed" ta="center" maw={300}>
color={cp.kind === "ARRIVED" ? "teal" : cp.kind === "DEPARTED" ? "blue" : "green"} Each station the train passes will be logged here with its timestamp.
> </Text>
{cp.kind} </Stack>
</Badge> ) : (
</Group> <Timeline active={track.checkpoints.length} bulletSize={22} lineWidth={2} color="green">
} {track.checkpoints.map((cp) => (
> <Timeline.Item
<Text size="xs" c="dimmed"> key={cp.id}
{formatDateTime(cp.occurredAt)} bullet={cp.kind === "ARRIVED" ? <CheckCircle2 size={13} /> : <MapPin size={12} />}
title={
<Group gap="sm">
<Text fw={700} size="sm">
{cp.label ?? `Station ${cp.sequenceNo}`}
</Text>
<Badge
size="xs"
radius="sm"
variant="light"
color={cp.kind === "ARRIVED" ? "teal" : cp.kind === "DEPARTED" ? "blue" : "green"}
>
{cp.kind}
</Badge>
</Group>
}
>
<Text size="xs" c="dimmed">
{formatDateTime(cp.occurredAt)}
</Text>
{cp.note ? (
<Text size="xs" mt={2}>
{cp.note}
</Text> </Text>
{cp.note ? <Text size="xs">{cp.note}</Text> : null} ) : null}
</Timeline.Item> </Timeline.Item>
))} ))}
</Timeline> </Timeline>
)} )}
</Stack>
</Paper> </Paper>
</Stack> </Stack>
); );