mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
533 lines
20 KiB
TypeScript
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;
|