Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx

1144 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Accordion,
ActionIcon,
Alert,
Badge,
Box,
Button,
Group,
Loader,
Paper,
Stack,
Tabs,
Text,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertTriangle,
ArrowLeft,
Boxes,
CalendarDays,
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock,
FileSignature,
Hourglass,
Layers,
Package,
PlayCircle,
RefreshCw,
Ruler,
TrainFront,
Weight,
XCircle,
} from "lucide-react";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import { KpiStrip, PageContainer } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import {
TrainConsistView,
CompositionBookingTabs,
} from "@/components/trainScheduling/compositionEditor";
import {
BookingPipeline,
HeroChip,
totalBookingCount,
WindowStatusPill,
} from "@/components/trainScheduling/batchVisuals";
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
import { BookingsManager } from "./BookingsManager";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
BatchBoardBookingDetail,
BatchBoardBookingState,
BatchWindowGroup,
BookingAllocationStatus,
} from "@/types/trainScheduling";
const STATE_META: Record<
BatchBoardBookingState,
{ label: string; color: string; icon: typeof CheckCircle2 }
> = {
ALLOCATED: { label: "Allocated", color: "edr-green", icon: CheckCircle2 },
SELECTED_FOR_BATCH: {
label: "Selected for batch",
color: "orange",
icon: Clock,
},
READY: { label: "Ready for batch", color: "teal", icon: Hourglass },
WAITING: { label: "Paid · waiting", color: "blue", icon: Hourglass },
PENDING_CONTRACT: {
label: "Pending contract",
color: "gray",
icon: Hourglass,
},
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
};
const ALLOC_META: Record<
BookingAllocationStatus,
{ label: string; color: string }
> = {
ASSIGNED: { label: "Wagons assigned", color: "edr-green" },
NOT_ATTEMPTED: { label: "Not allocated", color: "gray" },
DEFERRED: { label: "Deferred", color: "orange" },
FAILED: { label: "Allocation failed", color: "red" },
};
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
const fmtMeters = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
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))
: "—";
const initials = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase() || "?";
function StateBadge({ state }: { state: BatchBoardBookingState }) {
const meta = STATE_META[state];
const Icon = meta.icon;
return (
<Badge
variant="light"
color={meta.color}
radius="sm"
leftSection={<Icon size={11} />}
>
{meta.label}
</Badge>
);
}
function AllocationBadge({
status,
issue,
}: {
status: BookingAllocationStatus;
issue: string | null;
}) {
const meta = ALLOC_META[status];
const badge = (
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
);
if (!issue) return badge;
return (
<Tooltip label={issue} multiline maw={320} withArrow>
<Group gap={4} wrap="nowrap">
{badge}
<AlertTriangle size={14} color="var(--mantine-color-red-6)" />
</Group>
</Tooltip>
);
}
const bookingCellMeta = {
headerClassName: ruleEngineTable.headerCell,
cellClassName: ruleEngineTable.bodyCell,
};
const BOOKING_COLUMNS: ColumnDef<BatchBoardBookingDetail>[] = [
{
id: "reference",
header: "Reference",
meta: bookingCellMeta,
cell: ({ row }) => {
const b = row.original;
return (
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={700} c="dark.5">
{b.reference}
</Text>
{b.isGovernment ? (
<Badge size="xs" variant="light" color="grape">
Gov
</Badge>
) : null}
</Group>
);
},
},
{
id: "customer",
header: "Customer",
meta: bookingCellMeta,
cell: ({ row }) => {
const b = row.original;
return (
<Group gap={8} wrap="nowrap">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 26,
height: 26,
borderRadius: 8,
flexShrink: 0,
background: "#FEF1D5",
border: "1px solid #FBD171",
}}
>
<Text size="10px" fw={800} style={{ color: "#B26C09" }}>
{initials(b.company)}
</Text>
</Box>
<Text size="sm" c="gray.7" truncate>
{b.company}
</Text>
</Group>
);
},
},
{
id: "contractSigned",
header: "Contract signed",
meta: bookingCellMeta,
cell: ({ row }) => (
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{fmtDateTime(row.original.fullyExecutedAt)} EAT
</Text>
),
},
{
id: "selectedForBatch",
header: "Selected for batch",
meta: bookingCellMeta,
cell: ({ row }) => {
const b = row.original;
if (!b.selectedForBatchAt) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
return (
<>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{fmtDateTime(b.selectedForBatchAt)} EAT
</Text>
{b.paymentDeadline ? (
<Text
size="xs"
c="orange.7"
fw={600}
style={{ whiteSpace: "nowrap" }}
>
Pay by {fmtDateTime(b.paymentDeadline)} EAT
</Text>
) : null}
</>
);
},
},
{
id: "capacity",
header: "Capacity",
meta: bookingCellMeta,
cell: ({ row }) => {
const b = row.original;
return (
<Group gap={4} wrap="nowrap">
<Badge variant="default" radius="sm" size="sm">
{b.wagons}w
</Badge>
<Badge variant="default" radius="sm" size="sm">
{fmtTons(b.weightTons)}
</Badge>
</Group>
);
},
},
{
id: "state",
header: "Batch state",
meta: bookingCellMeta,
cell: ({ row }) => <StateBadge state={row.original.state} />,
},
{
id: "allocation",
header: "Wagon allocation",
meta: bookingCellMeta,
cell: ({ row }) => (
<AllocationBadge
status={row.original.allocationStatus}
issue={row.original.allocationIssue}
/>
),
},
];
function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) {
return (
<DataTable
columns={BOOKING_COLUMNS}
data={bookings}
status="success"
emptyMessage="No bookings in this batch window."
containerClassName="overflow-x-auto rounded-lg border border-edr-border"
/>
);
}
function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) {
const chips: Array<{ value: number; color: string; label: string }> = [
{ value: counts.allocated, color: "edr-green", label: "allocated" },
{ value: counts.selectedForBatch, color: "orange", label: "selected" },
{ value: counts.ready, color: "teal", label: "ready" },
{ value: counts.waiting, color: "blue", label: "waiting" },
{ value: counts.expired, color: "red", label: "expired" },
].filter((c) => c.value > 0);
return (
<Group gap={5} wrap="nowrap">
{chips.map((c) => (
<Tooltip key={c.label} label={`${c.value} ${c.label}`} withArrow>
<Group
gap={4}
wrap="nowrap"
style={{
padding: "2px 8px",
borderRadius: 999,
background: `var(--mantine-color-${c.color}-0)`,
border: `1px solid var(--mantine-color-${c.color}-2)`,
}}
>
<Box
w={6}
h={6}
style={{
borderRadius: 999,
background: `var(--mantine-color-${c.color}-6)`,
}}
/>
<Text size="xs" fw={700} c={`${c.color}.8`}>
{c.value}
</Text>
</Group>
</Tooltip>
))}
</Group>
);
}
/** "05 Jun 2026 · 06:00 09:00 EAT" → "06:00 09:00 EAT" (date lives in the day header). */
function timeLabelOf(label: string): string {
const idx = label.indexOf("·");
return idx >= 0 ? label.slice(idx + 1).trim() : label;
}
const EAT_TZ = "Africa/Addis_Ababa";
const dateKeyFmt = new Intl.DateTimeFormat("en-CA", {
timeZone: EAT_TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const dateLabelFmt = new Intl.DateTimeFormat("en-GB", {
timeZone: EAT_TZ,
weekday: "short",
day: "2-digit",
month: "short",
});
/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */
function windowDateKey(w: BatchWindowGroup): string {
if (w.date) return w.date;
if (w.start) return dateKeyFmt.format(new Date(w.start));
return "undated";
}
/** Human day label for a window — prefers the API field, falls back to `start`. */
function windowDateLabel(w: BatchWindowGroup): string {
if (w.dateLabel) return w.dateLabel;
if (w.start) return dateLabelFmt.format(new Date(w.start));
return "Undated";
}
function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
const total = window.bookings.length;
const hasIssues = window.bookings.some(
(b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED",
);
return (
<Accordion.Item value={window.key}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="md" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 10,
flexShrink: 0,
background: total ? "#FEF1D5" : "var(--mantine-color-gray-0)",
border: total
? "1px solid #FBD171"
: "1px solid var(--mantine-color-gray-2)",
color: total ? "#B26C09" : "var(--mantine-color-gray-5)",
}}
>
<Clock size={16} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fw={700} size="sm" truncate>
{timeLabelOf(window.label)}
</Text>
<Text size="xs" c="dimmed">
{total
? `${total} booking${total === 1 ? "" : "s"}`
: "Empty window"}
</Text>
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{hasIssues ? (
<Badge
variant="light"
color="red"
size="sm"
leftSection={<AlertTriangle size={10} />}
>
Issues
</Badge>
) : null}
<WindowCountChips counts={window.counts} />
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<BookingTable bookings={window.bookings} />
</Accordion.Panel>
</Accordion.Item>
);
}
export default function BatchScheduleDetailPage() {
const { scheduleId } = useParams<{ scheduleId: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const { data, isLoading, isFetching, refetch } = useQuery(
api.trainScheduling.batchBoardDetail.queryOptions({
input: { scheduleId: scheduleId ?? "" },
enabled: Boolean(scheduleId),
refetchInterval: 30_000,
}),
);
const runAllocation = useMutation(
api.trainScheduling.runAllocation.mutationOptions(),
);
const hasAssignedWagons = useMemo(
() =>
Boolean(
data?.windows.some((w) =>
w.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
) ||
data?.pendingContract.bookings.some(
(b) => b.allocationStatus === "ASSIGNED",
),
),
[data],
);
const scheduleDetailQuery = useQuery(
api.trainScheduling.scheduleDetail.queryOptions({
input: { id: scheduleId ?? "", freightType: "CONTAINER" },
enabled: Boolean(scheduleId),
}),
);
// Every booking on this schedule, flattened across windows + pending-contract,
// de-duplicated (a booking only appears once). Feeds the management table.
const allBookings = useMemo(() => {
if (!data) return [] as BatchBoardBookingDetail[];
const merged = [
...data.windows.flatMap((w) => w.bookings),
...data.pendingContract.bookings,
];
const byId = new Map<string, BatchBoardBookingDetail>();
for (const b of merged) if (!byId.has(b.id)) byId.set(b.id, b);
return [...byId.values()];
}, [data]);
// Batch bookings by state for the composition side panel (payment / expired lists).
const batchBookings = useMemo(() => {
const all = allBookings;
return {
awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"),
expired: all.filter((b) => b.state === "EXPIRED"),
};
}, [allBookings]);
const bookingsReadOnly = useMemo(
() => ["DISPATCHED", "ARRIVED"].includes(data?.status ?? ""),
[data?.status],
);
// Group the flat window list into per-day sections (one per EAT calendar date).
const dayGroups = useMemo(() => {
if (!data) return [];
const byDate = new Map<
string,
{
date: string;
dateLabel: string;
windows: BatchWindowGroup[];
totalBookings: number;
counts: BatchWindowGroup["counts"];
hasIssues: boolean;
}
>();
for (const w of data.windows) {
const dateKey = windowDateKey(w);
let group = byDate.get(dateKey);
if (!group) {
group = {
date: dateKey,
dateLabel: windowDateLabel(w),
windows: [],
totalBookings: 0,
counts: {
allocated: 0,
selectedForBatch: 0,
ready: 0,
waiting: 0,
expired: 0,
pendingContract: 0,
},
hasIssues: false,
};
byDate.set(dateKey, group);
}
group.windows.push(w);
group.totalBookings += w.bookings.length;
group.counts.allocated += w.counts.allocated;
group.counts.selectedForBatch += w.counts.selectedForBatch;
group.counts.ready += w.counts.ready;
group.counts.waiting += w.counts.waiting;
group.counts.expired += w.counts.expired;
group.counts.pendingContract += w.counts.pendingContract;
group.hasIssues =
group.hasIssues ||
w.bookings.some(
(b) =>
b.allocationStatus === "FAILED" ||
b.allocationStatus === "DEFERRED",
);
}
return [...byDate.values()];
}, [data]);
// Windows with bookings open by default (inside an expanded day).
const openWindowKeys = useMemo(
() =>
data
? data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key)
: [],
[data],
);
const todayEat = useMemo(
() =>
new Intl.DateTimeFormat("en-CA", {
timeZone: "Africa/Addis_Ababa",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date()),
[],
);
// Date-stepper: which day is currently shown. Default to today, else the first
// day with bookings, else the first day. Keep the selection if still valid.
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string | null>("overview");
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
null,
);
useEffect(() => {
if (!dayGroups.length) return;
if (selectedDate && dayGroups.some((d) => d.date === selectedDate)) return;
const preferred =
dayGroups.find((d) => d.date === todayEat) ??
dayGroups.find((d) => d.totalBookings > 0) ??
dayGroups[0];
setSelectedDate(preferred.date);
}, [dayGroups, selectedDate, todayEat]);
const selectedIndex = Math.max(
0,
dayGroups.findIndex((d) => d.date === selectedDate),
);
const selectedDay = dayGroups[selectedIndex];
const handleRunAllocation = () => {
runAllocation
.mutateAsync({ scheduleId: scheduleId ?? "" })
.then((result) => {
const failed = result.issues.filter(
(i) => i.status === "FAILED",
).length;
const deferred = result.deferred.length;
toast({
title: "Allocation run complete",
description:
failed || deferred
? `${result.assignedBookingIds.length} assigned · ${deferred} deferred · ${failed} failed`
: `${result.assignedBookingIds.length} booking(s) assigned to wagons`,
variant: failed ? "destructive" : "default",
});
void refetch();
})
.catch(() => {
toast({ title: "Allocation failed", variant: "destructive" });
});
};
if (isLoading || !data) {
return (
<PageContainer fluid>
<Group justify="center" py="xl">
<Loader color="edr-green" />
</Group>
</PageContainer>
);
}
const totalBookings = totalBookingCount(data.counts);
return (
<PageContainer fluid>
<Breadcrumbs
items={[
{ label: "Operations" },
{ label: "Batch board", href: "/dashboard/operations/batch-board" },
{ label: data.trainNumber ?? data.routeName ?? "Schedule" },
]}
/>
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab value="composition">
Train Composition{" "}
{scheduleDetailQuery.data?.trainSet?.wagons &&
scheduleDetailQuery.data.trainSet.wagons.length > 0 &&
`(${scheduleDetailQuery.data.trainSet.wagons.length})`}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview" pt="lg">
<Stack gap="lg">
<Group
justify="space-between"
align="flex-start"
wrap="wrap"
gap="md"
>
<Stack gap={9} style={{ minWidth: 0 }}>
<Group gap="sm" wrap="wrap" align="center">
<Button
variant="subtle"
color="gray"
radius="md"
px="sm"
leftSection={<ArrowLeft size={16} />}
onClick={() =>
navigate("/dashboard/operations/batch-board")
}
>
Back
</Button>
<Title order={2} fw={800}>
{data.trainNumber ?? data.routeName ?? "Schedule"}
</Title>
<WindowStatusPill status={data.bookingWindowStatus} />
<HeroChip>{data.status}</HeroChip>
</Group>
<RouteCorridor
origin={data.origin}
destination={data.destination}
/>
<Group gap={6} wrap="wrap">
<HeroChip icon={<CalendarDays size={12} />}>
{data.scheduleDate
? new Intl.DateTimeFormat("en-GB", {
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).format(new Date(data.scheduleDate)) + " EAT"
: "No date"}
</HeroChip>
{data.locomotive ? (
<HeroChip icon={<TrainFront size={12} />}>
Loco {data.locomotive.code} ·{" "}
{fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
{data.locomotive.maxTrainLengthMeters} m
</HeroChip>
) : null}
</Group>
</Stack>
<Group gap="sm">
<Button
variant="default"
radius="md"
leftSection={<RefreshCw size={16} />}
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<PlayCircle size={16} />}
loading={runAllocation.isPending}
onClick={handleRunAllocation}
>
Run allocation
</Button>
<Button
variant="light"
color="edr-green"
radius="md"
leftSection={<Layers size={16} />}
onClick={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${data.scheduleId}`,
)
}
>
Open schedule
</Button>
</Group>
</Group>
{!data.locomotive ? (
<Alert color="red" radius="md" icon={<AlertTriangle size={16} />}>
No locomotive assigned wagon allocation cannot run.
</Alert>
) : null}
<KpiStrip
items={[
{
label: "Allocated wagons",
value: data.capacity.allocatedWagons,
hint: "on this train",
icon: Boxes,
},
{
label: "Train length",
value: data.capacity.maxLengthMeters
? `${fmtMeters(data.capacity.allocatedLengthMeters)} / ${fmtMeters(data.capacity.maxLengthMeters)}`
: fmtMeters(data.capacity.allocatedLengthMeters),
icon: Ruler,
},
{
label: "Weight",
value: data.capacity.maxWeightTons
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
: fmtTons(data.capacity.usedWeightTons),
icon: Weight,
},
{
label: "Bookings",
value: totalBookings,
hint: `${data.counts.allocated} allocated · ${data.counts.expired} expired`,
icon: Package,
},
]}
/>
{/* Booking pipeline */}
<Paper
radius="lg"
withBorder
p="lg"
mt="lg"
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Group justify="space-between" mb="sm">
<Group gap={8} wrap="nowrap">
<ThemeIcon
size={32}
radius="md"
variant="light"
color="#F2A516"
>
<Package size={16} />
</ThemeIcon>
<Text fw={700}>Booking pipeline</Text>
</Group>
<Text size="sm" fw={700} c="dark.4">
{totalBookings} booking{totalBookings === 1 ? "" : "s"}
</Text>
</Group>
<BookingPipeline counts={data.counts} size={14} />
</Paper>
{data.allocationViolations.length ? (
<Alert
color="red"
mt="lg"
radius="lg"
icon={<AlertTriangle size={16} />}
title="Allocation constraints"
>
<Stack gap={4}>
{data.allocationViolations.map((v) => (
<Text key={v} size="sm">
{v}
</Text>
))}
</Stack>
</Alert>
) : null}
{/* Manage bookings — search, filter, remove / re-assign (bulk too) */}
<Paper
radius="lg"
withBorder
p="lg"
mt="lg"
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Group gap="sm" mb="md" wrap="nowrap" align="flex-start">
<ThemeIcon
size={38}
radius="md"
variant="light"
color="#F2A516"
>
<Package size={19} />
</ThemeIcon>
<Box>
<Title order={4}>Manage bookings</Title>
<Text size="sm" c="dimmed">
Search and filter every booking on this train. Remove an
allocated booking to free its wagons, or re-assign one that
is not yet allocated individually or in bulk.
</Text>
</Box>
</Group>
<BookingsManager
scheduleId={scheduleId ?? ""}
bookings={allBookings}
onChanged={() => void refetch()}
readOnly={bookingsReadOnly}
/>
</Paper>
{/* Batch windows */}
<Paper
radius="lg"
withBorder
p="lg"
mt="lg"
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Group gap="sm" mb={4} wrap="nowrap" align="flex-start">
<ThemeIcon
size={38}
radius="md"
variant="light"
color="#F2A516"
>
<Clock size={19} />
</ThemeIcon>
<Box>
<Title order={4}>Batch windows (EAT)</Title>
<Text size="sm" c="dimmed">
3-hour windows for every day from when the booking window
opened through the departure date. Bookings appear under the
date their contract was signed open a day to see its
windows.
</Text>
</Box>
</Group>
{dayGroups.length && selectedDay ? (
<>
{/* Date stepper — page back/forward through each day in the range */}
<Group
justify="center"
align="center"
wrap="nowrap"
gap="md"
mt="md"
>
<ActionIcon
variant="light"
color="#F2A516"
size="xl"
radius="xl"
aria-label="Previous day"
disabled={selectedIndex <= 0}
onClick={() =>
setSelectedDate(
dayGroups[selectedIndex - 1]?.date ?? null,
)
}
>
<ChevronLeft size={20} />
</ActionIcon>
<Paper
withBorder
radius="xl"
px="xl"
py="xs"
style={{
flex: 1,
maxWidth: 360,
textAlign: "center",
background: selectedDay.totalBookings
? "#FEF1D5"
: "white",
borderColor: selectedDay.totalBookings
? "#FBD171"
: "var(--mantine-color-gray-2)",
}}
>
<Group justify="center" gap={8} wrap="nowrap">
<CalendarDays size={15} color="#B26C09" />
<Text
fw={800}
style={{
color: selectedDay.totalBookings
? "#8A5304"
: "#0f172a",
}}
>
{selectedDay.dateLabel}
</Text>
{selectedDay.date === todayEat ? (
<Badge size="xs" variant="light" color="#F2A516">
Today
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" mt={2}>
{selectedDay.totalBookings
? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows`
: `${selectedDay.windows.length} windows · no bookings`}
</Text>
</Paper>
<ActionIcon
variant="light"
color="#F2A516"
size="xl"
radius="xl"
aria-label="Next day"
disabled={selectedIndex >= dayGroups.length - 1}
onClick={() =>
setSelectedDate(
dayGroups[selectedIndex + 1]?.date ?? null,
)
}
>
<ChevronRight size={20} />
</ActionIcon>
</Group>
<Group justify="space-between" align="center" mt="sm">
<Text size="xs" c="dimmed">
Day {selectedIndex + 1} of {dayGroups.length}
</Text>
<Group gap={6} wrap="nowrap">
{selectedDay.hasIssues ? (
<Badge
variant="light"
color="red"
size="sm"
leftSection={<AlertTriangle size={10} />}
>
Issues
</Badge>
) : null}
<WindowCountChips counts={selectedDay.counts} />
</Group>
</Group>
<Accordion
key={selectedDay.date}
multiple
defaultValue={openWindowKeys}
variant="separated"
radius="md"
mt="md"
className="bb-window-accordion"
>
{selectedDay.windows.map((window) => (
<WindowAccordionItem key={window.key} window={window} />
))}
</Accordion>
</>
) : (
<Text size="sm" c="dimmed" ta="center" py="lg">
No batch windows for this schedule.
</Text>
)}
{data.pendingContract.bookings.length ? (
<Accordion
multiple
defaultValue={["pending-contract"]}
variant="separated"
radius="md"
mt="md"
className="bb-window-accordion"
>
<Accordion.Item value="pending-contract">
<Accordion.Control>
<Group
justify="space-between"
wrap="nowrap"
pr="md"
gap="sm"
>
<Group gap="sm" wrap="nowrap">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 10,
flexShrink: 0,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
color: "var(--mantine-color-gray-6)",
}}
>
<FileSignature size={16} />
</Box>
<Box>
<Text fw={700} size="sm">
Pending contract
</Text>
<Text size="xs" c="dimmed">
Contract not signed yet not in any window
</Text>
</Box>
</Group>
<Badge variant="outline" color="gray" size="sm">
{data.pendingContract.bookings.length} booking
{data.pendingContract.bookings.length === 1
? ""
: "s"}
</Badge>
</Group>
</Accordion.Control>
<Accordion.Panel>
<BookingTable bookings={data.pendingContract.bookings} />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
) : null}
</Paper>
{/* Train composition diagram */}
{hasAssignedWagons && scheduleDetailQuery.data ? (
<Box mt="lg">
<TrainCompositionDiagram
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
freightType="CONTAINER"
trainNumber={scheduleDetailQuery.data.trainNumber}
totalLengthMeters={
scheduleDetailQuery.data.trainSet?.totalLengthMeters
}
/>
</Box>
) : null}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="composition" pt="lg">
{scheduleDetailQuery.data && scheduleDetailQuery.data.trainSet ? (
<Group align="stretch" gap="md" wrap="nowrap">
<Box style={{ flex: 1, minWidth: 0 }}>
<TrainConsistView
scheduleDetail={scheduleDetailQuery.data}
scheduleId={scheduleId ?? ""}
maxWagons={53}
highlightBookingId={selectedBookingId}
/>
</Box>
<Box style={{ width: 380, flexShrink: 0, minHeight: 520 }}>
<CompositionBookingTabs
scheduleDetail={scheduleDetailQuery.data}
scheduleId={scheduleId ?? ""}
awaitingPayment={batchBookings.awaitingPayment}
expired={batchBookings.expired}
selectedBookingId={selectedBookingId}
onSelectBooking={setSelectedBookingId}
/>
</Box>
</Group>
) : (
<Paper
radius="lg"
withBorder
py={64}
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Group justify="center">
<Loader color="edr-green" size="sm" />
<Text size="sm" c="dimmed">
Loading train composition
</Text>
</Group>
</Paper>
)}
</Tabs.Panel>
</Tabs>
</PageContainer>
);
}