mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 10:10:57 +00:00
430 lines
15 KiB
TypeScript
430 lines
15 KiB
TypeScript
import { useState } from "react";
|
||
import {
|
||
Alert,
|
||
Badge,
|
||
Button,
|
||
Checkbox,
|
||
Group,
|
||
Loader,
|
||
Paper,
|
||
Stack,
|
||
Table,
|
||
Text,
|
||
Tooltip,
|
||
} from "@mantine/core";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { AlertCircle, ArrowRight, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
|
||
|
||
import { api } from "@/services/api";
|
||
import { useToast } from "@/hooks/use-toast";
|
||
import type {
|
||
IntercityBookingRow,
|
||
IntercityCapacity,
|
||
} from "@/types/trainScheduling";
|
||
|
||
const parseError = (error: unknown, fallback: string) => {
|
||
const message = (error as { response?: { data?: { message?: string | string[] } } })
|
||
?.response?.data?.message;
|
||
if (Array.isArray(message)) return message.join("; ");
|
||
return message || (error as Error)?.message || fallback;
|
||
};
|
||
|
||
// A capacity axis can be null when the schedule's locomotive has no limit
|
||
// configured for it — render "—" instead of crashing on toFixed.
|
||
function fmt(n: number | null | undefined): string {
|
||
if (n == null) return "—";
|
||
return Number.isInteger(n) ? String(n) : n.toFixed(1);
|
||
}
|
||
|
||
function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) {
|
||
if (!capacity) {
|
||
return (
|
||
<Text size="sm" c="dimmed">
|
||
Capacity unknown — schedule has no locomotive/train set yet.
|
||
</Text>
|
||
);
|
||
}
|
||
return (
|
||
<Group gap="xs">
|
||
<Badge
|
||
variant="light"
|
||
color={capacity.wagons == null ? "gray" : capacity.wagons > 0 ? "teal" : "red"}
|
||
>
|
||
{fmt(capacity.wagons)} wagons free
|
||
</Badge>
|
||
<Badge
|
||
variant="light"
|
||
color={capacity.weightTons == null ? "gray" : capacity.weightTons > 0 ? "teal" : "red"}
|
||
>
|
||
{fmt(capacity.weightTons)} t free
|
||
</Badge>
|
||
<Badge
|
||
variant="light"
|
||
color={capacity.lengthMeters == null ? "gray" : capacity.lengthMeters > 0 ? "teal" : "red"}
|
||
>
|
||
{fmt(capacity.lengthMeters)} m free
|
||
</Badge>
|
||
</Group>
|
||
);
|
||
}
|
||
|
||
function NeedCells({ row }: { row: IntercityBookingRow }) {
|
||
const { need, wagonBreakdown } = row;
|
||
if (!need) return <Table.Td colSpan={3}>—</Table.Td>;
|
||
return (
|
||
<>
|
||
<Table.Td>
|
||
{wagonBreakdown?.length ? (
|
||
// Which wagon TYPES this train gives up, not just how many wagons —
|
||
// a break-bulk booking's count depends on each type's capacity and
|
||
// its configured items-per-wagon fit, so it differs per train.
|
||
<Stack gap={2}>
|
||
{wagonBreakdown.map((entry) => (
|
||
<Text key={entry.wagonTypeId} size="sm">
|
||
{entry.wagons} × {entry.code}
|
||
</Text>
|
||
))}
|
||
</Stack>
|
||
) : (
|
||
fmt(need.wagons)
|
||
)}
|
||
</Table.Td>
|
||
<Table.Td>{fmt(need.weightTons)} t</Table.Td>
|
||
<Table.Td>{fmt(need.lengthMeters)} m</Table.Td>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function CorridorCell({ row }: { row: IntercityBookingRow }) {
|
||
return (
|
||
<Group gap={6} wrap="nowrap">
|
||
<Text size="sm">{row.origin}</Text>
|
||
<ArrowRight size={13} />
|
||
<Text size="sm">{row.destination}</Text>
|
||
</Group>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Intercity ride-along desk for one import/export schedule: waiting intercity
|
||
* bookings whose corridor lies on this train's route, checked against the
|
||
* remaining wagon/weight/length budget. Accepting opens the customer's pay
|
||
* window; after payment the booking is allocated. Loading/unloading is
|
||
* confirmed manually when the train is physically at the booking's origin /
|
||
* destination yard (the server validates against recorded checkpoints).
|
||
*/
|
||
/** Plain-language journey states for the accepted ride-along table. */
|
||
const INTERCITY_STATUS_META: Record<string, { label: string; color: string }> = {
|
||
SELECTED_FOR_BATCH: { label: "Awaiting payment", color: "yellow" },
|
||
APPROVED: { label: "Ready to load (gov)", color: "edr-green" },
|
||
PAID: { label: "Paid — ready to load", color: "edr-green" },
|
||
IN_TRANSIT: { label: "Loaded — in transit", color: "indigo" },
|
||
COMPLETED: { label: "Delivered", color: "teal" },
|
||
};
|
||
|
||
export function IntercityRideAlongPanel({
|
||
scheduleId,
|
||
direction,
|
||
}: {
|
||
scheduleId: string;
|
||
direction: string | null | undefined;
|
||
}) {
|
||
const { toast } = useToast();
|
||
const queryClient = useQueryClient();
|
||
const [selected, setSelected] = useState<string[]>([]);
|
||
|
||
const candidatesQuery = useQuery(
|
||
api.trainScheduling.intercityCandidates.queryOptions({
|
||
input: { scheduleId },
|
||
refetchInterval: 60_000,
|
||
}),
|
||
);
|
||
|
||
// Accepting/loading/unloading a ride-along changes the schedule's booking
|
||
// list, the yard worklists AND this panel — refresh all three so the
|
||
// workspace board and yard-work tables never show a stale picture.
|
||
const invalidate = () => {
|
||
void queryClient.invalidateQueries({
|
||
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
|
||
});
|
||
void queryClient.invalidateQueries({
|
||
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
|
||
});
|
||
void queryClient.invalidateQueries({
|
||
queryKey: api.trainScheduling.scheduleDetail.queryKey({ id: scheduleId }),
|
||
});
|
||
};
|
||
|
||
const accept = useMutation(
|
||
api.trainScheduling.acceptIntercityBookings.mutationOptions({
|
||
onSuccess: (result) => {
|
||
setSelected([]);
|
||
void invalidate();
|
||
if (result.accepted.length > 0) {
|
||
toast({
|
||
title: `${result.accepted.length} intercity booking(s) accepted`,
|
||
description: "Customers have been asked to pay.",
|
||
});
|
||
}
|
||
for (const r of result.rejected) {
|
||
toast({
|
||
title: "Booking skipped",
|
||
description: r.reason,
|
||
variant: "destructive",
|
||
});
|
||
}
|
||
},
|
||
onError: (err) =>
|
||
toast({
|
||
title: "Accept failed",
|
||
description: parseError(err, "Could not accept intercity bookings"),
|
||
variant: "destructive",
|
||
}),
|
||
}),
|
||
);
|
||
|
||
const load = useMutation(
|
||
api.trainScheduling.loadIntercityBooking.mutationOptions({
|
||
onSuccess: () => {
|
||
void invalidate();
|
||
toast({ title: "Cargo loaded" });
|
||
},
|
||
onError: (err) =>
|
||
toast({
|
||
title: "Load failed",
|
||
description: parseError(err, "Could not confirm loading"),
|
||
variant: "destructive",
|
||
}),
|
||
}),
|
||
);
|
||
|
||
const unload = useMutation(
|
||
api.trainScheduling.unloadIntercityBooking.mutationOptions({
|
||
onSuccess: () => {
|
||
void invalidate();
|
||
toast({ title: "Cargo unloaded — booking completed" });
|
||
},
|
||
onError: (err) =>
|
||
toast({
|
||
title: "Unload failed",
|
||
description: parseError(err, "Could not confirm unloading"),
|
||
variant: "destructive",
|
||
}),
|
||
}),
|
||
);
|
||
|
||
// Intercity bookings only ride import/export trains.
|
||
if (direction !== "IMPORT" && direction !== "EXPORT") return null;
|
||
|
||
const data = candidatesQuery.data;
|
||
const candidates = data?.candidates ?? [];
|
||
const accepted = data?.accepted ?? [];
|
||
|
||
if (candidatesQuery.isLoading) {
|
||
return (
|
||
<Paper withBorder radius="lg" p="lg" mt="md">
|
||
<Group gap="xs">
|
||
<Loader size="xs" />
|
||
<Text size="sm" c="dimmed">
|
||
Loading intercity ride-along bookings…
|
||
</Text>
|
||
</Group>
|
||
</Paper>
|
||
);
|
||
}
|
||
|
||
if (candidates.length === 0 && accepted.length === 0) return null;
|
||
|
||
return (
|
||
<Paper withBorder radius="lg" p="lg" mt="md">
|
||
<Stack gap="md">
|
||
<Group justify="space-between" wrap="wrap">
|
||
<Group gap="xs">
|
||
<TrainFront size={18} />
|
||
<Text fw={700}>Intercity ride-along</Text>
|
||
</Group>
|
||
<CapacityBadges capacity={data?.remaining ?? null} />
|
||
</Group>
|
||
|
||
{candidates.length > 0 && (
|
||
<>
|
||
<Text size="sm" c="dimmed">
|
||
Waiting intercity bookings whose corridor lies on this train's
|
||
route. Accepting opens the customer's payment window against the
|
||
free capacity above.
|
||
</Text>
|
||
<Table.ScrollContainer minWidth={720}>
|
||
<Table verticalSpacing="xs" highlightOnHover>
|
||
<Table.Thead>
|
||
<Table.Tr>
|
||
<Table.Th w={36} />
|
||
<Table.Th>Booking</Table.Th>
|
||
<Table.Th>Customer</Table.Th>
|
||
<Table.Th>Corridor</Table.Th>
|
||
<Table.Th>Wagons</Table.Th>
|
||
<Table.Th>Weight</Table.Th>
|
||
<Table.Th>Length</Table.Th>
|
||
<Table.Th>Fits</Table.Th>
|
||
</Table.Tr>
|
||
</Table.Thead>
|
||
<Table.Tbody>
|
||
{candidates.map((row) => (
|
||
<Table.Tr key={row.id}>
|
||
<Table.Td>
|
||
<Checkbox
|
||
size="xs"
|
||
checked={selected.includes(row.id)}
|
||
onChange={(e) =>
|
||
setSelected((prev) =>
|
||
e.currentTarget.checked
|
||
? [...prev, row.id]
|
||
: prev.filter((id) => id !== row.id),
|
||
)
|
||
}
|
||
/>
|
||
</Table.Td>
|
||
<Table.Td>
|
||
<Group gap={6}>
|
||
<Text size="sm" fw={600}>
|
||
{row.reference ?? row.id.slice(0, 8)}
|
||
</Text>
|
||
{row.isGovernment && (
|
||
<Badge size="xs" variant="light" color="grape">
|
||
GOV
|
||
</Badge>
|
||
)}
|
||
</Group>
|
||
</Table.Td>
|
||
<Table.Td>
|
||
<Text size="sm">{row.customer}</Text>
|
||
</Table.Td>
|
||
<Table.Td>
|
||
<CorridorCell row={row} />
|
||
</Table.Td>
|
||
<NeedCells row={row} />
|
||
<Table.Td>
|
||
{row.fits ? (
|
||
<Badge size="sm" variant="light" color="teal">
|
||
Fits
|
||
</Badge>
|
||
) : (
|
||
<Tooltip label="Exceeds the remaining wagon/weight/length budget">
|
||
<Badge size="sm" variant="light" color="red">
|
||
No room
|
||
</Badge>
|
||
</Tooltip>
|
||
)}
|
||
</Table.Td>
|
||
</Table.Tr>
|
||
))}
|
||
</Table.Tbody>
|
||
</Table>
|
||
</Table.ScrollContainer>
|
||
<Group justify="flex-end">
|
||
<Button
|
||
size="xs"
|
||
color="edr-green"
|
||
loading={accept.isPending}
|
||
disabled={selected.length === 0}
|
||
onClick={() => accept.mutate({ scheduleId, bookingIds: selected })}
|
||
>
|
||
Accept {selected.length > 0 ? `${selected.length} ` : ""}onto this train
|
||
</Button>
|
||
</Group>
|
||
</>
|
||
)}
|
||
|
||
{accepted.length > 0 && (
|
||
<>
|
||
<Text size="sm" fw={600}>
|
||
On this train
|
||
</Text>
|
||
<Table.ScrollContainer minWidth={680}>
|
||
<Table verticalSpacing="xs">
|
||
<Table.Thead>
|
||
<Table.Tr>
|
||
<Table.Th>Booking</Table.Th>
|
||
<Table.Th>Customer</Table.Th>
|
||
<Table.Th>Corridor</Table.Th>
|
||
<Table.Th>Status</Table.Th>
|
||
<Table.Th />
|
||
</Table.Tr>
|
||
</Table.Thead>
|
||
<Table.Tbody>
|
||
{accepted.map((row) => (
|
||
<Table.Tr key={row.id}>
|
||
<Table.Td>
|
||
<Text size="sm" fw={600}>
|
||
{row.reference ?? row.id.slice(0, 8)}
|
||
</Text>
|
||
</Table.Td>
|
||
<Table.Td>
|
||
<Text size="sm">{row.customer}</Text>
|
||
</Table.Td>
|
||
<Table.Td>
|
||
<CorridorCell row={row} />
|
||
</Table.Td>
|
||
<Table.Td>
|
||
<Badge
|
||
size="sm"
|
||
variant="light"
|
||
color={
|
||
INTERCITY_STATUS_META[row.status ?? ""]?.color ?? "gray"
|
||
}
|
||
>
|
||
{INTERCITY_STATUS_META[row.status ?? ""]?.label ?? row.status}
|
||
</Badge>
|
||
</Table.Td>
|
||
<Table.Td>
|
||
<Group gap="xs" justify="flex-end">
|
||
{row.status === "PAID" && (
|
||
<Tooltip label="Train must be at the booking's origin yard">
|
||
<Button
|
||
size="compact-xs"
|
||
variant="light"
|
||
leftSection={<PackageCheck size={13} />}
|
||
loading={load.isPending}
|
||
onClick={() =>
|
||
load.mutate({ scheduleId, bookingId: row.id })
|
||
}
|
||
>
|
||
Load
|
||
</Button>
|
||
</Tooltip>
|
||
)}
|
||
{row.status === "IN_TRANSIT" && (
|
||
<Tooltip label="Train must be at the booking's destination yard">
|
||
<Button
|
||
size="compact-xs"
|
||
variant="light"
|
||
color="orange"
|
||
leftSection={<PackageOpen size={13} />}
|
||
loading={unload.isPending}
|
||
onClick={() =>
|
||
unload.mutate({ scheduleId, bookingId: row.id })
|
||
}
|
||
>
|
||
Unload
|
||
</Button>
|
||
</Tooltip>
|
||
)}
|
||
</Group>
|
||
</Table.Td>
|
||
</Table.Tr>
|
||
))}
|
||
</Table.Tbody>
|
||
</Table>
|
||
</Table.ScrollContainer>
|
||
</>
|
||
)}
|
||
|
||
{candidatesQuery.isError && (
|
||
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
|
||
{parseError(candidatesQuery.error, "Could not load intercity candidates")}
|
||
</Alert>
|
||
)}
|
||
</Stack>
|
||
</Paper>
|
||
);
|
||
}
|