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;
|
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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
Reference in New Issue
Block a user