mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 10:10:57 +00:00
add detail batch and allocation monitoring page
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { useState } from "react";
|
||||
import { ActionIcon, Badge, Box, Card, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import { Building2, Package, TrainFront, Weight, X } from "lucide-react";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import type { BookingDetailData } from "./BookingDetailModal";
|
||||
import { RemoveBookingConfirmModal, type RemovalTarget } from "./RemoveBookingConfirmModal";
|
||||
import { useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
interface AssignedBookingsPanelProps {
|
||||
scheduleDetail: TrainScheduleDetail;
|
||||
scheduleId: string;
|
||||
selectedBookingId?: string | null;
|
||||
onSelect: (booking: BookingDetailData) => void;
|
||||
}
|
||||
|
||||
export const AssignedBookingsPanel = ({
|
||||
scheduleDetail,
|
||||
scheduleId,
|
||||
selectedBookingId,
|
||||
onSelect,
|
||||
}: AssignedBookingsPanelProps) => {
|
||||
const { toast } = useToast();
|
||||
const unassign = useScheduleMutations(scheduleId).unassign;
|
||||
const isDispatched = scheduleDetail.status === "DISPATCHED";
|
||||
const [removalTarget, setRemovalTarget] = useState<RemovalTarget | null>(null);
|
||||
|
||||
const wagons = scheduleDetail.trainSet?.wagons ?? [];
|
||||
const wagonCountByBooking = new Map<string, number>();
|
||||
for (const w of wagons) {
|
||||
for (const a of w.allocations ?? []) {
|
||||
wagonCountByBooking.set(a.bookingId, (wagonCountByBooking.get(a.bookingId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const assignedBookings = (scheduleDetail.bookings ?? []).filter((b) =>
|
||||
wagonCountByBooking.has(b.id),
|
||||
);
|
||||
|
||||
const handleConfirmRemove = async () => {
|
||||
if (!removalTarget) return;
|
||||
try {
|
||||
await unassign.mutateAsync({ id: scheduleId, bookingId: removalTarget.bookingId });
|
||||
toast({ title: "Booking removed from train" });
|
||||
setRemovalTarget(null);
|
||||
} catch {
|
||||
toast({ title: "Could not remove booking", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
if (assignedBookings.length === 0) {
|
||||
return (
|
||||
<Stack align="center" gap="xs" py="xl">
|
||||
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
|
||||
<Package size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} c="gray.7">
|
||||
No assigned bookings
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" maw={220}>
|
||||
Assign a paid booking from the Unassigned tab to load it onto a wagon.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="xs">
|
||||
{assignedBookings.map((booking) => {
|
||||
const isActive = selectedBookingId === booking.id;
|
||||
return (
|
||||
<Card
|
||||
key={booking.id}
|
||||
padding="xs"
|
||||
radius="md"
|
||||
withBorder
|
||||
onClick={() =>
|
||||
onSelect({
|
||||
bookingId: booking.id,
|
||||
reference: booking.reference,
|
||||
company: booking.customer,
|
||||
freightType: scheduleDetail.freightType ?? null,
|
||||
weightTons: booking.weightTons ?? null,
|
||||
status: booking.status,
|
||||
})
|
||||
}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderColor: isActive ? freightBrand.primary : undefined,
|
||||
boxShadow: isActive ? `0 0 0 2px ${freightBrand.ring}` : undefined,
|
||||
background: isActive ? freightBrand.mutedBg : undefined,
|
||||
transition: "box-shadow 120ms ease",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon size={30} radius="md" variant="light" color="green">
|
||||
<Package size={16} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group justify="space-between" wrap="nowrap" gap={4}>
|
||||
<Text size="sm" fw={700} truncate>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<TrainFront size={9} />}
|
||||
>
|
||||
{wagonCountByBooking.get(booking.id)}
|
||||
</Badge>
|
||||
{!isDispatched ? (
|
||||
<Tooltip label="Remove from train" withArrow>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
loading={unassign.isPending && unassign.variables?.bookingId === booking.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRemovalTarget({
|
||||
bookingId: booking.id,
|
||||
reference: booking.reference,
|
||||
company: booking.customer,
|
||||
weightTons: booking.weightTons ?? null,
|
||||
wagonCount: wagonCountByBooking.get(booking.id) ?? 0,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
{booking.customer ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Building2 size={11} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="11px" c="dimmed" truncate>
|
||||
{booking.customer}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
<Group gap={4} wrap="nowrap" mt={2}>
|
||||
<Weight size={11} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="11px" c="dimmed">
|
||||
{(booking.weightTons ?? 0).toFixed(1)} T
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<RemoveBookingConfirmModal
|
||||
opened={Boolean(removalTarget)}
|
||||
onClose={() => setRemovalTarget(null)}
|
||||
onConfirm={handleConfirmRemove}
|
||||
isLoading={unassign.isPending}
|
||||
target={removalTarget}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Badge, Box, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { Building2, CreditCard, Landmark, Weight, XCircle } from "lucide-react";
|
||||
import type { BatchBoardBookingDetail } from "@/types/trainScheduling";
|
||||
import type { BookingDetailData } from "./BookingDetailModal";
|
||||
|
||||
interface BatchBookingListProps {
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
variant: "payment" | "expired";
|
||||
selectedBookingId?: string | null;
|
||||
onSelect: (booking: BookingDetailData) => void;
|
||||
emptyTitle: string;
|
||||
emptyHint: string;
|
||||
}
|
||||
|
||||
const fmtDateTime = (iso: string | null) =>
|
||||
iso
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(new Date(iso))
|
||||
: null;
|
||||
|
||||
export const BatchBookingList = ({
|
||||
bookings,
|
||||
variant,
|
||||
selectedBookingId,
|
||||
onSelect,
|
||||
emptyTitle,
|
||||
emptyHint,
|
||||
}: BatchBookingListProps) => {
|
||||
const accent = variant === "payment" ? "orange" : "red";
|
||||
const Icon = variant === "payment" ? CreditCard : XCircle;
|
||||
|
||||
if (bookings.length === 0) {
|
||||
return (
|
||||
<Stack align="center" gap="xs" py="xl">
|
||||
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
|
||||
<Icon size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} c="gray.7">
|
||||
{emptyTitle}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" maw={220}>
|
||||
{emptyHint}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{bookings.map((booking) => {
|
||||
const isActive = selectedBookingId === booking.id;
|
||||
const deadline = fmtDateTime(booking.paymentDeadline);
|
||||
return (
|
||||
<Card
|
||||
key={booking.id}
|
||||
padding="xs"
|
||||
radius="md"
|
||||
withBorder
|
||||
onClick={() =>
|
||||
onSelect({
|
||||
bookingId: booking.id,
|
||||
reference: booking.reference,
|
||||
company: booking.company,
|
||||
freightType: null,
|
||||
weightTons: booking.weightTons ?? null,
|
||||
status: variant === "payment" ? "Awaiting payment" : "Expired",
|
||||
})
|
||||
}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderColor: isActive ? `var(--mantine-color-${accent}-5)` : undefined,
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon size={30} radius="md" variant="light" color={accent}>
|
||||
<Icon size={16} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group justify="space-between" wrap="nowrap" gap={4}>
|
||||
<Text size="sm" fw={700} truncate>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
{booking.isGovernment ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<Landmark size={9} />}
|
||||
>
|
||||
Gov
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
{booking.company ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Building2 size={11} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="11px" c="dimmed" truncate>
|
||||
{booking.company}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
<Group justify="space-between" wrap="nowrap" mt={2} gap={6}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Weight size={11} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="11px" c="dimmed">
|
||||
{(booking.weightTons ?? 0).toFixed(1)} T · {booking.wagons}w
|
||||
</Text>
|
||||
</Group>
|
||||
{variant === "payment" && deadline ? (
|
||||
<Text size="10px" fw={700} c="orange.7" style={{ whiteSpace: "nowrap" }}>
|
||||
Pay by {deadline}
|
||||
</Text>
|
||||
) : variant === "expired" ? (
|
||||
<Badge size="xs" variant="light" color="red">
|
||||
Expired
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Badge, Box, Divider, Group, Modal, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import {
|
||||
Building2,
|
||||
Container as ContainerIcon,
|
||||
Fuel,
|
||||
MapPin,
|
||||
Package,
|
||||
TrainFront,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
|
||||
export interface BookingDetailData {
|
||||
bookingId: string;
|
||||
reference: string | null;
|
||||
company: string | null;
|
||||
freightType: string | null;
|
||||
weightTons: number | null;
|
||||
status: string | null;
|
||||
priorityScore?: number | null;
|
||||
}
|
||||
|
||||
interface BookingDetailModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
booking: BookingDetailData | null;
|
||||
/** All wagons in the consist — used to show where this booking sits. */
|
||||
wagons: Wagon[];
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<ThemeIcon size={28} radius="md" variant="light" color="green">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box style={{ textAlign: "right" }}>{value}</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export const BookingDetailModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
booking,
|
||||
wagons,
|
||||
}: BookingDetailModalProps) => {
|
||||
if (!booking) return null;
|
||||
|
||||
const bookingWagons = wagons.filter((w) =>
|
||||
(w.allocations ?? []).some((a) => a.bookingId === booking.bookingId),
|
||||
);
|
||||
const allocations = bookingWagons.flatMap((w) =>
|
||||
(w.allocations ?? [])
|
||||
.filter((a) => a.bookingId === booking.bookingId)
|
||||
.map((a) => ({ wagon: w, allocation: a })),
|
||||
);
|
||||
const containers = allocations.flatMap(({ allocation }) => allocation.containerItems ?? []);
|
||||
const isBulk = allocations.some(({ allocation }) =>
|
||||
(allocation.loadType ?? "").toUpperCase().includes("BULK"),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
size="md"
|
||||
radius="lg"
|
||||
title={
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: freightBrand.gradient,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<Package size={20} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={800}>{booking.reference ?? "Booking"}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Booking details
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Stack gap="sm">
|
||||
{booking.company ? (
|
||||
<InfoRow
|
||||
icon={<Building2 size={15} />}
|
||||
label="Company"
|
||||
value={
|
||||
<Text size="sm" fw={700}>
|
||||
{booking.company}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<InfoRow
|
||||
icon={isBulk ? <Fuel size={15} /> : <ContainerIcon size={15} />}
|
||||
label="Freight type"
|
||||
value={
|
||||
<Badge variant="light" color={isBulk ? "orange" : "cyan"}>
|
||||
{booking.freightType ?? (isBulk ? "BULK" : "CONTAINER")}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={<Weight size={15} />}
|
||||
label="Weight"
|
||||
value={
|
||||
<Text size="sm" fw={700}>
|
||||
{booking.weightTons != null ? `${booking.weightTons.toFixed(1)} T` : "—"}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={<TrainFront size={15} />}
|
||||
label="Wagons"
|
||||
value={
|
||||
bookingWagons.length ? (
|
||||
<Group gap={4} justify="flex-end">
|
||||
{bookingWagons.map((w) => (
|
||||
<Badge key={w.id} size="sm" variant="outline" color="green" radius="sm">
|
||||
#{w.sequenceNo}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
Not assigned to a wagon
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{booking.status ? (
|
||||
<InfoRow
|
||||
icon={<MapPin size={15} />}
|
||||
label="Status"
|
||||
value={
|
||||
<Badge variant="light" color="gray">
|
||||
{booking.status}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{containers.length ? (
|
||||
<>
|
||||
<Divider
|
||||
label={
|
||||
<Group gap={6}>
|
||||
<ContainerIcon size={13} />
|
||||
<Text size="xs" fw={700}>
|
||||
Containers ({containers.length})
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
<Stack gap={6}>
|
||||
{containers.map((c, i) => (
|
||||
<Group
|
||||
key={c.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<ContainerIcon size={14} color="var(--mantine-color-cyan-7)" />
|
||||
<Text size="sm" fw={600}>
|
||||
{c.containerNumber?.trim() || `Container ${i + 1}`}
|
||||
</Text>
|
||||
</Group>
|
||||
{c.grossWeightTons != null ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{Number(c.grossWeightTons).toFixed(1)} T
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge, Box, Group, Paper, ScrollArea, Tabs, Text, Tooltip } from "@mantine/core";
|
||||
import { CreditCard, History, Layers, PackageCheck, PackagePlus, XCircle } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { BatchBoardBookingDetail, TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { AssignedBookingsPanel } from "./AssignedBookingsPanel";
|
||||
import { UnassignedBookingsPanel } from "./UnassignedBookingsPanel";
|
||||
import { RemovalLogPanel } from "./RemovalLogPanel";
|
||||
import { BatchBookingList } from "./BatchBookingList";
|
||||
import { BookingDetailModal, type BookingDetailData } from "./BookingDetailModal";
|
||||
import {
|
||||
useCompositionRemovals,
|
||||
useUnassignedBookings,
|
||||
} from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
interface CompositionBookingTabsProps {
|
||||
scheduleDetail: TrainScheduleDetail;
|
||||
scheduleId: string;
|
||||
/** Bookings selected for batch with a payment notification sent (awaiting payment). */
|
||||
awaitingPayment?: BatchBoardBookingDetail[];
|
||||
/** Bookings whose payment window expired. */
|
||||
expired?: BatchBoardBookingDetail[];
|
||||
/** Booking id highlighted in the train consist (lifted to the page). */
|
||||
selectedBookingId?: string | null;
|
||||
onSelectBooking?: (bookingId: string | null) => void;
|
||||
}
|
||||
|
||||
type TabKey = "assigned" | "unassigned" | "payment" | "expired" | "removed";
|
||||
|
||||
const TAB_META: Record<TabKey, { label: string; icon: LucideIcon; color: string }> = {
|
||||
assigned: { label: "Assigned to train", icon: PackageCheck, color: "green" },
|
||||
unassigned: { label: "Unassigned (ready to load)", icon: PackagePlus, color: "orange" },
|
||||
payment: { label: "Awaiting payment", icon: CreditCard, color: "orange" },
|
||||
expired: { label: "Expired bookings", icon: XCircle, color: "red" },
|
||||
removed: { label: "Removed from train", icon: History, color: "gray" },
|
||||
};
|
||||
|
||||
export const CompositionBookingTabs = ({
|
||||
scheduleDetail,
|
||||
scheduleId,
|
||||
awaitingPayment = [],
|
||||
expired = [],
|
||||
selectedBookingId,
|
||||
onSelectBooking,
|
||||
}: CompositionBookingTabsProps) => {
|
||||
const [detailBooking, setDetailBooking] = useState<BookingDetailData | null>(null);
|
||||
const [tab, setTab] = useState<TabKey>("assigned");
|
||||
|
||||
const unassignedQuery = useUnassignedBookings(scheduleId);
|
||||
const removalsQuery = useCompositionRemovals(scheduleId);
|
||||
|
||||
const { assignedCount, freeWagons, freeWeightTons } = 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) {
|
||||
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]);
|
||||
|
||||
const counts: Record<TabKey, number> = {
|
||||
assigned: assignedCount,
|
||||
unassigned: unassignedQuery.data?.length ?? 0,
|
||||
payment: awaitingPayment.length,
|
||||
expired: expired.length,
|
||||
removed: removalsQuery.data?.length ?? 0,
|
||||
};
|
||||
|
||||
const handleSelect = (booking: BookingDetailData) => {
|
||||
setDetailBooking(booking);
|
||||
onSelectBooking?.(booking.bookingId);
|
||||
};
|
||||
|
||||
const TabButton = ({ value }: { value: TabKey }) => {
|
||||
const meta = TAB_META[value];
|
||||
const Icon = meta.icon;
|
||||
const active = tab === value;
|
||||
const count = counts[value];
|
||||
return (
|
||||
<Tooltip label={meta.label} withArrow position="top">
|
||||
<Tabs.Tab value={value} px={6}>
|
||||
<Group gap={5} wrap="nowrap" justify="center">
|
||||
<Icon size={15} />
|
||||
<Badge
|
||||
size="xs"
|
||||
circle
|
||||
variant={active ? "filled" : "light"}
|
||||
color={active ? meta.color : "gray"}
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Tabs.Tab>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderColor: "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
{/* Header — reflects the active tab */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px="md"
|
||||
py="sm"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${freightBrand.mutedBg}, white)`,
|
||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 9,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: freightBrand.gradient,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<Layers size={18} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={800} size="sm">
|
||||
{TAB_META[tab].label}
|
||||
</Text>
|
||||
<Text size="11px" c="dimmed">
|
||||
{counts[tab]} booking{counts[tab] === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(v) => v && setTab(v as TabKey)}
|
||||
variant="default"
|
||||
color="green"
|
||||
style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}
|
||||
>
|
||||
<Tabs.List grow>
|
||||
<TabButton value="assigned" />
|
||||
<TabButton value="unassigned" />
|
||||
<TabButton value="payment" />
|
||||
<TabButton value="expired" />
|
||||
<TabButton value="removed" />
|
||||
</Tabs.List>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }} type="auto" offsetScrollbars>
|
||||
<Tabs.Panel value="assigned" p="md">
|
||||
<AssignedBookingsPanel
|
||||
scheduleDetail={scheduleDetail}
|
||||
scheduleId={scheduleId}
|
||||
selectedBookingId={selectedBookingId}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="unassigned" p="md">
|
||||
<UnassignedBookingsPanel
|
||||
scheduleId={scheduleId}
|
||||
selectedBookingId={selectedBookingId}
|
||||
onSelect={handleSelect}
|
||||
freeWagons={freeWagons}
|
||||
freeWeightTons={freeWeightTons}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="payment" p="md">
|
||||
<BatchBookingList
|
||||
bookings={awaitingPayment}
|
||||
variant="payment"
|
||||
selectedBookingId={selectedBookingId}
|
||||
onSelect={handleSelect}
|
||||
emptyTitle="No bookings awaiting payment"
|
||||
emptyHint="Bookings selected for this batch with a payment notification sent will appear here."
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="expired" p="md">
|
||||
<BatchBookingList
|
||||
bookings={expired}
|
||||
variant="expired"
|
||||
selectedBookingId={selectedBookingId}
|
||||
onSelect={handleSelect}
|
||||
emptyTitle="No expired bookings"
|
||||
emptyHint="Bookings whose payment window lapsed will appear here."
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="removed" p="md">
|
||||
<RemovalLogPanel scheduleId={scheduleId} />
|
||||
</Tabs.Panel>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
|
||||
{/* Footer summary */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
px="md"
|
||||
py="xs"
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-gray-2)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<PackageCheck size={13} color="var(--mantine-color-green-7)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{assignedCount} on train
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<CreditCard size={13} color="var(--mantine-color-orange-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{counts.payment} to pay
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<XCircle size={13} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{counts.expired} expired
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<BookingDetailModal
|
||||
opened={Boolean(detailBooking)}
|
||||
onClose={() => setDetailBooking(null)}
|
||||
booking={detailBooking}
|
||||
wagons={scheduleDetail.trainSet?.wagons ?? []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from "react";
|
||||
import { Group, TextInput, Text } from "@mantine/core";
|
||||
import { useUpdateContainerItem } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
|
||||
interface ContainerNumberInputProps {
|
||||
value: string | null;
|
||||
itemId: string;
|
||||
scheduleId: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const ContainerNumberInput = ({
|
||||
value,
|
||||
itemId,
|
||||
scheduleId,
|
||||
disabled,
|
||||
}: ContainerNumberInputProps) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(value ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const updateMutation = useUpdateContainerItem(scheduleId);
|
||||
const isLoading = updateMutation.isPending;
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
await updateMutation.mutateAsync({
|
||||
itemId,
|
||||
containerNumber: inputValue || null,
|
||||
});
|
||||
setIsEditing(false);
|
||||
} catch (err) {
|
||||
setError("Failed to save");
|
||||
setInputValue(value ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (inputValue !== value) {
|
||||
handleSave();
|
||||
} else {
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
setInputValue(value ?? "");
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (disabled) {
|
||||
return <Text size="sm">{value || "TBD"}</Text>;
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Group gap={4}>
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.currentTarget.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
placeholder="Container #"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{error && <Text size="xs" c="red">{error}</Text>}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
size="sm"
|
||||
onClick={() => setIsEditing(true)}
|
||||
style={{ cursor: "pointer", textDecoration: "underline" }}
|
||||
title="Click to edit"
|
||||
>
|
||||
{value || "TBD"}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,527 @@
|
||||
import { Badge, Box, Group, HoverCard, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
Building2,
|
||||
Container as ContainerIcon,
|
||||
Fuel,
|
||||
Gauge,
|
||||
Package,
|
||||
TrainFront,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
type Locomotive = NonNullable<TrainScheduleDetail["trainSet"]>["locomotive"];
|
||||
|
||||
interface InteractiveTrainConsistProps {
|
||||
wagons: Wagon[];
|
||||
locomotive: Locomotive | null | undefined;
|
||||
/** Resolve the customer/company name for a booking id (joined from schedule bookings). */
|
||||
getCompany: (bookingId: string | undefined) => string | null;
|
||||
selectedWagonId: string | null;
|
||||
onSelectWagon: (wagon: Wagon) => void;
|
||||
/** Booking id to highlight across the train (e.g. selected in the side panel). */
|
||||
highlightBookingId?: string | null;
|
||||
}
|
||||
|
||||
const CONTAINER_GRADIENTS = [
|
||||
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
|
||||
"linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))",
|
||||
];
|
||||
const CONTAINER_BORDERS = ["var(--mantine-color-cyan-8)", "var(--mantine-color-blue-8)"];
|
||||
|
||||
function Wheels({ count = 2, dark = false }: { count?: number; dark?: boolean }) {
|
||||
return (
|
||||
<Group gap={count > 2 ? 10 : 18} justify="center" wrap="nowrap" mt={2}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: "50%",
|
||||
background: dark
|
||||
? "radial-gradient(circle at 35% 35%, #2c4a3a, #0f291b)"
|
||||
: "radial-gradient(circle at 35% 35%, var(--mantine-color-gray-5), var(--mantine-color-gray-8))",
|
||||
border: "2px solid var(--mantine-color-gray-4)",
|
||||
boxShadow: "inset 0 0 0 2px rgba(255,255,255,0.3), 0 1px 2px rgba(0,0,0,0.2)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function Coupler() {
|
||||
return (
|
||||
<Box style={{ width: 12, height: 70, display: "flex", alignItems: "center", flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 5,
|
||||
borderRadius: 3,
|
||||
background:
|
||||
"linear-gradient(90deg, var(--mantine-color-gray-4), var(--mantine-color-gray-6), var(--mantine-color-gray-4))",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function LocomotiveCar({ locomotive }: { locomotive: Locomotive }) {
|
||||
const code = locomotive?.code ?? "LOCO";
|
||||
return (
|
||||
<Box style={{ width: 120, flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
height: 70,
|
||||
borderRadius: "12px 22px 9px 9px",
|
||||
background: `linear-gradient(160deg, ${freightBrand.primaryLight} 0%, ${freightBrand.primary} 45%, ${freightBrand.primaryDark} 100%)`,
|
||||
boxShadow: `${freightBrand.shadowSm}, inset 0 1px 0 rgba(255,255,255,0.25)`,
|
||||
border: "1px solid rgba(0,0,0,0.1)",
|
||||
overflow: "hidden",
|
||||
padding: "8px 9px 7px",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{/* cab windows */}
|
||||
<Box style={{ position: "absolute", top: 9, right: 9, display: "flex", gap: 4 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 13,
|
||||
height: 11,
|
||||
borderRadius: "3px 5px 3px 3px",
|
||||
background: "linear-gradient(135deg, #E8FBFF 0%, #9ED9E8 100%)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{/* headlight */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 12,
|
||||
right: 5,
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: "#fde68a",
|
||||
boxShadow: "0 0 9px 3px rgba(253,230,138,0.9)",
|
||||
}}
|
||||
/>
|
||||
{/* hazard stripe */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 5,
|
||||
background: "repeating-linear-gradient(45deg, #fbbf24 0 6px, #1f2937 6px 12px)",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
/>
|
||||
<Group gap={5} wrap="nowrap" align="center">
|
||||
<TrainFront size={16} />
|
||||
<Text size="sm" fw={800} style={{ letterSpacing: 0.4 }}>
|
||||
{code}
|
||||
</Text>
|
||||
</Group>
|
||||
{locomotive?.maxPullWeightTons ? (
|
||||
<Group gap={3} wrap="nowrap" mt={3} style={{ opacity: 0.95 }}>
|
||||
<Gauge size={10} />
|
||||
<Text size="9px" fw={700}>
|
||||
{locomotive.maxPullWeightTons}T pull
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Box>
|
||||
<Wheels count={3} dark />
|
||||
<Text size="9px" ta="center" c="dimmed" mt={2} fw={700} style={{ letterSpacing: 1 }}>
|
||||
HEAD
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function WagonCar({
|
||||
wagon,
|
||||
company,
|
||||
selected,
|
||||
highlighted,
|
||||
onSelect,
|
||||
}: {
|
||||
wagon: Wagon;
|
||||
company: string | null;
|
||||
selected: boolean;
|
||||
highlighted: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const allocation = wagon.allocations?.[0];
|
||||
const isEmpty = !allocation;
|
||||
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
|
||||
const assigned = allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0;
|
||||
const capacity = wagon.capacityTons ?? 0;
|
||||
const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0;
|
||||
const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan";
|
||||
const accentVar = `var(--mantine-color-${accent}-6)`;
|
||||
|
||||
const containerNumbers = (allocation?.containerItems ?? []).map(
|
||||
(c) => c.containerNumber?.trim() || "—",
|
||||
);
|
||||
const blocks = containerNumbers.slice(0, 2);
|
||||
|
||||
const ringColor = selected
|
||||
? freightBrand.primary
|
||||
: highlighted
|
||||
? "var(--mantine-color-yellow-5)"
|
||||
: "transparent";
|
||||
|
||||
return (
|
||||
<HoverCard width={280} shadow="lg" radius="md" position="top" withArrow openDelay={120}>
|
||||
<HoverCard.Target>
|
||||
<Box
|
||||
onClick={onSelect}
|
||||
style={{ width: 120, flexShrink: 0, cursor: "pointer" }}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
height: 70,
|
||||
borderRadius: 11,
|
||||
background: isEmpty
|
||||
? "var(--mantine-color-gray-0)"
|
||||
: "linear-gradient(180deg, white, var(--mantine-color-gray-0))",
|
||||
border: isEmpty
|
||||
? "1.5px dashed var(--mantine-color-gray-4)"
|
||||
: "1px solid var(--mantine-color-gray-3)",
|
||||
boxShadow:
|
||||
ringColor !== "transparent"
|
||||
? `0 0 0 3px ${ringColor}, 0 4px 12px rgba(15,41,27,0.12)`
|
||||
: isEmpty
|
||||
? "none"
|
||||
: "0 3px 10px rgba(15,41,27,0.08)",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
transition: "box-shadow 120ms ease",
|
||||
}}
|
||||
>
|
||||
{/* top accent strip */}
|
||||
<Box
|
||||
style={{
|
||||
height: 4,
|
||||
background: isEmpty
|
||||
? "var(--mantine-color-gray-3)"
|
||||
: `linear-gradient(90deg, ${accentVar}, var(--mantine-color-${accent}-4))`,
|
||||
}}
|
||||
/>
|
||||
{/* header */}
|
||||
<Group justify="space-between" px={7} pt={3} wrap="nowrap">
|
||||
<Text size="10px" fw={800} c="gray.7">
|
||||
#{wagon.sequenceNo}
|
||||
</Text>
|
||||
{isEmpty ? (
|
||||
<Text size="8px" c="dimmed" fw={700} style={{ letterSpacing: 0.5 }}>
|
||||
EMPTY
|
||||
</Text>
|
||||
) : (
|
||||
<Group gap={2} wrap="nowrap">
|
||||
{isBulk ? (
|
||||
<Fuel size={10} color={accentVar} />
|
||||
) : (
|
||||
<ContainerIcon size={10} color={accentVar} />
|
||||
)}
|
||||
<Text size="8px" fw={700} c={`${accent}.7`} style={{ letterSpacing: 0.3 }}>
|
||||
{isBulk ? "BULK" : "CONT"}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* body */}
|
||||
<Box style={{ flex: 1, padding: "3px 7px", display: "flex", alignItems: "center" }}>
|
||||
{isEmpty ? (
|
||||
<Text size="9px" c="dimmed" ta="center" style={{ width: "100%" }}>
|
||||
Available
|
||||
</Text>
|
||||
) : isBulk ? (
|
||||
<Stack gap={2} style={{ width: "100%" }}>
|
||||
<Box
|
||||
style={{
|
||||
height: 16,
|
||||
borderRadius: 5,
|
||||
background: "var(--mantine-color-orange-0)",
|
||||
border: "1px solid var(--mantine-color-orange-2)",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: `${utilization}%`,
|
||||
background:
|
||||
"linear-gradient(90deg, var(--mantine-color-orange-6), var(--mantine-color-orange-4))",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
) : (
|
||||
<Group gap={3} justify="center" wrap="nowrap" style={{ width: "100%" }}>
|
||||
{(blocks.length ? blocks : ["—"]).map((cn, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 26,
|
||||
borderRadius: 4,
|
||||
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
|
||||
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 2px",
|
||||
}}
|
||||
>
|
||||
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
||||
{cn}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* footer */}
|
||||
<Box
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-gray-1)",
|
||||
padding: "2px 7px",
|
||||
background: isEmpty ? "transparent" : "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap={3}>
|
||||
<Text size="8px" c="dimmed" fw={600} truncate>
|
||||
{wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Wagon"}
|
||||
</Text>
|
||||
{!isEmpty ? (
|
||||
<Text size="8px" c="gray.6" fw={700} style={{ whiteSpace: "nowrap" }}>
|
||||
{assigned}T
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
<Wheels count={2} />
|
||||
</Box>
|
||||
</HoverCard.Target>
|
||||
|
||||
<HoverCard.Dropdown p="sm">
|
||||
<Stack gap={8}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 7,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: isEmpty
|
||||
? "var(--mantine-color-gray-1)"
|
||||
: freightBrand.gradient,
|
||||
color: isEmpty ? "var(--mantine-color-gray-6)" : "white",
|
||||
}}
|
||||
>
|
||||
<TrainFront size={15} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text size="sm" fw={800}>
|
||||
Wagon #{wagon.sequenceNo}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed">
|
||||
{wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Unassigned"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{!isEmpty ? (
|
||||
<Badge size="xs" variant="light" color={isBulk ? "orange" : "cyan"}>
|
||||
{isBulk ? "Bulk" : "Container"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{isEmpty ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Empty slot — available for allocation.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
{company ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Building2 size={13} color={freightBrand.primary} />
|
||||
<Text size="xs" fw={700} truncate>
|
||||
{company}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Package size={13} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{allocation?.bookingReference ?? "Unknown booking"}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{containerNumbers.length ? (
|
||||
<div>
|
||||
<Text size="10px" c="dimmed" fw={700} mb={3} tt="uppercase">
|
||||
Containers
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{containerNumbers.map((cn, i) => (
|
||||
<Badge key={i} size="xs" variant="outline" color="cyan" radius="sm">
|
||||
{cn}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isBulk && allocation?.bulkLoad?.cargoDescription ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{allocation.bulkLoad.cargoDescription}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Weight size={13} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{assigned}T / {capacity}T ({utilization}%)
|
||||
</Text>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
height: 5,
|
||||
borderRadius: 3,
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: `${utilization}%`,
|
||||
height: "100%",
|
||||
background:
|
||||
utilization >= 100
|
||||
? "var(--mantine-color-red-5)"
|
||||
: `var(--mantine-color-${accent}-5)`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Text size="10px" c="dimmed" ta="center">
|
||||
Click the wagon to edit or remove
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
export const InteractiveTrainConsist = ({
|
||||
wagons,
|
||||
locomotive,
|
||||
getCompany,
|
||||
selectedWagonId,
|
||||
onSelectWagon,
|
||||
highlightBookingId,
|
||||
}: InteractiveTrainConsistProps) => {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
padding: "8px 12px 18px",
|
||||
borderRadius: 14,
|
||||
background: "linear-gradient(180deg, var(--mantine-color-gray-0), white)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
overflowX: "auto",
|
||||
}}
|
||||
>
|
||||
<Group gap={0} wrap="nowrap" align="flex-start" style={{ minWidth: "min-content" }}>
|
||||
{locomotive ? <LocomotiveCar locomotive={locomotive} /> : null}
|
||||
{wagons.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" pl="md" pt="lg">
|
||||
No wagons assigned
|
||||
</Text>
|
||||
) : (
|
||||
wagons.map((wagon, i) => {
|
||||
const bookingId = wagon.allocations?.[0]?.bookingId;
|
||||
return (
|
||||
<Group key={wagon.id} gap={0} wrap="nowrap" align="flex-start">
|
||||
{i > 0 || locomotive ? <Coupler /> : null}
|
||||
<WagonCar
|
||||
wagon={wagon}
|
||||
company={getCompany(bookingId)}
|
||||
selected={selectedWagonId === wagon.id}
|
||||
highlighted={Boolean(highlightBookingId && bookingId === highlightBookingId)}
|
||||
onSelect={() => onSelectWagon(wagon)}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* track bed under the whole consist */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: 8,
|
||||
height: 8,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background:
|
||||
"repeating-linear-gradient(90deg, var(--mantine-color-gray-4) 0 5px, transparent 5px 20px)",
|
||||
opacity: 0.5,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 1,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
background: "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 1,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
background: "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Box, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { History, PackageX } from "lucide-react";
|
||||
import { useCompositionRemovals } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
|
||||
interface RemovalLogPanelProps {
|
||||
scheduleId: string;
|
||||
}
|
||||
|
||||
export const RemovalLogPanel = ({ scheduleId }: RemovalLogPanelProps) => {
|
||||
const removalQuery = useCompositionRemovals(scheduleId);
|
||||
|
||||
if (removalQuery.isLoading) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const removals = removalQuery.data ?? [];
|
||||
|
||||
if (removals.length === 0) {
|
||||
return (
|
||||
<Stack align="center" gap="xs" py="xl">
|
||||
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
|
||||
<History size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} c="gray.7">
|
||||
No removals yet
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" maw={220}>
|
||||
Bookings removed from this train will appear here for audit.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{removals.map((removal) => (
|
||||
<Card key={removal.id} padding="xs" radius="md" withBorder>
|
||||
<Group gap={8} wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon size={30} radius="md" variant="light" color="red">
|
||||
<PackageX size={16} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={700} truncate>
|
||||
{removal.bookingReference || "Unknown booking"}
|
||||
</Text>
|
||||
<Text size="11px" c="dimmed">
|
||||
Removed{" "}
|
||||
{new Date(removal.removedAt).toLocaleString("en-GB", {
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})}{" "}
|
||||
EAT
|
||||
</Text>
|
||||
{removal.notes ? (
|
||||
<Text size="11px" c="dimmed" mt={2} lineClamp={2}>
|
||||
{removal.notes}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Badge, Box, Button, Group, List, Modal, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { AlertTriangle, Bell, Building2, FileClock, PackageX, TrainFront, Undo2, Weight } from "lucide-react";
|
||||
|
||||
export interface RemovalTarget {
|
||||
bookingId: string;
|
||||
reference: string | null;
|
||||
company: string | null;
|
||||
weightTons: number | null;
|
||||
wagonCount: number;
|
||||
}
|
||||
|
||||
interface RemoveBookingConfirmModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
isLoading: boolean;
|
||||
target: RemovalTarget | null;
|
||||
}
|
||||
|
||||
export const RemoveBookingConfirmModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isLoading,
|
||||
target,
|
||||
}: RemoveBookingConfirmModalProps) => {
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
radius="lg"
|
||||
size="md"
|
||||
withCloseButton={false}
|
||||
title={
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<ThemeIcon size={40} radius="md" variant="light" color="red">
|
||||
<PackageX size={21} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={800}>Remove booking from train?</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
This change is logged and the customer is notified
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Booking summary */}
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text fw={800} size="sm">
|
||||
{target?.reference ?? "Booking"}
|
||||
</Text>
|
||||
<Badge variant="light" color="green" leftSection={<TrainFront size={10} />}>
|
||||
{target?.wagonCount ?? 0} wagon{target?.wagonCount === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap="lg" mt={6} wrap="wrap">
|
||||
{target?.company ? (
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Building2 size={13} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{target.company}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Weight size={13} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{(target?.weightTons ?? 0).toFixed(1)} T
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* What happens */}
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: "var(--mantine-color-orange-0)",
|
||||
border: "1px solid var(--mantine-color-orange-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap={6} mb={6} wrap="nowrap">
|
||||
<AlertTriangle size={14} color="var(--mantine-color-orange-7)" />
|
||||
<Text size="xs" fw={700} c="orange.8">
|
||||
Removing this booking will:
|
||||
</Text>
|
||||
</Group>
|
||||
<List spacing={6} size="xs" center>
|
||||
<List.Item
|
||||
icon={
|
||||
<ThemeIcon size={18} radius="xl" variant="light" color="orange">
|
||||
<Undo2 size={11} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
Return it to the unassigned pool
|
||||
</List.Item>
|
||||
<List.Item
|
||||
icon={
|
||||
<ThemeIcon size={18} radius="xl" variant="light" color="orange">
|
||||
<FileClock size={11} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
Create a removal log entry for audit
|
||||
</List.Item>
|
||||
<List.Item
|
||||
icon={
|
||||
<ThemeIcon size={18} radius="xl" variant="light" color="orange">
|
||||
<Bell size={11} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
Notify the customer to reschedule or cancel
|
||||
</List.Item>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<PackageX size={16} />}
|
||||
loading={isLoading}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Remove booking
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Button, Group, Modal, Stack, Text, Badge } from "@mantine/core";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
|
||||
type WagonWithAllocation = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
|
||||
interface RemoveBookingModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
wagon: WagonWithAllocation | null;
|
||||
onConfirm: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const RemoveBookingModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
wagon,
|
||||
onConfirm,
|
||||
isLoading,
|
||||
}: RemoveBookingModalProps) => {
|
||||
if (!wagon || !wagon.allocations?.[0]) return null;
|
||||
|
||||
const allocation = wagon.allocations[0];
|
||||
const booking = allocation.booking;
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Confirm Booking Removal" centered>
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Booking Details
|
||||
</Text>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">
|
||||
<strong>Reference:</strong> {booking?.reference || "N/A"}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>Freight Type:</strong>{" "}
|
||||
<Badge size="sm" variant="light">
|
||||
{booking?.freightType || "N/A"}
|
||||
</Badge>
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>Weight:</strong> {allocation.allocatedWeightTons?.toFixed(2) || 0} T
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>Wagon Slot:</strong> #{wagon.sequenceNo}
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" c="orange" fw={500}>
|
||||
⚠️ Warning: Removing this booking will:
|
||||
</Text>
|
||||
<ul style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
<li>Move the booking back to the unassigned pool</li>
|
||||
<li>Create a removal log for audit</li>
|
||||
<li>Notify the customer to reschedule or cancel</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={onConfirm} loading={isLoading}>
|
||||
Remove Booking
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge, Box, Group, Paper, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { MousePointerClick, TrainFront } from "lucide-react";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { TrainStatsBar } from "./TrainStatsBar";
|
||||
import { WagonCard } from "./WagonCard";
|
||||
import { InteractiveTrainConsist } from "./InteractiveTrainConsist";
|
||||
import { RemoveBookingModal } from "./RemoveBookingModal";
|
||||
import { useScheduleMutations, useRemoveWagonSlot } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
|
||||
interface TrainConsistViewProps {
|
||||
scheduleDetail: TrainScheduleDetail;
|
||||
scheduleId: string;
|
||||
maxWagons: number;
|
||||
/** Booking id selected in the side panel — highlights its wagons in the consist. */
|
||||
highlightBookingId?: string | null;
|
||||
}
|
||||
|
||||
function LegendDot({ color, label, dashed }: { color: string; label: string; dashed?: boolean }) {
|
||||
return (
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 3,
|
||||
background: dashed ? "var(--mantine-color-gray-1)" : `var(--mantine-color-${color}-5)`,
|
||||
border: dashed ? "1.5px dashed var(--mantine-color-gray-4)" : "none",
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export const TrainConsistView = ({
|
||||
scheduleDetail,
|
||||
scheduleId,
|
||||
maxWagons,
|
||||
highlightBookingId,
|
||||
}: TrainConsistViewProps) => {
|
||||
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
|
||||
const [removeModalOpen, setRemoveModalOpen] = useState(false);
|
||||
|
||||
const unassignMutation = useScheduleMutations(scheduleId).unassign;
|
||||
const removeWagonMutation = useRemoveWagonSlot(scheduleId);
|
||||
|
||||
const trainSet = scheduleDetail.trainSet;
|
||||
const wagons = trainSet?.wagons ?? [];
|
||||
|
||||
// Join company/customer name from schedule bookings by booking id.
|
||||
const companyByBooking = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const b of scheduleDetail.bookings ?? []) {
|
||||
if (b.id && b.customer) map.set(b.id, b.customer);
|
||||
}
|
||||
return map;
|
||||
}, [scheduleDetail.bookings]);
|
||||
|
||||
const selectedWagon = wagons.find((w) => w.id === selectedWagonId) ?? null;
|
||||
const loadedCount = wagons.filter((w) => (w.allocations?.length ?? 0) > 0).length;
|
||||
|
||||
const handleRemoveBooking = (wagon: Wagon) => {
|
||||
setSelectedWagonId(wagon.id);
|
||||
setRemoveModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmRemoveBooking = async () => {
|
||||
if (selectedWagon?.allocations?.[0]?.bookingId) {
|
||||
await unassignMutation.mutateAsync({
|
||||
id: scheduleId,
|
||||
bookingId: selectedWagon.allocations[0].bookingId,
|
||||
});
|
||||
setRemoveModalOpen(false);
|
||||
setSelectedWagonId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveWagon = async (wagonId: string) => {
|
||||
if (confirm("Are you sure you want to remove this wagon slot?")) {
|
||||
await removeWagonMutation.mutateAsync(wagonId);
|
||||
setSelectedWagonId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const weightUsed = wagons.reduce(
|
||||
(sum, w) => sum + (w.allocations?.[0]?.allocatedWeightTons ?? 0),
|
||||
0,
|
||||
);
|
||||
const lengthUsed = wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0);
|
||||
|
||||
return (
|
||||
<Stack gap="md" style={{ width: "100%" }}>
|
||||
<TrainStatsBar
|
||||
weightUsed={weightUsed}
|
||||
weightMax={scheduleDetail.locomotive?.maxWeightTons ?? trainSet?.locomotive?.maxPullWeightTons ?? null}
|
||||
lengthUsed={lengthUsed}
|
||||
lengthMax={scheduleDetail.locomotive?.maxLengthMeters ?? trainSet?.locomotive?.maxTrainLengthMeters ?? null}
|
||||
wagonCount={wagons.length}
|
||||
wagonMax={maxWagons}
|
||||
/>
|
||||
|
||||
{/* Consist panel */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)", overflow: "hidden" }}
|
||||
>
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px="md"
|
||||
py="sm"
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 9,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: freightBrand.gradient,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<TrainFront size={18} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={800} size="sm">
|
||||
Train consist
|
||||
</Text>
|
||||
<Text size="11px" c="dimmed">
|
||||
{wagons.length} wagons · {loadedCount} loaded · {wagons.length - loadedCount} empty
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="md" wrap="nowrap" visibleFrom="sm">
|
||||
<LegendDot color="cyan" label="Container" />
|
||||
<LegendDot color="orange" label="Bulk" />
|
||||
<LegendDot color="gray" label="Empty" dashed />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Box p="md">
|
||||
<InteractiveTrainConsist
|
||||
wagons={wagons}
|
||||
locomotive={trainSet?.locomotive}
|
||||
getCompany={(bookingId) => (bookingId ? companyByBooking.get(bookingId) ?? null : null)}
|
||||
selectedWagonId={selectedWagonId}
|
||||
onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))}
|
||||
highlightBookingId={highlightBookingId}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Selected wagon — editable detail card */}
|
||||
{selectedWagon ? (
|
||||
<Box>
|
||||
<Group gap={6} mb={6} wrap="nowrap">
|
||||
<Badge variant="light" color="green" radius="sm">
|
||||
Editing wagon #{selectedWagon.sequenceNo}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
Update container numbers or remove the booking
|
||||
</Text>
|
||||
</Group>
|
||||
<WagonCard
|
||||
wagon={selectedWagon}
|
||||
company={
|
||||
selectedWagon.allocations?.[0]?.bookingId
|
||||
? companyByBooking.get(selectedWagon.allocations[0].bookingId) ?? null
|
||||
: null
|
||||
}
|
||||
scheduleId={scheduleId}
|
||||
scheduleStatus={scheduleDetail.status}
|
||||
onRemoveBooking={handleRemoveBooking}
|
||||
onRemoveWagon={handleRemoveWagon}
|
||||
/>
|
||||
</Box>
|
||||
) : wagons.length ? (
|
||||
<Paper
|
||||
radius="md"
|
||||
py="sm"
|
||||
px="md"
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px dashed var(--mantine-color-gray-3)",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} justify="center" c="dimmed">
|
||||
<ThemeIcon size={24} radius="xl" variant="light" color="gray">
|
||||
<MousePointerClick size={13} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed">
|
||||
Click a wagon in the train to edit container numbers or remove its booking.
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<RemoveBookingModal
|
||||
opened={removeModalOpen}
|
||||
onClose={() => {
|
||||
setRemoveModalOpen(false);
|
||||
}}
|
||||
wagon={selectedWagon}
|
||||
onConfirm={handleConfirmRemoveBooking}
|
||||
isLoading={unassignMutation.isPending}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Box, Group, Paper, RingProgress, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { Ruler, Train, Weight } from "lucide-react";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
interface TrainStatsBarProps {
|
||||
weightUsed: number;
|
||||
weightMax: number | null;
|
||||
lengthUsed: number;
|
||||
lengthMax: number | null;
|
||||
wagonCount: number;
|
||||
wagonMax: number;
|
||||
}
|
||||
|
||||
function pctColor(pct: number) {
|
||||
if (pct >= 100) return "#fa5252";
|
||||
if (pct >= 85) return "#FB8C2E";
|
||||
return freightBrand.primary;
|
||||
}
|
||||
|
||||
function StatTile({
|
||||
icon,
|
||||
label,
|
||||
pct,
|
||||
current,
|
||||
max,
|
||||
unit,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
pct: number | null;
|
||||
current: string;
|
||||
max: string;
|
||||
unit: string;
|
||||
}) {
|
||||
const color = pct != null ? pctColor(pct) : freightBrand.primary;
|
||||
const clamped = pct != null ? Math.min(100, Math.max(0, pct)) : 0;
|
||||
return (
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<RingProgress
|
||||
size={62}
|
||||
thickness={6}
|
||||
roundCaps
|
||||
sections={[{ value: clamped, color }]}
|
||||
rootColor="var(--mantine-color-gray-1)"
|
||||
label={
|
||||
<Group justify="center">
|
||||
<ThemeIcon size={26} radius="xl" variant="transparent" style={{ color }}>
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="10px" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.6 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap={5} align="baseline" wrap="nowrap">
|
||||
<Text size="lg" fw={800} lh={1.1} c="dark.5" style={{ whiteSpace: "nowrap" }}>
|
||||
{current}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
/ {max} {unit}
|
||||
</Text>
|
||||
</Group>
|
||||
{pct != null ? (
|
||||
<Text size="10px" fw={700} style={{ color }}>
|
||||
{Math.round(pct)}% utilized
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="10px" c="dimmed">
|
||||
no limit set
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export const TrainStatsBar = ({
|
||||
weightUsed,
|
||||
weightMax,
|
||||
lengthUsed,
|
||||
lengthMax,
|
||||
wagonCount,
|
||||
wagonMax,
|
||||
}: TrainStatsBarProps) => {
|
||||
const weightPct = weightMax ? (weightUsed / weightMax) * 100 : null;
|
||||
const lengthPct = lengthMax ? (lengthUsed / lengthMax) * 100 : null;
|
||||
const wagonPct = wagonMax ? (wagonCount / wagonMax) * 100 : null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="md"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)", background: "white" }}
|
||||
>
|
||||
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="lg">
|
||||
<StatTile
|
||||
icon={<Weight size={15} />}
|
||||
label="Weight"
|
||||
pct={weightPct}
|
||||
current={weightUsed.toFixed(1)}
|
||||
max={weightMax?.toFixed(1) ?? "∞"}
|
||||
unit="T"
|
||||
/>
|
||||
<Box
|
||||
px={{ base: 0, xs: "lg" }}
|
||||
style={{
|
||||
borderLeft: "1px solid var(--mantine-color-gray-2)",
|
||||
borderRight: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<StatTile
|
||||
icon={<Ruler size={15} />}
|
||||
label="Length"
|
||||
pct={lengthPct}
|
||||
current={lengthUsed.toFixed(1)}
|
||||
max={lengthMax?.toFixed(1) ?? "∞"}
|
||||
unit="m"
|
||||
/>
|
||||
</Box>
|
||||
<StatTile
|
||||
icon={<Train size={15} />}
|
||||
label="Wagons"
|
||||
pct={wagonPct}
|
||||
current={String(wagonCount)}
|
||||
max={String(wagonMax)}
|
||||
unit=""
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
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 {
|
||||
useUnassignedBookings,
|
||||
useScheduleMutations,
|
||||
} from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
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 msg = resp?.data?.message;
|
||||
if (Array.isArray(msg)) return msg.join(", ");
|
||||
if (typeof msg === "string") return msg;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const UnassignedBookingsPanel = ({
|
||||
scheduleId,
|
||||
selectedBookingId,
|
||||
onSelect,
|
||||
freeWagons,
|
||||
freeWeightTons,
|
||||
}: UnassignedBookingsPanelProps) => {
|
||||
const { toast } = useToast();
|
||||
const unassignedQuery = useUnassignedBookings(scheduleId);
|
||||
const assignMutation = useScheduleMutations(scheduleId).assign;
|
||||
|
||||
const handleAssign = async (bookingId: string, reference: string | null) => {
|
||||
try {
|
||||
await assignMutation.mutateAsync({
|
||||
id: scheduleId,
|
||||
payload: { bookingIds: [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.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (unassignedQuery.isLoading) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const bookings = unassignedQuery.data ?? [];
|
||||
|
||||
if (bookings.length === 0) {
|
||||
return (
|
||||
<Stack align="center" gap="xs" py="xl">
|
||||
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
|
||||
<ContainerIcon size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} c="gray.7">
|
||||
No unassigned bookings
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" maw={220}>
|
||||
Paid bookings waiting for a wagon will appear here.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{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;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={booking.id}
|
||||
padding="xs"
|
||||
radius="md"
|
||||
withBorder
|
||||
onClick={() =>
|
||||
onSelect({
|
||||
bookingId: booking.id,
|
||||
reference: booking.reference,
|
||||
company: null,
|
||||
freightType: booking.freightType,
|
||||
weightTons: booking.cargoTotalWeightVgm ?? null,
|
||||
status: booking.status,
|
||||
priorityScore: booking.priorityScore,
|
||||
})
|
||||
}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderColor: isActive ? "var(--mantine-color-green-5)" : undefined,
|
||||
}}
|
||||
>
|
||||
<Stack gap={6}>
|
||||
<Group gap={8} wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon size={30} radius="md" variant="light" color="orange">
|
||||
<ContainerIcon size={16} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group justify="space-between" wrap="nowrap" gap={4}>
|
||||
<Text size="sm" fw={700} truncate>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
{booking.priorityScore ? (
|
||||
<Badge size="xs" color="green">
|
||||
P{booking.priorityScore}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap" mt={2}>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{booking.freightType}
|
||||
</Badge>
|
||||
<Group gap={3} wrap="nowrap">
|
||||
<Weight size={11} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="11px" c="dimmed">
|
||||
{weight.toFixed(1)} T
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{blockReason ? (
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<AlertTriangle size={12} color="var(--mantine-color-red-6)" />
|
||||
<Text size="10px" c="red.7" fw={600}>
|
||||
{blockReason}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
<Tooltip label={blockReason} disabled={fits} withArrow position="bottom">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
disabled={!fits}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleAssign(booking.id, booking.reference);
|
||||
}}
|
||||
loading={
|
||||
assignMutation.isPending &&
|
||||
assignMutation.variables?.payload.bookingIds?.[0] === booking.id
|
||||
}
|
||||
leftSection={<Plus size={12} />}
|
||||
>
|
||||
Assign to train
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Badge, Box, Button, Card, Group, Progress, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import {
|
||||
Building2,
|
||||
Container as ContainerIcon,
|
||||
Fuel,
|
||||
Package,
|
||||
TrainFront,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { ContainerNumberInput } from "./ContainerNumberInput";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
|
||||
interface WagonCardProps {
|
||||
wagon: Wagon;
|
||||
company?: string | null;
|
||||
scheduleId: string;
|
||||
scheduleStatus?: string;
|
||||
onRemoveBooking: (wagon: Wagon) => void;
|
||||
onRemoveWagon: (wagonId: string) => void;
|
||||
}
|
||||
|
||||
export const WagonCard = ({
|
||||
wagon,
|
||||
company,
|
||||
scheduleId,
|
||||
scheduleStatus,
|
||||
onRemoveBooking,
|
||||
onRemoveWagon,
|
||||
}: WagonCardProps) => {
|
||||
const isDispatched = scheduleStatus === "DISPATCHED";
|
||||
const allocation = wagon.allocations?.[0];
|
||||
const hasAllocations = Boolean(allocation);
|
||||
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
|
||||
|
||||
const weightUsed = allocation?.allocatedWeightTons ?? 0;
|
||||
const weightMax = wagon.capacityTons ?? 0;
|
||||
const weightPercent = weightMax ? (weightUsed / weightMax) * 100 : 0;
|
||||
|
||||
const wagonType = wagon.wagonType?.code || "UNKNOWN";
|
||||
|
||||
return (
|
||||
<Card padding="sm" radius="md" withBorder style={{ borderColor: freightBrand.mutedBorder }}>
|
||||
<Card.Section withBorder inheritPadding py="xs" style={{ background: freightBrand.mutedBg }}>
|
||||
<Group justify="space-between">
|
||||
<Group gap={6}>
|
||||
<ThemeIcon size={28} radius="md" variant="white" color="green">
|
||||
<TrainFront size={16} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Group gap={4}>
|
||||
<Text size="sm" fw={800}>
|
||||
Wagon #{wagon.sequenceNo}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="green">
|
||||
{wagonType}
|
||||
</Badge>
|
||||
</Group>
|
||||
{wagon.physicalWagonNumber || wagon.physicalWagonId ? (
|
||||
<Text size="10px" c="dimmed">
|
||||
{wagon.physicalWagonNumber || wagon.physicalWagonId?.slice(0, 8)}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Group>
|
||||
{hasAllocations ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={isBulk ? "orange" : "cyan"}
|
||||
leftSection={isBulk ? <Fuel size={11} /> : <ContainerIcon size={11} />}
|
||||
>
|
||||
{isBulk ? "Bulk" : "Container"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Card.Section>
|
||||
|
||||
<Stack gap="sm" mt="sm">
|
||||
{hasAllocations && allocation ? (
|
||||
<>
|
||||
{company ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Building2 size={14} color={freightBrand.primary} />
|
||||
<Text size="sm" fw={700} truncate>
|
||||
{company}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Package size={14} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{allocation.bookingReference || "Unknown booking"}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{allocation.loadType === "CONTAINER" && allocation.containerItems?.length ? (
|
||||
<Box>
|
||||
<Text size="10px" c="dimmed" fw={700} tt="uppercase" mb={4}>
|
||||
Containers
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
{allocation.containerItems.map((item, idx) => (
|
||||
<Group key={item.id} gap={8} wrap="nowrap">
|
||||
<ContainerIcon size={13} color="var(--mantine-color-cyan-7)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
#{idx + 1}
|
||||
</Text>
|
||||
<ContainerNumberInput
|
||||
value={item.containerNumber ?? null}
|
||||
itemId={item.id}
|
||||
scheduleId={scheduleId}
|
||||
disabled={isDispatched}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{isBulk ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Fuel size={14} color="var(--mantine-color-orange-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{allocation.bulkLoad?.cargoDescription || "Bulk load"}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
<Box>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
Weight
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{weightUsed.toFixed(1)} / {weightMax.toFixed(1)} T
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={Math.min(weightPercent, 100)}
|
||||
color={weightPercent > 90 ? "red" : weightPercent > 75 ? "orange" : "green"}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{!isDispatched ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="xs"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={() => onRemoveBooking(wagon)}
|
||||
fullWidth
|
||||
>
|
||||
Remove booking
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Stack gap="xs" align="center" py="sm">
|
||||
<ThemeIcon size={36} radius="xl" variant="light" color="gray">
|
||||
<TrainFront size={18} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
Empty slot
|
||||
</Text>
|
||||
{!isDispatched ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
leftSection={<Trash2 size={14} />}
|
||||
onClick={() => onRemoveWagon(wagon.id)}
|
||||
>
|
||||
Remove wagon
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export { TrainStatsBar } from "./TrainStatsBar";
|
||||
export { ContainerNumberInput } from "./ContainerNumberInput";
|
||||
export { RemoveBookingModal } from "./RemoveBookingModal";
|
||||
export { WagonCard } from "./WagonCard";
|
||||
export { TrainConsistView } from "./TrainConsistView";
|
||||
export { InteractiveTrainConsist } from "./InteractiveTrainConsist";
|
||||
export { BookingDetailModal } from "./BookingDetailModal";
|
||||
export { BatchBookingList } from "./BatchBookingList";
|
||||
export { RemoveBookingConfirmModal } from "./RemoveBookingConfirmModal";
|
||||
export { AssignedBookingsPanel } from "./AssignedBookingsPanel";
|
||||
export { UnassignedBookingsPanel } from "./UnassignedBookingsPanel";
|
||||
export { RemovalLogPanel } from "./RemovalLogPanel";
|
||||
export { CompositionBookingTabs } from "./CompositionBookingTabs";
|
||||
Reference in New Issue
Block a user