mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
feat(train-scheduling): implement day-level booking pool
- Added `unplaced` method in `BookingNotifierService` to log warnings for bookings that cannot be placed on any train. - Introduced `getAvailableDays` method in `TrainSchedulingService` to retrieve distinct days with open departures for a given route. - Created `AvailableDaysQueryDto` for querying available days based on origin and destination yards. - Updated `TrainSchedulingController` to expose an endpoint for available days. - Modified frontend components to support day-level booking, allowing customers to select only a day without pinning to a specific train. - Removed references to train schedules in booking forms and review steps, emphasizing day selection. - Added a database migration to create an index for efficient querying of bookings by route and day.
This commit is contained in:
@@ -302,8 +302,9 @@ export default function NewBookingPage() {
|
||||
const allLinesValid = lines.length > 0 && lines.every(lineValid);
|
||||
const sameYard = Boolean(originYardId && originYardId === destinationYardId);
|
||||
|
||||
const scheduleSatisfied =
|
||||
hasBookableSchedules ? Boolean(trainScheduleId) : Boolean(scheduledDate);
|
||||
// Day-level pool: a shipment DAY is enough to proceed. Pinning a specific train
|
||||
// (trainScheduleId) is an optional staff override — the batch engine otherwise
|
||||
// assigns the train. A selected schedule implies its day, so either satisfies.
|
||||
const departureSatisfied = Boolean(selectedSchedule) || Boolean(scheduledDate);
|
||||
|
||||
const canSubmit =
|
||||
@@ -311,7 +312,6 @@ export default function NewBookingPage() {
|
||||
Boolean(destinationYardId) &&
|
||||
!sameYard &&
|
||||
Boolean(tradeDirection) &&
|
||||
scheduleSatisfied &&
|
||||
Boolean(serviceTypeId) &&
|
||||
departureSatisfied &&
|
||||
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
|
||||
@@ -473,25 +473,25 @@ export default function NewBookingPage() {
|
||||
</Group>
|
||||
{hasBookableSchedules ? (
|
||||
<Select
|
||||
label="Train schedule"
|
||||
label="Train schedule (optional)"
|
||||
placeholder={
|
||||
originYardId && destinationYardId
|
||||
? "Select an open schedule on this route"
|
||||
? "Leave blank to let the batch engine assign a train"
|
||||
: "Pick origin & destination first"
|
||||
}
|
||||
data={scheduleOptions}
|
||||
value={trainScheduleId}
|
||||
onChange={setTrainScheduleId}
|
||||
searchable
|
||||
required
|
||||
clearable
|
||||
disabled={!originYardId || !destinationYardId || schedulesLoading}
|
||||
nothingFoundMessage="No open schedules on this route"
|
||||
description="The booking will be batched against this schedule once its contract is signed."
|
||||
description="Optional: pin to a specific train. Otherwise the booking joins the day pool and the engine assigns a train by priority."
|
||||
/>
|
||||
) : originYardId && destinationYardId ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No open train schedule on this route — set a preferred departure below. Staff can
|
||||
link a schedule later.
|
||||
No open train schedule on this route — set a preferred departure below. The batch
|
||||
engine assigns a train on that day, or staff can pin one later.
|
||||
</Text>
|
||||
) : null}
|
||||
<Group grow align="flex-end">
|
||||
|
||||
@@ -100,6 +100,7 @@ export const URL_CONSTANTS = {
|
||||
|
||||
TRAIN_SCHEDULING: {
|
||||
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
||||
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
|
||||
},
|
||||
|
||||
PAYMENTS: {
|
||||
|
||||
@@ -143,7 +143,6 @@ function mapBookingToFormValues(
|
||||
scheduledDate: booking.scheduledDate
|
||||
? new Date(booking.scheduledDate).toISOString().slice(0, 10)
|
||||
: "",
|
||||
trainScheduleId: (booking as { trainScheduleId?: string }).trainScheduleId ?? "",
|
||||
notes: "",
|
||||
containers: [],
|
||||
} as BookingFormInputValues;
|
||||
@@ -410,7 +409,8 @@ export default function EditBookingPage() {
|
||||
scheduledDate: data.scheduledDate
|
||||
? new Date(data.scheduledDate).toISOString()
|
||||
: undefined,
|
||||
trainScheduleId: data.trainScheduleId || undefined,
|
||||
// Day-level pool: the customer edits only the day; the engine assigns the
|
||||
// train, so trainScheduleId is not sent.
|
||||
contractType:
|
||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||
serviceTypeId: data.serviceTypeId,
|
||||
|
||||
@@ -279,7 +279,8 @@ export default function NewBookingPage() {
|
||||
destinationYardId: data.destinationYard,
|
||||
tradeDirection: direction!,
|
||||
cargoTypeId,
|
||||
trainScheduleId: data.trainScheduleId,
|
||||
// Day-level pool: the customer picks only a day (scheduledDate); the batch
|
||||
// engine assigns the train, so no trainScheduleId is sent.
|
||||
cargoTotalWeightVgm: totalWeight,
|
||||
isHazardous: data.isHazardous,
|
||||
allowConsolidation: data.consolidationEnabled,
|
||||
|
||||
@@ -113,8 +113,9 @@ export const bookingFormSchema = z
|
||||
originYard: z.string().min(1, "Select an origin yard."),
|
||||
destinationYard: z.string().min(1, "Select a destination yard."),
|
||||
shippingLine: z.string(),
|
||||
// Day-level pool: the customer selects only a DAY. The batch engine assigns
|
||||
// the specific train later, so no trainScheduleId is collected here.
|
||||
scheduledDate: z.string().min(1, "Select a shipment date."),
|
||||
trainScheduleId: z.string().min(1, "Select a shipment date."),
|
||||
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
||||
cargoWeight: z.string(),
|
||||
cargoTypePath: z.array(z.string()).default([]),
|
||||
@@ -236,7 +237,6 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
||||
destinationYard: "",
|
||||
shippingLine: "",
|
||||
scheduledDate: "",
|
||||
trainScheduleId: "",
|
||||
cargoWeight: "",
|
||||
cargoTypePath: [],
|
||||
cargoFreeText: "",
|
||||
@@ -272,7 +272,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
||||
"containers",
|
||||
"consolidationEnabled",
|
||||
],
|
||||
5: ["scheduledDate", "trainScheduleId"],
|
||||
5: ["scheduledDate"],
|
||||
6: ["documents"],
|
||||
7: ["notes"],
|
||||
};
|
||||
|
||||
@@ -5,10 +5,9 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
useMantineTheme
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -46,17 +45,15 @@ interface DayData {
|
||||
isToday: boolean;
|
||||
isCurrentMonth: boolean;
|
||||
isSelectedDate: boolean;
|
||||
schedules: Freight.BookableScheduleItem[];
|
||||
hasSchedule: boolean;
|
||||
/** True when the route has at least one departure on this day. */
|
||||
hasDeparture: boolean;
|
||||
}
|
||||
|
||||
export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
const theme = useMantineTheme();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [selectedDayForModal, setSelectedDayForModal] = useState<DayData | null>(null);
|
||||
|
||||
const selectedDate = form.watch("scheduledDate");
|
||||
const selectedScheduleId = form.watch("trainScheduleId");
|
||||
const originYardId = form.watch("originYard");
|
||||
const destinationYardId = form.watch("destinationYard");
|
||||
const cargoType = form.watch("cargoType");
|
||||
@@ -75,41 +72,21 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
[referenceData, destinationYardId],
|
||||
);
|
||||
|
||||
const { data: bookableSchedules } = useQuery(
|
||||
api.bookings.getBookableSchedules.queryOptions({
|
||||
// Day-level pool: the customer picks a DAY, not a train. We only fetch which
|
||||
// days have a departure — no capacity, no per-train detail. The batch engine
|
||||
// assigns the train later, distributing the day's pool by priority.
|
||||
const { data: availableDays } = useQuery(
|
||||
api.bookings.getAvailableDays.queryOptions({
|
||||
input: { originYardId, destinationYardId },
|
||||
enabled: !!originYardId && !!destinationYardId,
|
||||
}),
|
||||
);
|
||||
|
||||
// Group all schedules per date — multiple departures per day are allowed.
|
||||
// scheduleDate comes back as a full ISO timestamp; slice to "yyyy-MM-dd" to
|
||||
// match the format used by the calendar day keys.
|
||||
const schedulesByDate = useMemo(() => {
|
||||
const map = new Map<string, Freight.BookableScheduleItem[]>();
|
||||
if (bookableSchedules) {
|
||||
for (const s of bookableSchedules) {
|
||||
const dateKey = s.scheduleDate.slice(0, 10);
|
||||
const existing = map.get(dateKey) ?? [];
|
||||
map.set(dateKey, [...existing, s]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [bookableSchedules]);
|
||||
|
||||
const selectedSchedule = useMemo(
|
||||
() => bookableSchedules?.find((s) => s.id === selectedScheduleId),
|
||||
[bookableSchedules, selectedScheduleId],
|
||||
const departureDays = useMemo(
|
||||
() => new Set(availableDays ?? []),
|
||||
[availableDays],
|
||||
);
|
||||
|
||||
const availableCount = useMemo(() => {
|
||||
let count = 0;
|
||||
schedulesByDate.forEach((schedules) => {
|
||||
if (schedules.some((s) => s.remainingWagons > 0)) count++;
|
||||
});
|
||||
return count;
|
||||
}, [schedulesByDate]);
|
||||
|
||||
const days = useMemo((): DayData[] => {
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
@@ -118,19 +95,21 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
|
||||
return eachDayOfInterval({ start: calStart, end: calEnd }).map((date) => {
|
||||
const dateString = format(date, "yyyy-MM-dd");
|
||||
const schedules = schedulesByDate.get(dateString) ?? [];
|
||||
|
||||
return {
|
||||
day: date.getDate(),
|
||||
dateString,
|
||||
isToday: isToday(date),
|
||||
isCurrentMonth: isSameMonth(date, currentDate),
|
||||
isSelectedDate: selectedDate === dateString,
|
||||
schedules,
|
||||
hasSchedule: schedules.length > 0,
|
||||
hasDeparture: departureDays.has(dateString),
|
||||
};
|
||||
});
|
||||
}, [currentDate, schedulesByDate, selectedDate]);
|
||||
}, [currentDate, departureDays, selectedDate]);
|
||||
|
||||
const availableCount = useMemo(
|
||||
() => days.filter((d) => d.isCurrentMonth && d.hasDeparture).length,
|
||||
[days],
|
||||
);
|
||||
|
||||
const cargoSummary = useMemo(() => {
|
||||
if (!cargoType) return "Not selected";
|
||||
@@ -151,28 +130,9 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
const weeksCount = Math.ceil(days.length / 7);
|
||||
|
||||
const handleDayClick = (day: DayData) => {
|
||||
if (day.schedules.length > 1) {
|
||||
setSelectedDayForModal(day);
|
||||
} else if (day.schedules.length === 1) {
|
||||
form.setValue("scheduledDate", day.dateString, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue("trainScheduleId", day.schedules[0].id, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectScheduleFromModal = (scheduleId: string) => {
|
||||
if (selectedDayForModal) {
|
||||
form.setValue("scheduledDate", selectedDayForModal.dateString, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue("trainScheduleId", scheduleId, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
setSelectedDayForModal(null);
|
||||
}
|
||||
if (!day.hasDeparture) return;
|
||||
// Record only the day — no specific train is chosen.
|
||||
form.setValue("scheduledDate", day.dateString, { shouldValidate: true });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -229,7 +189,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
<Stack gap={14} px={24} py={18}>
|
||||
<Text fz={13} fw={600} c="edr-text.0">
|
||||
{originYardId && destinationYardId
|
||||
? `${availableCount} available departure${availableCount !== 1 ? "s" : ""} in ${format(currentDate, "MMMM")} — pick one to continue`
|
||||
? `${availableCount} day${availableCount !== 1 ? "s" : ""} with a departure in ${format(currentDate, "MMMM")} — pick one to continue`
|
||||
: "Select origin and destination to see available departures"}
|
||||
</Text>
|
||||
|
||||
@@ -268,11 +228,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
}}
|
||||
>
|
||||
{days.slice(wi * 7, wi * 7 + 7).map((d, di) => (
|
||||
<DayCell
|
||||
key={di}
|
||||
day={d}
|
||||
onDayClick={handleDayClick}
|
||||
/>
|
||||
<DayCell key={di} day={d} onDayClick={handleDayClick} />
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
@@ -311,7 +267,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
value={cargoSummary}
|
||||
/>
|
||||
|
||||
{selectedSchedule && selectedDate && (
|
||||
{selectedDate && (
|
||||
<Box
|
||||
p={14}
|
||||
style={{
|
||||
@@ -328,28 +284,15 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
c="edr-green.7"
|
||||
style={{ letterSpacing: "0.08em" }}
|
||||
>
|
||||
SELECTED DEPARTURE
|
||||
SELECTED DAY
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fw={800} fz={16} c="edr-text.0">
|
||||
{format(new Date(selectedDate + "T00:00:00"), "EEE, MMM d yyyy")}
|
||||
</Text>
|
||||
<Group justify="space-between">
|
||||
<Text fz={12.5} c="edr-muted">
|
||||
Train
|
||||
</Text>
|
||||
<Text fz={12.5} fw={700} c="edr-text.0">
|
||||
{selectedSchedule.trainNumber ?? selectedSchedule.id.slice(0, 8)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz={12.5} c="edr-muted">
|
||||
Wagons available
|
||||
</Text>
|
||||
<Text fz={12.5} fw={700} c="edr-text.0">
|
||||
{selectedSchedule.remainingWagons} / {selectedSchedule.maxWagons}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={12.5} c="edr-muted">
|
||||
Your train is confirmed by our freight desk after booking.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
@@ -380,154 +323,6 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* ── Schedule Selection Modal ──────────────────────────── */}
|
||||
<Modal
|
||||
opened={!!selectedDayForModal}
|
||||
onClose={() => setSelectedDayForModal(null)}
|
||||
centered
|
||||
size={520}
|
||||
radius={18}
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
overlayProps={{ backgroundOpacity: 0.5, blur: 3 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
px={24}
|
||||
py={20}
|
||||
style={{
|
||||
background: "linear-gradient(120deg, #0C1A2B 0%, #123047 70%, #0A6F4D 150%)",
|
||||
}}
|
||||
>
|
||||
<Group gap={7} align="center" mb={6}>
|
||||
<CalendarIcon size={15} color="#9FE9CC" />
|
||||
<Text fz={11} fw={700} tt="uppercase" c="#9FE9CC" style={{ letterSpacing: 0.6 }}>
|
||||
Available departures
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fw={800} fz={19} c="#fff">
|
||||
{selectedDayForModal
|
||||
? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEEE, MMM d yyyy")
|
||||
: ""}
|
||||
</Text>
|
||||
<Text fz={12.5} c="#A9BBCB" mt={2}>
|
||||
{selectedDayForModal?.schedules.length ?? 0} train
|
||||
{(selectedDayForModal?.schedules.length ?? 0) !== 1 ? "s" : ""} on{" "}
|
||||
{originName} → {destinationName}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Schedule list */}
|
||||
<Stack gap={12} p={24}>
|
||||
{selectedDayForModal?.schedules.map((schedule) => {
|
||||
const remaining = schedule.remainingWagons;
|
||||
const max = schedule.maxWagons || 1;
|
||||
const pct = Math.max(0, Math.min(100, Math.round((remaining / max) * 100)));
|
||||
const isSelected = schedule.id === selectedScheduleId;
|
||||
return (
|
||||
<Box
|
||||
key={schedule.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSelectScheduleFromModal(schedule.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelectScheduleFromModal(schedule.id);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
border: `1.5px solid ${isSelected ? theme.colors["edr-green"][5] : theme.colors["edr-border"][0]}`,
|
||||
background: isSelected ? theme.colors["edr-soft"][0] : "#fff",
|
||||
boxShadow: isSelected
|
||||
? `0 0 0 1px ${theme.colors["edr-green"][5]}`
|
||||
: "0 1px 2px rgba(16,24,40,0.04)",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Group gap={14} wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
flexShrink: 0,
|
||||
borderRadius: 13,
|
||||
background: "linear-gradient(135deg, #ECF6F1, #E0F1E9)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Train size={24} color={theme.colors["edr-green"][6]} />
|
||||
</Box>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={8} align="baseline">
|
||||
<Text fw={800} fz={18} c="edr-text.0">
|
||||
{format(new Date(schedule.scheduleDate), "HH:mm")}
|
||||
</Text>
|
||||
<Text fz={12.5} c="edr-muted">
|
||||
{schedule.trainNumber
|
||||
? `Train ${schedule.trainNumber}`
|
||||
: `#${schedule.id.slice(0, 6)}`}
|
||||
</Text>
|
||||
</Group>
|
||||
{/* capacity bar */}
|
||||
<Box mt={8}>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text fz={11} fw={600} c="edr-muted">
|
||||
{remaining} / {max} wagons free
|
||||
</Text>
|
||||
<Text fz={11} fw={700} c={pct > 25 ? "edr-green.7" : "#C77F09"}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: "#EEF2F6",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
background:
|
||||
pct > 25
|
||||
? `linear-gradient(90deg, ${theme.colors["edr-green"][7]}, ${theme.colors["edr-green"][5]})`
|
||||
: "#F2A516",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
flexShrink: 0,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: `2px solid ${isSelected ? theme.colors["edr-green"][5] : "#CBD5E1"}`,
|
||||
background: isSelected ? theme.colors["edr-green"][5] : "transparent",
|
||||
}}
|
||||
>
|
||||
{isSelected && <Check size={14} color="#fff" strokeWidth={3} />}
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -537,7 +332,7 @@ interface DayCellProps {
|
||||
onDayClick: (day: DayData) => void;
|
||||
}
|
||||
|
||||
function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
||||
function DayCell({ day: d, onDayClick }: DayCellProps) {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
if (!d.isCurrentMonth) {
|
||||
@@ -561,19 +356,19 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
||||
|
||||
const cellBg = d.isSelectedDate
|
||||
? theme.colors["edr-soft"][0]
|
||||
: d.hasSchedule
|
||||
: d.hasDeparture
|
||||
? "#FFFFFF"
|
||||
: "transparent";
|
||||
|
||||
const cellBorder = d.isSelectedDate
|
||||
? `2px solid ${theme.colors["edr-green"][5]}`
|
||||
: d.hasSchedule
|
||||
: d.hasDeparture
|
||||
? `1px solid ${theme.colors["edr-border"][0]}`
|
||||
: "none";
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => d.hasSchedule && onDayClick(d)}
|
||||
onClick={() => d.hasDeparture && onDayClick(d)}
|
||||
style={{
|
||||
height: 92,
|
||||
borderRadius: theme.radius.md,
|
||||
@@ -584,18 +379,18 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
cursor: d.hasSchedule ? "pointer" : "default",
|
||||
cursor: d.hasDeparture ? "pointer" : "default",
|
||||
transition: "all 150ms ease",
|
||||
boxShadow: d.hasSchedule && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
|
||||
boxShadow: d.hasDeparture && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (d.hasSchedule && !d.isSelectedDate) {
|
||||
if (d.hasDeparture && !d.isSelectedDate) {
|
||||
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.1)";
|
||||
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (d.hasSchedule && !d.isSelectedDate) {
|
||||
if (d.hasDeparture && !d.isSelectedDate) {
|
||||
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.05)";
|
||||
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
|
||||
}
|
||||
@@ -610,7 +405,7 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
||||
c={
|
||||
d.isToday && !d.isSelectedDate
|
||||
? "edr-green.6"
|
||||
: d.hasSchedule
|
||||
: d.hasDeparture
|
||||
? "edr-text.0"
|
||||
: "edr-muted"
|
||||
}
|
||||
@@ -635,50 +430,24 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
size={14}
|
||||
color="white"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
<Check size={14} color="white" strokeWidth={3} />
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Availability marker — a dot + count, never the schedule list itself. */}
|
||||
{d.hasSchedule && (
|
||||
{/* Availability marker — a single dot for days that have a departure.
|
||||
No counts or capacity are shown: it's a day-level pool. */}
|
||||
{d.hasDeparture && (
|
||||
<Box style={{ flex: 1, display: "flex", alignItems: "flex-end" }}>
|
||||
<Group
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px={9}
|
||||
py={4}
|
||||
<Box
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
backgroundColor: d.isSelectedDate
|
||||
? "#fff"
|
||||
: theme.colors["edr-soft"][0],
|
||||
border: `1px solid ${
|
||||
d.isSelectedDate
|
||||
? theme.colors["edr-green"][2]
|
||||
: "transparent"
|
||||
}`,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.colors["edr-green"][5],
|
||||
boxShadow: `0 0 0 3px ${theme.colors["edr-green"][0]}`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.colors["edr-green"][5],
|
||||
flexShrink: 0,
|
||||
boxShadow: `0 0 0 3px ${theme.colors["edr-green"][0]}`,
|
||||
}}
|
||||
/>
|
||||
<Text fz={11} fw={700} c="edr-green.7" style={{ whiteSpace: "nowrap" }}>
|
||||
{d.schedules.length} departure{d.schedules.length !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -326,10 +326,6 @@ export function Step8Review({
|
||||
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
|
||||
>
|
||||
<DetailRow label="Shipment date" value={scheduleLabel} />
|
||||
<DetailRow
|
||||
label="Train schedule"
|
||||
value={values.trainScheduleId ? "Selected" : "—"}
|
||||
/>
|
||||
</OverviewSection>
|
||||
|
||||
<OverviewSection
|
||||
@@ -444,8 +440,8 @@ export function Step8Review({
|
||||
label="Route selected"
|
||||
/>
|
||||
<ReadinessItem
|
||||
done={Boolean(values.scheduledDate && values.trainScheduleId)}
|
||||
label="Schedule selected"
|
||||
done={Boolean(values.scheduledDate)}
|
||||
label="Shipment day selected"
|
||||
/>
|
||||
<ReadinessItem
|
||||
done={
|
||||
|
||||
@@ -220,6 +220,13 @@ export const api = {
|
||||
>("train-scheduling", "bookableSchedules", ({ originYardId, destinationYardId }) =>
|
||||
bookingsService.getBookableSchedules({ originYardId, destinationYardId }),
|
||||
),
|
||||
|
||||
getAvailableDays: endpoint<
|
||||
{ originYardId?: string; destinationYardId?: string },
|
||||
string[]
|
||||
>("train-scheduling", "availableDays", ({ originYardId, destinationYardId }) =>
|
||||
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
|
||||
),
|
||||
},
|
||||
|
||||
payments: {
|
||||
|
||||
@@ -195,4 +195,18 @@ export const bookingsService = {
|
||||
);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Day-level pool: the days that have a departure on the route. The customer
|
||||
* picks a day; the engine assigns the train. No capacity is returned.
|
||||
*/
|
||||
getAvailableDays: async (
|
||||
query: Freight.AvailableDaysQuery = {},
|
||||
): Promise<string[]> => {
|
||||
const { data } = await client.get(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS,
|
||||
{ params: query },
|
||||
);
|
||||
return (data.data as Freight.AvailableDaysResponse).days;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user