Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/batchVisuals.tsx
Marshal 56de90892d Add booking window management and locomotive scheduling features
- Implemented migration to release stuck assigned locomotives.
- Added schedule window phases to train schedules.
- Created booking batch offers table for partial capacity bookings.
- Developed BookingSplitService to handle partial booking offers and splits.
- Introduced BookingWindowService to manage booking window lifecycle and transitions.
- Added BookingBatchOffer entity to represent offers made during booking splits.
- Enhanced locomotive options with warnings for scheduling.
- Created UpcomingWindowsSection component to display upcoming booking windows.
2026-07-03 03:36:06 +00:00

284 lines
7.1 KiB
TypeScript

import type { ReactNode } from "react";
import { Box, Group, Progress, Text, Tooltip } from "@mantine/core";
import type { BookingWindowPhase } from "@/types/trainScheduling";
import "./batchVisuals.css";
/**
* Shared visual building blocks for the batch board surfaces (list + detail).
* Everything keys off the same booking-pipeline color language so the two
* pages read as one clean, professional product on a white surface.
*/
export type BatchCounts = {
allocated: number;
selectedForBatch: number;
ready: number;
waiting: number;
pendingContract: number;
expired: number;
};
export const PIPELINE_STAGES: ReadonlyArray<{
key: keyof BatchCounts;
label: string;
color: string;
hint: string;
}> = [
{
key: "allocated",
label: "Allocated",
color: "edr-green",
hint: "Assigned to wagons on the train",
},
{
key: "selectedForBatch",
label: "Selected",
color: "orange",
hint: "Picked by batch — customer notified to pay",
},
{
key: "ready",
label: "Ready",
color: "teal",
hint: "Contract signed — waiting for batch pick",
},
{
key: "waiting",
label: "Waiting",
color: "blue",
hint: "Paid — waiting for a slot",
},
{
key: "pendingContract",
label: "Pending contract",
color: "gray",
hint: "Contract not signed yet",
},
{
key: "expired",
label: "Expired",
color: "red",
hint: "Payment deadline missed",
},
];
export function totalBookingCount(counts: BatchCounts): number {
return PIPELINE_STAGES.reduce((sum, stage) => sum + counts[stage.key], 0);
}
/**
* Stacked booking-pipeline bar: one colored segment per batch state, with an
* optional dot legend underneath. Reads as a single glanceable funnel.
*/
export function BookingPipeline({
counts,
size = 10,
showLegend = true,
}: {
counts: BatchCounts;
size?: number;
showLegend?: boolean;
}) {
const total = totalBookingCount(counts);
const stages = PIPELINE_STAGES.filter((s) => counts[s.key] > 0);
if (!total) {
return (
<Box>
<Progress value={0} size={size} radius="xl" />
<Text size="xs" c="dimmed" mt={6}>
No bookings yet
</Text>
</Box>
);
}
return (
<Box>
<Progress.Root size={size} radius="xl">
{stages.map((stage) => (
<Tooltip
key={stage.key}
label={`${counts[stage.key]} ${stage.label.toLowerCase()}${stage.hint}`}
withArrow
>
<Progress.Section
value={(counts[stage.key] / total) * 100}
color={stage.color}
/>
</Tooltip>
))}
</Progress.Root>
{showLegend ? (
<Group gap={12} mt={9} wrap="wrap">
{stages.map((stage) => (
<Group key={stage.key} gap={5} wrap="nowrap">
<Box
w={8}
h={8}
style={{
borderRadius: 999,
background: `var(--mantine-color-${stage.color}-6)`,
flexShrink: 0,
}}
/>
<Text size="xs" c="dimmed">
<Text component="span" size="xs" fw={700} c="dark.4">
{counts[stage.key]}
</Text>{" "}
{stage.label.toLowerCase()}
</Text>
</Group>
))}
</Group>
) : null}
</Box>
);
}
const WINDOW_META: Record<string, { color: string; label: string; pulse: boolean }> = {
OPEN: { color: "edr-green", label: "Window open", pulse: true },
FULL: { color: "orange", label: "Full", pulse: false },
CLOSED: { color: "gray", label: "Closed", pulse: false },
};
/**
* Booking-window status pill with a status dot (pulsing while OPEN), styled for
* a clean white surface.
*/
export function WindowStatusPill({ status }: { status: string }) {
const meta = WINDOW_META[status] ?? { color: "gray", label: status, pulse: false };
return (
<Group
gap={6}
wrap="nowrap"
style={{
display: "inline-flex",
padding: "4px 11px",
borderRadius: 999,
background: `var(--mantine-color-${meta.color}-0)`,
border: `1px solid var(--mantine-color-${meta.color}-2)`,
}}
>
<Box
w={7}
h={7}
className={meta.pulse ? "bb-pulse-dot" : undefined}
style={{
borderRadius: 999,
flexShrink: 0,
background: `var(--mantine-color-${meta.color}-6)`,
}}
/>
<Text
size="xs"
fw={700}
c={`${meta.color}.8`}
style={{ letterSpacing: 0.3, lineHeight: 1, whiteSpace: "nowrap" }}
>
{meta.label}
</Text>
</Group>
);
}
/**
* Small neutral info chip used in the page headers (date, loco, status, …).
* Clean light surface that reads well on white.
*/
export function HeroChip({
icon,
children,
}: {
icon?: ReactNode;
children: ReactNode;
}) {
return (
<Group
gap={6}
wrap="nowrap"
style={{
display: "inline-flex",
padding: "4px 10px",
borderRadius: 8,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
color: "var(--mantine-color-gray-7)",
}}
>
{icon}
<Text size="xs" fw={600} c="gray.7" style={{ whiteSpace: "nowrap" }}>
{children}
</Text>
</Group>
);
}
const PHASE_META: Record<
BookingWindowPhase,
{ color: string; label: string; pulse: boolean }
> = {
PRE_WINDOW: { color: "gray", label: "Pre-window", pulse: false },
OPEN: { color: "edr-green", label: "Booking open", pulse: true },
DOC_REVIEW: { color: "yellow", label: "Doc review", pulse: true },
PAYMENT: { color: "blue", label: "Payment", pulse: true },
CLOSED_FOR_DAY: { color: "dark", label: "Closed for day", pulse: false },
DONE: { color: "dark", label: "Done", pulse: false },
};
/**
* Import booking-cycle phase pill (OPEN → DOC_REVIEW → PAYMENT → …) with an
* optional cycle number. Same visual language as `WindowStatusPill`.
*/
export function WindowPhasePill({
phase,
cycleNo,
size = "md",
}: {
phase: BookingWindowPhase;
cycleNo?: number;
size?: "sm" | "md";
}) {
const meta = PHASE_META[phase] ?? {
color: "gray",
label: phase,
pulse: false,
};
const compact = size === "sm";
return (
<Group
gap={6}
wrap="nowrap"
style={{
display: "inline-flex",
padding: compact ? "2px 8px" : "4px 11px",
borderRadius: 999,
background: `var(--mantine-color-${meta.color}-0)`,
border: `1px solid var(--mantine-color-${meta.color}-2)`,
}}
>
<Box
w={compact ? 6 : 7}
h={compact ? 6 : 7}
className={meta.pulse ? "bb-pulse-dot" : undefined}
style={{
borderRadius: 999,
flexShrink: 0,
background: `var(--mantine-color-${meta.color}-6)`,
}}
/>
<Text
size="xs"
fw={700}
c={`${meta.color}.8`}
style={{ letterSpacing: 0.3, lineHeight: 1, whiteSpace: "nowrap" }}
>
{meta.label}
{cycleNo && cycleNo > 1 ? ` · cycle ${cycleNo}` : ""}
</Text>
</Group>
);
}