changes export flow

This commit is contained in:
Marshal
2026-07-30 11:30:34 +00:00
parent b671f07ce6
commit 2ca04b8f98
47 changed files with 1195 additions and 74 deletions

View File

@@ -86,6 +86,13 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
}, [opened]);
const handleBuild = async () => {
if (!trainName.trim()) {
toast({
title: "Enter the vogue number",
variant: "destructive",
});
return;
}
if (!yardId || locomotiveIds.length < 1) {
toast({
title: "Pick a yard and couple at least one locomotive",
@@ -108,7 +115,7 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
importTrainNumber: importTrainNumber.trim(),
currentYardId: yardId,
locomotiveIds,
...(trainName.trim() ? { trainName: trainName.trim() } : {}),
trainName: trainName.trim(),
...(notes.trim() ? { notes: notes.trim() } : {}),
});
toast({ title: `Train ${composition.code} built` });
@@ -143,11 +150,12 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
wagons are attached on the next screen.
</Text>
<TextInput
label="Name (optional)"
placeholder="e.g. Fertilizer block"
label="Vogue number"
placeholder="Enter vogue number"
value={trainName}
onChange={(e) => setTrainName(e.currentTarget.value)}
maxLength={100}
required
/>
<Group grow>
{/* Fixed by the import run — derived, never typed. */}

View File

@@ -363,14 +363,17 @@ export default function BookingWindowSettingsModal({
/>
<DurationField
label="Payment window"
description="Time a selected customer has to pay"
description={
isExport
? "Time an export customer has to pay before the reserved wagons are released — overrides the global export payment window for THIS train only"
: "Time a selected customer has to pay"
}
value={form.paymentWindowMinutes}
nativeUnit="minutes"
onChange={(v) =>
setForm((f) => f && { ...f, paymentWindowMinutes: v })
}
min={1}
disabled={isExport}
/>
</Group>
{!isExport ? (

View File

@@ -163,6 +163,7 @@ const WAGON_STATUS_OPTIONS = [
{ label: "Assigned", value: Freight.WagonStatus.Assigned },
{ label: "Maintenance", value: Freight.WagonStatus.Maintenance },
{ label: "Detained", value: Freight.WagonStatus.Detained },
{ label: "Out of service", value: Freight.WagonStatus.OutOfService },
];
// Statuses staff may set BY HAND on the create/edit form. ASSIGNED is omitted

View File

@@ -66,6 +66,7 @@ export default function TrainSchedulingGlobalRulesPage() {
"windowDurationHours",
"docReviewMinutes",
"paymentWindowMinutes",
"exportPaymentWindowMinutes",
];
const payload: Partial<Record<keyof TrainSchedulingGlobalRules, number>> = {};
for (const key of fields) {
@@ -209,8 +210,8 @@ export default function TrainSchedulingGlobalRulesPage() {
disabled={loading}
/>
<DurationField
label="Payment window"
description="Time a selected customer has to pay before the slot expires"
label="Import payment window"
description="Time an import/domestic customer has to pay before the reserved slot expires"
value={form.paymentWindowMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
@@ -219,6 +220,17 @@ export default function TrainSchedulingGlobalRulesPage() {
min={1}
disabled={loading}
/>
<DurationField
label="Export payment window"
description="Time an export customer has to pay before the reserved wagons are released"
value={form.exportPaymentWindowMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
setForm((current) => ({ ...current, exportPaymentWindowMinutes: value }))
}
min={1}
disabled={loading}
/>
</Stack>
</Card>

View File

@@ -119,7 +119,10 @@ export interface TrainSchedulingGlobalRules {
windowCloseHour: number;
windowDurationHours: number;
docReviewMinutes: number;
/** Import/domestic customer pay window, minutes. */
paymentWindowMinutes: number;
/** Export customer pay window, minutes — tuned separately from import. */
exportPaymentWindowMinutes: number;
/** Minutes before departure the import window closes; null = close at departure. */
importCloseOffsetMinutes: number | null;
/** Minutes before departure the export window closes; null = close at departure. */

View File

@@ -149,6 +149,10 @@ function mapBookingToFormValues(
destinationYard: yardIdFromBooking(booking.destinationYard, referenceData),
cargoType: booking.freightType === "BULK" ? "bulk" : "container",
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
bulkTotalWeightTons:
booking.bulkTotalWeightTons != null
? String(Number(booking.bulkTotalWeightTons))
: "",
isHazardous: booking.isHazardous ?? false,
isRefrigerated: booking.isRefrigerated ?? false,
bulkHazardousQty: String(Number(booking.bulkHazardousQuantity ?? 0)),
@@ -455,6 +459,15 @@ export default function EditBookingPage() {
: "IMPORT",
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
cargoTotalWeightVgm: totalWeight,
// Break-bulk (PER_ITEM commodity): actual tonnage alongside the item
// count, so wagon allocation can size indivisible items per wagon.
...(data.cargoType === "bulk" &&
referenceData?.cargo_type
?.flatMap((g) => g.children ?? [])
.find((c) => c.id === cargoTypeId)?.unit_of_measure === "PER_ITEM" &&
Number(data.bulkTotalWeightTons) > 0
? { bulkTotalWeightTons: Number(data.bulkTotalWeightTons) }
: {}),
// Containers: booking-level flags are the OR of the per-container switches;
// bulk uses the route-step toggles.
isHazardous:

View File

@@ -519,6 +519,11 @@ export default function NewBookingPage() {
// Day-level pool: the customer picks only a day (scheduledDate); the batch
// engine assigns the train, so no trainScheduleId is sent.
cargoTotalWeightVgm: totalWeight,
// Break-bulk: the actual tonnage travels alongside the item count so
// wagon allocation can size indivisible items per wagon.
...(data.cargoType === "bulk" && isPerItem && Number(data.bulkTotalWeightTons) > 0
? { bulkTotalWeightTons: Number(data.bulkTotalWeightTons) }
: {}),
// Booking-level flags drive the HAZARD / REEFER surcharge triggers. For
// containers they're the OR of the per-container switches; for bulk they
// come from the cargo-step toggles.

View File

@@ -120,7 +120,7 @@ function BookingActionModalBody({
leftSection={<CheckCircle2 size={16} />}
onClick={handleProceed}
loading={flow.proceedMutation.isPending}
disabled={!flow.scheduledDate}
disabled={!flow.scheduledDate || flow.requiresTrainSelection}
>
Proceed to operation
</Button>

View File

@@ -27,6 +27,7 @@ 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";
@@ -71,6 +72,11 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
setAdHocFile,
scheduledDate,
setScheduledDate,
isExportRail,
exportTrains,
exportTrainsLoading,
selectedTrainId,
setSelectedTrainId,
uploadMutation,
proceedMutation,
} = flow;
@@ -224,9 +230,9 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
Choose your shipment day
</Text>
<Text fz="12px" c="dimmed" mb="sm">
Only days with a scheduled departure that can carry your cargo type
can be selected. The operations team assigns the specific train for
that day.
{isExportRail
? "Only days with a scheduled departure that can carry your cargo type can be selected. Pick the train you want for that day below."
: "Only days with a scheduled departure that can carry your cargo type can be selected. The operations team assigns the specific train for that day."}
</Text>
<OperationDatePicker
originYardId={booking.originYard?.id}
@@ -235,13 +241,21 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
value={scheduledDate}
onChange={setScheduledDate}
/>
{scheduledDate && (
{scheduledDate && !isExportRail && (
<DayAvailabilityHint
bookingId={booking.id}
date={scheduledDate}
tradeDirection={booking.tradeDirection}
/>
)}
{scheduledDate && isExportRail && (
<ExportTrainPicker
options={exportTrains}
loading={exportTrainsLoading}
value={selectedTrainId}
onChange={setSelectedTrainId}
/>
)}
</Box>
)}

View File

@@ -0,0 +1,131 @@
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

@@ -27,7 +27,39 @@ export function useClearanceFlow(booking: Freight.IBooking) {
const [pending, setPending] = useState<Record<string, File>>({});
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
// Binding shipment day chosen for the operation request (yyyy-MM-dd).
const [scheduledDate, setScheduledDate] = useState<string>("");
const [scheduledDate, setScheduledDateState] = useState<string>("");
// Export rail only: the specific train picked for that day.
const [selectedTrainId, setSelectedTrainId] = useState<string>("");
// Mirrors the API's isRoadService: road/truck services dispatch a truck and
// never pick a train. IBooking.serviceType is a string code.
const serviceCode = String(booking.serviceType ?? "").toUpperCase();
const isRoad = serviceCode.startsWith("ROAD") || serviceCode.startsWith("TRUCK");
const isExportRail = booking.tradeDirection === "EXPORT" && !isRoad;
// A new day invalidates the old train pick.
const setScheduledDate = (date: string) => {
setScheduledDateState(date);
setSelectedTrainId("");
};
const exportTrainsQuery = useQuery(
api.bookings.getExportTrains.queryOptions({
input: { bookingId: booking.id, date: scheduledDate },
enabled: isExportRail && Boolean(scheduledDate),
}),
);
const exportTrains = useMemo(
() => (isExportRail ? (exportTrainsQuery.data ?? []) : []),
[isExportRail, exportTrainsQuery.data],
);
// Export must ride the train the customer picked — block proceed until a
// bookable train is chosen (when none is bookable, proceed stays allowed so
// the API can answer with the real capacity error).
const requiresTrainSelection =
isExportRail &&
exportTrains.some((t) => t.isOpen && t.fits) &&
!selectedTrainId;
const refresh = () => {
queryClient.invalidateQueries({
@@ -146,9 +178,14 @@ export function useClearanceFlow(booking: Freight.IBooking) {
};
const proceedToOperation = (opts?: { onSuccess?: () => void }) => {
if (!scheduledDate) return;
if (!scheduledDate || requiresTrainSelection) return;
proceedMutation.mutate(
{ id: booking.id, scheduledDate },
{
id: booking.id,
scheduledDate,
trainScheduleId:
isExportRail && selectedTrainId ? selectedTrainId : undefined,
},
{ onSuccess: opts?.onSuccess },
);
};
@@ -179,6 +216,13 @@ export function useClearanceFlow(booking: Freight.IBooking) {
// schedule
scheduledDate,
setScheduledDate,
// export train pick
isExportRail,
exportTrains,
exportTrainsLoading: exportTrainsQuery.isLoading,
selectedTrainId,
setSelectedTrainId,
requiresTrainSelection,
// mutations
uploadMutation,
proceedMutation,

View File

@@ -164,6 +164,10 @@ export const bookingFormSchema = z
scheduledDate: z.string().default(""),
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(),
// Break-bulk (PER_ITEM commodities) only: actual total weight in tons —
// cargoWeight then carries the item count. Empty for PER_TON bulk and
// container cargo.
bulkTotalWeightTons: z.string().default(""),
cargoTypePath: z.array(z.string()).default([]),
cargoFreeText: z.string(),
isHazardous: z.boolean(),
@@ -239,6 +243,16 @@ export const bookingFormSchema = z
},
{ message: "Enter a quantity greater than 0.", path: ["cargoWeight"] },
)
.refine(
(data) => {
// Filled only for PER_ITEM commodities (the field is hidden otherwise);
// when present it must be a positive tonnage.
if (data.cargoType !== "bulk" || !data.bulkTotalWeightTons) return true;
const tons = Number(data.bulkTotalWeightTons);
return !Number.isNaN(tons) && tons > 0;
},
{ message: "Enter a total weight greater than 0.", path: ["bulkTotalWeightTons"] },
)
.refine(
(data) => !(data.cargoType === "container" && data.containers.length === 0),
{ message: "Add at least one container.", path: ["containers"] },
@@ -381,6 +395,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
extraRoutes: [],
scheduledDate: "",
cargoWeight: "",
bulkTotalWeightTons: "",
cargoTypePath: [],
cargoFreeText: "",
isHazardous: false,

View File

@@ -350,6 +350,32 @@ export function Step5CargoDetails({
/>
)}
{/* Break-bulk: item count alone can't size wagons — indivisible items
pack by weight, so the actual total tonnage is captured too. */}
{selectedCommodity && !isGeneralContract && isPerItem && (
<Controller
name="bulkTotalWeightTons"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
id="bulkTotalWeightTons"
type="number"
onKeyDown={blockNegative}
label="Total weight (Tons) *"
placeholder="e.g. 800"
leftSection={<Weight className="h-4 w-4" />}
error={fieldState.error?.message}
description="Actual total weight of all items — used to work out how many items fit one wagon."
radius={10}
styles={fieldStyles}
min={0}
step={0.01}
/>
)}
/>
)}
{/* Cargo handling — how much of the cargo is hazardous / refrigerated,
in the SAME unit as the quantity above (tons or items). Shown once a
commodity is chosen so the unit is known; general contracts handle

View File

@@ -385,10 +385,11 @@ export default function ContractDetailPage() {
// slot, so the action is "none" and no booking button is shown.
const canBookShipment =
bookingAction.kind === "book" || bookingAction.kind === "rebook";
const canRequestShipment = bookingAction.kind === "request";
// TODO: bulk contracts pause here for now — remove isContainer gate once bulk flow resumes.
const canRequestShipment = bookingAction.kind === "request" && isContainer;
// Self-clearance import/export (ONE_TIME or GENERAL): one-click bare booking
// instance — the per-booking clearance runs first, so no window gate here.
const canInitiateBooking = bookingAction.kind === "initiate";
const canInitiateBooking = bookingAction.kind === "initiate" && isContainer;
return (
<Box style={{ padding: "28px 32px 40px" }}>

View File

@@ -407,10 +407,10 @@ export const api = {
),
proceedToOperation: endpoint<
{ id: string; scheduledDate: string },
{ id: string; scheduledDate: string; trainScheduleId?: string },
Freight.IBooking
>("bookings", "proceedToOperation", ({ id, scheduledDate }) =>
bookingsService.proceedToOperation(id, scheduledDate),
>("bookings", "proceedToOperation", ({ id, scheduledDate, trainScheduleId }) =>
bookingsService.proceedToOperation(id, scheduledDate, trainScheduleId),
),
checkPayment: endpoint<{ orderId: string }, { status: string }>(
@@ -468,6 +468,13 @@ export const api = {
bookingsService.getDayAvailability(bookingId, date),
),
getExportTrains: endpoint<
{ bookingId: string; date: string },
Freight.ExportTrainOption[]
>("train-scheduling", "exportTrains", ({ bookingId, date }) =>
bookingsService.getExportTrains(bookingId, date),
),
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
"train-scheduling",
"myBookingWindows",

View File

@@ -378,10 +378,11 @@ export const bookingsService = {
proceedToOperation: async (
id: string,
scheduledDate: string,
trainScheduleId?: string,
): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${id}/clearance/proceed`,
{ scheduledDate },
{ scheduledDate, ...(trainScheduleId ? { trainScheduleId } : {}) },
);
return data.data;
},
@@ -508,6 +509,18 @@ export const bookingsService = {
return (data.data as Freight.AvailableDaysResponse).days;
},
// Export train picker: the day's export trains with per-wagon-type free space.
getExportTrains: async (
bookingId: string,
date: string,
): Promise<Freight.ExportTrainOption[]> => {
const { data } = await client.get(
`/api/bookings/${bookingId}/export-trains`,
{ params: { date } },
);
return data.data as Freight.ExportTrainOption[];
},
// Advisory free-wagon count for a shipment day (planning hint, not enforced).
getDayAvailability: async (
bookingId: string,