Replace wagon/locomotive readiness with yard tracking, assign unassigned bookings from origin-yard fleet, standardize rates on USD with CBE ETB conversion, and update fleet/scheduling UI

This commit is contained in:
marshal
2026-06-14 01:32:39 +03:00
parent b73bf2154e
commit 87b0ce6339
45 changed files with 1249 additions and 520 deletions

View File

@@ -50,30 +50,20 @@ export const CompositionBookingTabs = ({
const unassignedQuery = useUnassignedBookings(scheduleId);
const removalsQuery = useCompositionRemovals(scheduleId);
const { assignedCount, freeWagons, freeWeightTons } = useMemo(() => {
const { assignedCount } = useMemo(() => {
const wagons = scheduleDetail.trainSet?.wagons ?? [];
const ids = new Set<string>();
let usedWeight = 0;
let empty = 0;
for (const w of wagons) {
const allocs = w.allocations ?? [];
if (allocs.length === 0) empty += 1;
for (const a of allocs) {
for (const a of w.allocations ?? []) {
ids.add(a.bookingId);
usedWeight += a.allocatedWeightTons ?? 0;
}
}
const maxWeight = scheduleDetail.trainSet?.locomotive?.maxPullWeightTons ?? null;
return {
assignedCount: ids.size,
freeWagons: empty,
freeWeightTons: maxWeight != null ? Math.max(0, maxWeight - usedWeight) : null,
};
}, [scheduleDetail.trainSet?.wagons, scheduleDetail.trainSet?.locomotive?.maxPullWeightTons]);
return { assignedCount: ids.size };
}, [scheduleDetail.trainSet?.wagons]);
const counts: Record<TabKey, number> = {
assigned: assignedCount,
unassigned: unassignedQuery.data?.length ?? 0,
unassigned: unassignedQuery.data?.bookings?.length ?? 0,
payment: awaitingPayment.length,
expired: expired.length,
removed: removalsQuery.data?.length ?? 0,
@@ -188,8 +178,6 @@ export const CompositionBookingTabs = ({
scheduleId={scheduleId}
selectedBookingId={selectedBookingId}
onSelect={handleSelect}
freeWagons={freeWagons}
freeWeightTons={freeWeightTons}
/>
</Tabs.Panel>

View File

@@ -1,25 +1,26 @@
import { Badge, Box, Button, Card, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { AlertTriangle, Container as ContainerIcon, Plus, TrainFront, Weight } from "lucide-react";
import { AlertTriangle, Container as ContainerIcon, MapPin, Plus, TrainFront } from "lucide-react";
import {
useUnassignedBookings,
useScheduleMutations,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useToast } from "@/hooks/use-toast";
import type { FleetAvailabilityRow } from "@/types/trainScheduling";
import type { BookingDetailData } from "./BookingDetailModal";
interface UnassignedBookingsPanelProps {
scheduleId: string;
selectedBookingId?: string | null;
onSelect: (booking: BookingDetailData) => void;
/** Empty wagon slots currently available on the train. */
freeWagons: number;
/** Remaining pull-weight headroom in tons, or null when no locomotive limit. */
freeWeightTons: number | null;
}
const parseError = (error: unknown): string | null => {
if (error && typeof error === "object" && "response" in error) {
const resp = (error as { response?: { data?: { message?: unknown } } }).response;
const resp = (error as {
response?: { data?: { message?: unknown; violations?: string[] } };
}).response;
const violations = resp?.data?.violations;
if (Array.isArray(violations) && violations.length) return violations.join("; ");
const msg = resp?.data?.message;
if (Array.isArray(msg)) return msg.join(", ");
if (typeof msg === "string") return msg;
@@ -27,29 +28,65 @@ const parseError = (error: unknown): string | null => {
return null;
};
const YardFleetBanner = ({ fleetAtOrigin }: { fleetAtOrigin: FleetAvailabilityRow[] }) => {
if (!fleetAtOrigin.length) {
return (
<Group gap={5} wrap="nowrap" px="sm" py={6} style={{ borderRadius: 8, background: "var(--mantine-color-red-0)", border: "1px solid var(--mantine-color-red-2)" }}>
<MapPin size={13} color="var(--mantine-color-red-6)" />
<Text size="xs" fw={700} c="red.7">
No wagons at origin yard
</Text>
</Group>
);
}
return (
<Group
gap="xs"
wrap="wrap"
px="sm"
py={6}
style={{
borderRadius: 8,
background: "var(--mantine-color-green-0)",
border: "1px solid var(--mantine-color-green-1)",
}}
>
<Group gap={5} wrap="nowrap">
<MapPin size={13} color="var(--mantine-color-green-7)" />
<Text size="xs" fw={700} c="green.8">
Origin yard
</Text>
</Group>
{fleetAtOrigin.map((row) => (
<Badge key={row.wagonTypeId} size="sm" variant="light" color="green">
{row.wagonTypeCode}: {row.available}
</Badge>
))}
</Group>
);
};
export const UnassignedBookingsPanel = ({
scheduleId,
selectedBookingId,
onSelect,
freeWagons,
freeWeightTons,
}: UnassignedBookingsPanelProps) => {
const { toast } = useToast();
const unassignedQuery = useUnassignedBookings(scheduleId);
const assignMutation = useScheduleMutations(scheduleId).assign;
const assignMutation = useScheduleMutations(scheduleId).assignUnassigned;
const handleAssign = async (bookingId: string, reference: string | null) => {
try {
await assignMutation.mutateAsync({
id: scheduleId,
payload: { bookingIds: [bookingId] },
bookingId,
});
toast({ title: `Assigned ${reference ?? "booking"} to the train` });
} catch (err) {
toast({
title: "Could not assign booking",
description:
parseError(err) ?? "No free wagon or not enough space for this booking.",
description: parseError(err) ?? "Assignment failed — check yard fleet and train limits.",
variant: "destructive",
});
}
@@ -63,7 +100,8 @@ export const UnassignedBookingsPanel = ({
);
}
const bookings = unassignedQuery.data ?? [];
const bookings = unassignedQuery.data?.bookings ?? [];
const fleetAtOrigin = unassignedQuery.data?.fleetAtOrigin ?? [];
if (bookings.length === 0) {
return (
@@ -81,53 +119,15 @@ export const UnassignedBookingsPanel = ({
);
}
const noFreeWagon = freeWagons <= 0;
return (
<Stack gap="xs">
{/* Capacity availability banner */}
<Group
justify="space-between"
wrap="nowrap"
px="sm"
py={6}
style={{
borderRadius: 8,
background: noFreeWagon ? "var(--mantine-color-red-0)" : "var(--mantine-color-green-0)",
border: `1px solid ${
noFreeWagon ? "var(--mantine-color-red-2)" : "var(--mantine-color-green-1)"
}`,
}}
>
<Group gap={5} wrap="nowrap">
<TrainFront
size={13}
color={noFreeWagon ? "var(--mantine-color-red-6)" : "var(--mantine-color-green-7)"}
/>
<Text size="xs" fw={700} c={noFreeWagon ? "red.7" : "green.8"}>
{freeWagons} free wagon{freeWagons === 1 ? "" : "s"}
</Text>
</Group>
{freeWeightTons != null ? (
<Group gap={5} wrap="nowrap">
<Weight size={13} color="var(--mantine-color-gray-6)" />
<Text size="xs" c="dimmed">
{freeWeightTons.toFixed(1)} T headroom
</Text>
</Group>
) : null}
</Group>
<YardFleetBanner fleetAtOrigin={fleetAtOrigin} />
{bookings.map((booking) => {
const isActive = selectedBookingId === booking.id;
const weight = booking.cargoTotalWeightVgm ?? 0;
const overWeight = freeWeightTons != null && weight > freeWeightTons;
const fits = !noFreeWagon && !overWeight;
const blockReason = noFreeWagon
? "No free wagon on this train"
: overWeight
? "Exceeds remaining weight headroom"
: null;
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
const fits = booking.canAssign;
const blockReason = booking.blockReason;
return (
<Card
@@ -141,7 +141,7 @@ export const UnassignedBookingsPanel = ({
reference: booking.reference,
company: null,
freightType: booking.freightType,
weightTons: booking.cargoTotalWeightVgm ?? null,
weightTons: Number.isFinite(weight) ? weight : null,
status: booking.status,
priorityScore: booking.priorityScore,
})
@@ -172,9 +172,9 @@ export const UnassignedBookingsPanel = ({
{booking.freightType}
</Badge>
<Group gap={3} wrap="nowrap">
<Weight size={11} color="var(--mantine-color-gray-6)" />
<TrainFront size={11} color="var(--mantine-color-gray-6)" />
<Text size="11px" c="dimmed">
{weight.toFixed(1)} T
{booking.wagonsRequired}× {booking.requiredWagonTypeCode}
</Text>
</Group>
</Group>
@@ -202,7 +202,7 @@ export const UnassignedBookingsPanel = ({
}}
loading={
assignMutation.isPending &&
assignMutation.variables?.payload.bookingIds?.[0] === booking.id
assignMutation.variables?.bookingId === booking.id
}
leftSection={<Plus size={12} />}
>