ui design for schedule

This commit is contained in:
Marshal
2026-06-09 23:25:52 +00:00
parent 5774d7db9d
commit 257428be18
19 changed files with 2088 additions and 408 deletions

View File

@@ -120,7 +120,7 @@ export function ContainerPlacementGrid({
value={progress}
size="sm"
radius="xl"
color={issues.length ? "yellow" : "teal"}
color={issues.length ? "yellow" : "green"}
/>
</Stack>
</Paper>
@@ -135,7 +135,7 @@ export function ContainerPlacementGrid({
</Stack>
) : (
<Badge
color="teal"
color="green"
variant="light"
size="sm"
w="fit-content"
@@ -163,7 +163,7 @@ export function ContainerPlacementGrid({
{unit.label} · {unit.containerTypeCode} · {unit.grossWeightTons}T
</Text>
</Stack>
<Badge size="sm" variant="light" color={isComplete ? "teal" : "gray"}>
<Badge size="sm" variant="light" color={isComplete ? "green" : "gray"}>
{isComplete ? "Ready" : "Pending"}
</Badge>
</Group>

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { ArrowRight, Package } from "lucide-react";
import {
Accordion,
@@ -36,12 +36,15 @@ function EligibleBookingRow({
wrap="nowrap"
p="sm"
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 10,
background: selected ? "var(--mantine-color-teal-0)" : undefined,
border: `1px solid ${
selected ? "var(--mantine-color-green-3)" : "var(--mantine-color-gray-2)"
}`,
borderRadius: 12,
background: selected ? "var(--mantine-color-green-0)" : "white",
transition: "border-color 120ms ease, background 120ms ease",
}}
>
<Checkbox checked={selected} onChange={onToggle} mt={4} />
<Checkbox checked={selected} onChange={onToggle} mt={4} color="green" />
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" wrap="wrap">
<Package size={14} />
@@ -202,7 +205,7 @@ export function EligibleBookingsPanel({
</Text>
</Stack>
<Group gap="xs" onClick={(e) => e.stopPropagation()}>
<Badge variant="light" color="teal">
<Badge variant="light" color="green">
{selectedInBucket.length} selected
</Badge>
<Button

View File

@@ -43,7 +43,7 @@ export function FleetAvailabilitySummary({
</Text>
</Stack>
</Group>
<Badge variant="light" color={totalShortfall > 0 ? "yellow" : "teal"}>
<Badge variant="light" color={totalShortfall > 0 ? "yellow" : "green"}>
{fillRate}% fleet coverage
</Badge>
</Group>
@@ -59,7 +59,7 @@ export function FleetAvailabilitySummary({
value={fillRate}
size="sm"
radius="xl"
color={totalShortfall > 0 ? "yellow" : "teal"}
color={totalShortfall > 0 ? "yellow" : "green"}
/>
</Stack>
) : null}
@@ -86,7 +86,7 @@ export function FleetAvailabilitySummary({
{row.shortfall}
</Badge>
) : (
<Text size="sm" c="teal">
<Text size="sm" c="green">
0
</Text>
)}

View File

@@ -41,15 +41,20 @@ export function ScheduleBookingsStep({
onRemove?: (bookingId: string) => void;
}) {
return (
<Paper p="md" radius="xl" withBorder>
<Tabs defaultValue={assignedBookings.length ? "on-train" : "add"} radius="lg" variant="pills">
<Paper p="md" radius="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Tabs
defaultValue={assignedBookings.length ? "on-train" : "add"}
radius="md"
variant="pills"
color="green"
>
<Tabs.List mb="md">
<Tabs.Tab
value="on-train"
leftSection={<Train size={14} />}
rightSection={
assignedBookings.length ? (
<Badge size="xs" variant="light" color="teal" circle>
<Badge size="xs" variant="light" color="green" circle>
{assignedBookings.length}
</Badge>
) : undefined
@@ -71,9 +76,9 @@ export function ScheduleBookingsStep({
justify="space-between"
p="sm"
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 10,
background: "var(--mantine-color-teal-0)",
border: "1px solid var(--mantine-color-green-2)",
borderRadius: 12,
background: "var(--mantine-color-green-0)",
}}
>
<Stack gap={4}>
@@ -82,7 +87,7 @@ export function ScheduleBookingsStep({
{booking.reference}
</Text>
{booking.weightTons != null ? (
<Badge variant="outline" size="xs">
<Badge variant="outline" size="xs" color="green">
{booking.weightTons}T
</Badge>
) : null}
@@ -92,7 +97,7 @@ export function ScheduleBookingsStep({
Assigned to this consist
</Text>
<ArrowRight size={12} />
<Text size="xs" c="teal">
<Text size="xs" c="green.7" fw={500}>
Ready for wagon plan
</Text>
</Group>

View File

@@ -54,7 +54,7 @@ export function PreviewSummary({
{ label: "Train length", value: `${summary.totalLengthMeters}m` },
];
return (
<Paper p="md" radius="xl" withBorder bg="teal.0">
<Paper p="md" radius="xl" withBorder bg="green.0">
<Text size="sm" fw={600} mb="sm">
Plan summary
</Text>

View File

@@ -49,7 +49,7 @@ export function SchedulingWorkflowHeader({
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="flex-start">
<ThemeIcon size={42} radius="xl" variant="gradient" gradient={{ from: "teal", to: "green", deg: 135 }}>
<ThemeIcon size={42} radius="xl" variant="gradient" gradient={{ from: "green", to: "teal", deg: 135 }}>
<Icon size={20} />
</ThemeIcon>
<Stack gap={4}>
@@ -63,7 +63,7 @@ export function SchedulingWorkflowHeader({
) : null}
</Stack>
</Group>
<Badge size="lg" variant="light" color="teal">
<Badge size="lg" variant="light" color="green">
Step {activeStep + 1} of {totalSteps}
</Badge>
</Group>
@@ -83,7 +83,7 @@ export function SchedulingWorkflowHeader({
{progress}%
</Text>
</Group>
<Progress value={progress} size="sm" radius="xl" color="teal" />
<Progress value={progress} size="sm" radius="xl" color="green" />
</Stack>
</Paper>
);

View File

@@ -0,0 +1,580 @@
import { useMemo } from "react";
import { Box, Group, Paper, Progress, Stack, Text, Tooltip } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { Box as BoxIcon, Container as ContainerIcon, Fuel, Gauge, TrainFront } from "lucide-react";
import { freightBrand } from "@/theme/freight-brand";
/**
* Visual train composition: a locomotive coupled to its wagons, drawn like a real
* consist. Long trains wrap into a serpentine (zig-zag) so the whole train stays on
* screen. Each wagon shows its load (container blocks or a bulk fill gauge), physical
* wagon number and tonnage.
*/
type DiagramWagonInput = {
sequenceNo: number;
capacityTons: number;
assignedWeightTons: number;
slotLoadType?: string | null;
wagonType?: { code?: string | null } | null;
wagonTypeCode?: string | null;
physicalWagonNumber?: string | null;
allocations?: Array<{
bookingReference?: string | null;
bookingId?: string;
loadType?: string | null;
containerItems?: Array<{ containerNumber?: string | null }> | null;
bulkLoad?: { weightTons?: number | null; cargoDescription?: string | null } | null;
}> | null;
};
type NormalizedWagon = {
sequenceNo: number;
capacityTons: number;
assignedWeightTons: number;
wagonTypeCode: string | null;
physicalWagonNumber: string | null;
isEmpty: boolean;
isBulk: boolean;
containerNumbers: string[];
bookingRefs: string[];
cargoDescription: string | null;
};
const CAR_WIDTH = 150; // car body + coupler footprint
function normalizeWagon(w: DiagramWagonInput, freightType?: string | null): NormalizedWagon {
const allocations = w.allocations ?? [];
const firstLoad = (
w.slotLoadType ??
allocations[0]?.loadType ??
freightType ??
""
)
.toString()
.toUpperCase();
const isBulk = firstLoad.includes("BULK");
const containerNumbers: string[] = [];
const bookingRefs: string[] = [];
let cargoDescription: string | null = null;
for (const alloc of allocations) {
if (alloc.bookingReference) bookingRefs.push(alloc.bookingReference);
for (const item of alloc.containerItems ?? []) {
containerNumbers.push(item.containerNumber?.trim() || "—");
}
if (alloc.bulkLoad?.cargoDescription) cargoDescription = alloc.bulkLoad.cargoDescription;
}
return {
sequenceNo: w.sequenceNo,
capacityTons: Number(w.capacityTons) || 0,
assignedWeightTons: Number(w.assignedWeightTons) || 0,
wagonTypeCode: w.wagonType?.code ?? w.wagonTypeCode ?? null,
physicalWagonNumber: w.physicalWagonNumber ?? null,
isEmpty: allocations.length === 0,
isBulk,
containerNumbers,
bookingRefs,
cargoDescription,
};
}
function chunk<T>(items: T[], size: number): T[][] {
if (size <= 0) return [items];
const rows: T[][] = [];
for (let i = 0; i < items.length; i += size) rows.push(items.slice(i, i + size));
return rows;
}
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 ? "#0f291b" : "var(--mantine-color-gray-7)",
border: "2px solid var(--mantine-color-gray-4)",
boxShadow: "inset 0 0 0 2px rgba(255,255,255,0.25)",
}}
/>
))}
</Group>
);
}
function Coupler() {
return (
<Box style={{ width: 16, height: 74, display: "flex", alignItems: "center", flexShrink: 0 }}>
<Box
style={{
width: "100%",
height: 6,
borderRadius: 3,
background:
"linear-gradient(90deg, var(--mantine-color-gray-4), var(--mantine-color-gray-6), var(--mantine-color-gray-4))",
}}
/>
</Box>
);
}
function LocomotiveCar({
code,
name,
maxPullWeightTons,
}: {
code: string;
name?: string | null;
maxPullWeightTons?: number | null;
}) {
return (
<Tooltip
label={`Locomotive ${code}${name ? ` · ${name}` : ""}${
maxPullWeightTons ? ` · pulls up to ${maxPullWeightTons}T` : ""
}`}
withArrow
>
<Box style={{ width: 134, flexShrink: 0 }}>
<Box
style={{
position: "relative",
height: 74,
borderRadius: "16px 22px 10px 10px",
background: freightBrand.gradient,
boxShadow: freightBrand.shadowSm,
border: "1px solid rgba(0,0,0,0.08)",
overflow: "hidden",
padding: "8px 10px",
color: "white",
}}
>
{/* cab windows */}
<Box
style={{
position: "absolute",
top: 8,
right: 10,
display: "flex",
gap: 4,
}}
>
<Box style={{ width: 14, height: 12, borderRadius: 3, background: "rgba(255,255,255,0.85)" }} />
<Box style={{ width: 14, height: 12, borderRadius: 3, background: "rgba(255,255,255,0.6)" }} />
</Box>
{/* headlight */}
<Box
style={{
position: "absolute",
bottom: 10,
right: 6,
width: 7,
height: 7,
borderRadius: "50%",
background: "#fde68a",
boxShadow: "0 0 8px 2px rgba(253,230,138,0.8)",
}}
/>
<Group gap={6} wrap="nowrap" align="center">
<TrainFront size={18} />
<Text size="sm" fw={800} style={{ letterSpacing: 0.3 }}>
{code}
</Text>
</Group>
<Text size="9px" mt={2} style={{ opacity: 0.85 }} lineClamp={1}>
{name ?? "Locomotive"}
</Text>
{maxPullWeightTons ? (
<Group gap={3} wrap="nowrap" mt={4} style={{ opacity: 0.95 }}>
<Gauge size={10} />
<Text size="9px" fw={600}>
{maxPullWeightTons}T pull
</Text>
</Group>
) : null}
</Box>
<Wheels count={3} dark />
<Text size="9px" ta="center" c="dimmed" mt={2} fw={700}>
HEAD
</Text>
</Box>
</Tooltip>
);
}
function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
const utilization =
wagon.capacityTons > 0
? Math.min(100, Math.round((wagon.assignedWeightTons / wagon.capacityTons) * 100))
: 0;
const accent = wagon.isEmpty ? "gray" : wagon.isBulk ? "orange" : "cyan";
const accentVar = `var(--mantine-color-${accent}-6)`;
const tooltipLabel = wagon.isEmpty
? `Wagon #${wagon.sequenceNo} · empty / available`
: `Wagon #${wagon.sequenceNo}${wagon.physicalWagonNumber ? ` · ${wagon.physicalWagonNumber}` : ""}\n${
wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : ""
}${
wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : ""
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}`;
// container blocks: one per container number (cap visual at 2 = TEU per wagon)
const blocks = wagon.containerNumbers.slice(0, 2);
return (
<Tooltip label={tooltipLabel} withArrow multiline maw={240} style={{ whiteSpace: "pre-line" }}>
<Box style={{ width: 134, flexShrink: 0 }}>
<Box
style={{
position: "relative",
height: 74,
borderRadius: 12,
background: wagon.isEmpty ? "var(--mantine-color-gray-0)" : "white",
border: wagon.isEmpty
? "1.5px dashed var(--mantine-color-gray-4)"
: "1px solid var(--mantine-color-gray-3)",
boxShadow: wagon.isEmpty ? "none" : "0 2px 8px rgba(15,41,27,0.06)",
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
{/* top accent strip */}
<Box
style={{
height: 4,
background: wagon.isEmpty
? "var(--mantine-color-gray-3)"
: `linear-gradient(90deg, ${accentVar}, var(--mantine-color-${accent}-4))`,
}}
/>
{/* header */}
<Group justify="space-between" px={8} pt={4} wrap="nowrap">
<Text size="10px" fw={800} c="gray.7">
#{wagon.sequenceNo}
</Text>
{wagon.isEmpty ? (
<Text size="9px" c="dimmed" fw={600}>
EMPTY
</Text>
) : (
<Group gap={3} wrap="nowrap">
{wagon.isBulk ? <Fuel size={11} color={accentVar} /> : <ContainerIcon size={11} color={accentVar} />}
<Text size="9px" fw={700} c={`${accent}.7`}>
{wagon.isBulk ? "BULK" : "CONT"}
</Text>
</Group>
)}
</Group>
{/* body */}
<Box style={{ flex: 1, padding: "4px 8px 6px", display: "flex", alignItems: "center" }}>
{wagon.isEmpty ? (
<Group gap={4} justify="center" style={{ width: "100%" }}>
<BoxIcon size={14} color="var(--mantine-color-gray-4)" />
<Text size="9px" c="dimmed">
Available
</Text>
</Group>
) : wagon.isBulk ? (
<Stack gap={3} style={{ width: "100%" }}>
<Box
style={{
height: 18,
borderRadius: 6,
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-5), var(--mantine-color-orange-3))",
}}
/>
</Box>
<Text size="9px" c="dimmed" ta="center">
{wagon.assignedWeightTons}/{wagon.capacityTons}T
</Text>
</Stack>
) : (
<Group gap={4} justify="center" wrap="nowrap" style={{ width: "100%" }}>
{(blocks.length ? blocks : ["—"]).map((cn, i) => (
<Box
key={i}
style={{
flex: 1,
minWidth: 0,
height: 26,
borderRadius: 5,
background: "linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
border: "1px solid var(--mantine-color-cyan-8)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0 3px",
}}
>
<Text size="8px" fw={700} c="white" truncate>
{cn}
</Text>
</Box>
))}
</Group>
)}
</Box>
{/* footer */}
<Box
style={{
borderTop: "1px solid var(--mantine-color-gray-1)",
padding: "2px 8px",
background: wagon.isEmpty ? "transparent" : "var(--mantine-color-gray-0)",
}}
>
<Text size="8px" c="dimmed" truncate>
{wagon.physicalWagonNumber ?? wagon.wagonTypeCode ?? "Wagon"}
</Text>
</Box>
</Box>
<Wheels count={2} />
</Box>
</Tooltip>
);
}
export function TrainCompositionDiagram({
locomotive,
wagons,
freightType,
trainNumber,
totalLengthMeters,
}: {
locomotive?: { code?: string | null; name?: string | null; maxPullWeightTons?: number | null } | null;
wagons: DiagramWagonInput[];
freightType?: string | null;
trainNumber?: string | null;
totalLengthMeters?: number | null;
}) {
const { ref, width } = useElementSize();
const normalized = useMemo(
() => wagons.map((w) => normalizeWagon(w, freightType)),
[wagons, freightType],
);
const stats = useMemo(() => {
const assigned = normalized.filter((w) => !w.isEmpty).length;
const totalWeight = normalized.reduce((s, w) => s + w.assignedWeightTons, 0);
const totalCapacity = normalized.reduce((s, w) => s + w.capacityTons, 0);
return {
total: normalized.length,
assigned,
empty: normalized.length - assigned,
totalWeight: Math.round(totalWeight * 100) / 100,
totalCapacity,
pullUtil:
locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0
? Math.min(100, Math.round((totalWeight / locomotive.maxPullWeightTons) * 100))
: null,
};
}, [normalized, locomotive]);
// cars-per-row from measured width; locomotive counts as one car
const perRow = Math.max(1, Math.floor((width || CAR_WIDTH) / CAR_WIDTH));
const cars = useMemo(
() => [{ kind: "loco" as const }, ...normalized.map((w) => ({ kind: "wagon" as const, w }))],
[normalized],
);
const rows = useMemo(() => chunk(cars, perRow), [cars, perRow]);
if (!locomotive && !wagons.length) return null;
return (
<Paper
radius="lg"
p="lg"
withBorder
style={{
borderColor: "var(--mantine-color-gray-2)",
background:
"linear-gradient(180deg, var(--mantine-color-gray-0) 0%, white 40%)",
}}
>
<Stack gap="md">
{/* Header */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<Group gap="sm" wrap="nowrap">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 38,
height: 38,
borderRadius: 10,
background: freightBrand.gradient,
color: "white",
}}
>
<TrainFront size={20} />
</Box>
<Stack gap={0}>
<Text fw={700}>Train composition</Text>
<Text size="xs" c="dimmed">
{trainNumber ? `${trainNumber} · ` : ""}
{stats.total} wagons · {stats.assigned} loaded · {stats.empty} empty
{totalLengthMeters ? ` · ${totalLengthMeters}m` : ""}
</Text>
</Stack>
</Group>
<Group gap="xs">
<LegendDot color="cyan" label="Container" />
<LegendDot color="orange" label="Bulk" />
<LegendDot color="gray" label="Empty" />
</Group>
</Group>
{/* Locomotive pull gauge */}
{stats.pullUtil != null ? (
<Box
p="xs"
style={{
borderRadius: 10,
background: "var(--mantine-color-green-0)",
border: "1px solid var(--mantine-color-green-1)",
}}
>
<Group justify="space-between" mb={4}>
<Text size="xs" fw={600} c="green.8">
Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T
</Text>
<Text size="xs" fw={700} c={stats.pullUtil > 95 ? "red.7" : "green.7"}>
{stats.pullUtil}%
</Text>
</Group>
<Progress
value={stats.pullUtil}
size="sm"
radius="xl"
color={stats.pullUtil > 95 ? "red" : stats.pullUtil > 80 ? "yellow" : "green"}
/>
</Box>
) : null}
{/* The train */}
<Box ref={ref} style={{ width: "100%" }}>
<Stack gap={0}>
{rows.map((row, rowIndex) => {
const reversed = rowIndex % 2 === 1;
const isLast = rowIndex === rows.length - 1;
// side where this row's track ends / turns down to the next row
const turnSide: "left" | "right" = rowIndex % 2 === 0 ? "right" : "left";
return (
<Box key={rowIndex}>
<Box style={{ position: "relative", paddingBottom: 6 }}>
{/* rail under the row */}
<Box
style={{
position: "absolute",
left: 4,
right: 4,
bottom: 8,
height: 4,
borderRadius: 2,
background:
"repeating-linear-gradient(90deg, var(--mantine-color-gray-5) 0 10px, var(--mantine-color-gray-3) 10px 16px)",
}}
/>
<Group
gap={0}
wrap="nowrap"
justify="flex-start"
style={{
flexDirection: reversed ? "row-reverse" : "row",
position: "relative",
}}
>
{row.map((car, carIndex) => (
<Group key={carIndex} gap={0} wrap="nowrap" style={{ flexDirection: reversed ? "row-reverse" : "row" }}>
{carIndex > 0 ? <Coupler /> : null}
{car.kind === "loco" ? (
locomotive ? (
<LocomotiveCar
code={locomotive.code ?? "LOCO"}
name={locomotive.name}
maxPullWeightTons={locomotive.maxPullWeightTons}
/>
) : null
) : (
<WagonCar wagon={car.w} />
)}
</Group>
))}
</Group>
</Box>
{/* serpentine turn connector to the next row */}
{!isLast ? (
<Box style={{ position: "relative", height: 16 }}>
<Box
style={{
position: "absolute",
top: -10,
height: 26,
width: 22,
borderBottom: "4px solid var(--mantine-color-gray-4)",
...(turnSide === "right"
? {
right: 4,
borderRight: "4px solid var(--mantine-color-gray-4)",
borderBottomRightRadius: 16,
}
: {
left: 4,
borderLeft: "4px solid var(--mantine-color-gray-4)",
borderBottomLeftRadius: 16,
}),
}}
/>
</Box>
) : null}
</Box>
);
})}
</Stack>
</Box>
</Stack>
</Paper>
);
}
function LegendDot({ color, label }: { color: string; label: string }) {
return (
<Group gap={5} wrap="nowrap">
<Box
style={{
width: 10,
height: 10,
borderRadius: 3,
background:
color === "gray"
? "var(--mantine-color-gray-2)"
: `var(--mantine-color-${color}-5)`,
border: color === "gray" ? "1.5px dashed var(--mantine-color-gray-4)" : "none",
}}
/>
<Text size="xs" c="dimmed">
{label}
</Text>
</Group>
);
}

View File

@@ -2,7 +2,7 @@ import { Badge, Card, Group, Progress, SimpleGrid, Stack, Text, ThemeIcon } from
import { Box, Package } from "lucide-react";
import type { TrainScheduleWagonAllocation, WagonPlanRow } from "@/types/trainScheduling";
type WagonSlot = WagonPlanRow | {
type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
sequenceNo: number;
capacityTons: number;
assignedWeightTons: number;
@@ -107,14 +107,11 @@ export function WagonPlanGrid({
</ThemeIcon>
<Stack gap={0}>
<Text fw={600} size="sm">
Wagon #{seq}
{wagon.physicalWagonNumber ?? `Wagon #${seq}`}
</Text>
<Text size="xs" c="dimmed">
{[typeCode, `Pos ${seq}`].filter(Boolean).join(" · ")}
</Text>
{typeCode ? (
<Text size="xs" c="dimmed">
{typeCode}
{wagon.physicalWagonNumber ? ` · ${wagon.physicalWagonNumber}` : ""}
</Text>
) : null}
</Stack>
</Group>
<Badge variant="light" size="sm" color={loadTypeColor(label, freightType)}>

View File

@@ -0,0 +1,220 @@
import type { ReactNode } from "react";
import { Badge, Box, Collapse, Group, Stack, Text, UnstyledButton } from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import { Check, ChevronDown, Lock } from "lucide-react";
import { freightBrand } from "@/theme/freight-brand";
export type WorkflowStepState = "complete" | "active" | "upcoming";
/**
* A single collapsible step in the scheduling workflow. Steps are stacked
* inside <WorkflowRail> which paints the continuous connector behind the
* status circles.
*/
export function WorkflowStep({
index,
icon: Icon,
title,
subtitle,
state,
open,
onToggle,
rightSlot,
locked = false,
children,
}: {
index: number;
icon: LucideIcon;
title: string;
subtitle?: string;
state: WorkflowStepState;
open: boolean;
onToggle: () => void;
rightSlot?: ReactNode;
locked?: boolean;
children: ReactNode;
}) {
const isComplete = state === "complete";
const isActive = state === "active";
const circle = (() => {
if (isComplete) {
return {
bg: freightBrand.primary,
color: "white",
border: freightBrand.primary,
shadow: `0 4px 10px ${freightBrand.ring}`,
};
}
if (isActive) {
return {
bg: "white",
color: freightBrand.primary,
border: freightBrand.primary,
shadow: `0 0 0 4px ${freightBrand.ring}`,
};
}
return {
bg: "var(--mantine-color-gray-1)",
color: "var(--mantine-color-gray-5)",
border: "var(--mantine-color-gray-3)",
shadow: "none",
};
})();
return (
<Group gap="md" align="stretch" wrap="nowrap">
{/* Status circle (sits above the rail) */}
<Box
style={{
width: 40,
flexShrink: 0,
display: "flex",
justifyContent: "center",
}}
>
<Box
style={{
width: 40,
height: 40,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: circle.bg,
color: circle.color,
border: `2px solid ${circle.border}`,
boxShadow: circle.shadow,
fontWeight: 700,
fontSize: 15,
transition: "all 150ms ease",
position: "relative",
zIndex: 1,
}}
>
{isComplete ? <Check size={20} /> : <Icon size={18} />}
</Box>
</Box>
{/* Step card */}
<Box
style={{
flex: 1,
minWidth: 0,
marginBottom: 4,
borderRadius: 16,
border: `1px solid ${
isActive ? freightBrand.mutedBorder : "var(--mantine-color-gray-2)"
}`,
background: isActive ? freightBrand.mutedBg : "white",
boxShadow: isActive ? `0 6px 20px ${freightBrand.ring}` : "none",
overflow: "hidden",
transition: "all 150ms ease",
}}
>
<UnstyledButton
onClick={onToggle}
style={{ display: "block", width: "100%", padding: "14px 18px" }}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text
size="xs"
fw={700}
c={isComplete || isActive ? "green.7" : "dimmed"}
style={{ letterSpacing: 0.6 }}
>
STEP {index + 1}
</Text>
{isComplete ? (
<Badge size="xs" variant="light" color="green" radius="sm">
Done
</Badge>
) : null}
</Group>
<Text fw={700} size="md" lh={1.2} truncate>
{title}
</Text>
{subtitle ? (
<Text size="xs" c="dimmed" truncate>
{subtitle}
</Text>
) : null}
</Stack>
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
{rightSlot}
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: 8,
background: open
? "var(--mantine-color-green-0)"
: "var(--mantine-color-gray-1)",
color: open
? "var(--mantine-color-green-7)"
: "var(--mantine-color-gray-6)",
}}
>
{locked ? (
<Lock size={14} />
) : (
<ChevronDown
size={16}
style={{
transform: open ? "rotate(180deg)" : "none",
transition: "transform 150ms ease",
}}
/>
)}
</Box>
</Group>
</Group>
</UnstyledButton>
<Collapse expanded={open}>
<Box
px="lg"
pb="lg"
pt={4}
style={{ borderTop: "1px solid var(--mantine-color-gray-1)" }}
>
<Box pt="md">{children}</Box>
</Box>
</Collapse>
</Box>
</Group>
);
}
/**
* Wraps a list of <WorkflowStep> and paints the continuous vertical rail that
* connects the status circles.
*/
export function WorkflowRail({ children }: { children: ReactNode }) {
return (
<Box style={{ position: "relative" }}>
{/* connector rail behind the circles (circle is 40px → center at 20) */}
<Box
style={{
position: "absolute",
left: 19,
top: 24,
bottom: 24,
width: 2,
background:
"linear-gradient(180deg, var(--mantine-color-green-3) 0%, var(--mantine-color-gray-3) 100%)",
borderRadius: 2,
pointerEvents: "none",
}}
/>
<Stack gap="md">{children}</Stack>
</Box>
);
}

View File

@@ -105,6 +105,22 @@ describe('containerPlacement.util', () => {
expect(placements[2]?.sequenceNo).toBe(2);
});
it('never shares a wagon between a 40ft and a 20ft when the 40ft comes first', () => {
// Regression: a 40ft (2 TEU) must occupy its own wagon and never pair with a 20ft.
const units40 = makeUnits('40GP', 40, 1);
const units20 = makeUnits('20GP', 20, 2);
const units = [...units40, ...units20];
const slots = [1, 2, 3, 4];
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(3);
// 40ft alone on slot 1
expect(placements[0]?.sequenceNo).toBe(1);
// both 20ft together on slot 2 — NOT on slot 1 with the 40ft
expect(placements[1]?.sequenceNo).toBe(2);
expect(placements[2]?.sequenceNo).toBe(2);
});
it('falls back to last slot when running out of slots', () => {
const units = makeUnits('20GP', 20, 6);
const slots = [1, 2]; // Only 2 slots available

View File

@@ -68,15 +68,19 @@ export function autoFillPlacements(
if (!units.length || !containerSlots.length) return [];
const placements: ContainerPlacement[] = [];
// Pack by TEU: a wagon holds 2 TEU (one 40ft, or two 20ft). This MUST mirror the
// backend allocation (allocateContainersToSlots) so a placement's sequenceNo lands on
// the same wagon the booking is allocated to — and a 40ft never shares with a 20ft.
const MAX_TEU_PER_WAGON = 2;
let currentSlotIndex = 0;
let unitsInCurrentSlot = 0;
let teuInCurrentSlot = 0;
for (const unit of units) {
const perWagon = unit.containersPerWagon ?? (unit.sizeFt && unit.sizeFt >= 40 ? 1 : 2);
const teu = unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1);
if (unitsInCurrentSlot >= perWagon) {
if (teuInCurrentSlot > 0 && teuInCurrentSlot + teu > MAX_TEU_PER_WAGON) {
currentSlotIndex += 1;
unitsInCurrentSlot = 0;
teuInCurrentSlot = 0;
}
const sequenceNo =
@@ -88,9 +92,10 @@ export function autoFillPlacements(
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo,
containerNumber: unit.containerNumber ?? undefined,
});
unitsInCurrentSlot += 1;
teuInCurrentSlot += teu;
}
return placements;

View File

@@ -0,0 +1,252 @@
import type { ReactNode } from "react";
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import { MapPin } from "lucide-react";
import { freightBrand } from "@/theme/freight-brand";
/**
* Shared visual building blocks for the Train Scheduling V2 surfaces.
* Everything keys off the freight brand green so the list + detail pages
* read as one cohesive, premium product.
*/
export const scheduleBrand = {
/** Deep green → emerald hero wash used across scheduling surfaces. */
heroGradient: `linear-gradient(135deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 48%, ${freightBrand.primaryLight} 120%)`,
/** Soft tinted surface for cards on white backgrounds. */
softSurface: `linear-gradient(135deg, ${freightBrand.mutedBg} 0%, #ffffff 60%, var(--mantine-color-gray-0) 100%)`,
ring: freightBrand.ring,
shadow: freightBrand.shadow,
shadowSm: freightBrand.shadowSm,
mutedBorder: freightBrand.mutedBorder,
} as const;
const STATUS_META: Record<
string,
{ color: string; dot: string; label?: string }
> = {
DRAFT: { color: "gray", dot: "var(--mantine-color-gray-5)" },
SCHEDULED: { color: "green", dot: freightBrand.primary },
DISPATCHED: { color: "teal", dot: "var(--mantine-color-teal-6)" },
ARRIVED: { color: "blue", dot: "var(--mantine-color-blue-6)" },
CANCELLED: { color: "red", dot: "var(--mantine-color-red-6)" },
};
export function statusMeta(status: string) {
return STATUS_META[status] ?? STATUS_META.DRAFT;
}
/**
* Status pill with a leading status dot — clearer at a glance than a plain
* badge and consistent everywhere a schedule status appears.
*/
export function StatusPill({
status,
size = "sm",
}: {
status: string;
size?: "sm" | "md";
}) {
const meta = statusMeta(status);
const isMd = size === "md";
return (
<Group
gap={isMd ? 8 : 6}
wrap="nowrap"
style={{
display: "inline-flex",
padding: isMd ? "5px 12px" : "3px 10px",
borderRadius: 999,
background: `var(--mantine-color-${meta.color}-0)`,
border: `1px solid var(--mantine-color-${meta.color}-2)`,
}}
>
<Box
w={isMd ? 8 : 7}
h={isMd ? 8 : 7}
style={{
borderRadius: 999,
background: meta.dot,
boxShadow: `0 0 0 3px var(--mantine-color-${meta.color}-1)`,
flexShrink: 0,
}}
/>
<Text
size={isMd ? "sm" : "xs"}
fw={600}
c={`${meta.color}.8`}
style={{ letterSpacing: 0.2, lineHeight: 1 }}
>
{meta.label ?? status}
</Text>
</Group>
);
}
/**
* Compact metric tile used in hero strips. `onDark` flips colors for use on
* the green hero gradient.
*/
export function StatTile({
icon: Icon,
label,
value,
hint,
onDark = false,
accent = freightBrand.primary,
}: {
icon?: LucideIcon;
label: string;
value: ReactNode;
hint?: ReactNode;
onDark?: boolean;
accent?: string;
}) {
return (
<Paper
p="md"
radius="lg"
style={
onDark
? {
background: "rgba(255,255,255,0.12)",
border: "1px solid rgba(255,255,255,0.18)",
backdropFilter: "blur(6px)",
}
: {
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
}
}
>
<Group gap="sm" wrap="nowrap" align="flex-start">
{Icon ? (
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 36,
height: 36,
borderRadius: 10,
flexShrink: 0,
background: onDark ? "rgba(255,255,255,0.16)" : `${accent}1a`,
color: onDark ? "white" : accent,
}}
>
<Icon size={18} />
</Box>
) : null}
<Stack gap={2} style={{ minWidth: 0 }}>
<Text
size="xs"
fw={600}
tt="uppercase"
style={{ letterSpacing: 0.4 }}
c={onDark ? "rgba(255,255,255,0.75)" : "dimmed"}
>
{label}
</Text>
<Text
fw={700}
size="lg"
lh={1.1}
c={onDark ? "white" : undefined}
style={{ whiteSpace: "nowrap" }}
>
{value}
</Text>
{hint ? (
<Text size="xs" c={onDark ? "rgba(255,255,255,0.7)" : "dimmed"}>
{hint}
</Text>
) : null}
</Stack>
</Group>
</Paper>
);
}
/**
* Origin → destination corridor visual: two anchored stops joined by a rail
* line. `variant="compact"` is for dense table rows; `default` for cards.
*/
export function RouteCorridor({
origin,
destination,
variant = "default",
onDark = false,
}: {
origin?: string | null;
destination?: string | null;
variant?: "default" | "compact";
onDark?: boolean;
}) {
const compact = variant === "compact";
const dim = onDark ? "rgba(255,255,255,0.7)" : "var(--mantine-color-gray-5)";
const strong = onDark ? "white" : "var(--mantine-color-gray-8)";
const lineColor = onDark ? "rgba(255,255,255,0.4)" : "var(--mantine-color-gray-3)";
const accent = onDark ? "white" : freightBrand.primary;
return (
<Group gap={compact ? 6 : 8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
<Box
w={compact ? 7 : 9}
h={compact ? 7 : 9}
style={{
borderRadius: 999,
flexShrink: 0,
border: `2px solid ${accent}`,
background: onDark ? "transparent" : "white",
}}
/>
<Text
size={compact ? "sm" : "sm"}
fw={600}
c={strong}
style={{ whiteSpace: "nowrap" }}
>
{origin ?? "—"}
</Text>
<Box
style={{
flex: 1,
minWidth: compact ? 16 : 24,
height: 0,
borderTop: `2px dashed ${lineColor}`,
position: "relative",
}}
>
<MapPin
size={compact ? 11 : 13}
color={dim}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: onDark ? "transparent" : "white",
}}
/>
</Box>
<Text
size={compact ? "sm" : "sm"}
fw={600}
c={strong}
style={{ whiteSpace: "nowrap" }}
>
{destination ?? "—"}
</Text>
<Box
w={compact ? 7 : 9}
h={compact ? 7 : 9}
style={{
borderRadius: 999,
flexShrink: 0,
background: accent,
}}
/>
</Group>
);
}

View File

@@ -2,7 +2,7 @@ import type { MantineTheme } from "@mantine/core";
export const schedulingWorkflow = {
stepper: {
color: "teal" as const,
color: "green" as const,
iconSize: 32,
size: "sm" as const,
},
@@ -12,11 +12,11 @@ export const schedulingWorkflow = {
withBorder: true,
},
heroGradient: (theme: MantineTheme) =>
`linear-gradient(135deg, ${theme.colors.teal[0]} 0%, ${theme.white} 55%, ${theme.colors.gray[0]} 100%)`,
`linear-gradient(135deg, ${theme.colors.green[0]} 0%, ${theme.white} 55%, ${theme.colors.gray[0]} 100%)`,
workflowGradient: (theme: MantineTheme) =>
`linear-gradient(180deg, ${theme.white} 0%, ${theme.colors.gray[0]} 100%)`,
accentColor: "teal" as const,
successColor: "teal" as const,
accentColor: "green" as const,
successColor: "green" as const,
warningColor: "yellow" as const,
};