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

fix wagon cncellation
This commit is contained in:
marshal
2026-08-07 09:32:27 +03:00
committed by GitHub
7 changed files with 501 additions and 19 deletions

View File

@@ -222,6 +222,27 @@ export class BookingWagonCancellationService {
} }
if (row.status !== 'FEE_PENDING') return; if (row.status !== 'FEE_PENDING') return;
// The fee can settle after loading started (slow payment). Never cut
// loaded cargo: leave the row FEE_PENDING and alert staff to resolve
// (reschedule the cut or refund the fee by hand).
const bookingNow = await this.bookingsRepository.findById(row.bookingId);
const movingNow = await this.dataSource.getRepository(WagonBookingAllocation).count({
where: { bookingId: row.bookingId, status: In(['LOADED', 'DEPARTED']) },
});
if (bookingNow?.loadedAt || movingNow > 0) {
this.logger.error(
`Wagon cancellation ${row.id}: fee paid but loading already started on booking ${row.bookingId} — left FEE_PENDING for manual resolution.`,
);
if (bookingNow) {
this.notifyStaff(
bookingNow,
'Wagon cancellation fee paid after loading started',
`${bookingNow.reference}: the customer paid the cancellation fee for ${row.wagonsCancelled} wagon(s), but loading has already started. Resolve manually (adjust the cut or refund the fee).`,
);
}
return;
}
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
const booking = await manager.getRepository(Booking).findOne({ const booking = await manager.getRepository(Booking).findOne({
where: { id: row.bookingId }, where: { id: row.bookingId },
@@ -233,7 +254,11 @@ export class BookingWagonCancellationService {
let droppedWeight = 0; let droppedWeight = 0;
if (quantities.bySize && Object.keys(quantities.bySize).length) { if (quantities.bySize && Object.keys(quantities.bySize).length) {
const units = await this.reduceContainerLines(manager, booking, quantities.bySize); // Specific-wagon requests already carry the exact unit snapshots;
// quantity requests trim LIFO and snapshot here.
const units = quantities.units?.length
? await this.reduceContainerUnitsExact(manager, booking, quantities.units)
: await this.reduceContainerLines(manager, booking, quantities.bySize);
quantities.units = units; quantities.units = units;
droppedWeight = round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)); droppedWeight = round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0));
await this.releaseContainerAllocations( await this.releaseContainerAllocations(
@@ -244,7 +269,12 @@ export class BookingWagonCancellationService {
} else { } else {
droppedWeight = Number(quantities.bulkTons ?? row.weightTons); droppedWeight = Number(quantities.bulkTons ?? row.weightTons);
await this.reduceBulk(manager, booking, droppedWeight); await this.reduceBulk(manager, booking, droppedWeight);
await this.releaseBulkAllocations(manager, booking.id, Number(row.wagonsCancelled)); await this.releaseBulkAllocations(
manager,
booking.id,
Number(row.wagonsCancelled),
quantities.allocationIds,
);
} }
// Mirror applySplit's bookkeeping: preSplitQuantities feeds the ONE_TIME // Mirror applySplit's bookkeeping: preSplitQuantities feeds the ONE_TIME
@@ -391,6 +421,14 @@ export class BookingWagonCancellationService {
'Wagon cancellation needs a contract booking (the credit is rebooked under the contract).', 'Wagon cancellation needs a contract booking (the credit is rebooked under the contract).',
); );
} }
// Cancellation is allowed strictly BEFORE loading/dispatch: both signals
// checked — per-wagon allocation status and the booking-level loading stamp
// (some flows confirm loading on the booking without flipping allocations).
if (booking.loadedAt) {
throw new BadRequestException(
'Cargo loading is confirmed for this booking — wagons can no longer be cancelled.',
);
}
const moving = await this.dataSource.getRepository(WagonBookingAllocation).count({ const moving = await this.dataSource.getRepository(WagonBookingAllocation).count({
where: { bookingId, status: In(['LOADED', 'DEPARTED']) }, where: { bookingId, status: In(['LOADED', 'DEPARTED']) },
}); });
@@ -412,6 +450,10 @@ export class BookingWagonCancellationService {
throw new BadRequestException('This booking has no wagon requirement to cancel from.'); throw new BadRequestException('This booking has no wagon requirement to cancel from.');
} }
if (dto.wagonAllocationIds?.length) {
return this.resolveCutFromAllocations(booking, dto.wagonAllocationIds, totalWagons);
}
if (booking.freightType === 'CONTAINER') { if (booking.freightType === 'CONTAINER') {
if (!dto.containers?.length) { if (!dto.containers?.length) {
throw new BadRequestException('Specify the container units to cancel per size.'); throw new BadRequestException('Specify the container units to cancel per size.');
@@ -471,6 +513,102 @@ export class BookingWagonCancellationService {
return { wagons, weightTons: tons, quantities: { bulkTons: tons } }; return { wagons, weightTons: tons, quantities: { bulkTons: tons } };
} }
/**
* Specific-wagon cancellation: the customer picked wagons in the Wagons tab.
* Everything is derived from the selected allocations — container bookings
* get their exact unit snapshots up front (T2 then cuts precisely these,
* not a LIFO guess), bulk gets the wagons' actual allocated tonnage.
*/
private async resolveCutFromAllocations(
booking: Booking,
allocationIds: string[],
totalWagons: number,
): Promise<RequestedCut> {
const allocations = await this.dataSource.getRepository(WagonBookingAllocation).find({
where: { id: In(allocationIds), bookingId: booking.id },
relations: { containerItems: true },
});
if (allocations.length !== allocationIds.length) {
throw new BadRequestException(
'Some selected wagons no longer belong to this booking — refresh and pick again.',
);
}
const notCancellable = allocations.filter(
(a) => a.status !== 'PLANNED' && a.status !== 'RESERVED',
);
if (notCancellable.length) {
throw new BadRequestException(
'A selected wagon is already loaded or departed and cannot be cancelled.',
);
}
const wagons = allocations.length;
if (wagons >= totalWagons) {
throw new BadRequestException(
'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.',
);
}
if (booking.freightType !== 'CONTAINER') {
const allocated = allocations.reduce(
(s, a) => s + Number(a.allocatedWeightTons || 0),
0,
);
const tons =
allocated > 0
? round3(allocated)
: round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons));
return {
wagons,
weightTons: tons,
quantities: { bulkTons: tons, allocationIds },
};
}
// Container: the selected wagons' items name the exact physical boxes.
const numbers = allocations
.flatMap((a) => a.containerItems ?? [])
.map((i) => i.containerNumber)
.filter((n): n is string => !!n);
if (!numbers.length) {
throw new BadRequestException(
'The selected wagons carry no container records — cancel by quantity instead.',
);
}
const lines = await this.dataSource.getRepository(BookingContainer).find({
where: { bookingId: booking.id },
});
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
const units: CancelledUnitSnapshot[] = [];
const bySize: Record<string, number> = {};
for (const line of lines) {
const size = line.containerSize ?? '';
const lineUnits = await unitRepo.find({ where: { bookingContainerId: line.id } });
for (const u of lineUnits) {
if (!numbers.includes(u.containerNumber)) continue;
units.push({
containerSize: size,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? null,
vgmTons: Number(u.vgmTons),
isHazardous: u.isHazardous,
isReefer: u.isReefer,
});
bySize[size] = (bySize[size] ?? 0) + 1;
}
}
if (units.length !== numbers.length) {
throw new BadRequestException(
'Wagon container records are out of sync with the booking — contact EDR support.',
);
}
return {
wagons,
weightTons: round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)),
quantities: { bySize, units, allocationIds },
};
}
/** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */ /** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */
private creditFor(booking: Booking, wagons: number): number { private creditFor(booking: Booking, wagons: number): number {
const totalWagons = Number(booking.wagonsRequired ?? 0); const totalWagons = Number(booking.wagonsRequired ?? 0);
@@ -569,6 +707,62 @@ export class BookingWagonCancellationService {
return snapshots; return snapshots;
} }
/**
* Cut EXACTLY the snapshotted units (specific-wagon cancellation): soft-delete
* them and rebalance each affected line. Returns the snapshots of the units
* actually cut, so drift since the request fails loudly instead of guessing.
*/
private async reduceContainerUnitsExact(
manager: EntityManager,
booking: Booking,
wanted: CancelledUnitSnapshot[],
): Promise<CancelledUnitSnapshot[]> {
const numbers = wanted.map((u) => u.containerNumber);
const lines = await manager.getRepository(BookingContainer).find({
where: { bookingId: booking.id },
});
const cut: CancelledUnitSnapshot[] = [];
for (const line of lines) {
const size = line.containerSize ?? '';
const lineUnits = await manager.getRepository(BookingContainerUnit).find({
where: { bookingContainerId: line.id },
});
const doomed = lineUnits.filter((u) => numbers.includes(u.containerNumber));
if (!doomed.length) continue;
await manager.getRepository(BookingContainerUnit).softDelete(doomed.map((u) => u.id));
for (const u of doomed) {
cut.push({
containerSize: size,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? null,
vgmTons: Number(u.vgmTons),
isHazardous: u.isHazardous,
isReefer: u.isReefer,
});
}
const kept = lineUnits.filter((u) => !numbers.includes(u.containerNumber));
if (!kept.length) {
await manager.getRepository(BookingContainer).softDelete(line.id);
continue;
}
const doomedVgm = round3(doomed.reduce((s, u) => s + Number(u.vgmTons || 0), 0));
await manager.getRepository(BookingContainer).update(line.id, {
quantity: kept.length,
wagonsRequired: round2(kept.length * wagonsPerUnitForSize(Number(size))),
totalVgmTons: round3(Number(line.totalVgmTons) - doomedVgm),
hazardousQuantity: kept.filter((u) => u.isHazardous).length,
reeferQuantity: kept.filter((u) => u.isReefer).length,
});
}
if (cut.length !== wanted.length) {
throw new BadRequestException(
`Booking changed since the request: ${cut.length}/${wanted.length} selected container(s) still on it.`,
);
}
return cut;
}
private async reduceBulk( private async reduceBulk(
manager: EntityManager, manager: EntityManager,
booking: Booking, booking: Booking,
@@ -623,19 +817,37 @@ export class BookingWagonCancellationService {
} }
} }
/** Free whole bulk wagons, newest allocations first. */ /**
* Free whole bulk wagons — the customer-picked allocations when given
* (specific-wagon cancel), topping up newest-first for any picked id that no
* longer exists (re-batch between request and fee payment).
*/
private async releaseBulkAllocations( private async releaseBulkAllocations(
manager: EntityManager, manager: EntityManager,
bookingId: string, bookingId: string,
wagons: number, wagons: number,
pickedIds?: string[],
): Promise<void> { ): Promise<void> {
const toFree = Math.round(wagons); const toFree = Math.round(wagons);
if (toFree <= 0) return; if (toFree <= 0) return;
const allocations = await manager.getRepository(WagonBookingAllocation).find({ let allocations: WagonBookingAllocation[] = [];
where: { bookingId }, if (pickedIds?.length) {
order: { createdAt: 'DESC' }, allocations = await manager.getRepository(WagonBookingAllocation).find({
take: toFree, where: { id: In(pickedIds), bookingId },
}); });
}
if (allocations.length < toFree) {
const have = new Set(allocations.map((a) => a.id));
const fill = await manager.getRepository(WagonBookingAllocation).find({
where: { bookingId },
order: { createdAt: 'DESC' },
});
for (const a of fill) {
if (allocations.length >= toFree) break;
if (!have.has(a.id)) allocations.push(a);
}
}
allocations = allocations.slice(0, toFree);
if (!allocations.length) return; if (!allocations.length) return;
const ids = allocations.map((a) => a.id); const ids = allocations.map((a) => a.id);
await manager await manager

View File

@@ -347,7 +347,8 @@ export class BookingsService {
*/ */
async wagonAllocations(bookingId: string): Promise<unknown[]> { async wagonAllocations(bookingId: string): Promise<unknown[]> {
return this.dataSource.query( return this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo", `SELECT a.id AS "allocationId",
tsw.sequence_no AS "sequenceNo",
w.wagon_number AS "wagonNumber", w.wagon_number AS "wagonNumber",
COALESCE(wt.name, wt.code) AS "wagonType", COALESCE(wt.name, wt.code) AS "wagonType",
wt.code AS "wagonTypeCode", wt.code AS "wagonTypeCode",

View File

@@ -9,6 +9,7 @@ import {
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
IsUUID,
MaxLength, MaxLength,
Min, Min,
ValidateNested, ValidateNested,
@@ -28,6 +29,18 @@ export class CancelContainerLineDto {
} }
export class RequestWagonCancellationDto { export class RequestWagonCancellationDto {
@ApiPropertyOptional({
description:
'Cancel SPECIFIC allocated wagons: wagon_booking_allocation ids from GET /bookings/:id/wagons. ' +
'When set, wagons/containers are derived from the selected wagons and the other fields are ignored.',
type: [String],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
wagonAllocationIds?: string[];
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'BULK bookings: number of wagons to cancel (tons derived proportionally)', description: 'BULK bookings: number of wagons to cancel (tons derived proportionally)',
}) })

View File

@@ -37,12 +37,19 @@ export interface CancelledQuantities {
/** Container bookings: units cut per container size. */ /** Container bookings: units cut per container size. */
bySize?: Record<string, number>; bySize?: Record<string, number>;
/** /**
* Container bookings: the exact physical units cut, snapshotted at fee * Container bookings: the exact physical units cut. Snapshotted at request
* settlement. The rebook reconstructs the new booking from THESE — never * time when the customer picked specific wagons, otherwise at fee settlement
* (LIFO trim). The rebook reconstructs the new booking from THESE — never
* from a soft-deleted-row scan, which could pick up units dropped by an * from a soft-deleted-row scan, which could pick up units dropped by an
* unrelated batch split on the same booking. * unrelated batch split on the same booking.
*/ */
units?: CancelledUnitSnapshot[]; units?: CancelledUnitSnapshot[];
/**
* Specific-wagon cancellation: the wagon_booking_allocation ids the customer
* picked in the Wagons tab. T2 releases exactly these (fallback to
* newest-first for any id that no longer exists, e.g. after a re-batch).
*/
allocationIds?: string[];
} }
/** /**

View File

@@ -345,7 +345,15 @@ export function ReadonlyBookingView({
{showWagonsTab && ( {showWagonsTab && (
<Tabs.Panel value="wagons"> <Tabs.Panel value="wagons">
<WagonsTab bookingId={booking.id} /> <WagonsTab
bookingId={booking.id}
cancellable={
booking.status === "PAID" &&
booking.paymentStatus === "PAID" &&
Boolean(booking.contractId)
}
onCancellationRequested={onBookingUpdated}
/>
</Tabs.Panel> </Tabs.Panel>
)} )}

View File

@@ -1,5 +1,19 @@
import { Box, Group, SimpleGrid, Skeleton, Table, Text, Tooltip } from "@mantine/core"; import {
import { useQuery } from "@tanstack/react-query"; Alert,
Box,
Button,
Checkbox,
Group,
Modal,
SimpleGrid,
Skeleton,
Stack,
Table,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Container, Container,
Gauge, Gauge,
@@ -10,16 +24,28 @@ import {
TrainFront, TrainFront,
TrainTrack, TrainTrack,
} from "lucide-react"; } from "lucide-react";
import type { ReactNode } from "react"; import { useState, type ReactNode } from "react";
import toast from "react-hot-toast";
import { import {
bookingsService, bookingsService,
type BookingWagonAllocation, type BookingWagonAllocation,
type WagonCancellationPreview,
} from "@/services/bookings.service"; } from "@/services/bookings.service";
import { api } from "@/services/api";
import { fmtDate, fmtWeight } from "../utils"; import { fmtDate, fmtWeight } from "../utils";
import { CardTitle, SectionCard } from "./layout"; import { CardTitle, SectionCard } from "./layout";
const apiErrorMessage = (error: unknown, fallback: string) => {
const data = (
error as { response?: { data?: { message?: string | string[] } } }
)?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return fallback;
};
// Mirrors CargoTab's local Flag/StatTile look so the two tabs read as one page. // Mirrors CargoTab's local Flag/StatTile look so the two tabs read as one page.
const STATUS_TONES: Record< const STATUS_TONES: Record<
BookingWagonAllocation["status"], BookingWagonAllocation["status"],
@@ -173,15 +199,39 @@ function LoadBar({ allocated, capacity }: { allocated: number; capacity: number
const th = { color: "#9AA8B5", fontSize: 11 } as const; const th = { color: "#9AA8B5", fontSize: 11 } as const;
function WagonCard({ wagon }: { wagon: BookingWagonAllocation }) { function WagonCard({
wagon,
selectable,
selected,
onToggle,
}: {
wagon: BookingWagonAllocation;
selectable?: boolean;
selected?: boolean;
onToggle?: () => void;
}) {
const allocated = Number(wagon.allocatedWeightTons || 0); const allocated = Number(wagon.allocatedWeightTons || 0);
const capacity = Number(wagon.capacityTons || 0); const capacity = Number(wagon.capacityTons || 0);
const containers = wagon.containers ?? []; const containers = wagon.containers ?? [];
return ( return (
<SectionCard> <SectionCard
style={
selected
? { outline: "2px solid #B45309", outlineOffset: -2, borderRadius: 16 }
: undefined
}
>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="sm"> <Group justify="space-between" align="flex-start" wrap="nowrap" mb="sm">
<Group gap={10} align="center" wrap="nowrap"> <Group gap={10} align="center" wrap="nowrap">
{selectable && (
<Checkbox
checked={!!selected}
onChange={onToggle}
color="orange"
aria-label={`Select wagon ${wagon.sequenceNo} for cancellation`}
/>
)}
<Box <Box
style={{ style={{
width: 40, width: 40,
@@ -310,13 +360,88 @@ function WagonCard({ wagon }: { wagon: BookingWagonAllocation }) {
* booking has been placed on a train — consist strip in marshalling order, * booking has been placed on a train — consist strip in marshalling order,
* per-wagon load/containers, and the train's route summary. * per-wagon load/containers, and the train's route summary.
*/ */
export function WagonsTab({ bookingId }: { bookingId: string }) { export function WagonsTab({
bookingId,
cancellable,
onCancellationRequested,
}: {
bookingId: string;
/** PAID contract booking — specific wagons may be selected for cancellation. */
cancellable?: boolean;
onCancellationRequested?: () => void;
}) {
const queryClient = useQueryClient();
const { data: wagons, isLoading } = useQuery({ const { data: wagons, isLoading } = useQuery({
queryKey: ["booking-wagons", bookingId], queryKey: ["booking-wagons", bookingId],
queryFn: () => bookingsService.getWagons(bookingId), queryFn: () => bookingsService.getWagons(bookingId),
enabled: !!bookingId, enabled: !!bookingId,
}); });
// Only needed to block a second request while one is awaiting its fee.
const { data: history } = useQuery({
...api.bookings.listWagonCancellations.queryOptions({ input: { bookingId } }),
enabled: !!bookingId && !!cancellable,
});
const hasOpenCancellation = (history?.items ?? []).some(
(r) => r.bookingId === bookingId && r.status === "FEE_PENDING",
);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [confirmOpen, setConfirmOpen] = useState(false);
const [reason, setReason] = useState("");
const [preview, setPreview] = useState<WagonCancellationPreview | null>(null);
const toggle = (allocationId: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(allocationId)) next.delete(allocationId);
else next.add(allocationId);
return next;
});
const previewMutation = useMutation({
mutationFn: () =>
bookingsService.previewWagonCancellation(bookingId, {
wagonAllocationIds: [...selected],
}),
onSuccess: setPreview,
onError: (e) => {
setPreview(null);
toast.error(apiErrorMessage(e, "Could not calculate the fee. Please try again."));
},
});
const requestMutation = useMutation({
mutationFn: () =>
bookingsService.requestWagonCancellation(bookingId, {
wagonAllocationIds: [...selected],
...(reason.trim() ? { reason: reason.trim() } : {}),
}),
onSuccess: () => {
setConfirmOpen(false);
setSelected(new Set());
setReason("");
setPreview(null);
toast.success(
"Cancellation requested — pay the fee in the wagon cancellation card to release these wagons.",
{ duration: 7000 },
);
void queryClient.invalidateQueries({ queryKey: ["booking-wagons", bookingId] });
void queryClient.invalidateQueries({
queryKey: api.bookings.listWagonCancellations.queryKey({ bookingId }),
});
onCancellationRequested?.();
},
onError: (e) =>
toast.error(apiErrorMessage(e, "Could not request the cancellation. Please try again.")),
});
const openConfirm = () => {
setPreview(null);
setConfirmOpen(true);
previewMutation.mutate();
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col gap-6" style={{ maxWidth: 980 }}> <div className="flex flex-col gap-6" style={{ maxWidth: 980 }}>
@@ -361,6 +486,7 @@ export function WagonsTab({ bookingId }: { bookingId: string }) {
); );
} }
const canSelect = !!cancellable && !hasOpenCancellation;
const first = wagons[0]; const first = wagons[0];
const totalAllocated = wagons.reduce( const totalAllocated = wagons.reduce(
(s, w) => s + Number(w.allocatedWeightTons || 0), (s, w) => s + Number(w.allocatedWeightTons || 0),
@@ -435,11 +561,122 @@ export function WagonsTab({ bookingId }: { bookingId: string }) {
</SimpleGrid> </SimpleGrid>
</SectionCard> </SectionCard>
{canSelect && (
<SectionCard>
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
<Box>
<Text fz={14} fw={800} c="#10202F">
Cancel specific wagons
</Text>
<Text fz={12.5} c="#9AA8B5">
Tick the wagons you want to cancel. A per-wagon cancellation fee
applies; the wagons stay yours until the fee is paid, and the
freight you paid for them becomes a rebooking credit.
</Text>
</Box>
<Button
color="orange"
disabled={selected.size === 0 || selected.size >= wagons.length}
onClick={openConfirm}
>
Cancel selected ({selected.size})
</Button>
</Group>
{selected.size >= wagons.length && selected.size > 0 && (
<Text fz={12} c="#B3362C" mt={6}>
You cannot cancel every wagon here to cancel the whole booking,
use the booking cancellation instead.
</Text>
)}
</SectionCard>
)}
{cancellable && hasOpenCancellation && (
<Alert color="yellow" variant="light">
A wagon cancellation is already awaiting its fee pay or withdraw it
in the wagon cancellation card before requesting another.
</Alert>
)}
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}> <SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
{wagons.map((w) => ( {wagons.map((w) => (
<WagonCard key={w.sequenceNo} wagon={w} /> <WagonCard
key={w.allocationId ?? w.sequenceNo}
wagon={w}
selectable={
canSelect &&
!!w.allocationId &&
(w.status === "PLANNED" || w.status === "RESERVED")
}
selected={!!w.allocationId && selected.has(w.allocationId)}
onToggle={() => w.allocationId && toggle(w.allocationId)}
/>
))} ))}
</SimpleGrid> </SimpleGrid>
<Modal
opened={confirmOpen}
onClose={() => setConfirmOpen(false)}
title="Cancel selected wagons"
centered
>
<Stack gap="sm">
<Text fz={13.5} c="#475569">
You are cancelling <b>{selected.size}</b> wagon(s). They stay
allocated to you until the cancellation fee is paid; after that the
paid freight for them becomes a credit you can rebook on another
day while your contract is valid.
</Text>
{previewMutation.isPending && <Skeleton height={64} radius={10} />}
{preview && (
<Box
p={12}
style={{
borderRadius: 10,
backgroundColor: "#FFFBEB",
border: "1px solid #FDE68A",
}}
>
<Group justify="space-between">
<Text fz={13} c="#92400E">
Cancellation fee ({preview.wagons} × {Number(preview.feePerWagon).toLocaleString()})
</Text>
<Text fz={14} fw={800} c="#92400E">
{Number(preview.feeAmount).toLocaleString()} {preview.feeCurrency}
</Text>
</Group>
<Group justify="space-between" mt={4}>
<Text fz={13} c="#0A6F4D">
Rebooking credit kept
</Text>
<Text fz={14} fw={800} c="#0A6F4D">
{Number(preview.creditAmount).toLocaleString()}
</Text>
</Group>
</Box>
)}
<Textarea
label="Reason (optional)"
placeholder="Why are you cancelling these wagons?"
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setConfirmOpen(false)}>
Keep wagons
</Button>
<Button
color="orange"
loading={requestMutation.isPending}
disabled={!preview}
onClick={() => requestMutation.mutate()}
>
Request cancellation
</Button>
</Group>
</Stack>
</Modal>
</div> </div>
); );
} }

View File

@@ -204,6 +204,8 @@ export interface BookingWagonContainer {
/** One allocated wagon of a booking, as returned by GET /bookings/:id/wagons. */ /** One allocated wagon of a booking, as returned by GET /bookings/:id/wagons. */
export interface BookingWagonAllocation { export interface BookingWagonAllocation {
/** wagon_booking_allocations id — the handle for cancelling this specific wagon. */
allocationId: string;
sequenceNo: number; sequenceNo: number;
wagonNumber: string | null; wagonNumber: string | null;
wagonType: string | null; wagonType: string | null;
@@ -266,6 +268,8 @@ export interface WagonCancellationPreview {
} }
export interface RequestWagonCancellationPayload { export interface RequestWagonCancellationPayload {
/** Cancel SPECIFIC wagons: allocationIds from getWagons. Overrides the fields below. */
wagonAllocationIds?: string[];
/** BULK bookings: number of wagons to cancel (tons derived proportionally). */ /** BULK bookings: number of wagons to cancel (tons derived proportionally). */
wagons?: number; wagons?: number;
/** CONTAINER bookings: units to cancel per size ("20"/"40", as stored on the line). */ /** CONTAINER bookings: units to cancel per size ("20"/"40", as stored on the line). */