Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx

533 lines
20 KiB
TypeScript

import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { isAxiosError } from 'axios';
import toast from 'react-hot-toast';
import { Calendar, RefreshCw, TrainTrack } from 'lucide-react';
import {
Badge,
Box,
Breadcrumbs,
Button,
Divider,
Group,
Modal,
Paper,
ScrollArea,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { useRoutes } from '@/hooks/useRoutes';
import { trainSchedulingService } from '@/services/trainScheduling.service';
const formatDate = (value?: string | null) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return new Intl.DateTimeFormat('en', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
};
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(', ');
if (typeof message === 'string') return message;
const violations = error.response?.data?.violations;
if (Array.isArray(violations)) return violations.join(', ');
}
return fallback;
};
const statusColor = (status?: string | null) => {
switch (status) {
case 'SCHEDULED':
return 'green';
case 'DISPATCHED':
return 'blue';
case 'ARRIVED':
return 'teal';
case 'CANCELLED':
return 'red';
case 'DRAFT':
return 'yellow';
default:
return 'gray';
}
};
function MetricTile({
label,
value,
}: {
label: string;
value: string | number;
}) {
return (
<Paper withBorder radius="md" p="md">
<Text size="xs" tt="uppercase" c="dimmed" fw={700}>
{label}
</Text>
<Text size="sm" fw={600} mt={8}>
{value}
</Text>
</Paper>
);
}
const TrainsPage = () => {
const qc = useQueryClient();
const [routeId, setRouteId] = useState('');
const [scheduleDate, setScheduleDate] = useState('');
const [selectedLocomotiveId, setSelectedLocomotiveId] = useState('');
const [detailId, setDetailId] = useState<string | null>(null);
const [scheduleSearch, setScheduleSearch] = useState('');
const [scheduleStatusFilter, setScheduleStatusFilter] = useState('ALL');
const routesQuery = useRoutes();
const locomotivesQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(),
queryFn: () => trainSchedulingService.getAvailableLocomotives(),
});
const schedulesQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules(),
queryFn: () => trainSchedulingService.listSchedules(),
});
const detailQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(detailId ?? ''),
queryFn: () => trainSchedulingService.getScheduleById(detailId!),
enabled: Boolean(detailId),
});
const activeRoutes = useMemo(
() => (routesQuery.data ?? []).filter((route) => route.isActive),
[routesQuery.data],
);
const selectedRoute = activeRoutes.find((route) => route.id === routeId) ?? null;
const selectedLocomotive = (locomotivesQuery.data ?? []).find(
(locomotive) => locomotive.id === selectedLocomotiveId,
);
const routeOptions = activeRoutes.map((route) => ({
value: route.id,
label: route.name,
}));
const locomotiveOptions = (locomotivesQuery.data ?? []).map((locomotive) => ({
value: locomotive.id,
label: `${locomotive.code} - ${locomotive.maxPullWeightTons}T / ${locomotive.maxTrainLengthMeters}m`,
}));
const filteredSchedules = useMemo(() => {
const query = scheduleSearch.trim().toLowerCase();
return (schedulesQuery.data ?? []).filter((schedule) => {
const matchesStatus =
scheduleStatusFilter === 'ALL' || schedule.status === scheduleStatusFilter;
if (!matchesStatus) return false;
if (!query) return true;
const haystack = [
schedule.id,
schedule.routeName ?? '',
schedule.origin ?? '',
schedule.destination ?? '',
schedule.locomotive?.code ?? '',
schedule.status,
]
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
}, [scheduleSearch, scheduleStatusFilter, schedulesQuery.data]);
const createMutation = useMutation({
mutationFn: () => {
if (!routeId || !scheduleDate || !selectedLocomotiveId) {
throw new Error('Please select route, departure date, and locomotive');
}
return trainSchedulingService.createSchedule({
routeId,
scheduleDate: new Date(`${scheduleDate}T08:00:00.000Z`).toISOString(),
locomotiveId: selectedLocomotiveId,
});
},
onSuccess: (data) => {
toast.success('Train schedule created');
setRouteId('');
setScheduleDate('');
setSelectedLocomotiveId('');
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() });
setDetailId(data.id);
},
onError: (error) => {
toast.error(parseError(error, 'Failed to create train schedule'));
},
});
const cancelMutation = useMutation({
mutationFn: (id: string) => trainSchedulingService.cancelSchedule(id),
onSuccess: (data) => {
toast.success('Train schedule cancelled');
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(data.id) });
setDetailId(data.id);
},
onError: (error) => {
toast.error(parseError(error, 'Failed to cancel train schedule'));
},
});
const detail = detailQuery.data;
return (
<Box p="lg">
<Stack gap="lg">
<Breadcrumbs>
<Text size="sm" c="dimmed">
Operations
</Text>
<Text size="sm" fw={600}>
Train schedules
</Text>
</Breadcrumbs>
<Paper withBorder radius="md">
<Stack gap={0}>
<Group justify="space-between" align="center" p="lg">
<Group align="flex-start" gap="md">
<ThemeIcon size={52} radius="md">
<TrainTrack size={28} />
</ThemeIcon>
<Box>
<Title order={2}>Train Schedules</Title>
<Text size="sm" c="dimmed" mt={4}>
Create the train schedule first, reserve the locomotive, and assign bookings and wagons later.
</Text>
</Box>
</Group>
<Button
variant="default"
leftSection={<RefreshCw size={16} />}
onClick={() => {
void routesQuery.refetch();
void schedulesQuery.refetch();
void locomotivesQuery.refetch();
}}
>
Refresh
</Button>
</Group>
<Divider />
<SimpleGrid cols={{ base: 1, xl: 2 }} spacing="lg" p="lg">
<Paper withBorder radius="md" p="md">
<Stack>
<Group gap="xs">
<Calendar size={16} />
<Title order={3}>Schedule builder</Title>
</Group>
<Select
label="Route"
placeholder="Select active route"
data={routeOptions}
value={routeId || null}
searchable
onChange={(value) => setRouteId(value ?? '')}
/>
<TextInput
label="Departure date"
type="date"
value={scheduleDate}
onChange={(event) => setScheduleDate(event.currentTarget.value)}
/>
<Select
label="Locomotive"
placeholder="Select available locomotive"
data={locomotiveOptions}
value={selectedLocomotiveId || null}
searchable
onChange={(value) => setSelectedLocomotiveId(value ?? '')}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<MetricTile
label="Origin"
value={selectedRoute?.originYard?.label ?? selectedRoute?.originYard?.code ?? '-'}
/>
<MetricTile
label="Destination"
value={selectedRoute?.destinationYard?.label ?? selectedRoute?.destinationYard?.code ?? '-'}
/>
<MetricTile
label="Locomotive capacity"
value={
selectedLocomotive
? `${selectedLocomotive.maxPullWeightTons}T / ${selectedLocomotive.maxTrainLengthMeters}m`
: '-'
}
/>
<MetricTile label="Next step" value="Assign bookings, then allocate wagons" />
</SimpleGrid>
<Button fullWidth loading={createMutation.isPending} onClick={() => createMutation.mutate()}>
Create schedule
</Button>
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Stack>
<Group justify="space-between" align="flex-start">
<Box>
<Title order={3}>Created schedules</Title>
<Text size="sm" c="dimmed" mt={4}>
Open a schedule to inspect the reserved locomotive and prepare for later booking and wagon work.
</Text>
</Box>
<Badge variant="light">{filteredSchedules.length} schedules</Badge>
</Group>
<SimpleGrid cols={{ base: 1, md: 2 }}>
<TextInput
placeholder="Search by schedule, route, locomotive, or status"
value={scheduleSearch}
onChange={(event) => setScheduleSearch(event.currentTarget.value)}
/>
<Select
placeholder="All statuses"
value={scheduleStatusFilter}
data={[
{ value: 'ALL', label: 'All statuses' },
{ value: 'DRAFT', label: 'DRAFT' },
{ value: 'SCHEDULED', label: 'SCHEDULED' },
{ value: 'DISPATCHED', label: 'DISPATCHED' },
{ value: 'ARRIVED', label: 'ARRIVED' },
{ value: 'CANCELLED', label: 'CANCELLED' },
]}
onChange={(value) => setScheduleStatusFilter(value ?? 'ALL')}
/>
</SimpleGrid>
<ScrollArea>
<Table striped highlightOnHover verticalSpacing="sm" miw={980}>
<Table.Thead>
<Table.Tr>
<Table.Th>Schedule</Table.Th>
<Table.Th>Departure</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th>Locomotive</Table.Th>
<Table.Th>Bookings</Table.Th>
<Table.Th>Wagons</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Length</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredSchedules.map((schedule) => (
<Table.Tr key={schedule.id}>
<Table.Td>
<Text size="xs" ff="monospace">
{schedule.id}
</Text>
</Table.Td>
<Table.Td>{formatDate(schedule.scheduleDate)}</Table.Td>
<Table.Td>
{schedule.routeName ?? `${schedule.origin ?? '-'} to ${schedule.destination ?? '-'}`}
</Table.Td>
<Table.Td>{schedule.locomotive?.code ?? '-'}</Table.Td>
<Table.Td>{schedule.bookingsCount}</Table.Td>
<Table.Td>{schedule.wagonCount}</Table.Td>
<Table.Td>{schedule.totalWeightTons} T</Table.Td>
<Table.Td>{schedule.totalLengthMeters} m</Table.Td>
<Table.Td>
<Badge color={statusColor(schedule.status)} variant="light">
{schedule.status}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<Button variant="default" size="xs" onClick={() => setDetailId(schedule.id)}>
View
</Button>
{schedule.status !== 'CANCELLED' ? (
<Button
color="red"
variant="light"
size="xs"
loading={cancelMutation.isPending}
onClick={() => cancelMutation.mutate(schedule.id)}
>
Cancel
</Button>
) : null}
</Group>
</Table.Td>
</Table.Tr>
))}
{!schedulesQuery.isLoading && filteredSchedules.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={10}>
<Text ta="center" c="dimmed" py="xl">
No train schedules matched the current filters.
</Text>
</Table.Td>
</Table.Tr>
) : null}
{schedulesQuery.isLoading ? (
<Table.Tr>
<Table.Td colSpan={10}>
<Text ta="center" c="dimmed" py="xl">
Loading schedules...
</Text>
</Table.Td>
</Table.Tr>
) : null}
</Table.Tbody>
</Table>
</ScrollArea>
</Stack>
</Paper>
</SimpleGrid>
</Stack>
</Paper>
</Stack>
<Modal
opened={Boolean(detailId)}
onClose={() => setDetailId(null)}
title="Train schedule detail"
size="80rem"
centered
>
{detail ? (
<Stack>
<Text size="sm" c="dimmed">
Inspect the selected schedule. Booking assignment and wagon allocation happen after schedule creation.
</Text>
<SimpleGrid cols={{ base: 1, md: 2, xl: 5 }}>
<MetricTile label="Schedule" value={detail.id} />
<MetricTile label="Departure" value={formatDate(detail.scheduledDepartureDate)} />
<MetricTile label="Route" value={detail.route?.name ?? '-'} />
<MetricTile
label="Origin / destination"
value={`${detail.originStation?.label ?? detail.originStation?.code ?? '-'} to ${
detail.destinationStation?.label ?? detail.destinationStation?.code ?? '-'
}`}
/>
<Paper withBorder radius="md" p="md">
<Text size="xs" tt="uppercase" c="dimmed" fw={700}>
Status
</Text>
<Badge color={statusColor(detail.status)} variant="light" mt={8}>
{detail.status}
</Badge>
</Paper>
</SimpleGrid>
<Paper withBorder radius="md" p="md">
<Title order={3}>Locomotive</Title>
<Text size="sm" c="dimmed" mt={8}>
{detail.trainSet?.locomotive
? `${detail.trainSet.locomotive.code} (${detail.trainSet.locomotive.maxPullWeightTons}T pull capacity / ${
detail.trainSet.locomotive.maxTrainLengthMeters ?? 0
}m)`
: 'No locomotive attached'}
</Text>
</Paper>
<Paper withBorder radius="md" p="md">
<Title order={3}>Wagons and allocations</Title>
{(detail.trainSet?.wagons?.length ?? 0) === 0 ? (
<Text size="sm" c="dimmed" mt="sm">
No wagons allocated yet.
</Text>
) : (
<Stack mt="md">
{(detail.trainSet?.wagons ?? []).map((wagon) => (
<Paper key={wagon.id} withBorder radius="md" p="md">
<Text fw={600}>
Wagon {wagon.sequenceNo} - {wagon.wagonType?.code ?? 'NW5'}
</Text>
<Text size="sm" c="dimmed" mt={4}>
{wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m
</Text>
</Paper>
))}
</Stack>
)}
</Paper>
<Paper withBorder radius="md" p="md">
<Title order={3}>Bookings in schedule</Title>
{detail.bookings.length === 0 ? (
<Text size="sm" c="dimmed" mt="sm">
No bookings assigned yet.
</Text>
) : (
<ScrollArea mt="md">
<Table striped highlightOnHover verticalSpacing="sm" miw={560}>
<Table.Thead>
<Table.Tr>
<Table.Th>Reference</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{detail.bookings.map((booking) => (
<Table.Tr key={booking.id}>
<Table.Td>{booking.reference ?? booking.id}</Table.Td>
<Table.Td>{booking.customer ?? '-'}</Table.Td>
<Table.Td>{booking.weightTons} T</Table.Td>
<Table.Td>
<Badge color={statusColor(booking.status)} variant="light">
{booking.status ?? '-'}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
)}
</Paper>
</Stack>
) : (
<Text size="sm" c="dimmed">
Loading schedule detail...
</Text>
)}
</Modal>
</Box>
);
};
export default TrainsPage;