mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 05:55:02 +00:00
fix(freight): gate loading on booking paymentStatus only; default schedule voyage no. to train's voyage number
This commit is contained in:
@@ -118,6 +118,7 @@ const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
|
||||
interface UnitErrors {
|
||||
containerNumber?: string;
|
||||
sealNumber?: string;
|
||||
vgmTons?: string;
|
||||
}
|
||||
|
||||
@@ -886,6 +887,11 @@ export default function GlCreateBookingForm() {
|
||||
} else if ((numberCounts.get(key) ?? 0) > 1) {
|
||||
errs.containerNumber = "Duplicate container number in this shipment.";
|
||||
}
|
||||
// Every container ships sealed and the yard checks the seal against
|
||||
// the booking — required alongside number and VGM (portal parity).
|
||||
if (!u.sealNumber.trim()) {
|
||||
errs.sealNumber = "Seal number is required.";
|
||||
}
|
||||
const vgm = Number(u.vgmTons);
|
||||
if (u.vgmTons.trim() === "" || Number.isNaN(vgm) || vgm <= 0) {
|
||||
errs.vgmTons = "Enter a valid VGM.";
|
||||
@@ -1047,6 +1053,12 @@ export default function GlCreateBookingForm() {
|
||||
|
||||
const dateError =
|
||||
!isIntercity && !scheduledDate ? "Select a shipment date." : undefined;
|
||||
// EXPORT completion locks the booking onto a train. Only raised once a day is
|
||||
// chosen — the picker is hidden until then and the date error covers it.
|
||||
const trainError =
|
||||
isExportPick && scheduledDate && !trainScheduleId
|
||||
? "Select a train for the shipment day."
|
||||
: undefined;
|
||||
const routeError =
|
||||
multiRoute && !contractRouteId ? "Select a route." : undefined;
|
||||
|
||||
@@ -1065,7 +1077,7 @@ export default function GlCreateBookingForm() {
|
||||
!e.returnQuantity,
|
||||
) &&
|
||||
unitErrors.every((line) =>
|
||||
line.every((e) => !e.containerNumber && !e.vgmTons),
|
||||
line.every((e) => !e.containerNumber && !e.sealNumber && !e.vgmTons),
|
||||
) &&
|
||||
!cargoDescriptionError
|
||||
: !bulkErrors.quantity &&
|
||||
@@ -1112,11 +1124,12 @@ export default function GlCreateBookingForm() {
|
||||
line.units.some(
|
||||
(u) =>
|
||||
!ISO_CONTAINER_NUMBER_REGEX.test(u.containerNumber.trim().toUpperCase()) ||
|
||||
!u.sealNumber.trim() ||
|
||||
!(Number(u.vgmTons) > 0),
|
||||
),
|
||||
);
|
||||
if (badUnit) {
|
||||
return `Every ${partner.reference} container needs a valid container number and a VGM above 0.`;
|
||||
return `Every ${partner.reference} container needs a valid container number, a seal number and a VGM above 0.`;
|
||||
}
|
||||
if (!partnerCargoDescription.trim()) {
|
||||
return `Describe the cargo carried in ${partner.reference}'s containers.`;
|
||||
@@ -1142,6 +1155,7 @@ export default function GlCreateBookingForm() {
|
||||
cargoValid &&
|
||||
!oddBlocksSubmit &&
|
||||
!dateError &&
|
||||
!trainError &&
|
||||
!routeError &&
|
||||
!partnerError &&
|
||||
!currencyError;
|
||||
@@ -1857,7 +1871,7 @@ export default function GlCreateBookingForm() {
|
||||
Container number *
|
||||
</Text>
|
||||
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
|
||||
Seal number
|
||||
Seal number *
|
||||
</Text>
|
||||
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
|
||||
VGM (tons) *
|
||||
@@ -1901,8 +1915,13 @@ export default function GlCreateBookingForm() {
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Optional"
|
||||
placeholder="e.g. SL0123456"
|
||||
value={unit.sealNumber}
|
||||
error={
|
||||
showErrors
|
||||
? unitErrors[lineIdx]?.[unitIdx]?.sealNumber
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
patchUnit(lineIdx, unitIdx, {
|
||||
sealNumber: e.currentTarget.value,
|
||||
@@ -2305,12 +2324,19 @@ export default function GlCreateBookingForm() {
|
||||
</Text>
|
||||
)}
|
||||
{isExportPick && scheduledDate ? (
|
||||
<ExportTrainPicker
|
||||
options={exportTrainsQuery.data ?? []}
|
||||
loading={exportTrainsQuery.isLoading}
|
||||
value={trainScheduleId}
|
||||
onChange={setTrainScheduleId}
|
||||
/>
|
||||
<>
|
||||
<ExportTrainPicker
|
||||
options={exportTrainsQuery.data ?? []}
|
||||
loading={exportTrainsQuery.isLoading}
|
||||
value={trainScheduleId}
|
||||
onChange={setTrainScheduleId}
|
||||
/>
|
||||
{showErrors && trainError && (
|
||||
<Text fz="xs" c="red" mt={6}>
|
||||
{trainError}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
</StepCard>
|
||||
@@ -2401,7 +2427,11 @@ export default function GlCreateBookingForm() {
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text fz={13} fw={500} c="#C0392B">
|
||||
Fix the highlighted fields to review the price.
|
||||
{trainError
|
||||
? "Select a train for the shipment day to review the price."
|
||||
: dateError && isExportPick
|
||||
? "Select a shipment day and a train to review the price."
|
||||
: "Fix the highlighted fields to review the price."}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -196,8 +196,13 @@ export function ConsolidationPartnerPanel({
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Seal number"
|
||||
label="Seal number *"
|
||||
value={unit.sealNumber}
|
||||
error={
|
||||
showErrors && !unit.sealNumber.trim()
|
||||
? "Seal number is required."
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
patchUnit(lineIdx, unitIdx, {
|
||||
sealNumber: e.currentTarget.value,
|
||||
|
||||
@@ -277,8 +277,15 @@ export function AllocateBookingWizard({
|
||||
if (!routeId || !scheduleDate || locomotiveIds.length < 2) {
|
||||
throw new Error("Select route, date, and at least two locomotives");
|
||||
}
|
||||
// This ad-hoc path has no built train (and so no run number) and no voyage
|
||||
// input, but voyage number is required at creation — default it to a
|
||||
// date-stamped placeholder that staff can edit later on the schedule.
|
||||
const voyageNumber = `V-${new Date(scheduleDate)
|
||||
.toISOString()
|
||||
.slice(0, 10)
|
||||
.replace(/-/g, "")}`;
|
||||
const created = await create.mutateAsync({
|
||||
payload: { routeId, scheduleDate, locomotiveIds },
|
||||
payload: { routeId, scheduleDate, voyageNumber, locomotiveIds },
|
||||
});
|
||||
showScheduleWarnings(created.warnings);
|
||||
setSelectedScheduleId(created.id);
|
||||
|
||||
@@ -382,7 +382,12 @@ export function IntercityRideAlongPanel({
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
{row.status === "PAID" && (
|
||||
{/* Paid = PAYMENT status only; still show Load only
|
||||
while the cargo has not ridden yet. */}
|
||||
{row.paymentStatus === "PAID" &&
|
||||
row.status !== "IN_TRANSIT" &&
|
||||
row.status !== "ARRIVED" &&
|
||||
row.status !== "COMPLETED" && (
|
||||
<Tooltip
|
||||
label={
|
||||
canLoad
|
||||
|
||||
@@ -534,7 +534,8 @@ function LocoDetailPanel({
|
||||
<Text size="sm" c="gray.5">No locomotive assigned yet.</Text>
|
||||
)}
|
||||
<Divider color="rgba(255,255,255,0.1)" label="Train" labelPosition="left" />
|
||||
<InfoRow label="Voyage / reference" value={schedule.reference} />
|
||||
<InfoRow label="Voyage number" value={schedule.voyageNumber} />
|
||||
<InfoRow label="Reference" value={schedule.reference} />
|
||||
<InfoRow label="Train number" value={schedule.trainNumber} />
|
||||
<InfoRow
|
||||
label="Train"
|
||||
|
||||
@@ -504,9 +504,10 @@ export default function TrainScheduleV2DetailPage() {
|
||||
b.originYardId === originYardId &&
|
||||
!b.loadedAt &&
|
||||
(b.loadingStatus ?? "UNLOADED") !== "LOADED" &&
|
||||
// Paid is read from the PAYMENT status only, never booking.status.
|
||||
(b.isGovernment
|
||||
? b.status === "APPROVED" || b.status === "PAID"
|
||||
: b.status === "PAID" ||
|
||||
? b.status === "APPROVED" || b.paymentStatus === "PAID"
|
||||
: b.paymentStatus === "PAID" ||
|
||||
// Shipping-line bookings ride from accept on the credit ledger.
|
||||
(Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")),
|
||||
);
|
||||
|
||||
@@ -158,6 +158,11 @@ export default function TrainScheduleV2ListPage() {
|
||||
const [routeId, setRouteId] = useState("");
|
||||
const [scheduleDate, setScheduleDate] = useState("");
|
||||
const [trainId, setTrainId] = useState("");
|
||||
// Voyage number for this departure — required. Auto-filled from the selected
|
||||
// train's own voyage number (typed in the Train Builder) when a train is
|
||||
// picked; legacy trains without one fall back to the direction-matched run
|
||||
// number. Staff may edit.
|
||||
const [voyageNumber, setVoyageNumber] = useState("");
|
||||
const [reverseWagonOrder, setReverseWagonOrder] = useState(false);
|
||||
// "" = a normal customer train; an id dedicates the departure to that
|
||||
// shipping line and hides it from every customer-facing view.
|
||||
@@ -461,6 +466,13 @@ export default function TrainScheduleV2ListPage() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!voyageNumber.trim()) {
|
||||
toast({
|
||||
title: "Voyage number is required",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Only build the window override when the toggle is on — off means "inherit
|
||||
// the global rules", which the API expresses as an absent windowRule.
|
||||
let windowRule: CreateScheduleWindowRulePayload | undefined;
|
||||
@@ -483,6 +495,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
routeId,
|
||||
scheduleDate: new Date(scheduleDate).toISOString(),
|
||||
trainId,
|
||||
voyageNumber: voyageNumber.trim(),
|
||||
reverseWagonOrder,
|
||||
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
|
||||
...(windowRule ? { windowRule } : {}),
|
||||
@@ -490,6 +503,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
});
|
||||
toast({ title: "Train schedule created" });
|
||||
showScheduleWarnings(created.warnings);
|
||||
setVoyageNumber("");
|
||||
setReverseWagonOrder(false);
|
||||
setShippingLineCompanyId("");
|
||||
setConfigureWindow(false);
|
||||
@@ -689,7 +703,20 @@ export default function TrainScheduleV2ListPage() {
|
||||
};
|
||||
})}
|
||||
value={trainId || null}
|
||||
onChange={(v) => setTrainId(v ?? "")}
|
||||
onChange={(v) => {
|
||||
setTrainId(v ?? "");
|
||||
// Default the voyage number to the picked train's own voyage
|
||||
// number (the Train Builder stores it as `trainName`). The run
|
||||
// number is a train number, not a voyage — only fall back to it
|
||||
// for legacy trains that have no voyage number yet; staff can
|
||||
// still override.
|
||||
const picked = (trainsQuery.data ?? []).find((t) => t.id === v);
|
||||
const runNumber =
|
||||
selectedRoute?.direction === "IMPORT"
|
||||
? picked?.importTrainNumber
|
||||
: picked?.exportTrainNumber;
|
||||
setVoyageNumber(picked?.trainName?.trim() || runNumber || "");
|
||||
}}
|
||||
searchable
|
||||
disabled={!routeId}
|
||||
nothingFoundMessage={
|
||||
@@ -698,6 +725,15 @@ export default function TrainScheduleV2ListPage() {
|
||||
: "Select a route first"
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Voyage number"
|
||||
description="Sailing/run number for this departure that yards and customs quote. Defaults to the selected train's voyage number — edit if needed."
|
||||
placeholder={trainId ? "e.g. V-2026-0620" : "Select a train first"}
|
||||
required
|
||||
maxLength={20}
|
||||
value={voyageNumber}
|
||||
onChange={(e) => setVoyageNumber(e.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Shipping line (optional)"
|
||||
description="Dedicate this departure to one shipping line. The train is then hidden from customers and shown only in that line's portal."
|
||||
@@ -930,7 +966,12 @@ function TrainIdentityCell({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||
let subtitle = "";
|
||||
if (schedule.train) {
|
||||
title = schedule.trainNumber ?? schedule.train.code;
|
||||
subtitle = [schedule.trainNumber ? schedule.train.code : null, schedule.train.trainName]
|
||||
// Show THIS departure's voyage number (the schedule's own), not the train's
|
||||
// voyage/name — one train serves many departures, each with its own voyage.
|
||||
subtitle = [
|
||||
schedule.trainNumber ? schedule.train.code : null,
|
||||
schedule.voyageNumber ? `Voyage ${schedule.voyageNumber}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
} else if (locos.length) {
|
||||
|
||||
@@ -205,7 +205,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{/* Work the cargo right here while the train is at the yard. */}
|
||||
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && (
|
||||
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.paymentStatus === "PAID" && (
|
||||
<Tooltip label={canLoad ? undefined : "You don't have permission to load cargo"} disabled={canLoad}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
|
||||
@@ -18,7 +18,9 @@ import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
|
||||
const isPaid = (item: WarehouseInventoryItem) => item.booking?.status === 'PAID';
|
||||
// "Paid" is the booking's PAYMENT status only — never booking.status === 'PAID'.
|
||||
const isPaid = (item: WarehouseInventoryItem) =>
|
||||
(item.booking?.paymentStatus ?? item.bookingPaymentStatus) === 'PAID';
|
||||
|
||||
/**
|
||||
* Loading Queue — manage inventory through the loading workflow.
|
||||
|
||||
@@ -192,6 +192,8 @@ export interface TrainScheduleListItem {
|
||||
createdAt?: string | null;
|
||||
scheduleDate: string;
|
||||
trainNumber?: string | null;
|
||||
/** Voyage (sailing) number for THIS departure — the schedule's own, not the train's. */
|
||||
voyageNumber?: string | null;
|
||||
/** Trade direction of this departure (IMPORT / EXPORT), when known. */
|
||||
direction?: string | null;
|
||||
routeName?: string | null;
|
||||
@@ -766,6 +768,8 @@ export interface TrainScheduleDetail {
|
||||
/** Cargo only (VGM/bulk tons) — the booked weight without wagon tare. */
|
||||
cargoWeightTons?: number;
|
||||
status: string | null;
|
||||
/** Payment status — the only signal that decides whether cargo may load. */
|
||||
paymentStatus?: string | null;
|
||||
schedulingStatus?: SchedulingStatus | null;
|
||||
freightType?: FreightType | string | null;
|
||||
/** DOMESTIC = intercity ride-along; rides only its own leg below. */
|
||||
@@ -1021,6 +1025,12 @@ export interface ReschedulePlan {
|
||||
export interface CreateTrainSchedulePayload {
|
||||
routeId: string;
|
||||
scheduleDate: string;
|
||||
/**
|
||||
* Voyage (sailing) number for this departure — required. The create dialog
|
||||
* pre-fills it with the selected train's direction-matched run number; staff
|
||||
* may override before submitting.
|
||||
*/
|
||||
voyageNumber: string;
|
||||
/** Built train (Train Builder) to run this departure — its locomotives are used. */
|
||||
trainId?: string;
|
||||
/** Hand-picked locomotives (minimum 2 — front and back). Ignored when trainId is set. */
|
||||
@@ -1132,6 +1142,8 @@ export interface IntercityBookingRow {
|
||||
id: string;
|
||||
reference: string | null;
|
||||
status: string;
|
||||
/** Payment status — the only signal that decides whether cargo may load. */
|
||||
paymentStatus?: string | null;
|
||||
freightType: FreightType | null;
|
||||
isGovernment: boolean;
|
||||
customer: string;
|
||||
@@ -1252,6 +1264,8 @@ export interface IntercityRideAlongRow {
|
||||
bookingId: string;
|
||||
reference: string | null;
|
||||
status: string;
|
||||
/** Payment status — the only signal that decides whether cargo may load. */
|
||||
paymentStatus?: string | null;
|
||||
freightType: string | null;
|
||||
weightTons: number | null;
|
||||
loadedAt: string | null;
|
||||
|
||||
@@ -228,6 +228,8 @@ export interface WarehouseInventoryItem {
|
||||
/** Flat booking summary fields attached by the inventory list (attachBookingSummaries). */
|
||||
bookingReference?: string | null;
|
||||
bookingStatus?: string | null;
|
||||
/** Payment status of the booking — the only signal that decides "paid". */
|
||||
bookingPaymentStatus?: string | null;
|
||||
customerName?: string | null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user