Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx
Marshal 835c9e111c feat(train-scheduling): implement container movement between wagons
- Added functionality to move containers between wagons in the train scheduling system.
- Introduced  API endpoint and service method to handle container movement.
- Updated  component to support drag-and-drop for rearranging containers.
- Enhanced  to allow moving containers to other wagons via a context menu.
- Implemented UI feedback for container movement actions, including loading states and success/error notifications.
- Updated relevant types and constants to accommodate new container movement logic.
- Added tests for the rule engine to ensure proper handling of hazardous bookings.
2026-07-21 23:02:06 +00:00

947 lines
31 KiB
TypeScript

import { useMemo, useState } from "react";
import { isAxiosError } from "axios";
import {
Badge,
Box,
Button,
Group,
Modal,
Paper,
Progress,
ScrollArea,
Select,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeftRight,
ArrowRight,
CheckCircle2,
Inbox,
PackageCheck,
PackageX,
// Repeat, // used by the hidden Move (reassign) button
Train,
Weight,
X,
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
EligibleContainerBooking,
FreightType,
TrainScheduleDetail,
} from "@/types/trainScheduling";
interface ScheduleWorkspacePanelProps {
schedule: TrainScheduleDetail;
/** Refetch the schedule detail after a mutation so both panels refresh. */
onChanged: () => void;
}
const GREEN = "var(--mantine-color-edr-green-6)";
/** Pull the API's violation detail out of an error (e.g. "No CW3 wagon available…"). */
function apiErrorMessage(error: unknown, fallback: string): string {
if (isAxiosError(error)) {
const data = error.response?.data as Record<string, unknown> | undefined;
const violations = data?.violations;
if (Array.isArray(violations) && violations.length) return violations.join(", ");
if (typeof data?.message === "string") return data.message;
if (Array.isArray(data?.message)) return (data.message as string[]).join(", ");
}
return fallback;
}
/**
* Deadline + label for the window phase this schedule is currently in.
* Phases run: window open (windowClosesAt) → document review (docReviewEndsAt)
* → payment (paymentPhaseEndsAt). Display only. Returns null off-phase.
*/
function phaseCountdown(
schedule: TrainScheduleDetail,
): { label: string; deadline: string } | null {
switch (schedule.windowPhase) {
case "OPEN":
return schedule.windowClosesAt
? { label: "Booking window closes in", deadline: schedule.windowClosesAt }
: null;
case "DOC_REVIEW":
return schedule.docReviewEndsAt
? { label: "Document review ends in", deadline: schedule.docReviewEndsAt }
: null;
case "PAYMENT":
return schedule.paymentPhaseEndsAt
? { label: "Payment window ends in", deadline: schedule.paymentPhaseEndsAt }
: null;
default:
return null;
}
}
/** GROSS weight already on this train (each booking's cargo + wagon tare) —
* compared against the locomotive pull limit, which is a gross ceiling. */
function usedWeight(schedule: TrainScheduleDetail): number {
return (schedule.bookings ?? []).reduce(
(sum, b) => sum + (Number(b.weightTons) || 0),
0,
);
}
/**
* Pull capacity of the set = the WEAKEST locomotive's max pull weight (0 when
* unknown). The API caps at the weakest loco, not the sum of all locos — a
* consist can only pull as hard as its weakest engine. Both sides of this meter
* are gross: `usedWeight` sums per-booking gross (cargo + wagon tare).
*/
function pullCapacity(schedule: TrainScheduleDetail): number {
const set = schedule.trainSet;
if (!set) return 0;
const locos =
set.locomotives && set.locomotives.length > 0
? set.locomotives
: set.locomotive
? [set.locomotive]
: [];
if (locos.length === 0) return 0;
return Math.min(...locos.map((l) => Number(l.maxPullWeightTons) || 0));
}
export function ScheduleWorkspacePanel({
schedule,
onChanged,
}: ScheduleWorkspacePanelProps) {
const { toast } = useToast();
const freightType: FreightType | undefined =
schedule.freightType === "CONTAINER" || schedule.freightType === "BULK"
? schedule.freightType
: undefined;
const locked = ["DISPATCHED", "ARRIVED"].includes(schedule.status);
const canManage = ["DRAFT", "SCHEDULED"].includes(schedule.status);
// Pool = accepted, ready-to-pay bookings on THIS train's route+day that are not
// yet linked to any schedule (same filter the auto-batch uses).
const poolQuery = useQuery(
api.trainScheduling.eligibleBookings.queryOptions({
input: {
filters: {
originStationId: schedule.originStation?.id,
destinationStationId: schedule.destinationStation?.id,
trainScheduleId: schedule.id,
},
freightType,
},
enabled: Boolean(schedule.originStation?.id && schedule.destinationStation?.id),
}),
);
const onTrainIds = useMemo(
() => new Set((schedule.bookings ?? []).map((b) => b.id)),
[schedule.bookings],
);
const pool: EligibleContainerBooking[] = useMemo(
() => (poolQuery.data?.items ?? []).filter((b) => !onTrainIds.has(b.id)),
[poolQuery.data, onTrainIds],
);
const onTrain = schedule.bookings ?? [];
// ── Mutations (reuse the existing endpoints) ───────────────────────────────
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
const assignUnassigned = useMutation(
api.trainScheduling.assignUnassignedBooking.mutationOptions(),
);
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const setLoading = useMutation(
api.trainScheduling.setLoadingStatus.mutationOptions(),
);
const confirmLoading = useMutation(
api.trainScheduling.confirmLoading.mutationOptions(),
);
const moveSchedule = useMutation(
api.trainScheduling.moveBookingSchedule.mutationOptions(),
);
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
const [moveTarget, setMoveTarget] = useState<string | null>(null);
// Pool → pick a same-day schedule with free wagons and place the booking there.
const [poolAssign, setPoolAssign] = useState<{ id: string; reference: string } | null>(
null,
);
const [poolTarget, setPoolTarget] = useState<string | null>(null);
const { data: targets } = useQuery(
api.trainScheduling.bookableSchedules.queryOptions({
input: {
originYardId: schedule.originStation?.id,
destinationYardId: schedule.destinationStation?.id,
},
enabled: Boolean(
schedule.originStation?.id && schedule.destinationStation?.id,
),
}),
);
const moveOptions = useMemo(
() =>
(targets ?? [])
.filter((s) => s.id !== schedule.id)
.map((s) => ({
value: s.id,
label: `${s.routeName ?? `${s.origin}${s.destination}`} · ${new Date(
s.scheduleDate,
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} free`,
})),
[targets, schedule.id],
);
// Every schedule departing on THIS train's day (EAT) — a paid booking waiting
// for a wagon may board any of them, so staff pick whichever has wagons free.
const eatDayOf = (iso: string) =>
new Date(iso).toLocaleDateString("en-CA", { timeZone: "Africa/Addis_Ababa" });
const sameDayOptions = useMemo(() => {
const day = eatDayOf(schedule.scheduledDepartureDate);
return (targets ?? [])
.filter((s) => eatDayOf(s.scheduleDate) === day)
.map((s) => ({
value: s.id,
label: `${s.id === schedule.id ? "This train · " : ""}${
s.routeName ?? `${s.origin}${s.destination}`
} · ${s.remainingWagons}/${s.maxWagons} wagons free`,
}));
}, [targets, schedule.id, schedule.scheduledDepartureDate]);
// ── Capacity meter (by cargo weight vs locomotive pull) ────────────────────
const used = usedWeight(schedule);
const capacity = pullCapacity(schedule);
const pct = capacity > 0 ? Math.min(100, Math.round((used / capacity) * 100)) : 0;
const over = capacity > 0 && used > capacity;
const forceAdd = (bookingId: string, ref: string, weightTons: number) => {
const wouldOverfill = capacity > 0 && used + (weightTons || 0) > capacity;
assign
.mutateAsync({
id: schedule.id,
freightType,
payload: {
bookingIds: [...onTrainIds, bookingId],
forceAssign: true,
},
})
.then(() => {
toast({
title: `${ref} added to train`,
description: wouldOverfill
? "Force-added past the pull-weight limit — review capacity."
: "Wagons auto-pinned.",
variant: wouldOverfill ? "destructive" : undefined,
});
onChanged();
void poolQuery.refetch();
})
.catch((error) =>
toast({
title: "Could not add booking",
description: apiErrorMessage(error, "Validation failed — check capacity and status."),
variant: "destructive",
}),
);
};
const removeFromTrain = (bookingId: string, ref: string) => {
unassign
.mutateAsync({ id: schedule.id, bookingId })
.then(() => {
toast({ title: `${ref} removed from train` });
onChanged();
void poolQuery.refetch();
})
.catch((error) =>
toast({
title: "Could not remove booking",
description: apiErrorMessage(error, "Please try again."),
variant: "destructive",
}),
);
};
const toggleLoaded = (
bookingId: string,
ref: string,
next: "LOADED" | "UNLOADED",
) => {
setLoading
.mutateAsync({ id: schedule.id, bookingIds: [bookingId], loadingStatus: next })
.then(() => {
toast({
title:
next === "LOADED"
? `${ref} marked loaded`
: `${ref} marked unloaded`,
});
onChanged();
})
.catch((error) =>
toast({
title: "Could not update loading status",
description: apiErrorMessage(error, "Please try again."),
variant: "destructive",
}),
);
};
const doConfirmLoading = () => {
confirmLoading
.mutateAsync({ id: schedule.id })
.then(() => {
toast({ title: "Loading confirmed", description: "The train is cleared to dispatch." });
onChanged();
})
.catch((error) =>
toast({
title: "Could not confirm loading",
description: apiErrorMessage(
error,
"Grant the Djibouti gatepass first, then confirm loading.",
),
variant: "destructive",
}),
);
};
// Point the pool booking at the chosen same-day train, then put it on wagons.
// If the wagon step fails (that train is short too) the booking stays paid &
// unassigned in the pool — nothing is lost, staff just pick another train.
const doPoolAssign = () => {
if (!poolAssign || !poolTarget) return;
const { id: bookingId, reference } = poolAssign;
moveSchedule
.mutateAsync({ bookingId, trainScheduleId: poolTarget })
.then(() => assignUnassigned.mutateAsync({ id: poolTarget, bookingId }))
.then(() => {
toast({
title: `${reference} assigned`,
description: "Booking placed on the selected train with wagons pinned.",
});
setPoolAssign(null);
onChanged();
void poolQuery.refetch();
})
.catch((error) =>
toast({
title: `Could not assign ${reference}`,
description: apiErrorMessage(
error,
"The selected train has no free wagon of the required type.",
),
variant: "destructive",
}),
);
};
const doMove = () => {
if (!moveBookingId || !moveTarget) return;
moveSchedule
.mutateAsync({ bookingId: moveBookingId, trainScheduleId: moveTarget })
.then(() => {
toast({ title: "Booking reassigned to another train" });
setMoveBookingId(null);
onChanged();
void poolQuery.refetch();
})
.catch((error) =>
toast({
title: "Could not reassign booking",
description: apiErrorMessage(error, "Target train may be closed or full."),
variant: "destructive",
}),
);
};
return (
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="lg">
{/* Header + capacity meter */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="sm" align="center" wrap="nowrap">
<ThemeIcon size={40} radius="md" variant="light" color="edr-green">
<PackageCheck size={20} />
</ThemeIcon>
<div>
<Text fw={700}>Allocation workspace</Text>
<Text size="xs" c="dimmed">
Manually add paid, unassigned bookings, remove, or reassign them
</Text>
</div>
</Group>
<Box miw={240} style={{ flex: "0 1 320px" }}>
<Group justify="space-between" mb={4} gap={4}>
<Group gap={6} align="center">
<Weight size={14} color={over ? "#B42318" : undefined} />
<Text size="xs" fw={600} c={over ? "red" : "dimmed"}>
Load {used.toFixed(1)}T
{capacity > 0 ? ` / ${capacity.toFixed(0)}T pull` : ""}
</Text>
</Group>
{over ? (
<Badge color="red" variant="light" size="sm" radius="sm">
Over capacity
</Badge>
) : (
<Text size="xs" c="dimmed">
{capacity > 0 ? `${pct}%` : "—"}
</Text>
)}
</Group>
<Progress
value={capacity > 0 ? pct : 0}
color={over ? "red" : pct > 85 ? "orange" : "edr-green"}
radius="xl"
size="md"
/>
</Box>
</Group>
{(() => {
const cd = phaseCountdown(schedule);
return cd ? (
<Group
gap={8}
p="xs"
wrap="nowrap"
align="center"
style={{
borderRadius: 10,
background: "var(--mantine-color-blue-0)",
border: "1px solid var(--mantine-color-blue-2)",
}}
>
<CountdownTimer deadline={cd.deadline} label={cd.label} size="sm" />
</Group>
) : null;
})()}
{over ? (
<Group
gap={8}
p="xs"
wrap="nowrap"
align="center"
style={{
borderRadius: 10,
background: "var(--mantine-color-red-0)",
border: "1px solid var(--mantine-color-red-2)",
}}
>
<AlertTriangle size={16} color="#B42318" />
<Text size="xs" c="red.8" fw={500}>
This train is loaded beyond its locomotive pull weight. Force-adds are
allowed, but review before dispatch.
</Text>
</Group>
) : null}
{locked ? (
<Text size="sm" c="dimmed">
This train is {schedule.status.toLowerCase()} bookings can no longer be
changed.
</Text>
) : null}
{/* Loading confirmation — required before dispatch for import-Djibouti
trains; shown for every direction so staff have one place to confirm. */}
{canManage ? (
<Group
gap={10}
p="sm"
wrap="nowrap"
align="center"
justify="space-between"
style={{
borderRadius: 10,
background: schedule.loadingConfirmed
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-yellow-0)",
border: `1px solid ${
schedule.loadingConfirmed
? "var(--mantine-color-edr-green-2)"
: "var(--mantine-color-yellow-3)"
}`,
}}
>
<Group gap={8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
{schedule.loadingConfirmed ? (
<CheckCircle2 size={18} color="var(--mantine-color-edr-green-7)" />
) : (
<PackageCheck size={18} color="#B7791F" />
)}
<Text size="sm" fw={600}>
{schedule.loadingConfirmed
? "Loading confirmed — cleared to dispatch"
: "Confirm loading before dispatching this train"}
</Text>
</Group>
{!schedule.loadingConfirmed ? (
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={14} />}
loading={confirmLoading.isPending}
onClick={doConfirmLoading}
>
Confirm loading
</Button>
) : null}
</Group>
) : null}
{/* Two-panel board */}
<Group align="stretch" gap="lg" grow wrap="wrap">
{/* Pool */}
<PanelColumn
title="Paid · unassigned"
hint="Paid · this route & day · not on a train"
count={pool.length}
accent="#F2A516"
loading={poolQuery.isLoading}
emptyIcon={Inbox}
emptyText="No paid, unassigned bookings waiting for this train."
>
{pool.map((b) => (
<BookingCard
key={b.id}
reference={b.reference}
customer={b.customer}
weightTons={b.weightTons}
status={b.status}
waitingForWagon={b.schedulingStatus === "WAITING_FOR_WAGON"}
right={
canManage ? (
<Group gap={6} wrap="nowrap" justify="flex-end">
<Tooltip label="Force-add to this train" withArrow>
<Button
size="compact-sm"
color="edr-green"
radius="md"
rightSection={<ArrowRight size={14} />}
loading={assign.isPending}
onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
>
Add
</Button>
</Tooltip>
<Tooltip
label="Pick any train departing this day that has wagons free"
withArrow
>
<Button
size="compact-sm"
variant="light"
color="edr-green"
radius="md"
leftSection={<ArrowLeftRight size={13} />}
onClick={() => {
setPoolAssign({ id: b.id, reference: b.reference });
setPoolTarget(null);
}}
>
Add to
</Button>
</Tooltip>
</Group>
) : null
}
/>
))}
</PanelColumn>
{/* On train */}
<PanelColumn
title="On this train"
hint="Allocated bookings"
count={onTrain.length}
accent="#0EA371"
emptyIcon={Train}
emptyText="No bookings allocated yet. Add one from the pool."
>
{onTrain.map((b) => (
<BookingCard
key={b.id}
reference={b.reference ?? b.id.slice(0, 8)}
customer={b.customer}
weightTons={b.weightTons}
status={b.status}
intercity={b.tradeDirection === "DOMESTIC"}
leg={
b.origin &&
b.destination &&
(b.originYardId !== schedule.originStation?.id ||
b.destinationYardId !== schedule.destinationStation?.id)
? `${b.origin}${b.destination}`
: null
}
loadingStatus={b.wagonAssigned ? b.loadingStatus ?? "UNLOADED" : undefined}
right={
canManage ? (
<Group gap={6} wrap="nowrap" justify="flex-end">
{b.wagonAssigned ? (
<Tooltip
label={
(b.loadingStatus ?? "UNLOADED") === "LOADED"
? "Mark cargo unloaded from wagon"
: "Mark cargo loaded onto wagon"
}
withArrow
>
<Button
size="compact-sm"
variant={
(b.loadingStatus ?? "UNLOADED") === "LOADED"
? "light"
: "filled"
}
color="edr-green"
radius="md"
leftSection={
(b.loadingStatus ?? "UNLOADED") === "LOADED" ? (
<PackageX size={13} />
) : (
<PackageCheck size={13} />
)
}
loading={setLoading.isPending}
onClick={() =>
toggleLoaded(
b.id,
b.reference ?? b.id.slice(0, 8),
(b.loadingStatus ?? "UNLOADED") === "LOADED"
? "UNLOADED"
: "LOADED",
)
}
>
{(b.loadingStatus ?? "UNLOADED") === "LOADED"
? "Unload"
: "Load"}
</Button>
</Tooltip>
) : null}
{/* Reassign-to-another-train — hidden for now.
<Tooltip label="Reassign to another train" withArrow>
<Button
size="compact-sm"
variant="subtle"
color="orange"
radius="md"
leftSection={<Repeat size={13} />}
onClick={() => {
setMoveBookingId(b.id);
setMoveTarget(null);
}}
>
Move
</Button>
</Tooltip>
*/}
<Tooltip label="Remove from this train" withArrow>
<Button
size="compact-sm"
variant="light"
color="red"
radius="md"
leftSection={<X size={13} />}
loading={unassign.isPending}
onClick={() =>
removeFromTrain(b.id, b.reference ?? b.id.slice(0, 8))
}
>
Remove
</Button>
</Tooltip>
</Group>
) : null
}
/>
))}
</PanelColumn>
</Group>
</Stack>
{/* Pool → same-day train assignment modal */}
<Modal
opened={Boolean(poolAssign)}
onClose={() => setPoolAssign(null)}
title={
<Group gap={8}>
<Train size={18} />
<Text fw={700}>
Assign {poolAssign?.reference ?? "booking"} to a train on this day
</Text>
</Group>
}
centered
radius="lg"
>
<Stack gap="md">
<Text size="xs" c="dimmed">
All open trains departing on this schedule&apos;s day. Pick one with
free wagons the booking is placed and its wagons pinned in one step.
</Text>
<Select
label="Target train (same day)"
placeholder="Select a departure"
data={sameDayOptions}
value={poolTarget}
onChange={setPoolTarget}
searchable
nothingFoundMessage="No open schedules depart on this day"
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setPoolAssign(null)}>
Cancel
</Button>
<Button
color="edr-green"
disabled={!poolTarget}
loading={moveSchedule.isPending || assignUnassigned.isPending}
leftSection={<CheckCircle2 size={16} />}
onClick={doPoolAssign}
>
Assign to train
</Button>
</Group>
</Stack>
</Modal>
{/* Reassign modal */}
<Modal
opened={Boolean(moveBookingId)}
onClose={() => setMoveBookingId(null)}
title={
<Group gap={8}>
<ArrowLeftRight size={18} />
<Text fw={700}>Reassign booking to another train</Text>
</Group>
}
centered
radius="lg"
>
<Stack gap="md">
<Select
label="Target train (same route, open window)"
placeholder="Select an open schedule"
data={moveOptions}
value={moveTarget}
onChange={setMoveTarget}
searchable
nothingFoundMessage="No other open schedules on this route"
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setMoveBookingId(null)}>
Cancel
</Button>
<Button
color="edr-green"
disabled={!moveTarget}
loading={moveSchedule.isPending}
leftSection={<CheckCircle2 size={16} />}
onClick={doMove}
>
Reassign
</Button>
</Group>
</Stack>
</Modal>
</Paper>
);
}
// ── Sub-components ───────────────────────────────────────────────────────────
function PanelColumn({
title,
hint,
count,
accent,
loading,
emptyIcon: EmptyIcon,
emptyText,
children,
}: {
title: string;
hint: string;
count: number;
accent: string;
loading?: boolean;
emptyIcon: typeof Inbox;
emptyText: string;
children: React.ReactNode;
}) {
const isEmpty = !loading && count === 0;
return (
<Paper
radius="lg"
withBorder
p="md"
miw={280}
style={{
flex: 1,
borderColor: "var(--mantine-color-gray-2)",
background: `linear-gradient(180deg, ${accent}0A 0%, transparent 90px)`,
}}
>
<Group justify="space-between" align="center" mb="sm">
<Group gap={8} align="center">
<Box w={8} h={8} style={{ borderRadius: 999, background: accent }} />
<Text fw={700} size="sm">
{title}
</Text>
<Badge variant="light" color="gray" radius="sm" size="sm">
{count}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{hint}
</Text>
</Group>
{isEmpty ? (
<Stack align="center" gap={6} py={32}>
<EmptyIcon size={24} color="var(--mantine-color-gray-4)" />
<Text size="xs" c="dimmed" ta="center" maw={220}>
{emptyText}
</Text>
</Stack>
) : (
<ScrollArea.Autosize mah={420} type="hover">
<Stack gap={8} pr={4}>
{loading ? (
<Text size="xs" c="dimmed" py="md" ta="center">
Loading
</Text>
) : (
children
)}
</Stack>
</ScrollArea.Autosize>
)}
</Paper>
);
}
function BookingCard({
reference,
customer,
weightTons,
status,
loadingStatus,
waitingForWagon,
intercity,
leg,
right,
}: {
reference: string;
customer?: string | null;
weightTons?: number | null;
status?: string | null;
loadingStatus?: "LOADED" | "UNLOADED";
/** Paid, but no wagon of the required type was free — waiting for one. */
waitingForWagon?: boolean;
/** DOMESTIC ride-along riding only part of this train's corridor. */
intercity?: boolean;
/** "Origin → Destination" when the booking rides a sub-corridor leg. */
leg?: string | null;
right?: React.ReactNode;
}) {
return (
<Paper
radius="md"
withBorder
p="sm"
style={{
borderColor: "var(--mantine-color-gray-2)",
transition: "border-color 120ms ease, box-shadow 120ms ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = GREEN;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
}}
>
<Group justify="space-between" align="center" wrap="nowrap" gap="sm">
<Stack gap={3} style={{ minWidth: 0 }}>
<Group gap={8} align="center" wrap="nowrap">
<Text size="sm" fw={700} truncate>
{reference}
</Text>
{status ? <BookingStatusBadge status={status} /> : null}
{intercity ? (
<Tooltip
label="Intercity ride-along — rides only its own leg of this train's corridor"
withArrow
>
<Badge size="sm" radius="sm" variant="filled" color="indigo">
Intercity
</Badge>
</Tooltip>
) : null}
{waitingForWagon ? (
<Tooltip
label="Paid, but no wagon of the required type was free. Free a wagon or assign it to a same-day train that has one."
withArrow
>
<Badge size="sm" radius="sm" variant="light" color="orange">
Waiting for wagon
</Badge>
</Tooltip>
) : null}
{loadingStatus ? (
<Badge
size="sm"
radius="sm"
variant={loadingStatus === "LOADED" ? "filled" : "light"}
color={loadingStatus === "LOADED" ? "edr-green" : "gray"}
>
{loadingStatus === "LOADED" ? "Loaded" : "Unloaded"}
</Badge>
) : null}
</Group>
<Group gap={10} align="center" wrap="nowrap">
<Text size="xs" c="dimmed" truncate>
{customer ?? "—"}
</Text>
{weightTons != null ? (
<Group gap={3} align="center" wrap="nowrap">
<Weight size={11} color="var(--mantine-color-gray-5)" />
<Text size="xs" c="dimmed">
{Number(weightTons).toFixed(1)}T
</Text>
</Group>
) : null}
{leg ? (
<Text size="xs" c="indigo.7" fw={600} style={{ whiteSpace: "nowrap" }}>
{leg}
</Text>
) : null}
</Group>
</Stack>
{right ? <Box style={{ flexShrink: 0 }}>{right}</Box> : null}
</Group>
</Paper>
);
}