Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice

This commit is contained in:
natib21
2026-07-03 23:33:12 +00:00
66 changed files with 3174 additions and 536 deletions

View File

@@ -1,5 +1,5 @@
import { Download, Zap, FileText, Clock } from "lucide-react";
import { Stack, Text, Button } from "@mantine/core";
import { Zap, Clock } from "lucide-react";
import { Stack, Text } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { BookingActionsMenu } from "./BookingActionsMenu";
@@ -14,21 +14,11 @@ interface BookingActionsToolbarProps {
mutations: Mutations;
}
/** Detail-page actions: primary toolbar + downloads. */
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
/** Detail-page actions: primary staff-action toolbar. */
export function BookingActionsToolbar({ booking }: BookingActionsToolbarProps) {
const row = toBookingListRow(booking);
const { status } = booking;
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
const blob = await fn();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") {
return null;
}
@@ -101,23 +91,6 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
<BookingActionsMenu row={row} variant="toolbar" />
</Stack>
</SectionCard>
{status === "CONTRACT_READY" && (
<SectionCard icon={FileText} title="Documents">
<Button
variant="default"
leftSection={<Download size={16} />}
onClick={() =>
downloadBlob(
() => mutations.downloadContract(),
`contract-${booking.reference}.txt`,
)
}
>
Download contract
</Button>
</SectionCard>
)}
</Stack>
);
}

View File

@@ -0,0 +1,202 @@
import { useMemo } from "react";
import { Boxes, Container as ContainerIcon, Snowflake, Flame } from "lucide-react";
import { Badge, Box, Group, Stack, Table, Text, ThemeIcon } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./SectionCard";
export interface BookingContainerUnitsCardProps {
booking: BookingDetail;
}
interface FlatUnit {
id: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
isHazardous?: boolean;
isReefer?: boolean;
typeLabel: string;
sizeFt?: number;
}
/**
* The physical container manifest: one row per container with its number, type,
* seal, and weight (VGM). Per-unit numbers are only captured for contract-drawdown
* bookings — when a line has no units the card falls back to the aggregate
* type/qty/weight so it still renders something for plain bookings.
*/
export function BookingContainerUnitsCard({ booking }: BookingContainerUnitsCardProps) {
const lines = booking.bookingContainers ?? [];
const units: FlatUnit[] = useMemo(
() =>
lines.flatMap((line) =>
(line.units ?? []).map((u) => ({
id: u.id,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber,
vgmTons: Number(u.vgmTons) || 0,
isHazardous: u.isHazardous,
isReefer: u.isReefer,
typeLabel: line.containerType?.label ?? line.containerType?.code ?? "—",
sizeFt: line.containerType?.sizeFt,
})),
),
[lines],
);
// Container bookings only — bulk has no container manifest.
if (booking.freightType === "BULK" || lines.length === 0) return null;
const totalUnits = units.length;
const totalVgm = units.reduce((sum, u) => sum + u.vgmTons, 0);
return (
<SectionCard
icon={Boxes}
title="Containers"
subtitle={
totalUnits > 0
? "Each physical container with its number and weight"
: "Per-container numbers were not captured for this booking"
}
accent="teal"
extra={
totalUnits > 0 ? (
<Badge color="teal" variant="light" radius="sm">
{totalUnits} container{totalUnits === 1 ? "" : "s"}
</Badge>
) : (
<Badge color="gray" variant="light" radius="sm">
{lines.length} line{lines.length === 1 ? "" : "s"}
</Badge>
)
}
>
{totalUnits > 0 ? (
<Stack gap="md">
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: 40 }}>#</Table.Th>
<Table.Th>Container No.</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Seal</Table.Th>
<Table.Th ta="right">Weight (VGM)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{units.map((u, i) => (
<Table.Tr key={u.id}>
<Table.Td>
<Text size="sm" c="dimmed">
{i + 1}
</Text>
</Table.Td>
<Table.Td>
<Group gap={8} wrap="nowrap" align="center">
<ThemeIcon size={26} radius="md" variant="light" color="teal">
<ContainerIcon size={15} />
</ThemeIcon>
<Text size="sm" fw={700} ff="monospace">
{u.containerNumber}
</Text>
{u.isReefer ? (
<ThemeIcon size={20} radius="sm" variant="light" color="blue" title="Reefer">
<Snowflake size={12} />
</ThemeIcon>
) : null}
{u.isHazardous ? (
<ThemeIcon size={20} radius="sm" variant="light" color="red" title="Hazardous">
<Flame size={12} />
</ThemeIcon>
) : null}
</Group>
</Table.Td>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text size="sm">{u.typeLabel}</Text>
{u.sizeFt ? (
<Badge color="gray" variant="light" radius="sm" size="sm">
{u.sizeFt}FT
</Badge>
) : null}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c={u.sealNumber ? undefined : "dimmed"}>
{u.sealNumber || "—"}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={700}>
{u.vgmTons.toFixed(3)} t
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group
justify="space-between"
pt="sm"
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
>
<Text size="sm" fw={600} c="dimmed">
Total weight (VGM)
</Text>
<Text size="sm" fw={800} c="teal.7">
{totalVgm.toFixed(3)} t
</Text>
</Group>
</Stack>
) : (
// Fallback: no per-unit numbers — show the aggregate lines.
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>VGM / unit</Table.Th>
<Table.Th ta="right">Total VGM</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{lines.map((line) => {
const perUnit = Number(line.vgmPerUnitTons) || 0;
return (
<Table.Tr key={line.id}>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{line.containerType?.label ?? line.containerType?.code ?? "—"}
</Text>
{line.containerType?.sizeFt ? (
<Badge color="gray" variant="light" radius="sm" size="sm">
{line.containerType.sizeFt}FT
</Badge>
) : null}
</Group>
</Table.Td>
<Table.Td>{line.quantity}</Table.Td>
<Table.Td>{perUnit.toFixed(3)} t</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={700}>
{(line.quantity * perUnit).toFixed(3)} t
</Text>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Box>
)}
</SectionCard>
);
}

View File

@@ -8,6 +8,7 @@ export * from "./BookingDetailHeader";
export * from "./BookingLifecycleStepper";
export * from "./BookingRouteCard";
export * from "./BookingContainersCard";
export * from "./BookingContainerUnitsCard";
export * from "./BookingApprovalCard";
export * from "./BookingReviewNotesCard";
export * from "./BookingPaymentCard";

View File

@@ -58,6 +58,25 @@ import {
StepLabel,
} from "./gl-booking-form/form-ui";
/** All booking-window times are communicated in East Africa Time. */
const EAT_TZ = "Africa/Addis_Ababa";
function fmtWindowOpensAt(iso: string): string {
const date = new Date(iso).toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: EAT_TZ,
});
const time = new Date(iso).toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: EAT_TZ,
});
return `${date} · ${time}`;
}
interface UnitDraft {
containerNumber: string;
sealNumber: string;
@@ -106,6 +125,33 @@ export default function GlCreateBookingForm() {
enabled: Boolean(requestId),
});
// Same window-gating the customer sees: GL may only create a booking while a
// booking window is OPEN for one of the contract's routes.
const contractId = contract?.id ?? id;
const { data: bookingWindows, isLoading: windowsLoading } = useQuery({
...api.trainScheduling.contractBookingWindows.queryOptions({
input: { contractId: contractId ?? "" },
}),
enabled: Boolean(contractId),
});
const windowOpen = useMemo(
() => (bookingWindows ?? []).some((w) => w.isOpenNow),
[bookingWindows],
);
// Soonest future window across all routes, used for the "next window" notice.
const nextWindow = useMemo(() => {
const now = Date.now();
return (bookingWindows ?? [])
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
.sort(
(a, b) =>
new Date(a.windowOpensAt!).getTime() -
new Date(b.windowOpensAt!).getTime(),
)[0];
}, [bookingWindows]);
const [scheduledDate, setScheduledDate] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
@@ -314,12 +360,13 @@ export default function GlCreateBookingForm() {
);
const canSubmit =
windowOpen &&
Boolean(scheduledDate) &&
(!needsRouteSelect || Boolean(contractRouteId)) &&
(isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0);
const handleSubmit = () => {
if (!scheduledDate || !contract) return;
if (!scheduledDate || !contract || !windowOpen) return;
const payload: Freight.CreateBookingUnderContractDto = {
scheduledDate,
@@ -451,6 +498,33 @@ export default function GlCreateBookingForm() {
</Alert>
) : null}
{!windowsLoading && !windowOpen ? (
<Alert
color="orange"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Booking window is closed"
mb="lg"
>
GL can create a booking only while a window is open.{" "}
{nextWindow?.windowOpensAt ? (
<>
Next window: <b>{fmtWindowOpensAt(nextWindow.windowOpensAt)} EAT</b>{" "}
for{" "}
<b>
{nextWindow.origin ?? "Origin"} {nextWindow.destination ?? "Destination"}
</b>
.
</>
) : (
<>No upcoming booking window scheduled.</>
)}
</Alert>
) : null}
{windowsLoading || windowOpen ? (
<>
<Stack gap="lg" maw={896} mx="auto">
<StepCard>
<StepHeader
@@ -846,6 +920,8 @@ export default function GlCreateBookingForm() {
</Stack>
) : null}
</Modal>
</>
) : null}
</PageContainer>
);
}

View File

@@ -208,6 +208,10 @@ function codeLabel(code?: string | null): string | null {
export interface ContractDocumentsCardProps {
files: ContractFile[];
/** Card heading. Defaults to "Documents". */
title?: string;
/** Message shown when there are no files. */
emptyText?: string;
/** Open the file inline in a viewer modal. */
onView?: (file: ContractFile) => void;
/** Download the file to disk. */
@@ -217,13 +221,15 @@ export interface ContractDocumentsCardProps {
/** Rich list of the contract's attached documents: type, size, view + download. */
export function ContractDocumentsCard({
files,
title = "Documents",
emptyText = "No documents attached to this contract.",
onView,
onDownload,
}: ContractDocumentsCardProps) {
return (
<SectionCard
icon={FileText}
title="Documents"
title={title}
accent="indigo"
extra={
<Badge color="gray" variant="light" radius="sm">
@@ -233,7 +239,7 @@ export function ContractDocumentsCard({
>
{files.length === 0 ? (
<Text size="sm" c="dimmed">
No documents attached to this contract.
{emptyText}
</Text>
) : (
<Stack gap="xs">

View File

@@ -0,0 +1,123 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Group, NumberInput, Select, Stack } from "@mantine/core";
export type DurationUnit = "minutes" | "hours" | "days";
const UNIT_MINUTES: Record<DurationUnit, number> = {
minutes: 1,
hours: 60,
days: 1440,
};
const UNIT_OPTIONS: { value: DurationUnit; label: string }[] = [
{ value: "minutes", label: "min" },
{ value: "hours", label: "hr" },
{ value: "days", label: "day" },
];
/** Convert a value expressed in `from` units to `to` units. */
function convert(value: number, from: DurationUnit, to: DurationUnit): number {
return (value * UNIT_MINUTES[from]) / UNIT_MINUTES[to];
}
/** Pick the largest unit that keeps a value a clean-ish whole number, so a
* stored 0.0667h loads back as "4 min" rather than "0.0667 hr". */
function bestDisplayUnit(minutes: number): DurationUnit {
if (minutes <= 0) return "minutes";
if (minutes % 1440 === 0) return "days";
if (minutes % 60 === 0) return "hours";
return "minutes";
}
export interface DurationFieldProps {
label: string;
description?: string;
/** Current value, expressed in `nativeUnit` (what the API/DB stores). */
value: number | string;
/** The unit the parent stores/sends. The field converts to this on change. */
nativeUnit: DurationUnit;
/** Called with the value converted back to `nativeUnit` (or "" when blank). */
onChange: (nativeValue: number | "") => void;
/** Smallest allowed value, in `nativeUnit`. */
min?: number;
disabled?: boolean;
}
export default function DurationField({
label,
description,
value,
nativeUnit,
onChange,
min,
disabled,
}: DurationFieldProps) {
const nativeMinutes = useMemo(() => {
const num = value === "" || value == null ? NaN : Number(value);
return Number.isFinite(num) ? num * UNIT_MINUTES[nativeUnit] : NaN;
}, [value, nativeUnit]);
// Display unit is user-driven; seed it from the incoming value once.
const [unit, setUnit] = useState<DurationUnit>(() =>
Number.isFinite(nativeMinutes) ? bestDisplayUnit(nativeMinutes) : nativeUnit,
);
// The value usually arrives async (after the initial "" render), so the
// useState seed above runs before it exists. Re-pick the friendliest display
// unit the first time a real value shows up — but never again, so the user's
// manual unit choice sticks.
const seeded = useRef(false);
useEffect(() => {
if (!seeded.current && Number.isFinite(nativeMinutes)) {
seeded.current = true;
setUnit(bestDisplayUnit(nativeMinutes));
}
}, [nativeMinutes]);
const displayValue: number | "" = Number.isFinite(nativeMinutes)
? Number(convert(nativeMinutes, "minutes", unit).toFixed(4))
: "";
const emitNative = (display: number | "", displayUnit: DurationUnit) => {
if (display === "" || !Number.isFinite(Number(display))) {
onChange("");
return;
}
const native = convert(Number(display), displayUnit, nativeUnit);
onChange(Number(native.toFixed(6)));
};
return (
<Stack gap={4}>
<Group gap="xs" align="flex-end" wrap="nowrap">
<NumberInput
label={label}
description={description}
value={displayValue}
onChange={(v) =>
emitNative(v === "" ? "" : Number(v), unit)
}
clampBehavior="none"
allowDecimal
min={min != null ? convert(min, nativeUnit, unit) : 0}
disabled={disabled}
style={{ flex: 1 }}
/>
<Select
aria-label={`${label} unit`}
data={UNIT_OPTIONS}
value={unit}
onChange={(next) => {
if (!next) return;
// Only the display unit changes; the stored native value stays put.
// displayValue re-derives from it on the next render.
setUnit(next as DurationUnit);
}}
allowDeselect={false}
disabled={disabled}
w={90}
/>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,593 @@
import { useMemo, useState } from "react";
import {
Badge,
Box,
Button,
Group,
Modal,
Paper,
Progress,
ScrollArea,
Select,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeftRight,
ArrowRight,
CheckCircle2,
Inbox,
PackageCheck,
Repeat,
Train,
Weight,
X,
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
EligibleContainerBooking,
FreightType,
TrainScheduleDetail,
} from "@/types/trainScheduling";
interface ScheduleWorkspacePanelProps {
schedule: TrainScheduleDetail;
/** Refetch the schedule detail after a mutation so both panels refresh. */
onChanged: () => void;
}
const GREEN = "var(--mantine-color-edr-green-6)";
/**
* Deadline + label for the window phase this schedule is currently in.
* Phases run: window open (windowClosesAt) → document review (docReviewEndsAt)
* → payment (paymentPhaseEndsAt). Display only. Returns null off-phase.
*/
function phaseCountdown(
schedule: TrainScheduleDetail,
): { label: string; deadline: string } | null {
switch (schedule.windowPhase) {
case "OPEN":
return schedule.windowClosesAt
? { label: "Booking window closes in", deadline: schedule.windowClosesAt }
: null;
case "DOC_REVIEW":
return schedule.docReviewEndsAt
? { label: "Document review ends in", deadline: schedule.docReviewEndsAt }
: null;
case "PAYMENT":
return schedule.paymentPhaseEndsAt
? { label: "Payment window ends in", deadline: schedule.paymentPhaseEndsAt }
: null;
default:
return null;
}
}
/** Cargo weight already allocated to this train (sum of on-train bookings). */
function usedWeight(schedule: TrainScheduleDetail): number {
return (schedule.bookings ?? []).reduce(
(sum, b) => sum + (Number(b.weightTons) || 0),
0,
);
}
/** Max pull weight across all locomotives on the set (0 when unknown). */
function pullCapacity(schedule: TrainScheduleDetail): number {
const set = schedule.trainSet;
if (!set) return 0;
const locos =
set.locomotives && set.locomotives.length > 0
? set.locomotives
: set.locomotive
? [set.locomotive]
: [];
return locos.reduce((sum, l) => sum + (Number(l.maxPullWeightTons) || 0), 0);
}
export function ScheduleWorkspacePanel({
schedule,
onChanged,
}: ScheduleWorkspacePanelProps) {
const { toast } = useToast();
const freightType: FreightType | undefined =
schedule.freightType === "CONTAINER" || schedule.freightType === "BULK"
? schedule.freightType
: undefined;
const locked = ["DISPATCHED", "ARRIVED"].includes(schedule.status);
const canManage = ["DRAFT", "SCHEDULED"].includes(schedule.status);
// Pool = accepted, ready-to-pay bookings on THIS train's route+day that are not
// yet linked to any schedule (same filter the auto-batch uses).
const poolQuery = useQuery(
api.trainScheduling.eligibleBookings.queryOptions({
input: {
filters: {
originStationId: schedule.originStation?.id,
destinationStationId: schedule.destinationStation?.id,
trainScheduleId: schedule.id,
},
freightType,
},
enabled: Boolean(schedule.originStation?.id && schedule.destinationStation?.id),
}),
);
const onTrainIds = useMemo(
() => new Set((schedule.bookings ?? []).map((b) => b.id)),
[schedule.bookings],
);
const pool: EligibleContainerBooking[] = useMemo(
() => (poolQuery.data?.items ?? []).filter((b) => !onTrainIds.has(b.id)),
[poolQuery.data, onTrainIds],
);
const onTrain = schedule.bookings ?? [];
// ── Mutations (reuse the existing endpoints) ───────────────────────────────
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const moveSchedule = useMutation(
api.trainScheduling.moveBookingSchedule.mutationOptions(),
);
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
const [moveTarget, setMoveTarget] = useState<string | null>(null);
const { data: targets } = useQuery(
api.trainScheduling.bookableSchedules.queryOptions({
input: {
originYardId: schedule.originStation?.id,
destinationYardId: schedule.destinationStation?.id,
},
enabled: Boolean(
schedule.originStation?.id && schedule.destinationStation?.id,
),
}),
);
const moveOptions = useMemo(
() =>
(targets ?? [])
.filter((s) => s.id !== schedule.id)
.map((s) => ({
value: s.id,
label: `${s.routeName ?? `${s.origin}${s.destination}`} · ${new Date(
s.scheduleDate,
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} free`,
})),
[targets, schedule.id],
);
// ── Capacity meter (by cargo weight vs locomotive pull) ────────────────────
const used = usedWeight(schedule);
const capacity = pullCapacity(schedule);
const pct = capacity > 0 ? Math.min(100, Math.round((used / capacity) * 100)) : 0;
const over = capacity > 0 && used > capacity;
const forceAdd = (bookingId: string, ref: string, weightTons: number) => {
const wouldOverfill = capacity > 0 && used + (weightTons || 0) > capacity;
assign
.mutateAsync({
id: schedule.id,
freightType,
payload: {
bookingIds: [...onTrainIds, bookingId],
forceAssign: true,
},
})
.then(() => {
toast({
title: `${ref} added to train`,
description: wouldOverfill
? "Force-added past the pull-weight limit — review capacity."
: "Wagons auto-pinned.",
variant: wouldOverfill ? "destructive" : undefined,
});
onChanged();
void poolQuery.refetch();
})
.catch(() =>
toast({ title: "Could not add booking", variant: "destructive" }),
);
};
const removeFromTrain = (bookingId: string, ref: string) => {
unassign
.mutateAsync({ id: schedule.id, bookingId })
.then(() => {
toast({ title: `${ref} removed from train` });
onChanged();
void poolQuery.refetch();
})
.catch(() =>
toast({ title: "Could not remove booking", variant: "destructive" }),
);
};
const doMove = () => {
if (!moveBookingId || !moveTarget) return;
moveSchedule
.mutateAsync({ bookingId: moveBookingId, trainScheduleId: moveTarget })
.then(() => {
toast({ title: "Booking reassigned to another train" });
setMoveBookingId(null);
onChanged();
void poolQuery.refetch();
})
.catch(() =>
toast({ title: "Could not reassign booking", variant: "destructive" }),
);
};
return (
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="lg">
{/* Header + capacity meter */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="sm" align="center" wrap="nowrap">
<ThemeIcon size={40} radius="md" variant="light" color="edr-green">
<PackageCheck size={20} />
</ThemeIcon>
<div>
<Text fw={700}>Allocation workspace</Text>
<Text size="xs" c="dimmed">
Manually add ready-to-pay bookings, remove, or reassign them
</Text>
</div>
</Group>
<Box miw={240} style={{ flex: "0 1 320px" }}>
<Group justify="space-between" mb={4} gap={4}>
<Group gap={6} align="center">
<Weight size={14} color={over ? "#B42318" : undefined} />
<Text size="xs" fw={600} c={over ? "red" : "dimmed"}>
Load {used.toFixed(1)}T
{capacity > 0 ? ` / ${capacity.toFixed(0)}T pull` : ""}
</Text>
</Group>
{over ? (
<Badge color="red" variant="light" size="sm" radius="sm">
Over capacity
</Badge>
) : (
<Text size="xs" c="dimmed">
{capacity > 0 ? `${pct}%` : "—"}
</Text>
)}
</Group>
<Progress
value={capacity > 0 ? pct : 0}
color={over ? "red" : pct > 85 ? "orange" : "edr-green"}
radius="xl"
size="md"
/>
</Box>
</Group>
{(() => {
const cd = phaseCountdown(schedule);
return cd ? (
<Group
gap={8}
p="xs"
wrap="nowrap"
align="center"
style={{
borderRadius: 10,
background: "var(--mantine-color-blue-0)",
border: "1px solid var(--mantine-color-blue-2)",
}}
>
<CountdownTimer deadline={cd.deadline} label={cd.label} size="sm" />
</Group>
) : null;
})()}
{over ? (
<Group
gap={8}
p="xs"
wrap="nowrap"
align="center"
style={{
borderRadius: 10,
background: "var(--mantine-color-red-0)",
border: "1px solid var(--mantine-color-red-2)",
}}
>
<AlertTriangle size={16} color="#B42318" />
<Text size="xs" c="red.8" fw={500}>
This train is loaded beyond its locomotive pull weight. Force-adds are
allowed, but review before dispatch.
</Text>
</Group>
) : null}
{locked ? (
<Text size="sm" c="dimmed">
This train is {schedule.status.toLowerCase()} bookings can no longer be
changed.
</Text>
) : null}
{/* Two-panel board */}
<Group align="stretch" gap="lg" grow wrap="wrap">
{/* Pool */}
<PanelColumn
title="Ready to pay"
hint="Accepted · this route & day"
count={pool.length}
accent="#F2A516"
loading={poolQuery.isLoading}
emptyIcon={Inbox}
emptyText="No ready-to-pay bookings waiting for this train."
>
{pool.map((b) => (
<BookingCard
key={b.id}
reference={b.reference}
customer={b.customer}
weightTons={b.weightTons}
status={b.status}
right={
canManage ? (
<Tooltip label="Force-add to this train" withArrow>
<Button
size="compact-sm"
color="edr-green"
radius="md"
rightSection={<ArrowRight size={14} />}
loading={assign.isPending}
onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
>
Add
</Button>
</Tooltip>
) : null
}
/>
))}
</PanelColumn>
{/* On train */}
<PanelColumn
title="On this train"
hint="Allocated bookings"
count={onTrain.length}
accent="#0EA371"
emptyIcon={Train}
emptyText="No bookings allocated yet. Add one from the pool."
>
{onTrain.map((b) => (
<BookingCard
key={b.id}
reference={b.reference ?? b.id.slice(0, 8)}
customer={b.customer}
weightTons={b.weightTons}
status={b.status}
right={
canManage ? (
<Group gap={6} wrap="nowrap" justify="flex-end">
<Tooltip label="Reassign to another train" withArrow>
<Button
size="compact-sm"
variant="subtle"
color="orange"
radius="md"
leftSection={<Repeat size={13} />}
onClick={() => {
setMoveBookingId(b.id);
setMoveTarget(null);
}}
>
Move
</Button>
</Tooltip>
<Tooltip label="Remove from this train" withArrow>
<Button
size="compact-sm"
variant="light"
color="red"
radius="md"
leftSection={<X size={13} />}
loading={unassign.isPending}
onClick={() =>
removeFromTrain(b.id, b.reference ?? b.id.slice(0, 8))
}
>
Remove
</Button>
</Tooltip>
</Group>
) : null
}
/>
))}
</PanelColumn>
</Group>
</Stack>
{/* Reassign modal */}
<Modal
opened={Boolean(moveBookingId)}
onClose={() => setMoveBookingId(null)}
title={
<Group gap={8}>
<ArrowLeftRight size={18} />
<Text fw={700}>Reassign booking to another train</Text>
</Group>
}
centered
radius="lg"
>
<Stack gap="md">
<Select
label="Target train (same route, open window)"
placeholder="Select an open schedule"
data={moveOptions}
value={moveTarget}
onChange={setMoveTarget}
searchable
nothingFoundMessage="No other open schedules on this route"
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setMoveBookingId(null)}>
Cancel
</Button>
<Button
color="edr-green"
disabled={!moveTarget}
loading={moveSchedule.isPending}
leftSection={<CheckCircle2 size={16} />}
onClick={doMove}
>
Reassign
</Button>
</Group>
</Stack>
</Modal>
</Paper>
);
}
// ── Sub-components ───────────────────────────────────────────────────────────
function PanelColumn({
title,
hint,
count,
accent,
loading,
emptyIcon: EmptyIcon,
emptyText,
children,
}: {
title: string;
hint: string;
count: number;
accent: string;
loading?: boolean;
emptyIcon: typeof Inbox;
emptyText: string;
children: React.ReactNode;
}) {
const isEmpty = !loading && count === 0;
return (
<Paper
radius="lg"
withBorder
p="md"
miw={280}
style={{
flex: 1,
borderColor: "var(--mantine-color-gray-2)",
background: `linear-gradient(180deg, ${accent}0A 0%, transparent 90px)`,
}}
>
<Group justify="space-between" align="center" mb="sm">
<Group gap={8} align="center">
<Box w={8} h={8} style={{ borderRadius: 999, background: accent }} />
<Text fw={700} size="sm">
{title}
</Text>
<Badge variant="light" color="gray" radius="sm" size="sm">
{count}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{hint}
</Text>
</Group>
{isEmpty ? (
<Stack align="center" gap={6} py={32}>
<EmptyIcon size={24} color="var(--mantine-color-gray-4)" />
<Text size="xs" c="dimmed" ta="center" maw={220}>
{emptyText}
</Text>
</Stack>
) : (
<ScrollArea.Autosize mah={420} type="hover">
<Stack gap={8} pr={4}>
{loading ? (
<Text size="xs" c="dimmed" py="md" ta="center">
Loading
</Text>
) : (
children
)}
</Stack>
</ScrollArea.Autosize>
)}
</Paper>
);
}
function BookingCard({
reference,
customer,
weightTons,
status,
right,
}: {
reference: string;
customer?: string | null;
weightTons?: number | null;
status?: string | null;
right?: React.ReactNode;
}) {
return (
<Paper
radius="md"
withBorder
p="sm"
style={{
borderColor: "var(--mantine-color-gray-2)",
transition: "border-color 120ms ease, box-shadow 120ms ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = GREEN;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
}}
>
<Group justify="space-between" align="center" wrap="nowrap" gap="sm">
<Stack gap={3} style={{ minWidth: 0 }}>
<Group gap={8} align="center" wrap="nowrap">
<Text size="sm" fw={700} truncate>
{reference}
</Text>
{status ? <BookingStatusBadge status={status} /> : null}
</Group>
<Group gap={10} align="center" wrap="nowrap">
<Text size="xs" c="dimmed" truncate>
{customer ?? "—"}
</Text>
{weightTons != null ? (
<Group gap={3} align="center" wrap="nowrap">
<Weight size={11} color="var(--mantine-color-gray-5)" />
<Text size="xs" c="dimmed">
{Number(weightTons).toFixed(1)}T
</Text>
</Group>
) : null}
</Group>
</Stack>
{right ? <Box style={{ flexShrink: 0 }}>{right}</Box> : null}
</Group>
</Paper>
);
}