mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1150 from Tria-plc/freight_feature/usermanagement
fix wagon cncellation
This commit is contained in:
@@ -222,6 +222,27 @@ export class BookingWagonCancellationService {
|
||||
}
|
||||
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) => {
|
||||
const booking = await manager.getRepository(Booking).findOne({
|
||||
where: { id: row.bookingId },
|
||||
@@ -233,7 +254,11 @@ export class BookingWagonCancellationService {
|
||||
let droppedWeight = 0;
|
||||
|
||||
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;
|
||||
droppedWeight = round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0));
|
||||
await this.releaseContainerAllocations(
|
||||
@@ -244,7 +269,12 @@ export class BookingWagonCancellationService {
|
||||
} else {
|
||||
droppedWeight = Number(quantities.bulkTons ?? row.weightTons);
|
||||
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
|
||||
@@ -391,6 +421,14 @@ export class BookingWagonCancellationService {
|
||||
'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({
|
||||
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.');
|
||||
}
|
||||
|
||||
if (dto.wagonAllocationIds?.length) {
|
||||
return this.resolveCutFromAllocations(booking, dto.wagonAllocationIds, totalWagons);
|
||||
}
|
||||
|
||||
if (booking.freightType === 'CONTAINER') {
|
||||
if (!dto.containers?.length) {
|
||||
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 } };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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). */
|
||||
private creditFor(booking: Booking, wagons: number): number {
|
||||
const totalWagons = Number(booking.wagonsRequired ?? 0);
|
||||
@@ -569,6 +707,62 @@ export class BookingWagonCancellationService {
|
||||
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(
|
||||
manager: EntityManager,
|
||||
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(
|
||||
manager: EntityManager,
|
||||
bookingId: string,
|
||||
wagons: number,
|
||||
pickedIds?: string[],
|
||||
): Promise<void> {
|
||||
const toFree = Math.round(wagons);
|
||||
if (toFree <= 0) return;
|
||||
const allocations = await manager.getRepository(WagonBookingAllocation).find({
|
||||
where: { bookingId },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: toFree,
|
||||
});
|
||||
let allocations: WagonBookingAllocation[] = [];
|
||||
if (pickedIds?.length) {
|
||||
allocations = await manager.getRepository(WagonBookingAllocation).find({
|
||||
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;
|
||||
const ids = allocations.map((a) => a.id);
|
||||
await manager
|
||||
|
||||
@@ -347,7 +347,8 @@ export class BookingsService {
|
||||
*/
|
||||
async wagonAllocations(bookingId: string): Promise<unknown[]> {
|
||||
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",
|
||||
COALESCE(wt.name, wt.code) AS "wagonType",
|
||||
wt.code AS "wagonTypeCode",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
@@ -28,6 +29,18 @@ export class CancelContainerLineDto {
|
||||
}
|
||||
|
||||
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({
|
||||
description: 'BULK bookings: number of wagons to cancel (tons derived proportionally)',
|
||||
})
|
||||
|
||||
@@ -37,12 +37,19 @@ export interface CancelledQuantities {
|
||||
/** Container bookings: units cut per container size. */
|
||||
bySize?: Record<string, number>;
|
||||
/**
|
||||
* Container bookings: the exact physical units cut, snapshotted at fee
|
||||
* settlement. The rebook reconstructs the new booking from THESE — never
|
||||
* Container bookings: the exact physical units cut. Snapshotted at request
|
||||
* 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
|
||||
* unrelated batch split on the same booking.
|
||||
*/
|
||||
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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -345,7 +345,15 @@ export function ReadonlyBookingView({
|
||||
|
||||
{showWagonsTab && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { Box, Group, SimpleGrid, Skeleton, Table, Text, Tooltip } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
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 {
|
||||
Container,
|
||||
Gauge,
|
||||
@@ -10,16 +24,28 @@ import {
|
||||
TrainFront,
|
||||
TrainTrack,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
bookingsService,
|
||||
type BookingWagonAllocation,
|
||||
type WagonCancellationPreview,
|
||||
} from "@/services/bookings.service";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
import { fmtDate, fmtWeight } from "../utils";
|
||||
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.
|
||||
const STATUS_TONES: Record<
|
||||
BookingWagonAllocation["status"],
|
||||
@@ -173,15 +199,39 @@ function LoadBar({ allocated, capacity }: { allocated: number; capacity: number
|
||||
|
||||
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 capacity = Number(wagon.capacityTons || 0);
|
||||
const containers = wagon.containers ?? [];
|
||||
|
||||
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 gap={10} align="center" wrap="nowrap">
|
||||
{selectable && (
|
||||
<Checkbox
|
||||
checked={!!selected}
|
||||
onChange={onToggle}
|
||||
color="orange"
|
||||
aria-label={`Select wagon ${wagon.sequenceNo} for cancellation`}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
style={{
|
||||
width: 40,
|
||||
@@ -310,13 +360,88 @@ function WagonCard({ wagon }: { wagon: BookingWagonAllocation }) {
|
||||
* booking has been placed on a train — consist strip in marshalling order,
|
||||
* 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({
|
||||
queryKey: ["booking-wagons", bookingId],
|
||||
queryFn: () => bookingsService.getWagons(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) {
|
||||
return (
|
||||
<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 totalAllocated = wagons.reduce(
|
||||
(s, w) => s + Number(w.allocatedWeightTons || 0),
|
||||
@@ -435,11 +561,122 @@ export function WagonsTab({ bookingId }: { bookingId: string }) {
|
||||
</SimpleGrid>
|
||||
</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}>
|
||||
{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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -204,6 +204,8 @@ export interface BookingWagonContainer {
|
||||
|
||||
/** One allocated wagon of a booking, as returned by GET /bookings/:id/wagons. */
|
||||
export interface BookingWagonAllocation {
|
||||
/** wagon_booking_allocations id — the handle for cancelling this specific wagon. */
|
||||
allocationId: string;
|
||||
sequenceNo: number;
|
||||
wagonNumber: string | null;
|
||||
wagonType: string | null;
|
||||
@@ -266,6 +268,8 @@ export interface WagonCancellationPreview {
|
||||
}
|
||||
|
||||
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). */
|
||||
wagons?: number;
|
||||
/** CONTAINER bookings: units to cancel per size ("20"/"40", as stored on the line). */
|
||||
|
||||
Reference in New Issue
Block a user