mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
export flow and fix intercity issue
This commit is contained in:
@@ -48,7 +48,7 @@ import {
|
||||
X,
|
||||
} 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 { PageContainer } from "@/components/page";
|
||||
@@ -268,6 +268,8 @@ export default function GlCreateBookingForm() {
|
||||
}, [bookingWindows]);
|
||||
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
// EXPORT rail completion: the specific train GL picks for the shipment day.
|
||||
const [trainScheduleId, setTrainScheduleId] = useState("");
|
||||
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
// The customer states the billing currency on their shipment request — GL
|
||||
@@ -578,6 +580,45 @@ export default function GlCreateBookingForm() {
|
||||
enabled: cargoQuery !== null && !isIntercity,
|
||||
});
|
||||
|
||||
// EXPORT completes pick the TRAIN, not just the day (portal parity). Only
|
||||
// when completing an initiated instance — a fresh GL create goes through
|
||||
// clearance and picks its train there.
|
||||
const isExportPick =
|
||||
contract?.tradeDirection === "EXPORT" && Boolean(completeBookingId);
|
||||
const wagonsEstimate = useMemo(() => {
|
||||
if (!isContainer) return undefined;
|
||||
const ft20 = containerLines
|
||||
.filter((l) => parseInt(l.containerSize, 10) === 20)
|
||||
.reduce((s, l) => s + Number(l.quantity || 0), 0);
|
||||
const ft40 = containerLines
|
||||
.filter((l) => parseInt(l.containerSize, 10) === 40)
|
||||
.reduce((s, l) => s + Number(l.quantity || 0), 0);
|
||||
const wagons = Math.ceil(ft20 / 2) + ft40;
|
||||
return wagons > 0 ? wagons : undefined;
|
||||
}, [isContainer, containerLines]);
|
||||
const exportTrainsQuery = useQuery({
|
||||
...api.trainScheduling.exportTrains.queryOptions({
|
||||
input: {
|
||||
bookingId: completeBookingId ?? "",
|
||||
date: scheduledDate,
|
||||
cargo: {
|
||||
containerSizes: isContainer
|
||||
? containerLines
|
||||
.filter((l) => Number(l.quantity || 0) >= 1)
|
||||
.map((l) => l.containerSize)
|
||||
: undefined,
|
||||
cargoTypeCode: !isContainer
|
||||
? (contract?.pricingBreakdown?.lineItems?.find(
|
||||
(li) => li.cargoTypeCode,
|
||||
)?.cargoTypeCode ?? undefined)
|
||||
: undefined,
|
||||
wagons: wagonsEstimate,
|
||||
},
|
||||
},
|
||||
}),
|
||||
enabled: isExportPick && Boolean(scheduledDate),
|
||||
});
|
||||
|
||||
/**
|
||||
* Line handling totals are a roll-up of the per-container switches — the
|
||||
* count is however many containers ticked each service. Recomputed on every
|
||||
@@ -846,6 +887,8 @@ export default function GlCreateBookingForm() {
|
||||
...(scheduledDate
|
||||
? { scheduledDate: new Date(scheduledDate).toISOString() }
|
||||
: {}),
|
||||
// EXPORT rail: lock the booking onto the picked train.
|
||||
...(trainScheduleId ? { trainScheduleId } : {}),
|
||||
...(notes.trim() ? { notes: notes.trim() } : {}),
|
||||
// Equipment return: WITH_RETURN contracts derive it server-side from the
|
||||
// per-line return quantities; only legacy contracts (no value chosen at
|
||||
@@ -1667,7 +1710,11 @@ export default function GlCreateBookingForm() {
|
||||
availableDays={availableDays ?? []}
|
||||
isLoading={daysLoading}
|
||||
value={scheduledDate}
|
||||
onChange={setScheduledDate}
|
||||
onChange={(d) => {
|
||||
setScheduledDate(d);
|
||||
// A new day invalidates the old train pick.
|
||||
setTrainScheduleId("");
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{showErrors && dateError && (
|
||||
@@ -1675,6 +1722,14 @@ export default function GlCreateBookingForm() {
|
||||
{dateError}
|
||||
</Text>
|
||||
)}
|
||||
{isExportPick && scheduledDate ? (
|
||||
<ExportTrainPicker
|
||||
options={exportTrainsQuery.data ?? []}
|
||||
loading={exportTrainsQuery.isLoading}
|
||||
value={trainScheduleId}
|
||||
onChange={setTrainScheduleId}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
</StepCard>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PaginatedResponse } from "@edr/types";
|
||||
import type { Freight, PaginatedResponse } from "@edr/types";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||
@@ -438,6 +438,31 @@ export const api = {
|
||||
],
|
||||
),
|
||||
|
||||
exportTrains: endpoint<
|
||||
{
|
||||
bookingId: string;
|
||||
date: string;
|
||||
cargo?: {
|
||||
containerSizes?: string[];
|
||||
cargoTypeCode?: string;
|
||||
wagons?: number;
|
||||
};
|
||||
},
|
||||
Freight.ExportTrainOption[]
|
||||
>(
|
||||
"train-scheduling",
|
||||
"export-trains",
|
||||
({ bookingId, date, cargo }) =>
|
||||
trainSchedulingService.getExportTrains(bookingId, date, cargo),
|
||||
({ bookingId, date, cargo }) => [
|
||||
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||
"export-trains",
|
||||
bookingId,
|
||||
date,
|
||||
JSON.stringify(cargo ?? {}),
|
||||
],
|
||||
),
|
||||
|
||||
trainTrack: endpoint<{ id: string }, TrainTrackResponse>(
|
||||
"train-scheduling",
|
||||
"track",
|
||||
|
||||
@@ -189,6 +189,29 @@ export const trainSchedulingService = {
|
||||
|
||||
// Cargo-aware day pool (matching wagons + open train capacity). `containers`
|
||||
// is serialized as a JSON string param (the server parses it).
|
||||
// Export train picker for a booking's shipment day; cargo params cover bare
|
||||
// instances whose cargo only exists on the form so far.
|
||||
getExportTrains: async (
|
||||
bookingId: string,
|
||||
date: string,
|
||||
cargo?: { containerSizes?: string[]; cargoTypeCode?: string; wagons?: number },
|
||||
): Promise<Freight.ExportTrainOption[]> => {
|
||||
const response = await client.get<Freight.ExportTrainOption[]>(
|
||||
`/bookings/${bookingId}/export-trains`,
|
||||
{
|
||||
params: {
|
||||
date,
|
||||
...(cargo?.containerSizes?.length
|
||||
? { containerSizes: cargo.containerSizes.join(",") }
|
||||
: {}),
|
||||
...(cargo?.cargoTypeCode ? { cargoTypeCode: cargo.cargoTypeCode } : {}),
|
||||
...(cargo?.wagons ? { wagons: cargo.wagons } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getAvailableDaysForCargo: async (
|
||||
query: Freight.AvailableDaysForCargoQuery,
|
||||
): Promise<string[]> => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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[]>(
|
||||
|
||||
@@ -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[];
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user