export flow and fix intercity issue

This commit is contained in:
Marshal
2026-07-30 13:11:35 +00:00
parent 9208050cb5
commit e80aac877f
19 changed files with 486 additions and 27 deletions

View File

@@ -15,7 +15,7 @@ import {
} from "lucide-react";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { ExportTrainPicker, isViewable } from "@edr/ui-common";
import { IconSquare } from "../BookingDetailPage/components/Documents";
import {
@@ -27,7 +27,6 @@ import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingDocNoun } from "./bookingNextAction";
import { OperationDatePicker } from "./OperationDatePicker";
import { DayAvailabilityHint } from "./DayAvailabilityHint";
import { ExportTrainPicker } from "./ExportTrainPicker";
import type { ClearanceFlowController } from "./useClearanceFlow";
const BORDER = "#E6ECF2";

View File

@@ -1,131 +0,0 @@
import { Badge, Box, Group, Loader, Stack, Text, UnstyledButton } from "@mantine/core";
import { CheckCircle2, TrainFront } from "lucide-react";
import type { Freight } from "@edr/types";
const BORDER = "#E6ECF2";
const SELECTED = "#0E7A5F";
function departureLabel(iso: string): string {
const d = new Date(iso);
return d.toLocaleString("en-GB", {
weekday: "short",
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
timeZone: "Africa/Addis_Ababa",
});
}
function closesLabel(iso: string | null): string | null {
if (!iso) return null;
return new Date(iso).toLocaleString("en-GB", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
timeZone: "Africa/Addis_Ababa",
});
}
export interface ExportTrainPickerProps {
options: Freight.ExportTrainOption[];
loading: boolean;
value: string;
onChange: (scheduleId: string) => void;
}
/**
* Export shipment-day train picker: one card per export train that day, with
* live free-wagon space per wagon type for THIS booking's cargo. Full or
* not-yet-open trains render disabled — the pick locks the booking onto that
* train when the operation request is submitted.
*/
export function ExportTrainPicker({
options,
loading,
value,
onChange,
}: ExportTrainPickerProps) {
if (loading) {
return (
<Group gap="xs" mt="sm">
<Loader size="xs" />
<Text fz="12px" c="dimmed">
Checking trains for this day
</Text>
</Group>
);
}
if (!options.length) return null;
return (
<Box mt="sm">
<Text fz="13px" fw={700} c="#10202F" mb={6}>
Choose your train
</Text>
<Stack gap="xs">
{options.map((option) => {
const bookable = option.isOpen && option.fits;
const selected = value === option.scheduleId;
const closes = closesLabel(option.bookingClosesAt);
return (
<UnstyledButton
key={option.scheduleId}
onClick={() => bookable && onChange(option.scheduleId)}
disabled={!bookable}
style={{
border: `1.5px solid ${selected ? SELECTED : BORDER}`,
borderRadius: 10,
padding: "10px 12px",
opacity: bookable ? 1 : 0.55,
cursor: bookable ? "pointer" : "not-allowed",
background: selected ? "#F2FAF7" : "#FFFFFF",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="xs" align="flex-start" wrap="nowrap">
<TrainFront size={16} color={selected ? SELECTED : "#5B6B7A"} />
<Box>
<Text fz="13px" fw={600} c="#10202F">
Departs {departureLabel(option.departure)} EAT
</Text>
<Text fz="12px" c="dimmed">
{option.freeWagons} wagon{option.freeWagons === 1 ? "" : "s"} free
for your cargo · you need {option.neededWagons}
{closes ? ` · booking closes ${closes} EAT` : ""}
</Text>
<Group gap={6} mt={4}>
{option.byWagonType.map((t) => (
<Badge
key={t.wagonTypeId ?? "default"}
size="sm"
variant="light"
color={t.freeWagons > 0 ? "teal" : "gray"}
>
{t.code ?? t.name ?? "Wagon"}: {t.freeWagons} free
</Badge>
))}
</Group>
</Box>
</Group>
{selected ? (
<CheckCircle2 size={18} color={SELECTED} />
) : !option.isOpen ? (
<Badge size="sm" color="gray" variant="light">
Not open
</Badge>
) : !option.fits ? (
<Badge size="sm" color="red" variant="light">
Too little space
</Badge>
) : null}
</Group>
</UnstyledButton>
);
})}
</Stack>
</Box>
);
}

View File

@@ -49,7 +49,7 @@ import {
} from "lucide-react";
import type { Freight } from "@edr/types";
import { OperationDatePicker } from "@edr/ui-common";
import { ExportTrainPicker, OperationDatePicker } from "@edr/ui-common";
import { api } from "@/services/api";
import {
contractsService,
@@ -342,6 +342,10 @@ function NewShipmentBookingForm({
...(values.scheduledDate
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
: {}),
// EXPORT rail: lock the booking onto the train the customer picked.
...(values.trainScheduleId
? { trainScheduleId: values.trainScheduleId }
: {}),
...(legacyReturnToggle
? {
equipmentReturn: values.withReturn
@@ -505,7 +509,12 @@ function NewShipmentBookingForm({
return quantities in the cargo step; WITHOUT_RETURN locked it off. */}
{contract.freightType === "CONTAINER" &&
!contract.equipmentReturn && <EquipmentReturnStep form={form} />}
<ScheduleStep form={form} contract={contract} routes={routes} />
<ScheduleStep
form={form}
contract={contract}
routes={routes}
completeBookingId={completeBookingId ?? null}
/>
<NotesSection form={form} />
</Stack>
</Box>
@@ -934,10 +943,13 @@ function ScheduleStep({
form,
contract,
routes,
completeBookingId,
}: {
form: ShipmentForm;
contract: Freight.IContract;
routes: Freight.IContractRoute[];
/** Set when completing an initiated instance — enables the train picker. */
completeBookingId: string | null;
}) {
const contractRouteId = form.watch("contractRouteId");
const route = routes.find((r) => r.id === contractRouteId) ?? routes[0];
@@ -996,6 +1008,50 @@ function ScheduleStep({
enabled: cargoQuery !== null && !isIntercity,
});
// Export completion picks the TRAIN, not just the day (mirrors the
// clearance-flow picker). Only when an initiated instance exists — a plain
// drawdown create goes through clearance and picks its train there.
const scheduledDate = form.watch("scheduledDate");
const selectedTrainId = form.watch("trainScheduleId");
const isExportPick =
contract.tradeDirection === "EXPORT" && Boolean(completeBookingId);
const wagonsEstimate = useMemo(() => {
if (contract.freightType !== "CONTAINER") return undefined;
const lines = containerLines ?? [];
const ft20 = lines
.filter((l) => l.containerSize === "20ft")
.reduce((s, l) => s + Number(l.quantity || 0), 0);
const ft40 = lines
.filter((l) => l.containerSize === "40ft")
.reduce((s, l) => s + Number(l.quantity || 0), 0);
const wagons = Math.ceil(ft20 / 2) + ft40;
return wagons > 0 ? wagons : undefined;
}, [contract.freightType, containerLines]);
const exportTrainsQuery = useQuery({
...api.bookings.getExportTrains.queryOptions({
input: {
bookingId: completeBookingId ?? "",
date: scheduledDate ?? "",
cargo: {
containerSizes:
contract.freightType === "CONTAINER"
? (containerLines ?? [])
.filter((l) => Number(l.quantity || 0) >= 1)
.map((l) => l.containerSize)
: undefined,
cargoTypeCode:
contract.freightType === "BULK"
? (contract.pricingBreakdown?.lineItems?.find(
(li) => li.cargoTypeCode,
)?.cargoTypeCode ?? undefined)
: undefined,
wagons: wagonsEstimate,
},
},
}),
enabled: isExportPick && Boolean(scheduledDate),
});
if (isIntercity) {
return (
<StepCard>
@@ -1067,7 +1123,11 @@ function ScheduleStep({
availableDays={availableDays ?? []}
isLoading={isLoading}
value={field.value ?? ""}
onChange={(d) => field.onChange(d)}
onChange={(d) => {
field.onChange(d);
// A new day invalidates the old train pick.
form.setValue("trainScheduleId", "");
}}
/>
</Box>
{fieldState.error?.message && (
@@ -1075,6 +1135,14 @@ function ScheduleStep({
{fieldState.error.message}
</Text>
)}
{isExportPick && scheduledDate ? (
<ExportTrainPicker
options={exportTrainsQuery.data ?? []}
loading={exportTrainsQuery.isLoading}
value={selectedTrainId ?? ""}
onChange={(id) => form.setValue("trainScheduleId", id)}
/>
) : null}
</Box>
)}
/>

View File

@@ -73,6 +73,8 @@ const containerLineSchema = z.object({
const shipmentFormBase = z.object({
contractRouteId: z.string().default(""),
scheduledDate: z.string().default(""),
// EXPORT rail: the specific train picked for the shipment day (schedule id).
trainScheduleId: z.string().default(""),
// The contract quotes in USD; the customer picks the billing currency for
// THIS shipment. Intercity is forced to ETB (server-enforced too).
paymentCurrency: z.enum(["USD", "ETB"]).default("USD"),

View File

@@ -469,10 +469,18 @@ export const api = {
),
getExportTrains: endpoint<
{ bookingId: string; date: string },
{
bookingId: string;
date: string;
cargo?: {
containerSizes?: string[];
cargoTypeCode?: string;
wagons?: number;
};
},
Freight.ExportTrainOption[]
>("train-scheduling", "exportTrains", ({ bookingId, date }) =>
bookingsService.getExportTrains(bookingId, date),
>("train-scheduling", "exportTrains", ({ bookingId, date, cargo }) =>
bookingsService.getExportTrains(bookingId, date, cargo),
),
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(

View File

@@ -510,13 +510,25 @@ export const bookingsService = {
},
// Export train picker: the day's export trains with per-wagon-type free space.
// The cargo params cover bare contract instances (nothing persisted yet) —
// sizes/code/wagons come from what the customer is entering on the form.
getExportTrains: async (
bookingId: string,
date: string,
cargo?: { containerSizes?: string[]; cargoTypeCode?: string; wagons?: number },
): Promise<Freight.ExportTrainOption[]> => {
const { data } = await client.get(
`/api/bookings/${bookingId}/export-trains`,
{ params: { date } },
{
params: {
date,
...(cargo?.containerSizes?.length
? { containerSizes: cargo.containerSizes.join(",") }
: {}),
...(cargo?.cargoTypeCode ? { cargoTypeCode: cargo.cargoTypeCode } : {}),
...(cargo?.wagons ? { wagons: cargo.wagons } : {}),
},
},
);
return data.data as Freight.ExportTrainOption[];
},