mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 09:42:53 +00:00
- 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.
284 lines
7.1 KiB
TypeScript
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>
|
|
);
|
|
}
|