Merge pull request #734 from Tria-plc/freight_feature/usermanagement

fix u=issue
This commit is contained in:
marshal
2026-07-16 15:02:40 +03:00
committed by GitHub
12 changed files with 416 additions and 37 deletions

View File

@@ -26,6 +26,7 @@ import {
TextInput,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertCircle,
@@ -642,6 +643,20 @@ export default function GlCreateBookingForm() {
});
}, [isContainer, contract, containerLines, contractWithReturn]);
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
// the booking can never be planned. The server rejects it too (the price
// modal's `pairingErrors`), but that only lands after GL has filled the whole
// form — mirror the customer portal (new-booking-form/schema.ts `calcWagons`)
// and block it inline instead. Size strings arrive as "20ft" from the contract
// scope but as a bare "20" from the rebook seed, so match on the leading digits.
const ft20Total = useMemo(() => {
if (!isContainer) return 0;
return containerLines
.filter((l) => parseInt(l.containerSize, 10) === 20)
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
}, [isContainer, containerLines]);
const hasOdd20ft = ft20Total % 2 === 1;
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
const bulkErrors = useMemo<BulkErrors>(() => {
@@ -688,7 +703,7 @@ export default function GlCreateBookingForm() {
)
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
const formValid = cargoValid && !dateError && !routeError;
const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError;
/** The create-booking DTO from the current form state — shared by the
* authoritative price preview and the actual submit so what GL confirms is
@@ -1286,6 +1301,21 @@ export default function GlCreateBookingForm() {
</Box>
))
)}
{hasOdd20ft ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Total})`}
>
20ft containers travel two per wagon, so they must be booked in
even numbers. Add one more 20ft container or remove one (e.g.
book {ft20Total + 1} or {ft20Total - 1} instead of {ft20Total})
the booking cannot be created with an unpaired 20ft container.
</Alert>
) : null}
</Stack>
</StepCard>
) : (
@@ -1530,14 +1560,27 @@ export default function GlCreateBookingForm() {
</Alert>
) : null}
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={openPriceModal}
<Tooltip
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
withArrow
disabled={!hasOdd20ft}
>
Review price &amp; book
</Button>
{/* Mantine tooltips get no pointer events from a disabled button,
so the wrapper carries the hover target. */}
<Box>
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={openPriceModal}
// Same hard block the customer portal applies at review time —
// an unpaired 20ft can never be planned onto a wagon.
disabled={hasOdd20ft}
>
Review price &amp; book
</Button>
</Box>
</Tooltip>
</Group>
</Box>
</Box>

View File

@@ -36,7 +36,9 @@ export function PinWagonsForm({
autoFillOnMount?: boolean;
}) {
const originYardId = schedule.originStation?.id;
const slots = schedule.trainSet?.wagons ?? [];
// Consist-only rows are the built train's coupled-but-empty wagons — display
// entries with no TrainSetWagon slot behind them, so nothing can be pinned.
const slots = (schedule.trainSet?.wagons ?? []).filter((w) => !w.consistOnly);
const [assignments, setAssignments] = useState<Record<string, string>>({});
const wagonOptionsByType = useMemo(() => {

View File

@@ -359,7 +359,9 @@ function WagonCar({
{isEmpty ? (
<Text size="xs" c="dimmed">
Empty slot available for allocation.
{wagon.consistOnly
? "Empty wagon — coupled on the train, no load planned."
: "Empty slot — available for allocation."}
</Text>
) : (
<Stack gap={6}>

View File

@@ -167,9 +167,9 @@ export const WagonCard = ({
<TrainFront size={18} />
</ThemeIcon>
<Text size="sm" c="dimmed">
Empty slot
{wagon.consistOnly ? "Empty wagon — coupled on the train" : "Empty slot"}
</Text>
{!isDispatched ? (
{!isDispatched && !wagon.consistOnly ? (
<Button
variant="subtle"
color="gray"

View File

@@ -31,8 +31,6 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
import EditTrainDetailsModal from "@/components/trainBuilder/EditTrainDetailsModal";
import {
directionColor,
directionRowStyle,
trainStatusColor,
trainStatusLabel,
} from "@/components/trainBuilder/trainStatus";
@@ -164,14 +162,9 @@ export default function TrainBuilderListPage() {
return (
<Stack gap={2}>
{active?.trainNumber ? (
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={700} ff="monospace" lh={1.2}>
{active.trainNumber}
</Text>
<Badge size="xs" variant="light" color={directionColor(active.direction)}>
{active.direction ?? "—"}
</Badge>
</Group>
<Text size="sm" fw={700} ff="monospace" lh={1.2}>
{active.trainNumber}
</Text>
) : null}
<Text size="xs" c="dimmed" ff="monospace" lh={1.2}>
IMP {row.original.importTrainNumber ?? "—"} · EXP{" "}
@@ -348,7 +341,6 @@ export default function TrainBuilderListPage() {
data={trains}
status={tableStatus}
onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)}
rowStyle={(train) => directionRowStyle(train.activeSchedule?.direction)}
error={
trainsQuery.isError
? {

View File

@@ -1,6 +1,7 @@
import type { ColumnDef } from "@edr/ui-common";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
@@ -38,6 +39,10 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
directionColor,
directionRowStyle,
} from "@/components/trainBuilder/trainStatus";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
@@ -303,9 +308,20 @@ export default function TrainScheduleV2ListPage() {
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Stack gap={4}>
<Text size="sm" fw={600} lh={1.2}>
{row.original.routeName ?? "—"}
</Text>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} lh={1.2}>
{row.original.routeName ?? "—"}
</Text>
{row.original.direction ? (
<Badge
size="xs"
variant="light"
color={directionColor(row.original.direction)}
>
{row.original.direction}
</Badge>
) : null}
</Group>
<Box maw={220}>
<RouteCorridor
origin={row.original.origin}
@@ -660,6 +676,7 @@ export default function TrainScheduleV2ListPage() {
onRowClick={(schedule) =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
}
rowStyle={(schedule) => directionRowStyle(schedule.direction)}
error={
schedulesQuery.isError
? {
@@ -909,7 +926,18 @@ function ScheduleCard({
</Box>
<Group justify="space-between" align="center">
<FreightTypeBadge freightType={schedule.freightType} />
<Group gap={6} wrap="nowrap">
<FreightTypeBadge freightType={schedule.freightType} />
{schedule.direction ? (
<Badge
size="xs"
variant="light"
color={directionColor(schedule.direction)}
>
{schedule.direction}
</Badge>
) : null}
</Group>
<Group gap={6} wrap="nowrap">
<MetricChip value={schedule.bookingsCount} label="bkg" />
<MetricChip value={schedule.wagonCount} label="wgn" />

View File

@@ -168,6 +168,8 @@ export interface TrainScheduleListItem {
createdAt?: string | null;
scheduleDate: string;
trainNumber?: string | null;
/** Trade direction of this departure (IMPORT / EXPORT), when known. */
direction?: string | null;
routeName?: string | null;
origin: string | null;
destination: string | null;
@@ -608,6 +610,11 @@ export interface TrainScheduleDetail {
name: string;
} | null;
allocations: TrainScheduleWagonAllocation[];
/**
* Coupled-but-empty wagon of the built train — no TrainSetWagon slot
* behind it, so remove/edit actions do not apply.
*/
consistOnly?: boolean;
}>;
} | null;
bookings: Array<{

View File

@@ -21,6 +21,7 @@ import {
Textarea,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertCircle,
@@ -269,6 +270,19 @@ function NewShipmentBookingForm({
mode: "onChange",
});
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
// the booking can never be planned. The server's shipment validation reports
// it too, but only once the price modal opens — block it inline instead, the
// same way the direct-booking wizard does (new-booking-form `calcWagons`).
const watchedContainers = form.watch("containers");
const ft20Total =
contract.freightType === "CONTAINER"
? (watchedContainers ?? [])
.filter((l) => l.containerSize === "20ft")
.reduce((sum, l) => sum + Number(l.quantity || 0), 0)
: 0;
const hasOdd20ft = ft20Total % 2 === 1;
const submitMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
completeBookingId
@@ -367,6 +381,8 @@ function NewShipmentBookingForm({
// run it for every freight type; container contracts additionally get
// overweight warnings + 20ft pairing hard-blocks surfaced in the modal.
const handleReview = form.handleSubmit((values) => {
// An unpaired 20ft can never be planned onto a wagon — don't even price it.
if (hasOdd20ft) return;
setPendingValues(values);
validateMutation.reset();
validateMutation.mutate(buildDto(values));
@@ -483,15 +499,26 @@ function NewShipmentBookingForm({
}}
>
<Group justify="flex-end" className="mx-auto max-w-4xl">
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
<Tooltip
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
withArrow
disabled={!hasOdd20ft}
>
Review price &amp; book
</Button>
{/* Mantine tooltips get no pointer events from a disabled button,
so the wrapper carries the hover target. */}
<Box>
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
disabled={hasOdd20ft}
>
Review price &amp; book
</Button>
</Box>
</Tooltip>
</Group>
</Box>
</form>
@@ -1265,6 +1292,28 @@ function CargoStep({
This contract has no container sizes in scope.
</Text>
)}
{(() => {
const ft20 = lines
.filter((l) => l.containerSize === "20ft")
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
if (ft20 % 2 !== 1) return null;
return (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20})`}
>
<Text fz={13}>
20ft containers travel two per wagon, so they must be booked in
even numbers. Please add one more 20ft container or remove one
(e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20}) the
booking cannot be submitted with an unpaired 20ft container.
</Text>
</Alert>
);
})()}
</Stack>
</StepCard>
);

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Group,
@@ -13,7 +14,7 @@ import {
Textarea,
Title,
} from "@mantine/core";
import { ArrowLeft, CalendarDays, Send } from "lucide-react";
import { AlertCircle, ArrowLeft, CalendarDays, Send } from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { DatePickerInput } from "@mantine/dates";
@@ -98,7 +99,14 @@ export default function NewShipmentRequestPage() {
contract.cargoScope?.[0];
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
// 20ft containers ride two per wagon, so an odd total can never be planned —
// and GL's create-booking form blocks it too, so an odd request would only
// dead-end there. Same even-number rule the booking forms apply.
const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0;
const hasOdd20ft = ft20Requested % 2 === 1;
const handleSubmit = () => {
if (hasOdd20ft) return;
const dto: Freight.CreateBookingRequestDto = {
contractRouteId: route?.id,
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
@@ -202,6 +210,23 @@ export default function NewShipmentRequestPage() {
/>
)}
{hasOdd20ft ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Requested})`}
>
<Text fz={13}>
20ft containers travel two per wagon, so they must be requested
in even numbers. Please add one more 20ft container or remove
one (e.g. request {ft20Requested + 1} or {ft20Requested - 1}{" "}
instead of {ft20Requested}).
</Text>
</Alert>
) : null}
{capacity?.length ? (
<Text size="xs" c="dimmed">
Remaining capacity is shown on the contract GL will validate your request.
@@ -220,6 +245,7 @@ export default function NewShipmentRequestPage() {
leftSection={<Send size={16} />}
loading={submit.isPending}
onClick={handleSubmit}
disabled={hasOdd20ft}
>
Submit shipment request
</Button>