mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-05 20:03:39 +00:00
feat: add BookingWagonsPanel component for displaying allocated wagons in booking details
- Implemented BookingWagonsPanel to show allocated wagons, their containers, and export functionality. - Integrated the new panel into BookingRequestDetailPage and BookingRequestsPage. - Enhanced wagon cancellation modal to support rebooking of wagon cancellations with partner units. - Updated API service to include a method for downloading wagons workbook. - Modified types and constants to accommodate new features related to wagons. - Adjusted various components and pages to ensure compatibility with the new wagon-related functionality.
This commit is contained in:
@@ -124,6 +124,8 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () =
|
||||
};
|
||||
|
||||
it('refuses an odd-20ft rebook without a GL-picked partner', async () => {
|
||||
// An odd credit always leaves a half-empty wagon, so GL must name who fills
|
||||
// it — the rebook is refused rather than shipping a half-empty wagon.
|
||||
await expect(
|
||||
makeSvc().rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.toThrow(/pick a consolidation partner/i);
|
||||
@@ -143,6 +145,74 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () =
|
||||
}),
|
||||
).rejects.toThrow(/already shares a wagon/i);
|
||||
});
|
||||
|
||||
/**
|
||||
* An EXPIRED partner has no pay window left, so pairing the PAID rebook
|
||||
* straight onto it strands the shared wagon: neither half can board and
|
||||
* nothing ever breaks the pair (BK-2026-001114). Its cargo must move to a
|
||||
* fresh booking that carries its own invoice.
|
||||
*/
|
||||
it('clones an EXPIRED partner into a new booking instead of pairing the dead one', async () => {
|
||||
const dead = {
|
||||
id: 'p1',
|
||||
reference: 'BK-2026-001114',
|
||||
status: 'EXPIRED',
|
||||
contractId: 'c1',
|
||||
consolidationPartnerId: null,
|
||||
paymentCurrency: 'USD',
|
||||
originYardId: 'y1',
|
||||
destinationYardId: 'y2',
|
||||
tradeDirection: 'IMPORT',
|
||||
scheduledDate: '2026-09-01',
|
||||
bookingContainers: [
|
||||
{
|
||||
containerSize: '20ft',
|
||||
quantity: 1,
|
||||
hazardousQuantity: 0,
|
||||
reeferQuantity: 0,
|
||||
containerType: { sizeFt: 20 },
|
||||
units: [
|
||||
{
|
||||
containerNumber: 'PCONT0',
|
||||
sealNumber: null,
|
||||
vgmTons: 9,
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const clone = { ...dead, id: 'p1-clone', reference: 'BK-2026-001116', status: 'SUBMITTED' };
|
||||
|
||||
const svc = makeSvc(dead) as Record<string, unknown>;
|
||||
let createdUnderContract: string | null = null;
|
||||
let pairedWith: string | null = null;
|
||||
(svc as { bookingsRepository: Record<string, unknown> }).bookingsRepository = {
|
||||
findById: async () => source,
|
||||
findByIdWithFiles: async (id: string) => (id === 'p1-clone' ? clone : dead),
|
||||
hasSpentCancellationCredit: async () => false,
|
||||
};
|
||||
(svc as { contractBooking: unknown }).contractBooking = {
|
||||
createUnderContract: async (contractId: string) => {
|
||||
createdUnderContract = contractId;
|
||||
return { booking: { id: 'p1-clone' } };
|
||||
},
|
||||
};
|
||||
(svc as { notifyCustomer: unknown }).notifyCustomer = () => undefined;
|
||||
|
||||
const cloned = await (
|
||||
svc as unknown as {
|
||||
cloneDeadPartner(p: unknown, d: string): Promise<{ id: string; reference: string }>;
|
||||
}
|
||||
).cloneDeadPartner(dead, '2026-09-01');
|
||||
|
||||
// The dead booking is left dead; the clone is what gets paired and paid.
|
||||
expect(cloned.id).toBe('p1-clone');
|
||||
expect(cloned.reference).toBe('BK-2026-001116');
|
||||
expect(createdUnderContract).toBe('c1');
|
||||
expect(pairedWith).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1035,6 +1035,9 @@ export class BookingWagonCancellationService {
|
||||
let partner: Booking | null = null;
|
||||
if (oddFt20) {
|
||||
createDto.skipAutoConsolidation = true;
|
||||
// An odd credit always leaves a half-empty wagon, so GL names who fills
|
||||
// it. The candidate list is wide enough (any unpaired, unspent booking on
|
||||
// the day) that a partner is expected to exist.
|
||||
if (!dto.partnerBookingId) {
|
||||
throw new BadRequestException(
|
||||
'This credit carries an odd 20ft container — pick a consolidation partner booking to share its wagon (see the rebook-partners list).',
|
||||
@@ -1045,6 +1048,15 @@ export class BookingWagonCancellationService {
|
||||
dto.partnerBookingId,
|
||||
dto.scheduledDate,
|
||||
);
|
||||
// A dead partner cannot be paid where it stands — its cargo moves to a
|
||||
// fresh booking that can carry its own invoice and pay window.
|
||||
if (['EXPIRED', 'CANCELLED'].includes(partner.status)) {
|
||||
partner = await this.cloneDeadPartner(
|
||||
partner,
|
||||
dto.scheduledDate,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
const created = await this.contractBooking.createUnderContract(
|
||||
source.contractId,
|
||||
@@ -1079,6 +1091,15 @@ export class BookingWagonCancellationService {
|
||||
);
|
||||
}
|
||||
if (partner) {
|
||||
// Corrections GL made to the partner's own containers while pairing —
|
||||
// scoped to that booking by the repository, so a stray id cannot touch
|
||||
// another booking's cargo.
|
||||
if (dto.partnerUnits?.length) {
|
||||
await this.bookingsRepository.patchContainerUnitsForBooking(
|
||||
partner.id,
|
||||
dto.partnerUnits,
|
||||
);
|
||||
}
|
||||
// Consolidated rebook: never allocate the half-wagon booking alone. It
|
||||
// rides PAID and the batch engine settles the pair atomically once the
|
||||
// partner's own invoice is paid.
|
||||
@@ -1130,6 +1151,13 @@ export class BookingWagonCancellationService {
|
||||
status: string;
|
||||
scheduledDate: string | null;
|
||||
ft20Quantity: number;
|
||||
units: Array<{
|
||||
id: string;
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber: string | null;
|
||||
vgmTons: number;
|
||||
}>;
|
||||
}>
|
||||
> {
|
||||
const row = await this.mustFind(cancellationId);
|
||||
@@ -1150,6 +1178,18 @@ export class BookingWagonCancellationService {
|
||||
ft20Quantity: (b.bookingContainers ?? [])
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
|
||||
// Editable while pairing — GL corrects these on the rebook form.
|
||||
units: (b.bookingContainers ?? []).flatMap((line) =>
|
||||
(line.units ?? []).map((u) => ({
|
||||
id: u.id,
|
||||
containerSize: line.containerType?.sizeFt
|
||||
? `${line.containerType.sizeFt}ft`
|
||||
: '',
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber ?? null,
|
||||
vgmTons: Number(u.vgmTons ?? 0),
|
||||
})),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1168,11 +1208,29 @@ export class BookingWagonCancellationService {
|
||||
`Booking ${partner.reference} already shares a wagon with another booking.`,
|
||||
);
|
||||
}
|
||||
if (!['SUBMITTED', 'PENDING_CONSOLIDATION'].includes(partner.status)) {
|
||||
// Mirrors findRebookConsolidationCandidates: a partner need not be a live
|
||||
// committed shipment. One that lost its slot or was called off still has
|
||||
// cargo to move, and the rebooked wagon is how it moves.
|
||||
if (
|
||||
![
|
||||
'SUBMITTED',
|
||||
'PENDING_CONSOLIDATION',
|
||||
'CLEARANCE_READY',
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
'EXPIRED',
|
||||
'CANCELLED',
|
||||
].includes(partner.status)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} cannot be consolidated (status ${partner.status}).`,
|
||||
);
|
||||
}
|
||||
// A booking whose own credit was already rebooked elsewhere is spent.
|
||||
if (await this.bookingsRepository.hasSpentCancellationCredit(partner.id)) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} has already been rebooked from its cancellation credit.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
partner.originYardId !== source.originYardId ||
|
||||
partner.destinationYardId !== source.destinationYardId ||
|
||||
@@ -1200,6 +1258,74 @@ export class BookingWagonCancellationService {
|
||||
return partner;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dead (EXPIRED/CANCELLED) partner still has cargo to move, but it can no
|
||||
* longer be paid: its pay window is gone and finalizing it issues nothing a
|
||||
* customer can settle, so pairing the PAID rebook with it strands the shared
|
||||
* wagon forever (BK-2026-001114: EXPIRED/PENDING, paired to a PAID rebook,
|
||||
* no payment_deadline — neither half could ever board). So the cargo is
|
||||
* cloned into a fresh booking under the same contract, which finalizes
|
||||
* normally into its own invoice and pay window; the dead booking stays dead.
|
||||
*/
|
||||
private async cloneDeadPartner(
|
||||
partner: Booking,
|
||||
scheduledDate: string,
|
||||
userId?: string,
|
||||
): Promise<Booking> {
|
||||
if (!partner.contractId) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} has no contract to rebook its cargo under — pick a live partner instead.`,
|
||||
);
|
||||
}
|
||||
const dto: CreateBookingUnderContractDto = {
|
||||
scheduledDate,
|
||||
paymentCurrency: partner.paymentCurrency ?? undefined,
|
||||
// GL already chose this pairing — the auto-matcher must not re-home the
|
||||
// clone behind their back (same reasoning as the rebooked side).
|
||||
skipAutoConsolidation: true,
|
||||
containers: (partner.bookingContainers ?? []).map((line) => {
|
||||
const units = line.units ?? [];
|
||||
return {
|
||||
containerSize: line.containerSize ?? undefined,
|
||||
quantity: Number(line.quantity),
|
||||
units: units.map((u) => ({
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber ?? '',
|
||||
vgmTons: u.vgmTons,
|
||||
isHazardous: u.isHazardous,
|
||||
isReefer: u.isReefer,
|
||||
})),
|
||||
hazardousQuantity: Number(line.hazardousQuantity ?? 0),
|
||||
reeferQuantity: Number(line.reeferQuantity ?? 0),
|
||||
};
|
||||
}) as CreateBookingUnderContractDto['containers'],
|
||||
};
|
||||
const created = await this.contractBooking.createUnderContract(
|
||||
partner.contractId,
|
||||
dto,
|
||||
{ id: userId ?? partner.createdByUserId ?? undefined },
|
||||
{ permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] },
|
||||
// The dead partner's own contract may have lapsed while it sat expired;
|
||||
// its cargo is still the cargo GL picked to fill the shared wagon.
|
||||
{ allowExpiredContract: true },
|
||||
);
|
||||
const clone = await this.bookingsRepository.findByIdWithFiles(
|
||||
created.booking.id,
|
||||
);
|
||||
if (!clone) {
|
||||
throw new NotFoundException(
|
||||
`Replacement booking for ${partner.reference} could not be loaded.`,
|
||||
);
|
||||
}
|
||||
this.notifyCustomer(
|
||||
partner,
|
||||
'Replacement booking created',
|
||||
`${partner.reference} had expired, so its cargo moved to ${clone.reference} to share a wagon with a rebooked shipment. Pay ${clone.reference} to board.`,
|
||||
clone.id,
|
||||
);
|
||||
return clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link the rebooked (already PAID) booking with the GL-picked partner. A
|
||||
* parked partner is resumed the way pairConsolidation would resume it —
|
||||
|
||||
@@ -599,6 +599,34 @@ export class BookingsController {
|
||||
return this.bookingsService.wagonAllocations(id);
|
||||
}
|
||||
|
||||
@Get(":id/wagons/export")
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Download the booking's allocated wagons as an Excel workbook (customer name + one row per wagon)",
|
||||
})
|
||||
async wagonAllocationsExport(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
const { filename, buffer } =
|
||||
await this.bookingsService.wagonAllocationsWorkbook(id);
|
||||
res.setHeader(
|
||||
"Content-Type",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
);
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
// ── Partial wagon cancellation (paid bookings) ────────────────────────────
|
||||
// Customer endpoints are ownership-scoped (no portal permission keys); the
|
||||
// staff history/void/rebook variants are permission-gated below.
|
||||
|
||||
@@ -6,6 +6,7 @@ import { registerExchangeModule } from "../exchange-settings/exchange-module-opt
|
||||
|
||||
// import { CustomersModule } from '../customers/customers.module';
|
||||
import { CompaniesModule } from '../companies/companies.module';
|
||||
import { ExportsModule } from '../exports/exports.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { MinioModule } from '../minio/minio.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
@@ -105,6 +106,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
// CustomersModule,
|
||||
RuleEngineModule,
|
||||
FileUploadSettingsModule,
|
||||
ExportsModule,
|
||||
SignaturesModule,
|
||||
registerExchangeModule(),
|
||||
],
|
||||
|
||||
@@ -34,10 +34,12 @@ import {
|
||||
DocumentReviewStatus,
|
||||
} from './entities/booking-document-review.entity';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
|
||||
import { BookingContainerUnit } from './entities/booking-container-unit.entity';
|
||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import {
|
||||
BookingContractSignature,
|
||||
@@ -332,21 +334,22 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
* Bookings a GL operator may manually link to `booking` as its odd-20ft
|
||||
* consolidation partner (Path B customs flow). Unlike
|
||||
* {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact
|
||||
* quantity complement — this lists CANDIDATES for a human to choose from, so
|
||||
* the filter is deliberately looser: any other customs booking on the same
|
||||
* route/direction that is itself carrying an odd 20ft count. Two odd counts
|
||||
* always sum to even, so any pick fills the shared wagon.
|
||||
* quantity complement — this lists CANDIDATES for a human to choose from, but
|
||||
* every row must still be a legal pick: another customs booking on the same
|
||||
* route/direction, riding the same booking day, that is itself carrying an odd
|
||||
* 20ft count. Two odd counts always sum to even, so any pick fills the shared
|
||||
* wagon.
|
||||
*
|
||||
* Bare instances awaiting completion have no persisted containers yet, so the
|
||||
* odd-count test runs on the requested container lines when they exist and the
|
||||
* booking is offered as a candidate when they do not (GL enters its cargo on
|
||||
* the split form).
|
||||
* A booking whose cargo is not entered yet is NOT a candidate: with no
|
||||
* container lines its 20ft count is unknown, so pairing with it cannot be
|
||||
* shown to fill the wagon. Same rule as
|
||||
* {@link findRebookConsolidationCandidates}.
|
||||
*/
|
||||
async findManualConsolidationCandidates(
|
||||
booking: Booking,
|
||||
limit = 50,
|
||||
): Promise<Booking[]> {
|
||||
const rows = await this.repository
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('b')
|
||||
.leftJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||
@@ -376,17 +379,27 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
'PENDING_CONSOLIDATION',
|
||||
],
|
||||
})
|
||||
.orderBy('b.createdAt', 'ASC')
|
||||
.take(limit)
|
||||
.getMany();
|
||||
});
|
||||
|
||||
// Odd-20ft test in memory: a bare instance has no containers yet (GL fills
|
||||
// them on the split form) and stays a candidate; one that already carries
|
||||
// cargo qualifies only when its 20ft total is odd.
|
||||
// Same EAT booking day — the pair shares one physical wagon, so it must
|
||||
// board one train. Applied only when this booking has a date of its own;
|
||||
// without one there is no day to match against and route/direction stand
|
||||
// alone, mirroring findComplementaryConsolidationPartner.
|
||||
if (booking.scheduledDate) {
|
||||
qb.andWhere(
|
||||
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
|
||||
{ bookingDate: booking.scheduledDate },
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await qb.orderBy('b.createdAt', 'ASC').take(limit).getMany();
|
||||
|
||||
// Odd-20ft test in memory. A booking with no container lines has an unknown
|
||||
// 20ft count, so it cannot be shown to complete the wagon and is not
|
||||
// offered.
|
||||
return rows.filter((row) => {
|
||||
const lines = row.bookingContainers ?? [];
|
||||
if (lines.length === 0) return true;
|
||||
if (lines.length === 0) return false;
|
||||
const ft20 = lines
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
|
||||
@@ -396,10 +409,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
|
||||
/**
|
||||
* Candidate partners for rebooking an odd-20ft cancellation credit: unpaired
|
||||
* odd-20ft bookings on the same route/direction riding the requested day —
|
||||
* SUBMITTED (committed direct booking) or parked PENDING_CONSOLIDATION.
|
||||
* odd-20ft bookings on the same route/direction riding the requested day.
|
||||
* Unlike {@link findManualConsolidationCandidates} this is not customs-only:
|
||||
* GL picks who shares the rebooked wagon whatever the contract kind.
|
||||
*
|
||||
* The status set is deliberately wide. A partner here is not required to be a
|
||||
* live, committed shipment — a booking that lost its slot (EXPIRED) or was
|
||||
* cancelled still has cargo that GL can put back on a train, and pairing it
|
||||
* with the rebooked credit is how both halves get moving again. What it must
|
||||
* not be is already spoken for: a booking whose own cancellation credit has
|
||||
* been rebooked elsewhere is excluded, as is one already paired.
|
||||
*/
|
||||
async findRebookConsolidationCandidates(
|
||||
booking: Booking,
|
||||
@@ -410,6 +429,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.createQueryBuilder('b')
|
||||
.leftJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||
// Units come back so GL can correct the partner's container numbers,
|
||||
// seals and VGMs while pairing.
|
||||
.leftJoinAndSelect('bc.units', 'unit')
|
||||
.leftJoinAndSelect('b.company', 'company')
|
||||
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||
.andWhere('b.consolidationPartnerId IS NULL')
|
||||
@@ -423,8 +445,27 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
tradeDirection: booking.tradeDirection,
|
||||
})
|
||||
.andWhere('b.status IN (:...statuses)', {
|
||||
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||
statuses: [
|
||||
'SUBMITTED',
|
||||
'PENDING_CONSOLIDATION',
|
||||
'CLEARANCE_READY',
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
// Lost its slot or was called off — its cargo is still real and can
|
||||
// ride the rebooked wagon.
|
||||
'EXPIRED',
|
||||
'CANCELLED',
|
||||
],
|
||||
})
|
||||
// A cancelled booking whose own credit was already spent on a rebook is
|
||||
// gone — pairing with it would hand the same cargo out twice.
|
||||
.andWhere(
|
||||
`NOT EXISTS (
|
||||
SELECT 1 FROM freight.booking_wagon_cancellations c
|
||||
WHERE c.booking_id = b.id
|
||||
AND c.rebooked_booking_id IS NOT NULL
|
||||
AND c.deleted_at IS NULL
|
||||
)`,
|
||||
)
|
||||
// Same EAT booking day as the rebook — the pair shares one physical
|
||||
// wagon, so it must board one train.
|
||||
.andWhere(
|
||||
@@ -729,6 +770,89 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
return new Set(rows.map((r) => r.bookingId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookings among `bookingIds` that hold a redeemable wagon-cancellation
|
||||
* credit — the cut is settled (CREDIT_AVAILABLE), the credit is worth
|
||||
* something, and it has not been spent on a rebook yet. Surfaced on the GL
|
||||
* clearance queue so a paid-for credit is visibly rebookable from the list
|
||||
* rather than only from the booking's own page.
|
||||
*/
|
||||
async findBookingsWithRedeemableCredit(
|
||||
bookingIds: string[],
|
||||
): Promise<Map<string, string>> {
|
||||
if (bookingIds.length === 0) return new Map();
|
||||
const rows = (await this.dataSource
|
||||
.getRepository(BookingWagonCancellation)
|
||||
.createQueryBuilder('c')
|
||||
.select('c.booking_id', 'bookingId')
|
||||
.addSelect('c.id', 'cancellationId')
|
||||
.where('c.booking_id IN (:...bookingIds)', { bookingIds })
|
||||
.andWhere('c.status = :status', { status: 'CREDIT_AVAILABLE' })
|
||||
.andWhere('c.credit_amount > 0')
|
||||
.andWhere('c.rebooked_booking_id IS NULL')
|
||||
.andWhere('c.deleted_at IS NULL')
|
||||
.getRawMany()) as Array<{ bookingId: string; cancellationId: string }>;
|
||||
return new Map(rows.map((r) => [r.bookingId, r.cancellationId]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply container-unit corrections (number / seal / VGM) to units that belong
|
||||
* to `bookingId`. The ownership join is the point: a unit id from another
|
||||
* booking silently matches nothing rather than editing a stranger's cargo.
|
||||
* Sizes and quantities are never touched — only the identifying details.
|
||||
* Returns how many units were actually updated.
|
||||
*/
|
||||
async patchContainerUnitsForBooking(
|
||||
bookingId: string,
|
||||
patches: Array<{
|
||||
id: string;
|
||||
containerNumber?: string;
|
||||
sealNumber?: string;
|
||||
vgmTons?: number;
|
||||
}>,
|
||||
): Promise<number> {
|
||||
if (patches.length === 0) return 0;
|
||||
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
|
||||
const owned = await unitRepo
|
||||
.createQueryBuilder('u')
|
||||
.innerJoin('u.bookingContainer', 'bc')
|
||||
.where('bc.booking_id = :bookingId', { bookingId })
|
||||
.andWhere('u.id IN (:...ids)', { ids: patches.map((p) => p.id) })
|
||||
.select('u.id', 'id')
|
||||
.getRawMany<{ id: string }>();
|
||||
const ownedIds = new Set(owned.map((r) => r.id));
|
||||
|
||||
let updated = 0;
|
||||
for (const patch of patches) {
|
||||
if (!ownedIds.has(patch.id)) continue;
|
||||
const set: Record<string, unknown> = {};
|
||||
if (patch.containerNumber !== undefined)
|
||||
set.containerNumber = patch.containerNumber;
|
||||
if (patch.sealNumber !== undefined) set.sealNumber = patch.sealNumber;
|
||||
if (patch.vgmTons !== undefined) set.vgmTons = patch.vgmTons;
|
||||
if (Object.keys(set).length === 0) continue;
|
||||
await unitRepo.update(patch.id, set as never);
|
||||
updated += 1;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has this booking's own wagon-cancellation credit already been spent on a
|
||||
* rebook? Such a booking must not be offered or accepted as a consolidation
|
||||
* partner — its cargo has already moved to the rebooked booking.
|
||||
*/
|
||||
async hasSpentCancellationCredit(bookingId: string): Promise<boolean> {
|
||||
const count = await this.dataSource
|
||||
.getRepository(BookingWagonCancellation)
|
||||
.createQueryBuilder('c')
|
||||
.where('c.booking_id = :bookingId', { bookingId })
|
||||
.andWhere('c.rebooked_booking_id IS NOT NULL')
|
||||
.andWhere('c.deleted_at IS NULL')
|
||||
.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
findDocumentReview(
|
||||
bookingId: string,
|
||||
settingCode: string,
|
||||
@@ -1043,9 +1167,38 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
select: { bookingId: true, trainScheduleId: true },
|
||||
});
|
||||
const scheduleByBooking = new Map(links.map((link) => [link.bookingId, link.trainScheduleId]));
|
||||
|
||||
// The allocated train's own departure date — distinct from the customer's
|
||||
// requested `booking.scheduledDate`. The list column shows this once a
|
||||
// booking is on a train, so fetch it alongside the link ids.
|
||||
const scheduleIds = [...new Set([...scheduleByBooking.values()].filter(Boolean))] as string[];
|
||||
const schedules = scheduleIds.length
|
||||
? await this.dataSource.getRepository(TrainSchedule).find({
|
||||
where: { id: In(scheduleIds) },
|
||||
select: {
|
||||
id: true,
|
||||
reference: true,
|
||||
trainNumber: true,
|
||||
status: true,
|
||||
scheduledDepartureDate: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const scheduleById = new Map(schedules.map((schedule) => [schedule.id, schedule]));
|
||||
|
||||
for (const item of items) {
|
||||
(item as Booking & { trainScheduleId?: string | null }).trainScheduleId =
|
||||
scheduleByBooking.get(item.id) ?? null;
|
||||
const scheduleId = scheduleByBooking.get(item.id) ?? null;
|
||||
const enriched = item as Booking & {
|
||||
trainScheduleId?: string | null;
|
||||
trainScheduleReference?: string | null;
|
||||
trainScheduleDepartureDate?: string | null;
|
||||
};
|
||||
enriched.trainScheduleId = scheduleId;
|
||||
const schedule = scheduleId ? scheduleById.get(scheduleId) : undefined;
|
||||
enriched.trainScheduleReference = schedule?.reference ?? schedule?.trainNumber ?? null;
|
||||
enriched.trainScheduleDepartureDate = schedule?.scheduledDepartureDate
|
||||
? new Date(schedule.scheduledDepartureDate).toISOString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
|
||||
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { TabularExportService } from '../exports/tabular-export.service';
|
||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
@@ -104,6 +105,38 @@ export interface PaginatedBookings {
|
||||
};
|
||||
}
|
||||
|
||||
/** One container on an allocated wagon (raw SQL json_agg projection). */
|
||||
export interface WagonAllocationContainer {
|
||||
containerNumber: string | null;
|
||||
sealNumber: string | null;
|
||||
positionOnWagon: number | null;
|
||||
grossWeightTons: number | null;
|
||||
sizeFt: number | null;
|
||||
}
|
||||
|
||||
/** One allocated wagon as returned by `wagonAllocations` (raw SQL projection). */
|
||||
export interface WagonAllocationRow {
|
||||
allocationId: string;
|
||||
sequenceNo: number | null;
|
||||
wagonNumber: string | null;
|
||||
wagonType: string | null;
|
||||
wagonTypeCode: string | null;
|
||||
/** numeric columns arrive as strings from pg. */
|
||||
tareWeightTons: string | null;
|
||||
capacityTons: string | null;
|
||||
lengthMeters: string | null;
|
||||
allocatedWeightTons: string | null;
|
||||
loadType: string | null;
|
||||
status: string | null;
|
||||
trainNumber: string | null;
|
||||
departureAt: string | Date | null;
|
||||
originStation: string | null;
|
||||
destinationStation: string | null;
|
||||
bulkCargoDescription: string | null;
|
||||
bulkQuantity: string | null;
|
||||
containers: WagonAllocationContainer[];
|
||||
}
|
||||
|
||||
/** One wagon line on the carriage acceptance sheet (raw SQL projection). */
|
||||
interface CarriageAcceptanceWagonRow {
|
||||
sequenceNo: number;
|
||||
@@ -172,6 +205,7 @@ export class BookingsService {
|
||||
private readonly bookingContractService: BookingContractService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly tabularExport: TabularExportService,
|
||||
) {}
|
||||
|
||||
async assignCustomerTruck(
|
||||
@@ -447,7 +481,7 @@ export class BookingsService {
|
||||
* an array per wagon, bulk load description when the wagon carries bulk).
|
||||
* Empty array until the booking has been allocated onto a train.
|
||||
*/
|
||||
async wagonAllocations(bookingId: string): Promise<unknown[]> {
|
||||
async wagonAllocations(bookingId: string): Promise<WagonAllocationRow[]> {
|
||||
return this.dataSource.query(
|
||||
`SELECT a.id AS "allocationId",
|
||||
tsw.sequence_no AS "sequenceNo",
|
||||
@@ -501,6 +535,103 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Wagons tab's Excel export: the booking's customer identity in the KPI
|
||||
* header, then one row per allocated wagon.
|
||||
*
|
||||
* Container numbers are flattened into a single cell rather than exploded
|
||||
* into one row per container — the sheet is a wagon manifest, and a reader
|
||||
* counting rows must get the wagon count.
|
||||
*/
|
||||
async wagonAllocationsWorkbook(
|
||||
bookingId: string,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
const wagons = await this.wagonAllocations(bookingId);
|
||||
|
||||
// Same precedence the booking list uses: a shipping line owns its bookings
|
||||
// directly, a government booking names its institution, everyone else is
|
||||
// the customer company.
|
||||
// `shippingLineCompany` is attached by `findById` (attachShippingLineCompanies),
|
||||
// not a declared relation on the entity — hence the cast, matching that helper.
|
||||
const shippingLine = (booking as Booking & { shippingLineCompany?: { name?: string } })
|
||||
.shippingLineCompany;
|
||||
const customerName =
|
||||
shippingLine?.name ??
|
||||
(booking.isGovernment ? booking.governmentInstitution : null) ??
|
||||
booking.company?.name ??
|
||||
'—';
|
||||
|
||||
const rows = wagons.map((w) => ({
|
||||
sequenceNo: w.sequenceNo,
|
||||
wagonNumber: w.wagonNumber ?? '—',
|
||||
wagonType: w.wagonType ?? '—',
|
||||
loadType: w.loadType ?? '—',
|
||||
status: w.status ?? '—',
|
||||
tareWeightTons: w.tareWeightTons === null ? null : Number(w.tareWeightTons),
|
||||
capacityTons: w.capacityTons === null ? null : Number(w.capacityTons),
|
||||
allocatedWeightTons:
|
||||
w.allocatedWeightTons === null ? null : Number(w.allocatedWeightTons),
|
||||
lengthMeters: w.lengthMeters === null ? null : Number(w.lengthMeters),
|
||||
containerCount: w.containers?.length ?? 0,
|
||||
containerNumbers:
|
||||
(w.containers ?? []).map((c) => c.containerNumber).filter(Boolean).join(', ') || '—',
|
||||
sealNumbers:
|
||||
(w.containers ?? []).map((c) => c.sealNumber).filter(Boolean).join(', ') || '—',
|
||||
bulkCargo: w.bulkCargoDescription ?? '—',
|
||||
bulkQuantity: w.bulkQuantity === null ? null : Number(w.bulkQuantity),
|
||||
trainNumber: w.trainNumber ?? '—',
|
||||
departureAt: w.departureAt ? new Date(w.departureAt).toISOString().slice(0, 10) : '—',
|
||||
originStation: w.originStation ?? '—',
|
||||
destinationStation: w.destinationStation ?? '—',
|
||||
// Repeated on every row so the sheet survives being filtered, sorted or
|
||||
// pasted into a combined workbook, where the header block is lost.
|
||||
customerName,
|
||||
bookingReference: booking.reference,
|
||||
}));
|
||||
|
||||
const totalAllocated = rows.reduce(
|
||||
(sum, r) => sum + (r.allocatedWeightTons ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const buffer = await this.tabularExport.toXlsx({
|
||||
title: `Wagons ${booking.reference}`.slice(0, 31),
|
||||
description: `Wagons allocated to booking ${booking.reference} — ${customerName}`,
|
||||
label: 'booking:wagon-allocations',
|
||||
kpis: [
|
||||
{ label: 'Wagons', value: rows.length },
|
||||
{ label: 'Containers', value: rows.reduce((sum, r) => sum + r.containerCount, 0) },
|
||||
{ label: 'Allocated weight', value: Number(totalAllocated.toFixed(3)), unit: 't' },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'bookingReference', label: 'Booking', type: 'string' },
|
||||
{ key: 'customerName', label: 'Customer', type: 'string' },
|
||||
{ key: 'sequenceNo', label: 'Seq', type: 'number' },
|
||||
{ key: 'wagonNumber', label: 'Wagon number', type: 'string' },
|
||||
{ key: 'wagonType', label: 'Wagon type', type: 'string' },
|
||||
{ key: 'loadType', label: 'Load type', type: 'string' },
|
||||
{ key: 'status', label: 'Status', type: 'string' },
|
||||
{ key: 'tareWeightTons', label: 'Tare', type: 'tons' },
|
||||
{ key: 'capacityTons', label: 'Capacity', type: 'tons' },
|
||||
{ key: 'allocatedWeightTons', label: 'Allocated', type: 'tons' },
|
||||
{ key: 'lengthMeters', label: 'Length (m)', type: 'number' },
|
||||
{ key: 'containerCount', label: 'Containers', type: 'number' },
|
||||
{ key: 'containerNumbers', label: 'Container numbers', type: 'string' },
|
||||
{ key: 'sealNumbers', label: 'Seal numbers', type: 'string' },
|
||||
{ key: 'bulkCargo', label: 'Bulk cargo', type: 'string' },
|
||||
{ key: 'bulkQuantity', label: 'Bulk quantity', type: 'number' },
|
||||
{ key: 'trainNumber', label: 'Train', type: 'string' },
|
||||
{ key: 'departureAt', label: 'Departure', type: 'date' },
|
||||
{ key: 'originStation', label: 'Origin', type: 'string' },
|
||||
{ key: 'destinationStation', label: 'Destination', type: 'string' },
|
||||
],
|
||||
rows,
|
||||
});
|
||||
|
||||
return { filename: `wagons-${booking.reference}.xlsx`, buffer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the booking amount across its wagons, proportional to allocated weight
|
||||
* (equal shares when no weights are recorded). The last row absorbs the rounding
|
||||
|
||||
@@ -105,6 +105,31 @@ export class RebookContainerLineDto {
|
||||
units!: RebookUnitDto[];
|
||||
}
|
||||
|
||||
/** One edited container unit on the consolidation partner booking. */
|
||||
export class PartnerUnitPatchDto {
|
||||
@ApiProperty({ description: 'Id of the partner booking container unit being edited' })
|
||||
@IsUUID()
|
||||
id!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Container number' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
containerNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Seal number' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
sealNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'VGM (tons) of the unit' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
vgmTons?: number;
|
||||
}
|
||||
|
||||
export class RebookCancelledWagonsDto {
|
||||
@ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' })
|
||||
@IsDateString()
|
||||
@@ -132,6 +157,19 @@ export class RebookCancelledWagonsDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
partnerBookingId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Corrections to the partner booking\'s own container units (number / seal ' +
|
||||
'/ VGM). Only the units listed are touched; sizes and quantities are never ' +
|
||||
'changed. Ignored unless partnerBookingId is set.',
|
||||
type: [PartnerUnitPatchDto],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PartnerUnitPatchDto)
|
||||
partnerUnits?: PartnerUnitPatchDto[];
|
||||
}
|
||||
|
||||
export class FilterWagonCancellationsDto {
|
||||
|
||||
@@ -43,6 +43,9 @@ function makeService(overrides?: {
|
||||
findBookingsWithUnreviewedDocuments: jest
|
||||
.fn()
|
||||
.mockResolvedValue(new Set<string>()),
|
||||
findBookingsWithRedeemableCredit: jest
|
||||
.fn()
|
||||
.mockResolvedValue(new Map<string, string>()),
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn().mockResolvedValue(booking),
|
||||
|
||||
@@ -1347,6 +1347,17 @@ export class BookingClearanceService {
|
||||
.hasDocumentsAwaitingReview = pending.has(b.id);
|
||||
}
|
||||
|
||||
// A cancelled booking may still hold a paid-for wagon-cancellation credit.
|
||||
// GL redeems it from this queue, so the row carries the cancellation id the
|
||||
// rebook action needs.
|
||||
const credits = await this.bookingsRepository.findBookingsWithRedeemableCredit(
|
||||
filtered.map((b) => b.id),
|
||||
);
|
||||
for (const b of filtered) {
|
||||
(b as Booking & { rebookableCancellationId?: string | null })
|
||||
.rebookableCancellationId = credits.get(b.id) ?? null;
|
||||
}
|
||||
|
||||
const rows = await this.attachContractSummary(filtered);
|
||||
return this.narrowToYardScope(rows, user);
|
||||
}
|
||||
|
||||
@@ -183,8 +183,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
|
||||
{ quantity: 4, containerType: { sizeFt: 20 } },
|
||||
],
|
||||
},
|
||||
// A bare instance has no cargo yet — GL enters it on the split form, so it
|
||||
// stays a candidate.
|
||||
// Cargo not entered yet — its 20ft count is unknown, so it cannot be
|
||||
// shown to fill the wagon and is not offered.
|
||||
{ id: 'bare', reference: 'BK-BARE', bookingContainers: [] },
|
||||
];
|
||||
|
||||
@@ -198,7 +198,7 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
|
||||
rows.filter((row) => {
|
||||
void booking;
|
||||
const lines = row.bookingContainers ?? [];
|
||||
if (lines.length === 0) return true;
|
||||
if (lines.length === 0) return false;
|
||||
const ft20 = lines
|
||||
.filter((l) => Number(l.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
|
||||
@@ -209,8 +209,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
|
||||
});
|
||||
|
||||
const candidates = await service.listConsolidationCandidates('c-1', 'b-1');
|
||||
expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD', 'BK-BARE']);
|
||||
expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD']);
|
||||
expect(candidates[0].ft20Quantity).toBe(3);
|
||||
expect(candidates[1].hasCargo).toBe(false);
|
||||
expect(candidates[0].hasCargo).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Container, FileSpreadsheet, Train } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import type { BookingWagonRow } from "@/types/trainScheduling";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { MetricTile } from "./MetricTile";
|
||||
|
||||
/** pg returns numerics as strings; everything here is arithmetic on tons/metres. */
|
||||
const num = (value: number | string | null | undefined): number => {
|
||||
const parsed = Number(value ?? 0);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
const tons = (value: number | string | null | undefined): string =>
|
||||
`${num(value).toLocaleString(undefined, { maximumFractionDigits: 3 })} t`;
|
||||
|
||||
/** Allocation status → badge colour. PLANNED is the pre-loading default. */
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
PLANNED: "blue",
|
||||
LOADED: "edr-green",
|
||||
UNLOADED: "gray",
|
||||
CANCELLED: "red",
|
||||
};
|
||||
|
||||
/**
|
||||
* The booking detail page's "Wagons" tab: every wagon allocated to this booking,
|
||||
* with its containers or bulk load, plus an Excel export of the same list.
|
||||
*
|
||||
* A booking has no wagons until it is paid and placed on a train, so the empty
|
||||
* state is the normal case for most of a booking's life — it explains the
|
||||
* precondition rather than reading as an error.
|
||||
*/
|
||||
export function BookingWagonsPanel({
|
||||
bookingId,
|
||||
bookingReference,
|
||||
}: {
|
||||
bookingId: string;
|
||||
bookingReference: string;
|
||||
}) {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
api.trainScheduling.bookingWagons.queryOptions({ input: { bookingId } }),
|
||||
);
|
||||
|
||||
const wagons = useMemo<BookingWagonRow[]>(() => data ?? [], [data]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const containerCount = wagons.reduce(
|
||||
(sum, w) => sum + (w.containers?.length ?? 0),
|
||||
0,
|
||||
);
|
||||
const allocated = wagons.reduce(
|
||||
(sum, w) => sum + num(w.allocatedWeightTons),
|
||||
0,
|
||||
);
|
||||
const capacity = wagons.reduce((sum, w) => sum + num(w.capacityTons), 0);
|
||||
return { containerCount, allocated, capacity };
|
||||
}, [wagons]);
|
||||
|
||||
// The train is a property of the allocation, so every wagon on this booking
|
||||
// carries the same one — read it off the first row rather than per row.
|
||||
const train = wagons[0];
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const blob = await bookingsService.downloadWagonsWorkbook(bookingId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `wagons-${bookingReference}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
// Blob response: the JSON reason is inside the Blob, so the sync path
|
||||
// would surface only "Request failed with status code 400".
|
||||
toast.error(await extractDownloadErrorMessage(error));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={Train}
|
||||
title="Allocated wagons"
|
||||
subtitle={
|
||||
wagons.length
|
||||
? `${wagons.length} wagon${wagons.length === 1 ? "" : "s"}${
|
||||
train?.trainNumber ? ` on train ${train.trainNumber}` : ""
|
||||
}`
|
||||
: "No wagons allocated yet"
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<FileSpreadsheet size={15} />}
|
||||
loading={exporting}
|
||||
// The sheet would be headers with no rows — nothing to hand over.
|
||||
disabled={wagons.length === 0}
|
||||
onClick={() => void handleExport()}
|
||||
>
|
||||
Export Excel
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{isError ? (
|
||||
<Text size="sm" c="red">
|
||||
Could not load the wagon allocations for this booking.
|
||||
</Text>
|
||||
) : wagons.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Wagons appear here once the booking is paid and allocated onto a train.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
<MetricTile label="Wagons" value={String(wagons.length)} />
|
||||
<MetricTile
|
||||
label="Containers"
|
||||
value={String(totals.containerCount)}
|
||||
/>
|
||||
<MetricTile label="Allocated" value={tons(totals.allocated)} />
|
||||
<MetricTile label="Capacity" value={tons(totals.capacity)} />
|
||||
</SimpleGrid>
|
||||
|
||||
{train?.departureAt ? (
|
||||
<Group gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
Departs {formatDate(train.departureAt)}
|
||||
</Text>
|
||||
{train.originStation && train.destinationStation ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
· {train.originStation} → {train.destinationStation}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
<Table.ScrollContainer minWidth={720}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Seq</Table.Th>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Allocated</Table.Th>
|
||||
<Table.Th ta="right">Capacity</Table.Th>
|
||||
<Table.Th>Load</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{wagons.map((w) => (
|
||||
<Table.Tr key={w.allocationId}>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{w.sequenceNo ?? "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{w.wagonNumber ?? "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{w.wagonType ?? "—"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLORS[w.status] ?? "gray"}
|
||||
>
|
||||
{w.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text size="sm">{tons(w.allocatedWeightTons)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text size="sm" c="dimmed">
|
||||
{tons(w.capacityTons)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{w.containers?.length ? (
|
||||
<Stack gap={2}>
|
||||
{w.containers.map((c, i) => (
|
||||
<Group
|
||||
key={`${w.allocationId}-${c.containerNumber ?? i}`}
|
||||
gap={6}
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Container
|
||||
size={13}
|
||||
style={{ opacity: 0.5, flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="xs">
|
||||
{c.containerNumber ?? "—"}
|
||||
{c.sizeFt ? ` · ${c.sizeFt}ft` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
) : w.bulkCargoDescription || w.loadType === "BULK" ? (
|
||||
<Text size="xs">
|
||||
{w.bulkCargoDescription ?? "Bulk"}
|
||||
{w.bulkQuantity ? ` · ${num(w.bulkQuantity)}` : ""}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export * from "./SectionCard";
|
||||
export * from "./ClearanceReviewSection";
|
||||
export * from "./BookingDocumentsPanel";
|
||||
export * from "./BookingTrucksPanel";
|
||||
export * from "./BookingWagonsPanel";
|
||||
export * from "./ContractOrdersPanel";
|
||||
export * from "./MetricTile";
|
||||
export * from "./BookingDetailToolbar";
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import { OperationDatePicker } from "@edr/ui-common";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
import { toDayString } from "@/hooks/useListControls";
|
||||
import { api as rpc } from "@/services/api";
|
||||
import { formatMoney } from "@/lib/format";
|
||||
import {
|
||||
hasOddFt20,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type WagonCancellation,
|
||||
} from "./types";
|
||||
|
||||
|
||||
/** Editable rebook unit — prefilled from the cancelled snapshot. */
|
||||
interface RebookUnitDraft {
|
||||
containerSize: string;
|
||||
@@ -21,6 +22,46 @@ interface RebookUnitDraft {
|
||||
vgmTons: number | "";
|
||||
}
|
||||
|
||||
/** Editable unit on the consolidation partner — prefilled from its own cargo. */
|
||||
interface PartnerUnitDraft {
|
||||
id: string;
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber: string;
|
||||
vgmTons: number | "";
|
||||
}
|
||||
|
||||
const partnerDraftsFrom = (c: RebookPartnerCandidate | undefined) =>
|
||||
(c?.units ?? []).map((u) => ({
|
||||
id: u.id,
|
||||
containerSize: u.containerSize,
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber ?? "",
|
||||
vgmTons: Number(u.vgmTons) || ("" as const),
|
||||
}));
|
||||
|
||||
/** Only the units GL actually changed are sent. */
|
||||
const partnerUnitsPayload = (
|
||||
drafts: PartnerUnitDraft[],
|
||||
original: PartnerUnitDraft[],
|
||||
) =>
|
||||
drafts
|
||||
.filter((d, i) => {
|
||||
const o = original[i];
|
||||
return (
|
||||
!o ||
|
||||
d.containerNumber !== o.containerNumber ||
|
||||
d.sealNumber !== o.sealNumber ||
|
||||
d.vgmTons !== o.vgmTons
|
||||
);
|
||||
})
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
containerNumber: d.containerNumber.trim(),
|
||||
sealNumber: d.sealNumber.trim(),
|
||||
...(d.vgmTons !== "" ? { vgmTons: Number(d.vgmTons) } : {}),
|
||||
}));
|
||||
|
||||
const draftsFrom = (r: WagonCancellation): RebookUnitDraft[] =>
|
||||
(r.cancelledQuantities?.units ?? []).map((u) => ({
|
||||
containerSize: u.containerSize,
|
||||
@@ -62,30 +103,75 @@ export function RebookWagonCancellationModal({
|
||||
/** Called after a successful rebook with the new booking id (when the API returns it). */
|
||||
onRebooked?: (result: { bookingId?: string }) => void;
|
||||
}) {
|
||||
const [date, setDate] = useState<Date | null>(null);
|
||||
// Held as the picker's own `yyyy-MM-dd` string, never a Date: converting a
|
||||
// local-midnight Date back with toISOString() shifts it into the previous day
|
||||
// in any timezone east of UTC (EAT is +03), which both mis-rendered the
|
||||
// selection and submitted the wrong shipment day.
|
||||
const [date, setDate] = useState<string | null>(null);
|
||||
const [partnerId, setPartnerId] = useState<string | null>(null);
|
||||
const [partnerDrafts, setPartnerDrafts] = useState<PartnerUnitDraft[]>([]);
|
||||
const [drafts, setDrafts] = useState<RebookUnitDraft[]>([]);
|
||||
|
||||
// Fresh form per row: the modal instance is long-lived on the host page.
|
||||
useEffect(() => {
|
||||
setDate(null);
|
||||
setPartnerId(null);
|
||||
setPartnerDrafts([]);
|
||||
setDrafts(cancellation ? draftsFrom(cancellation) : []);
|
||||
}, [cancellation]);
|
||||
|
||||
const needsPartner = cancellation ? hasOddFt20(cancellation) : false;
|
||||
|
||||
// The rebook rides the same lane with the same cargo as the cancelled
|
||||
// shipment, so the shipment day must come from the days that lane actually
|
||||
// runs — an arbitrary calendar day has no train and no wagon capacity.
|
||||
const daysQuery = useMemo(() => {
|
||||
const b = cancellation?.booking;
|
||||
if (!b?.originYardId || !b?.destinationYardId) return null;
|
||||
const containers = Object.entries(
|
||||
cancellation?.cancelledQuantities?.bySize ?? {},
|
||||
)
|
||||
.map(([containerSize, quantity]) => ({
|
||||
containerSize,
|
||||
quantity: Number(quantity || 0),
|
||||
}))
|
||||
.filter((c) => c.quantity >= 1);
|
||||
if (containers.length > 0) {
|
||||
return {
|
||||
originYardId: b.originYardId,
|
||||
destinationYardId: b.destinationYardId,
|
||||
freightType: "CONTAINER" as const,
|
||||
containers,
|
||||
};
|
||||
}
|
||||
const tons = Number(cancellation?.weightTons || 0);
|
||||
if (tons <= 0) return null;
|
||||
return {
|
||||
originYardId: b.originYardId,
|
||||
destinationYardId: b.destinationYardId,
|
||||
freightType: "BULK" as const,
|
||||
totalWeightTons: tons,
|
||||
};
|
||||
}, [cancellation]);
|
||||
|
||||
const { data: availableDays, isLoading: daysLoading } = useQuery({
|
||||
...rpc.trainScheduling.availableDaysForCargo.queryOptions({
|
||||
input: daysQuery ?? { freightType: "BULK" as const },
|
||||
}),
|
||||
enabled: Boolean(cancellation) && daysQuery !== null,
|
||||
});
|
||||
const partners = useQuery({
|
||||
queryKey: [
|
||||
"wagon-cancellations",
|
||||
cancellation?.id,
|
||||
"rebook-partners",
|
||||
date ? toDayString(date) : null,
|
||||
date,
|
||||
],
|
||||
enabled: Boolean(cancellation && needsPartner && date),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<RebookPartnerCandidate[]>(
|
||||
`/bookings/wagon-cancellations/${cancellation!.id}/rebook-partners`,
|
||||
{ params: { scheduledDate: toDayString(date!) } },
|
||||
{ params: { scheduledDate: date } },
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
@@ -96,15 +182,28 @@ export function RebookWagonCancellationModal({
|
||||
const res = await api.post<{ bookingId?: string }>(
|
||||
`/bookings/wagon-cancellations/${cancellation!.id}/rebook`,
|
||||
{
|
||||
scheduledDate: toDayString(date!),
|
||||
scheduledDate: date,
|
||||
...(drafts.length ? { containers: containersPayload(drafts) } : {}),
|
||||
...(partnerId ? { partnerBookingId: partnerId } : {}),
|
||||
...(() => {
|
||||
if (!partnerId) return {};
|
||||
const original = partnerDraftsFrom(
|
||||
(partners.data ?? []).find((c) => c.id === partnerId),
|
||||
);
|
||||
const changed = partnerUnitsPayload(partnerDrafts, original);
|
||||
return changed.length ? { partnerUnits: changed } : {};
|
||||
})(),
|
||||
},
|
||||
);
|
||||
return res.data ?? {};
|
||||
},
|
||||
});
|
||||
|
||||
const patchPartnerDraft = (i: number, patch: Partial<PartnerUnitDraft>) =>
|
||||
setPartnerDrafts((prev) =>
|
||||
prev.map((d, idx) => (idx === i ? { ...d, ...patch } : d)),
|
||||
);
|
||||
|
||||
const patchDraft = (i: number, patch: Partial<RebookUnitDraft>) =>
|
||||
setDrafts((prev) => prev.map((x, idx) => (idx === i ? { ...x, ...patch } : x)));
|
||||
|
||||
@@ -115,6 +214,9 @@ export function RebookWagonCancellationModal({
|
||||
title="Rebook cancelled wagons"
|
||||
centered
|
||||
radius="md"
|
||||
// Wide enough for the calendar plus two container-unit editors side by
|
||||
// side without the number / seal / VGM fields cramping.
|
||||
size="xl"
|
||||
>
|
||||
{cancellation && (
|
||||
<Stack gap="sm">
|
||||
@@ -123,21 +225,30 @@ export function RebookWagonCancellationModal({
|
||||
{cancellation.wagonsCancelled} wagon(s) · credit{" "}
|
||||
{formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)}
|
||||
</Text>
|
||||
<DatePickerInput
|
||||
label="Shipment day"
|
||||
placeholder="Pick the day"
|
||||
value={date}
|
||||
onChange={(v) => {
|
||||
setDate(v ? new Date(v) : null);
|
||||
<Text size="sm" fw={600}>
|
||||
Shipment day
|
||||
</Text>
|
||||
<OperationDatePicker
|
||||
fullWidth
|
||||
availableDays={daysQuery === null ? [] : (availableDays ?? [])}
|
||||
isLoading={daysQuery !== null && daysLoading}
|
||||
emptyMessage={
|
||||
daysQuery === null
|
||||
? "This cancellation has no route or cargo on record — the available shipment days cannot be worked out."
|
||||
: "No train day on this route can take this cargo right now."
|
||||
}
|
||||
value={date ?? ""}
|
||||
onChange={(d) => {
|
||||
setDate(d || null);
|
||||
setPartnerId(null);
|
||||
setPartnerDrafts([]);
|
||||
}}
|
||||
minDate={new Date()}
|
||||
radius="md"
|
||||
/>
|
||||
{needsPartner && (
|
||||
<Select
|
||||
label="Consolidation partner"
|
||||
description="This credit has an odd 20ft container — pick the odd booking that shares its wagon. The rebooked booking is paid; it ships once the partner pays."
|
||||
withAsterisk
|
||||
placeholder={
|
||||
!date
|
||||
? "Pick the day first"
|
||||
@@ -150,7 +261,14 @@ export function RebookWagonCancellationModal({
|
||||
label: `${c.reference} · ${c.companyName ?? "—"} · ${c.ft20Quantity}×20ft`,
|
||||
}))}
|
||||
value={partnerId}
|
||||
onChange={setPartnerId}
|
||||
onChange={(v) => {
|
||||
setPartnerId(v);
|
||||
setPartnerDrafts(
|
||||
partnerDraftsFrom(
|
||||
(partners.data ?? []).find((c) => c.id === v),
|
||||
),
|
||||
);
|
||||
}}
|
||||
disabled={!date}
|
||||
searchable
|
||||
radius="md"
|
||||
@@ -161,12 +279,66 @@ export function RebookWagonCancellationModal({
|
||||
!partners.isLoading &&
|
||||
(partners.data ?? []).length === 0 && (
|
||||
<Text size="xs" c="orange">
|
||||
No odd-20ft booking rides that day — pick another day or wait for
|
||||
a partner booking.
|
||||
No odd-20ft booking rides that day — pick another day, or wait
|
||||
for a booking that can share this wagon.
|
||||
</Text>
|
||||
)}
|
||||
{partnerId && partnerDrafts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={600}>
|
||||
Partner containers
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Correct the partner booking's own container details if they
|
||||
changed — sizes and quantities stay as booked.
|
||||
</Text>
|
||||
{partnerDrafts.map((d, i) => (
|
||||
<Group key={d.id} gap={8} wrap="nowrap" align="flex-end">
|
||||
<TextInput
|
||||
label={`${d.containerSize || "Container"}`}
|
||||
value={d.containerNumber}
|
||||
onChange={(e) =>
|
||||
patchPartnerDraft(i, {
|
||||
containerNumber: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ flex: 1.4 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Seal no."
|
||||
value={d.sealNumber}
|
||||
onChange={(e) =>
|
||||
patchPartnerDraft(i, { sealNumber: e.currentTarget.value })
|
||||
}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="VGM (t)"
|
||||
type="number"
|
||||
value={d.vgmTons === "" ? "" : String(d.vgmTons)}
|
||||
onChange={(e) => {
|
||||
const raw = e.currentTarget.value;
|
||||
patchPartnerDraft(i, {
|
||||
vgmTons: raw === "" ? "" : Number(raw),
|
||||
});
|
||||
}}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
{drafts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" fw={600}>
|
||||
This booking's containers
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Correct the container details if they changed — sizes and
|
||||
quantities stay as cancelled.
|
||||
|
||||
@@ -29,6 +29,11 @@ export interface WagonCancellation {
|
||||
reference: string;
|
||||
customsClearingEnabled?: boolean;
|
||||
company?: { name: string };
|
||||
/** Route of the cancelled shipment — the rebook rides the same lane, so the
|
||||
* shipment-day picker offers only days that lane actually runs. */
|
||||
originYardId?: string | null;
|
||||
destinationYardId?: string | null;
|
||||
freightType?: string | null;
|
||||
};
|
||||
rebookedBooking?: { id: string; reference: string };
|
||||
feeInvoice?: { invoiceNumber: string; status: string };
|
||||
@@ -48,6 +53,14 @@ export interface WagonCancellationListResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface RebookPartnerUnit {
|
||||
id: string;
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber: string | null;
|
||||
vgmTons: number;
|
||||
}
|
||||
|
||||
export interface RebookPartnerCandidate {
|
||||
id: string;
|
||||
reference: string;
|
||||
@@ -55,6 +68,8 @@ export interface RebookPartnerCandidate {
|
||||
status: string;
|
||||
scheduledDate: string | null;
|
||||
ft20Quantity: number;
|
||||
/** The partner's own container units — editable while pairing. */
|
||||
units?: RebookPartnerUnit[];
|
||||
}
|
||||
|
||||
export const WAGON_CANCELLATION_STATUS_CHIP: Record<
|
||||
|
||||
@@ -18,8 +18,8 @@ import type { ConsolidationCandidate } from "@/services/contracts.service";
|
||||
/**
|
||||
* Picker for the booking that shares this booking's wagon. The server has
|
||||
* already narrowed the list to bookings that can legally pair — same route and
|
||||
* direction, customs clearing, an odd 20ft count of their own and not already
|
||||
* linked to someone else — so every row here is a valid choice.
|
||||
* direction, same booking day, customs clearing, an odd 20ft count of their own
|
||||
* and not already linked to someone else — so every row here is a valid choice.
|
||||
*/
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
@@ -55,8 +55,8 @@ export function ConsolidationPartnerPicker({
|
||||
Pick the parent booking
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Customs bookings on the same route that also carry an odd number of
|
||||
20ft containers.
|
||||
Customs bookings on the same route and booking day that also carry
|
||||
an odd number of 20ft containers.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
@@ -84,9 +84,10 @@ export function ConsolidationPartnerPicker({
|
||||
title="No booking available to share this wagon"
|
||||
>
|
||||
<Text fz="sm">
|
||||
No other customs booking on this route currently carries an odd
|
||||
number of 20ft containers. Either wait for one, or switch the
|
||||
shared-wagon option off and book an even number of 20ft containers.
|
||||
No other customs booking on this route and booking day currently
|
||||
carries an odd number of 20ft containers. Either wait for one, or
|
||||
switch the shared-wagon option off and book an even number of 20ft
|
||||
containers.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
@@ -113,9 +114,7 @@ export function ConsolidationPartnerPicker({
|
||||
? ` · ${candidate.tradeDirection}`
|
||||
: ""}
|
||||
{" · "}
|
||||
{candidate.hasCargo
|
||||
? `${candidate.ft20Quantity} × 20ft`
|
||||
: "cargo not entered yet"}
|
||||
{`${candidate.ft20Quantity} × 20ft`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
|
||||
@@ -226,6 +226,7 @@ export const URL_CONSTANTS = {
|
||||
CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
|
||||
CARRIAGE_ACCEPTANCE_SHEET: (id: string) =>
|
||||
`/bookings/${id}/carriage-acceptance-sheet`,
|
||||
WAGONS_EXPORT: (id: string) => `/bookings/${id}/wagons/export`,
|
||||
EXPORT_HANDOVER_MODE: (id: string) =>
|
||||
`/bookings/${id}/export-handover-mode`,
|
||||
SUMMARY: (id: string) => `/bookings/${id}/summary`,
|
||||
|
||||
@@ -44,6 +44,18 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
||||
booking.serviceType?.name ??
|
||||
booking.serviceType?.code,
|
||||
trainScheduleId: booking.trainScheduleId ?? null,
|
||||
// List rows carry the flat departure date; detail responses carry the fuller
|
||||
// summary object instead — fall back to it so a row mapped from either shape
|
||||
// shows the same date.
|
||||
trainScheduleDepartureDate:
|
||||
booking.trainScheduleDepartureDate ??
|
||||
booking.trainScheduleSummary?.scheduledDepartureDate ??
|
||||
null,
|
||||
trainScheduleReference:
|
||||
booking.trainScheduleReference ??
|
||||
booking.trainScheduleSummary?.reference ??
|
||||
booking.trainScheduleSummary?.trainNumber ??
|
||||
null,
|
||||
isGovernment: booking.isGovernment ?? false,
|
||||
governmentInstitution: booking.governmentInstitution ?? null,
|
||||
consolidationPartnerId: booking.consolidationPartnerId ?? null,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Ship,
|
||||
Train,
|
||||
Truck,
|
||||
Wallet,
|
||||
Weight,
|
||||
@@ -63,6 +64,7 @@ import {
|
||||
BookingSchedulingWindowCard,
|
||||
BookingDocumentsPanel,
|
||||
BookingTrucksPanel,
|
||||
BookingWagonsPanel,
|
||||
ContractOrdersPanel,
|
||||
} from "@/components/bookings/detail";
|
||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||
@@ -219,9 +221,11 @@ export default function BookingRequestDetailPage() {
|
||||
? "documents"
|
||||
: requestedTab === "trucks"
|
||||
? "trucks"
|
||||
: requestedTab === "additional-charges"
|
||||
? "additional-charges"
|
||||
: "overview";
|
||||
: requestedTab === "wagons"
|
||||
? "wagons"
|
||||
: requestedTab === "additional-charges"
|
||||
? "additional-charges"
|
||||
: "overview";
|
||||
const setActiveTab = (tab: string | null) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (tab && tab !== "overview") next.set("tab", tab);
|
||||
@@ -522,6 +526,9 @@ export default function BookingRequestDetailPage() {
|
||||
<Tabs.Tab value="trucks" leftSection={<Truck size={16} />}>
|
||||
Trucks
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="wagons" leftSection={<Train size={16} />}>
|
||||
Wagons
|
||||
</Tabs.Tab>
|
||||
{canSeeAdditionalCharges && (
|
||||
<Tabs.Tab
|
||||
value="additional-charges"
|
||||
@@ -549,6 +556,12 @@ export default function BookingRequestDetailPage() {
|
||||
<Tabs.Panel value="trucks">
|
||||
<BookingTrucksPanel bookingId={booking.id} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="wagons">
|
||||
<BookingWagonsPanel
|
||||
bookingId={booking.id}
|
||||
bookingReference={booking.reference}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
{canSeeAdditionalCharges && (
|
||||
<Tabs.Panel value="additional-charges">
|
||||
<AdditionalPaymentsTab bookingId={booking.id} onViewFile={view} />
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Ship,
|
||||
Train,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
@@ -609,7 +610,7 @@ export default function BookingRequestsPage() {
|
||||
},
|
||||
{
|
||||
id: "scheduled",
|
||||
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
|
||||
header: () => <span className={bookingTable.headerCell}>Requested</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar className="size-3.5" />
|
||||
@@ -617,6 +618,50 @@ export default function BookingRequestsPage() {
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
// The date of the train the booking is actually allocated to. Empty until
|
||||
// allocation, which is why it is separate from the requested date above —
|
||||
// the two differ whenever staff move a booking to another day.
|
||||
id: "scheduledDate",
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>Scheduled date</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
if (!b.trainScheduleDepartureDate) {
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Not scheduled
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const movedFromRequest =
|
||||
b.scheduledDate &&
|
||||
new Date(b.trainScheduleDepartureDate).toDateString() !==
|
||||
new Date(b.scheduledDate).toDateString();
|
||||
return (
|
||||
<div className="space-y-0.5 py-1">
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<Train className="size-3.5 text-muted-foreground" />
|
||||
{formatDate(b.trainScheduleDepartureDate)}
|
||||
</span>
|
||||
{b.trainScheduleReference ? (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{b.trainScheduleReference}
|
||||
</p>
|
||||
) : null}
|
||||
{movedFromRequest ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-4 px-1 text-[9px] font-medium"
|
||||
>
|
||||
Date changed
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "priority",
|
||||
header: () => <span className={bookingTable.headerCell}>Priority</span>,
|
||||
|
||||
@@ -58,6 +58,12 @@ import {
|
||||
summarizeRequestedCargo,
|
||||
} from "@/features/clearance/requestedCargo";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { api } from "@/auth/http";
|
||||
import {
|
||||
RebookWagonCancellationModal,
|
||||
canRebookWagonCancellations,
|
||||
type WagonCancellation,
|
||||
} from "@/components/bookings/wagon-cancellation";
|
||||
import "./contract-clearance-table.css";
|
||||
|
||||
/** Yards carry `label` (API) — older shapes used `name`/`code`. */
|
||||
@@ -214,6 +220,7 @@ export default function ContractClearanceListPage() {
|
||||
const canCreateBooking =
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
|
||||
!isDjiboutiGl(user);
|
||||
const canRebookCredit = canRebookWagonCancellations(user);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [tab, setTab] = useState<TabKey>("all");
|
||||
@@ -234,6 +241,21 @@ export default function ContractClearanceListPage() {
|
||||
refetch,
|
||||
} = useBookingEtClearanceQueue(true);
|
||||
|
||||
// Credit rebook opens the shared modal, which needs the full cancellation
|
||||
// row — the queue only carries its id, so fetch it on demand.
|
||||
const [creditRebook, setCreditRebook] = useState<WagonCancellation | null>(
|
||||
null,
|
||||
);
|
||||
const openCreditRebook = useCallback(async (row: ShipmentBookingRow) => {
|
||||
const res = await api.get<
|
||||
{ items?: WagonCancellation[] } | WagonCancellation[]
|
||||
>(`/bookings/${row.id}/wagon-cancellations`);
|
||||
const body = res.data;
|
||||
const list = Array.isArray(body) ? body : (body?.items ?? []);
|
||||
const match = list.find((c) => c.id === row.rebookableCancellationId);
|
||||
if (match) setCreditRebook(match);
|
||||
}, []);
|
||||
|
||||
// Shipment requests carry the requested quantities (per container type, or
|
||||
// bulk weight/items). Map them onto the booking rows by createdBookingId so
|
||||
// the queue shows what each shipment was requested for.
|
||||
@@ -272,6 +294,7 @@ export default function ContractClearanceListPage() {
|
||||
// A bare initiated instance has no cargo/price yet — GL still has to
|
||||
// create (complete) the booking.
|
||||
bookingCreated: Number(b.totalAmount ?? 0) > 0,
|
||||
rebookableCancellationId: b.rebookableCancellationId ?? null,
|
||||
})) as ShipmentBookingRow[];
|
||||
}, [bookingQueue, requestedByBooking]);
|
||||
|
||||
@@ -602,12 +625,14 @@ export default function ContractClearanceListPage() {
|
||||
hasFilters={hasFilters}
|
||||
onClearFilters={clearFilters}
|
||||
canCreateBooking={canCreateBooking}
|
||||
canRebookCredit={canRebookCredit}
|
||||
onOpen={openBooking}
|
||||
onCreateBooking={(row) =>
|
||||
navigate(
|
||||
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
|
||||
)
|
||||
}
|
||||
onRebookCredit={openCreditRebook}
|
||||
onRebook={(row) =>
|
||||
// Re-complete the SAME expired booking (new day, same finished
|
||||
// per-booking clearance) — a fresh instance would force the
|
||||
@@ -623,6 +648,15 @@ export default function ContractClearanceListPage() {
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
<RebookWagonCancellationModal
|
||||
cancellation={creditRebook}
|
||||
onClose={() => setCreditRebook(null)}
|
||||
onRebooked={() => {
|
||||
setCreditRebook(null);
|
||||
// The credit is spent and a new booking exists — both change the queue.
|
||||
void refetch();
|
||||
}}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -650,6 +684,8 @@ interface ShipmentBookingRow {
|
||||
createdAt: string | null;
|
||||
/** true once GL has actually created (completed) the booking. */
|
||||
bookingCreated: boolean;
|
||||
/** Unspent wagon-cancellation credit on this booking, if any. */
|
||||
rebookableCancellationId: string | null;
|
||||
}
|
||||
|
||||
type PaginationState = ReturnType<typeof usePagination>["pagination"];
|
||||
@@ -666,9 +702,11 @@ function ShipmentBookingsTable({
|
||||
hasFilters,
|
||||
onClearFilters,
|
||||
canCreateBooking,
|
||||
canRebookCredit,
|
||||
onOpen,
|
||||
onCreateBooking,
|
||||
onRebook,
|
||||
onRebookCredit,
|
||||
onViewContract,
|
||||
}: {
|
||||
rows: ShipmentBookingRow[];
|
||||
@@ -681,9 +719,11 @@ function ShipmentBookingsTable({
|
||||
hasFilters: boolean;
|
||||
onClearFilters: () => void;
|
||||
canCreateBooking: boolean;
|
||||
canRebookCredit: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
onCreateBooking: (row: ShipmentBookingRow) => void;
|
||||
onRebook: (row: ShipmentBookingRow) => void;
|
||||
onRebookCredit: (row: ShipmentBookingRow) => void;
|
||||
onViewContract: (contractId: string) => void;
|
||||
}) {
|
||||
// A bare initiated instance that has cleared but not yet been created by GL.
|
||||
@@ -701,6 +741,14 @@ function ShipmentBookingsTable({
|
||||
r.customs &&
|
||||
r.status === "EXPIRED";
|
||||
|
||||
// A cancelled booking whose wagon-cancellation credit is paid for and unspent.
|
||||
// Redeeming it is a different action from re-completing an expired booking —
|
||||
// it opens the credit rebook modal rather than the completion form. Gated on
|
||||
// the rebook permission (not booking-creation) so the button matches exactly
|
||||
// who the API lets through.
|
||||
const hasRebookableCredit = (r: ShipmentBookingRow) =>
|
||||
canRebookCredit && Boolean(r.rebookableCancellationId);
|
||||
|
||||
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -840,6 +888,7 @@ function ShipmentBookingsTable({
|
||||
const r = row.original;
|
||||
const bookable = isBookable(r);
|
||||
const rebookable = isRebookable(r);
|
||||
const creditRebookable = hasRebookableCredit(r);
|
||||
return (
|
||||
<Group
|
||||
justify="flex-end"
|
||||
@@ -870,6 +919,17 @@ function ShipmentBookingsTable({
|
||||
Rebook
|
||||
</Button>
|
||||
) : null}
|
||||
{creditRebookable ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="teal"
|
||||
radius="md"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
onClick={() => onRebookCredit(r)}
|
||||
>
|
||||
Rebook credit
|
||||
</Button>
|
||||
) : null}
|
||||
<Menu shadow="md" radius="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
@@ -904,6 +964,14 @@ function ShipmentBookingsTable({
|
||||
Rebook (GL)
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{creditRebookable ? (
|
||||
<Menu.Item
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
onClick={() => onRebookCredit(r)}
|
||||
>
|
||||
Rebook cancellation credit
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{r.contractId ? (
|
||||
<Menu.Item
|
||||
leftSection={<ExternalLink size={14} />}
|
||||
|
||||
@@ -810,6 +810,14 @@ export const bookingsService = {
|
||||
return ensurePdfBlob(response.data as Blob);
|
||||
},
|
||||
|
||||
/** The Wagons tab's Excel export — customer name plus one row per wagon. */
|
||||
downloadWagonsWorkbook: async (id: string): Promise<Blob> => {
|
||||
const response = await client.get(B.WAGONS_EXPORT(id), {
|
||||
responseType: "blob",
|
||||
});
|
||||
return response.data as Blob;
|
||||
},
|
||||
|
||||
getDjClearanceQueue: async (): Promise<BookingDetail[]> => {
|
||||
const response = await client.get(B.CLEARANCE_DJ_QUEUE);
|
||||
return (unwrap(response.data) ?? []) as BookingDetail[];
|
||||
|
||||
@@ -216,6 +216,14 @@ export interface BookingDetail {
|
||||
wagonsRequired?: number | null;
|
||||
scheduledAt?: string | null;
|
||||
trainScheduleId?: string | null;
|
||||
/**
|
||||
* The allocated train's departure date, attached by the LIST endpoint (the
|
||||
* detail endpoint carries the fuller `trainScheduleSummary` instead). This is
|
||||
* the operational date, as opposed to the customer-requested `scheduledDate`.
|
||||
*/
|
||||
trainScheduleDepartureDate?: string | null;
|
||||
/** The allocated train's reference (S-YYYY-NNNNN) or train number. */
|
||||
trainScheduleReference?: string | null;
|
||||
/** Operational status of the allocated train (null until scheduled). */
|
||||
trainScheduleStatus?: string | null;
|
||||
/** The allocated train's identity + clock, attached by the detail endpoint. */
|
||||
@@ -244,6 +252,12 @@ export interface BookingDetail {
|
||||
allDocsApproved?: boolean;
|
||||
/** ET clearance queue: a customer document is PENDING or QUERIED. */
|
||||
hasDocumentsAwaitingReview?: boolean;
|
||||
/**
|
||||
* ET clearance queue: id of an unspent wagon-cancellation credit on this
|
||||
* booking (CREDIT_AVAILABLE, worth > 0, not yet rebooked). Null when there is
|
||||
* none — GL rebooks the credit straight from the queue row.
|
||||
*/
|
||||
rebookableCancellationId?: string | null;
|
||||
contractKind?: "ONE_TIME" | "GENERAL" | null;
|
||||
contractId?: string | null;
|
||||
/** Reference of the contract this booking was created under (list column + search). */
|
||||
@@ -304,6 +318,10 @@ export interface BookingListRow {
|
||||
schedulingStatus?: string;
|
||||
serviceTypeLabel?: string;
|
||||
trainScheduleId?: string | null;
|
||||
/** Departure date of the train this booking is allocated to; null until scheduled. */
|
||||
trainScheduleDepartureDate?: string | null;
|
||||
/** Reference of the train this booking is allocated to. */
|
||||
trainScheduleReference?: string | null;
|
||||
isGovernment?: boolean;
|
||||
governmentInstitution?: string | null;
|
||||
consolidationPartnerId?: string | null;
|
||||
|
||||
@@ -1258,10 +1258,24 @@ export interface BookingWagonRow {
|
||||
allocatedWeightTons: number | string | null;
|
||||
loadType: string | null;
|
||||
status: string;
|
||||
/** Numeric columns arrive as strings from pg — parse before arithmetic. */
|
||||
tareWeightTons?: number | string | null;
|
||||
capacityTons?: number | string | null;
|
||||
lengthMeters?: number | string | null;
|
||||
/** The train this wagon rides on, and where it runs. */
|
||||
trainNumber?: string | null;
|
||||
departureAt?: string | null;
|
||||
originStation?: string | null;
|
||||
destinationStation?: string | null;
|
||||
/** Set only when the wagon carries bulk rather than containers. */
|
||||
bulkCargoDescription?: string | null;
|
||||
bulkQuantity?: number | string | null;
|
||||
containers: Array<{
|
||||
containerNumber: string | null;
|
||||
sizeFt: number | null;
|
||||
grossWeightTons: number | string | null;
|
||||
sealNumber?: string | null;
|
||||
positionOnWagon?: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user