Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx
Marshal 53d8655dc3 Enhance booking and signature functionalities
- Updated MySignaturePage title to Signature
2026-08-01 22:03:18 +00:00

430 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}