add per-container handling options for hazardous, reefer, and return services

- Introduced new boolean fields (isHazardous, isReefer, isReturn) in UnitDraft and related interfaces to allow individual container handling options.
- Updated emptyUnit function to initialize these new fields.
- Modified GlCreateBookingForm to handle and display these options for each container.
- Adjusted calculations for hazardous, reefer, and return quantities based on the new handling options.
- Updated the schema for container units and booking container lines to include handling options.
- Added migration to support the new return flag in the database.
- Enhanced various components to reflect gross weight calculations, ensuring consistency across the application.
This commit is contained in:
Marshal
2026-07-18 19:20:45 +00:00
parent a7041ee70f
commit 0dead281ce
28 changed files with 585 additions and 250 deletions

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-container handling opt-in: each physical container can now be marked
* hazardous / reefer / with-return individually, next to its VGM. The hazardous
* and reefer flags already existed on the unit row; only the return leg was
* missing, so a booking of 20 containers with 10 returning empty can bill the
* WITH_RETURN surcharge on 10 instead of all 20.
*
* Backfill: existing rows keep false. The line-level counts
* (booking_container.return_quantity etc.) stay authoritative for bookings made
* before this change — the rule engine falls back to them when no unit is flagged.
*/
export class AddContainerUnitReturnFlag2370000000000 implements MigrationInterface {
name = 'AddContainerUnitReturnFlag2370000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."booking_container_units" ADD COLUMN IF NOT EXISTS "is_return" boolean NOT NULL DEFAULT false`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."booking_container_units" DROP COLUMN IF EXISTS "is_return"`,
);
}
}

View File

@@ -305,6 +305,10 @@ export class BookingPricingService {
vgmPerUnitTons: vgm,
totalVgmTons: qty * vgm,
isReefer: ct.isReefer,
// Per-container opt-ins — PER_CONTAINER surcharges bill these.
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
reeferQuantity: Number(bc.reeferQuantity ?? 0),
returnQuantity: Number(bc.returnQuantity ?? 0),
},
perWagon: containersPerWagonForSize(ct.sizeFt),
quantity: qty,

View File

@@ -32,6 +32,10 @@ export class BookingContainerUnit extends BaseEntity {
@Column({ name: 'is_reefer', type: 'boolean', default: false })
isReefer!: boolean;
/** This container ships back empty after unloading (equipment return). */
@Column({ name: 'is_return', type: 'boolean', default: false })
isReturn!: boolean;
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
sortOrder!: number;

View File

@@ -40,7 +40,10 @@ import { ContractsRepository } from './contracts.repository';
import { ClearanceFeeService } from './clearance-fee.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
import {
CreateBookingContainerLineDto,
CreateBookingUnderContractDto,
} from './dto/create-booking-under-contract.dto';
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
@@ -283,8 +286,8 @@ export class ContractBookingService {
tradeDirection: contract.tradeDirection,
freightType,
cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
cargoTotalWeightVgm: this.resolveBulkTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
firstMilePickupLat: contract.firstMilePickupLat ?? null,
@@ -1451,6 +1454,53 @@ export class ContractBookingService {
);
}
/**
* Per-line handling counts. Each physical container carries its own hazardous
* / reefer / return switch (entered next to its VGM), so the count is however
* many units opted in. Forms that predate per-unit switches send line-level
* counts and no unit flags — those are honoured as-is.
*/
private handlingCounts(line: CreateBookingContainerLineDto): {
hazardousQuantity: number;
reeferQuantity: number;
returnQuantity: number;
} {
const units = line.units ?? [];
const flagged = units.some((u) => u.isHazardous || u.isReefer || u.isReturn);
if (!flagged) {
return {
hazardousQuantity: Number(line.hazardousQuantity ?? 0),
reeferQuantity: Number(line.reeferQuantity ?? 0),
returnQuantity: Number(line.returnQuantity ?? 0),
};
}
return {
hazardousQuantity: units.filter((u) => u.isHazardous).length,
reeferQuantity: units.filter((u) => u.isReefer).length,
returnQuantity: units.filter((u) => u.isReturn).length,
};
}
/**
* Booking-level hazardous / reefer flags. The CONTRACT gates the service; the
* per-container opt-ins decide whether THIS shipment actually uses it. A
* container contract that allows hazardous but a booking where nobody ticked
* the switch is not a hazardous booking, and must not fire the surcharge.
* Bulk keeps the contract flag — it has its own bulk*Quantity fields.
*/
private resolveShipmentHandlingFlag(
contract: Contract,
dto: CreateBookingUnderContractDto,
field: 'hazardousQuantity' | 'reeferQuantity',
): boolean {
const gated = field === 'hazardousQuantity' ? contract.isHazardous : contract.isReefer;
if (!gated) return false;
if (contract.freightType !== 'CONTAINER') return true;
const lines = dto.containers ?? [];
if (!lines.length) return Boolean(gated);
return lines.some((l) => this.handlingCounts(l)[field] > 0);
}
/**
* Resolve the booking's equipment return from the per-line return quantities
* (container freight). The CONTRACT gates the service — like hazardous:
@@ -1470,7 +1520,7 @@ export class ContractBookingService {
const lines = dto.containers ?? [];
for (const line of lines) {
const qty = Number(line.returnQuantity ?? 0);
const qty = this.handlingCounts(line).returnQuantity;
if (qty === 0) continue;
if (contract.equipmentReturn !== 'WITH_RETURN') {
throw new BadRequestException(
@@ -1486,7 +1536,7 @@ export class ContractBookingService {
}
if (contract.equipmentReturn === 'WITH_RETURN') {
const anyReturn = lines.some((l) => Number(l.returnQuantity ?? 0) > 0);
const anyReturn = lines.some((l) => this.handlingCounts(l).returnQuantity > 0);
return anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN';
}
return legacy;
@@ -1524,9 +1574,10 @@ export class ContractBookingService {
);
}
const counts = this.handlingCounts(line);
const containerType = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
contract.isReefer || counts.reeferQuantity > 0,
);
const vgmPerUnit = line.units.length
@@ -1540,12 +1591,10 @@ export class ContractBookingService {
containerTypeId: containerType.id,
containerSize: line.containerSize,
quantity: line.quantity,
hazardousQuantity: line.hazardousQuantity ?? 0,
reeferQuantity: line.reeferQuantity ?? 0,
hazardousQuantity: counts.hazardousQuantity,
reeferQuantity: counts.reeferQuantity,
returnQuantity:
contract.equipmentReturn === 'WITH_RETURN'
? (line.returnQuantity ?? 0)
: 0,
contract.equipmentReturn === 'WITH_RETURN' ? counts.returnQuantity : 0,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: totalVgm,
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)),
@@ -1564,6 +1613,8 @@ export class ContractBookingService {
vgmTons: unit.vgmTons,
isHazardous: unit.isHazardous ?? false,
isReefer: unit.isReefer ?? false,
isReturn:
contract.equipmentReturn === 'WITH_RETURN' && (unit.isReturn ?? false),
sortOrder: sortOrder++,
}),
);
@@ -1664,8 +1715,8 @@ export class ContractBookingService {
paymentCurrency: contract.paymentCurrency,
serviceTypeId: contract.serviceTypeId,
cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
isGovernment: contract.isGovernment,
shippingLineId: null,
@@ -1680,11 +1731,11 @@ export class ContractBookingService {
containerTypeId: ct.id,
containerSize: line.containerSize,
quantity: line.quantity,
hazardousQuantity: line.hazardousQuantity ?? 0,
reeferQuantity: line.reeferQuantity ?? 0,
hazardousQuantity: this.handlingCounts(line).hazardousQuantity,
reeferQuantity: this.handlingCounts(line).reeferQuantity,
returnQuantity:
contract.equipmentReturn === 'WITH_RETURN'
? (line.returnQuantity ?? 0)
? this.handlingCounts(line).returnQuantity
: 0,
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
totalVgmTons,

View File

@@ -50,6 +50,15 @@ export class CreateContainerUnitDto {
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isReefer?: boolean;
@ApiPropertyOptional({
default: false,
description: 'This container ships back empty (equipment return).',
})
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isReturn?: boolean;
}
export class CreateBookingContainerLineDto {

View File

@@ -0,0 +1,28 @@
import { deriveRateType } from './rate-type.util';
describe('deriveRateType — surcharge triggers', () => {
// Every surcharge trigger must land on its own rateType. A trigger with no
// mapping falls through to the base-freight branch and is silently stored as
// CANCELLATION_FEE, which both mislabels the booking's rate snapshot and
// hides the rate from contract pricing (which looks rateTypes up by name).
it.each([
['HAZARDOUS', 'HAZARD_SURCHARGE'],
['REEFER', 'REEFER_SURCHARGE'],
['WITH_RETURN', 'RETURN_SURCHARGE'],
['OVERWEIGHT', 'OVERWEIGHT_PER_TON'],
['SHIPPING_LINE', 'DOUBLE_HANDLING'],
['CONSOLIDATION', 'LASHING'],
['CANCELLATION', 'CANCELLATION_FEE'],
['DEMURRAGE', 'DEMURRAGE'],
['PIL_EXTRA_FEE', 'PIL_EXTRA_FEE'],
['CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE'],
] as const)('maps trigger %s to %s', (trigger, expected) => {
expect(deriveRateType({ appliesTo: 'OTHER', trigger })).toBe(expected);
});
it('does not fall back to CANCELLATION_FEE for the empty-return service', () => {
expect(deriveRateType({ appliesTo: 'OTHER', trigger: 'WITH_RETURN' })).not.toBe(
'CANCELLATION_FEE',
);
});
});

View File

@@ -25,6 +25,12 @@ export function deriveRateType(input: {
return 'HAZARD_SURCHARGE';
case 'REEFER':
return 'REEFER_SURCHARGE';
// Empty-container return service. Contract pricing looks this rateType up
// by name, so without the mapping a WITH_RETURN rate fell through to the
// base-freight branch and was stored as CANCELLATION_FEE — invisible to
// the contract, and mislabelled on the booking's snapshot.
case 'WITH_RETURN':
return 'RETURN_SURCHARGE';
case 'OVERWEIGHT':
return 'OVERWEIGHT_PER_TON';
case 'SHIPPING_LINE':

View File

@@ -42,6 +42,14 @@ export interface BookingContainerEvalInput {
isReefer?: boolean;
isOverweight?: boolean;
overweightExcessTons?: number | null;
/**
* How many individual containers on this line opted into each handling
* service. PER_CONTAINER surcharges bill these counts, not the line
* quantity — 20 containers with 10 hazardous bill hazard on 10.
*/
hazardousQuantity?: number;
reeferQuantity?: number;
returnQuantity?: number;
}
export interface BookingEvaluationInput {
@@ -270,6 +278,27 @@ export class RuleEngineService {
(sum, r) => sum + (r.overweightExcessTons ?? 0),
0,
);
/**
* Containers that opted into this trigger's handling service, summed
* across lines. null when the trigger isn't per-container handling (or
* no line carries a count) so the caller falls back to the full count.
*/
const optedInCount = (trigger: string | null): number | null => {
const field =
trigger === 'HAZARDOUS'
? 'hazardousQuantity'
: trigger === 'REEFER'
? 'reeferQuantity'
: trigger === 'WITH_RETURN'
? 'returnQuantity'
: null;
if (!field) return null;
const total = input.containers.reduce(
(sum, c) => sum + Number(c[field] ?? 0),
0,
);
return total > 0 ? total : null;
};
let triggerValue: number | null = null;
let calculatedAmount: number;
@@ -285,7 +314,11 @@ export class RuleEngineService {
calculatedAmount = triggerValue * rateValue;
break;
case 'PER_CONTAINER':
triggerValue = containerCount;
// Handling surcharges bill only the containers that opted in, not the
// whole line — 20 containers with 10 hazardous bill hazard on 10.
// Legacy bookings carry no per-container counts (all 0) while their
// booking-level flag is set, so fall back to the full count there.
triggerValue = optedInCount(rate.trigger) ?? containerCount;
calculatedAmount = triggerValue * rateValue;
break;
case 'PER_WAGON':

View File

@@ -958,20 +958,21 @@ describe('BookingBatchService — built-train wagon capacity', () => {
// assertion below that says "not full" proves those axes are ignored.
const scheduleId = 'schedule-built';
const reservedBooking = (id: string) =>
const reservedBooking = (id: string, leg?: { origin: string; dest: string }) =>
({
id,
freightType: 'BULK',
cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload
bookingContainers: [],
originYardId: 'yard-a',
destinationYardId: 'yard-b',
originYardId: leg?.origin ?? 'yard-a',
destinationYardId: leg?.dest ?? 'yard-b',
}) as unknown as Booking;
const buildService = (opts: {
physicalWagons: number;
reserved: Booking[];
maxWagons?: number;
routeStops?: string[];
}) => {
const schedule = {
id: scheduleId,
@@ -979,7 +980,7 @@ describe('BookingBatchService — built-train wagon capacity', () => {
bookingWindowStatus: 'OPEN',
originStationId: 'yard-a',
destinationStationId: 'yard-b',
routeId: null,
routeId: opts.routeStops ? 'route-1' : null,
scheduleBookings: [],
trainSet: {
locomotive: {
@@ -992,14 +993,23 @@ describe('BookingBatchService — built-train wagon capacity', () => {
},
};
const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) };
const milestoneRepo = {
find: jest
.fn()
.mockResolvedValue(
(opts.routeStops ?? []).map((yardId, i) => ({ yardId, sequenceNo: i + 1 })),
),
};
const genericRepo = {
find: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined),
};
const dataSource = {
getRepository: jest.fn((entity: { name?: string }) =>
entity?.name === 'Wagon' ? wagonRepo : genericRepo,
),
getRepository: jest.fn((entity: { name?: string }) => {
if (entity?.name === 'Wagon') return wagonRepo;
if (entity?.name === 'RouteMilestone') return milestoneRepo;
return genericRepo;
}),
transaction: jest.fn(),
};
const service = new BookingBatchService(
@@ -1040,6 +1050,22 @@ describe('BookingBatchService — built-train wagon capacity', () => {
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
it('is FULL when sub-leg bookings hold every physical wagon of a milestone route', async () => {
// Regression: 50 wagons sold Negad→Mojo on a Doraleh→…→Dire Dawa corridor
// left the pass-through edges reading "free" in the per-edge budget, so the
// full train's window cycled OPEN forever and the day pool never expired.
// A wagon is committed for the whole trip — leg-free edges are not capacity.
const { service } = buildService({
physicalWagons: 2,
routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'],
reserved: [
reservedBooking('b1', { origin: 'yard-m1', dest: 'yard-m2' }),
reservedBooking('b2', { origin: 'yard-m1', dest: 'yard-m2' }),
],
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
});
it('reports over-allocation when the consist is trimmed below committed bookings', async () => {
const { service } = buildService({
physicalWagons: 1,

View File

@@ -3607,11 +3607,19 @@ export class BookingBatchService implements OnModuleInit {
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
// Built train: the physical consist is the only capacity axis, and a wagon
// is committed to its booking for the WHOLE trip — wagon allocation has no
// leg concept, so a wagon hauling Negad→Mojo cargo can never be re-sold for
// the Doraleh→Negad edge it merely passes through. Count commitments
// train-wide, not per corridor edge: the per-edge budget read "free slots"
// on pass-through legs of a sold-out consist, so the window of a full train
// cycled OPEN forever instead of concluding DONE (and the day pool's
// leftover bookings were never expired).
const physicalWagons = await this.builtTrainWagonCount(schedule);
if (physicalWagons != null) {
return (await this.committedWagons(schedule)) >= physicalWagons;
}
if ((await this.remainingWagons(schedule)) <= 0) return true;
// Built train: the physical consist is the only capacity axis. Weight and
// length were enforced when the consist was assembled (builder /
// adjust-consist), so a free wagon slot means the train genuinely has room.
if ((await this.builtTrainWagonCount(schedule)) != null) return false;
const locomotive = schedule.trainSet?.locomotive;
if (!locomotive) return false; // no weight/length limits to bind against
const wagonDims = await this.loadWagonDims();
@@ -3620,6 +3628,29 @@ export class BookingBatchService implements OnModuleInit {
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
}
/**
* Wagons the schedule's allocated + reserved bookings occupy train-wide,
* regardless of which corridor leg each rides. Deduped by booking id — a
* booking mid-settle can momentarily be both linked and reserved.
*/
private async committedWagons(schedule: TrainSchedule): Promise<number> {
const wagonDims = await this.loadWagonDims();
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(
schedule.id,
);
const byId = new Map(
[...allocated, ...reserved].map((b) => [b.id, b] as const),
);
let total = 0;
for (const booking of byId.values()) {
total += this.wagonsFor(booking, wagonDims);
}
return total;
}
/**
* Smallest gross weight / shortest length one more wagon could add: the
* lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted,

View File

@@ -64,15 +64,15 @@ export class IntercityService {
booking.destinationYardId,
);
return {
...this.mapBooking(booking),
...this.mapBooking(booking, need),
need,
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
};
}),
accepted: accepted.map((booking) => ({
...this.mapBooking(booking),
need: capacity?.needFor(booking) ?? null,
})),
accepted: accepted.map((booking) => {
const need = capacity?.needFor(booking) ?? null;
return { ...this.mapBooking(booking, need), need };
}),
};
}
@@ -294,7 +294,12 @@ export class IntercityService {
return { schedule, booking };
}
private mapBooking(booking: Booking) {
/**
* `need` carries the GROSS weight (cargo + wagon tare) the capacity budget is
* spent in. Prefer it, so the row's weight sits on the same axis as the
* remaining-capacity figure shown beside it; cargo VGM is the fallback.
*/
private mapBooking(booking: Booking, need?: { weightTons: number } | null) {
return {
id: booking.id,
reference: booking.reference,
@@ -310,7 +315,7 @@ export class IntercityService {
booking.destinationYard?.label ??
booking.destinationYard?.code ??
'Unknown destination',
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
weightTons: need?.weightTons ?? Number(booking.cargoTotalWeightVgm ?? 0),
paymentDeadline: booking.paymentDeadline?.toISOString() ?? null,
};
}

View File

@@ -267,6 +267,8 @@ export interface CompositionUnassignedBookingRow {
freightType: string | null;
priorityScore: number;
cargoTotalWeightVgm: number;
/** GROSS: cargo VGM + tare of every wagon the booking occupies. */
grossWeightTons: number;
status: string | null;
schedulingStatus: string | null;
wagonsRequired: number;
@@ -3866,11 +3868,18 @@ export class TrainSchedulingService {
}
const totalWeightTons = totalAssignedWeight(fittingBookings);
// Every weight limit below (global max, loco pull) is a GROSS axis, so the
// figure spent against it must be gross too — cargo alone under-reports the
// train by the full consist tare and disagrees with the assign path.
const totalTareTons = roundTons(
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
);
const grossWeightTons = roundTons(totalWeightTons + totalTareTons);
const totalLengthMeters = roundTons(
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
);
if (totalWeightTons > trainLimits.maxWeightTons) {
const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`;
if (grossWeightTons > trainLimits.maxWeightTons) {
const message = `Total gross weight ${grossWeightTons}T (${totalWeightTons}T cargo + ${totalTareTons}T wagon tare) exceeds max train weight ${trainLimits.maxWeightTons}T`;
if (!violations.includes(message) && !warnings.includes(message)) {
pushLimit([message]);
}
@@ -3897,7 +3906,7 @@ export class TrainSchedulingService {
if (
setLimits &&
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
totalWeightTons ||
grossWeightTons ||
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
totalLengthMeters)
) {
@@ -3918,7 +3927,7 @@ export class TrainSchedulingService {
!inServiceLocomotives.some(
(l) =>
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
totalWeightTons &&
grossWeightTons &&
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
totalLengthMeters,
)
@@ -3940,6 +3949,9 @@ export class TrainSchedulingService {
summary: {
totalBookings: fittingBookings.length,
totalWeightTons,
/** GROSS: cargo + the tare of every wagon in the plan. */
grossWeightTons,
totalTareTons,
// Human-readable wagon type(s) of the plan — mixed consists list all.
wagonType: plannedTypeCodes.join('/') || 'NONE',
wagonsNeeded: wagonPlan.length,
@@ -7036,6 +7048,15 @@ export class TrainSchedulingService {
shortfall: 0,
}));
// Gross weight needs the scheduling graph (containers, cargo type, wagon
// types) that the trimmed select above deliberately skips.
const tareDims = await this.loadWagonTareDims();
const fullById = new Map(
(await this.bookingsRepository.findByIdsForScheduling(unassigned.map((b) => b.id))).map(
(b) => [b.id, b],
),
);
const bookings = await Promise.all(
unassigned.map(async (b) => {
const assignability = await this.previewUnassignedBookingAssignability(
@@ -7050,6 +7071,11 @@ export class TrainSchedulingService {
freightType: b.freightType ?? null,
priorityScore: b.priorityScore ?? 0,
cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 0),
// GROSS: cargo + tare of the wagons the booking occupies.
grossWeightTons: this.grossBookingWeightTons(
(fullById.get(b.id) ?? b) as Booking,
tareDims,
),
status: b.status ?? null,
schedulingStatus: b.schedulingStatus ?? null,
...assignability,

View File

@@ -128,6 +128,10 @@ interface UnitDraft {
containerNumber: string;
sealNumber: string;
vgmTons: string;
/** Handling is per physical container; the line counts roll these up. */
isHazardous: boolean;
isReefer: boolean;
isReturn: boolean;
}
/** Mirrors the portal shipment form's container line: line-level quantity +
@@ -150,7 +154,14 @@ interface BulkDraft {
}
function emptyUnit(): UnitDraft {
return { containerNumber: "", sealNumber: "", vgmTons: "" };
return {
containerNumber: "",
sealNumber: "",
vgmTons: "",
isHazardous: false,
isReefer: false,
isReturn: false,
};
}
function emptyLine(size: string): ContainerLineDraft {
@@ -285,6 +296,20 @@ export default function GlCreateBookingForm() {
// Legacy contracts (no equipment return chosen at creation) keep the old
// booking-level toggle.
const legacyReturnToggle = isContainer && !contract?.equipmentReturn;
/**
* Handling switches offered on each container row — only the services this
* contract was created with, since the server rejects the others.
*/
const handlingColumns = (
[
contract?.isHazardous && { key: "isHazardous", label: "Hazardous" },
contract?.isReefer && { key: "isReefer", label: "Refrigerated" },
contractWithReturn && { key: "isReturn", label: "With return" },
] as Array<false | undefined | { key: keyof UnitDraft; label: string }>
).filter(Boolean) as Array<{
key: "isHazardous" | "isReefer" | "isReturn";
label: string;
}>;
// Intercity shipments ride a passing import/export train staff pick at
// finalize time — no shipment day is chosen and no window gate applies.
const isIntercity = contract?.tradeDirection === "DOMESTIC";
@@ -488,6 +513,18 @@ export default function GlCreateBookingForm() {
enabled: cargoQuery !== null && !isIntercity,
});
/**
* Line handling totals are a roll-up of the per-container switches — the
* count is however many containers ticked each service. Recomputed on every
* unit change so the price estimate and payload follow the switches.
*/
const withDerivedCounts = (line: ContainerLineDraft): ContainerLineDraft => ({
...line,
hazardousQuantity: String(line.units.filter((u) => u.isHazardous).length),
reeferQuantity: String(line.units.filter((u) => u.isReefer).length),
returnQuantity: String(line.units.filter((u) => u.isReturn).length),
});
// Keep the units array length in sync with the entered quantity.
const syncUnits = (lineIdx: number, qty: number) => {
setContainerLines((prev) =>
@@ -496,7 +533,7 @@ export default function GlCreateBookingForm() {
const next = [...line.units];
while (next.length < qty) next.push(emptyUnit());
next.length = Math.max(0, qty);
return { ...line, units: next };
return withDerivedCounts({ ...line, units: next });
}),
);
};
@@ -511,11 +548,16 @@ export default function GlCreateBookingForm() {
unitIdx: number,
patch: Partial<UnitDraft>,
) =>
patchLine(lineIdx, {
units: containerLines[lineIdx].units.map((u, i) =>
i === unitIdx ? { ...u, ...patch } : u,
setContainerLines((prev) =>
prev.map((l, i) =>
i === lineIdx
? withDerivedCounts({
...l,
units: l.units.map((u, j) => (j === unitIdx ? { ...u, ...patch } : u)),
})
: l,
),
});
);
// Same client-side validation as the customer portal shipment form
// (new-shipment-form/schema.ts): ISO container numbers unique within the
@@ -560,10 +602,15 @@ export default function GlCreateBookingForm() {
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length),
returnQuantity: String(imported.filter((r) => r.withReturn).length),
// The spreadsheet marks handling per row — carry it onto the
// container it belongs to rather than collapsing it to a line count.
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
vgmTons: String(r.vgmTons),
isHazardous: Boolean(r.hazardous),
isReefer: Boolean(r.reefer),
isReturn: Boolean(r.withReturn),
})),
};
}),
@@ -742,6 +789,11 @@ export default function GlCreateBookingForm() {
containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
vgmTons: Number(u.vgmTons) || 0,
// Per-container handling — the server rolls these into the line
// counts and bills each surcharge on the ticked containers only.
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
...(contractWithReturn ? { isReturn: Boolean(u.isReturn) } : {}),
})),
}));
} else {
@@ -1175,73 +1227,24 @@ export default function GlCreateBookingForm() {
radius={10}
styles={fieldStyles}
/>
{contract.isHazardous && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="Hazardous qty"
min={0}
value={line.hazardousQuantity}
error={
showErrors
? lineErrors[lineIdx]?.hazardousQuantity
: undefined
}
onChange={(e) =>
patchLine(lineIdx, {
hazardousQuantity: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
)}
{contract.isReefer && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="Reefer qty"
min={0}
value={line.reeferQuantity}
error={
showErrors
? lineErrors[lineIdx]?.reeferQuantity
: undefined
}
onChange={(e) =>
patchLine(lineIdx, {
reeferQuantity: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
)}
{contractWithReturn && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="With return qty"
description="Containers EDR returns empty"
min={0}
value={line.returnQuantity}
error={
showErrors
? lineErrors[lineIdx]?.returnQuantity
: undefined
}
onChange={(e) =>
patchLine(lineIdx, {
returnQuantity: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
)}
</Group>
<StepLabel>Per-container details</StepLabel>
{handlingColumns.length > 0 ? (
<Text fz={11} c="dimmed" mt={4}>
Tick the services each individual container needs
charges apply only to the containers ticked
{handlingColumns
.map((col) => {
const count = line.units.filter(
(u) => u[col.key],
).length;
return count > 0 ? ` · ${count} ${col.label.toLowerCase()}` : "";
})
.join("")}
.
</Text>
) : null}
<Stack gap={10} mt={8}>
{line.units.map((unit, unitIdx) => (
<Group key={unitIdx} gap={10} grow align="flex-start">
@@ -1296,6 +1299,22 @@ export default function GlCreateBookingForm() {
radius={10}
styles={fieldStyles}
/>
{handlingColumns.map((col) => (
<Switch
key={col.key}
checked={Boolean(unit[col.key])}
aria-label={`${col.label} — container ${unitIdx + 1}`}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
[col.key]: e.currentTarget.checked,
})
}
label={unitIdx === 0 ? col.label : undefined}
labelPosition="right"
size="sm"
mt={unitIdx === 0 ? 26 : 6}
/>
))}
</Group>
))}
</Stack>

View File

@@ -40,17 +40,21 @@ export function PreviewSummary({
summary?: {
totalBookings: number;
totalWeightTons: number;
grossWeightTons?: number;
totalTareTons?: number;
wagonType: string;
wagonsNeeded: number;
totalLengthMeters: number;
};
}) {
if (!summary) return null;
// GROSS — the axis every train limit is spent against.
const gross = summary.grossWeightTons ?? summary.totalWeightTons;
const stats = [
{ label: "Bookings", value: String(summary.totalBookings) },
{ label: "Wagons", value: String(summary.wagonsNeeded) },
{ label: "Wagon type", value: summary.wagonType },
{ label: "Total weight", value: `${summary.totalWeightTons}T` },
{ label: "Gross weight", value: `${gross}T` },
{ label: "Train length", value: `${summary.totalLengthMeters}m` },
];
return (

View File

@@ -99,9 +99,8 @@ function usedWeight(schedule: TrainScheduleDetail): number {
/**
* Pull capacity of the set = the WEAKEST locomotive's max pull weight (0 when
* unknown). The API caps at the weakest loco, not the sum of all locos — a
* consist can only pull as hard as its weakest engine. Note: the API also adds
* the consist tare to the used weight when it checks this cap; tare isn't
* available client-side, so this meter compares cargo-only load against pull.
* consist can only pull as hard as its weakest engine. Both sides of this meter
* are gross: `usedWeight` sums per-booking gross (cargo + wagon tare).
*/
function pullCapacity(schedule: TrainScheduleDetail): number {
const set = schedule.trainSet;

View File

@@ -46,6 +46,8 @@ type NormalizedWagon = {
const CAR_WIDTH = 150; // car body + coupler footprint
const round1 = (n: number) => Math.round(n * 10) / 10;
function normalizeWagon(w: DiagramWagonInput, freightType?: string | null): NormalizedWagon {
const allocations = w.allocations ?? [];
const firstLoad = (
@@ -268,10 +270,11 @@ const CONTAINER_BORDERS = [
];
function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
// GROSS on both sides: cargo + tare vs rated payload + tare.
const grossTons = round1(wagon.assignedWeightTons + wagon.tareWeightTons);
const maxGrossTons = round1(wagon.capacityTons + wagon.tareWeightTons);
const utilization =
wagon.capacityTons > 0
? Math.min(100, Math.round((wagon.assignedWeightTons / wagon.capacityTons) * 100))
: 0;
maxGrossTons > 0 ? Math.min(100, Math.round((grossTons / maxGrossTons) * 100)) : 0;
const accent = wagon.isEmpty ? "gray" : wagon.isBulk ? "orange" : "cyan";
const accentVar = `var(--mantine-color-${accent}-6)`;
@@ -281,8 +284,8 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : ""
}${
wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : ""
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)${
wagon.tareWeightTons ? `\nTare: ${wagon.tareWeightTons}T` : ""
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nGross: ${grossTons}/${maxGrossTons}T (${utilization}%)\nCargo: ${wagon.assignedWeightTons}T${
wagon.tareWeightTons ? ` · Tare: ${wagon.tareWeightTons}T` : ""
}`;
// container blocks: one per container number (cap visual at 2 = TEU per wagon)
@@ -369,7 +372,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
/>
</Box>
<Text size="9px" c="dimmed" ta="center" fw={600}>
{wagon.assignedWeightTons}/{wagon.capacityTons}T
{grossTons}/{maxGrossTons}T
</Text>
</Stack>
) : (
@@ -442,7 +445,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
</Text>
{!wagon.isEmpty ? (
<Text size="8px" c="gray.6" fw={700} style={{ whiteSpace: "nowrap" }}>
{wagon.assignedWeightTons}T
{grossTons}T
</Text>
) : null}
</Group>

View File

@@ -6,6 +6,7 @@ type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
sequenceNo: number;
capacityTons: number;
assignedWeightTons: number;
tareWeightTons?: number | null;
slotLoadType?: string;
wagonType?: { code: string } | null;
wagonTypeCode?: string;
@@ -21,6 +22,8 @@ type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
}>;
};
const round1 = (n: number) => Math.round(n * 10) / 10;
function loadTypeColor(loadType: string | undefined, freightType?: string | null) {
const normalized = loadType?.toUpperCase() ?? "";
if (normalized.includes("BULK")) return "orange";
@@ -68,8 +71,16 @@ export function WagonPlanGrid({
);
}
const totalCapacity = wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0);
const totalAssigned = wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0);
// GROSS on both sides: cargo + tare vs rated payload + tare.
const totalTare = round1(
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
);
const totalCapacity = round1(
wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0) + totalTare,
);
const totalAssigned = round1(
wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0) + totalTare,
);
const usedSlots = wagonPlan.filter((w) => (w.allocations?.length ?? 0) > 0).length;
const isBulk = freightType === "BULK" || wagonPlan.every((w) => w.slotLoadType === "BULK" || (!w.slotLoadType && w.allocations?.[0]?.loadType === "Bulk"));
@@ -82,7 +93,7 @@ export function WagonPlanGrid({
</Text>
{isBulk ? (
<Text size="sm" c="dimmed">
Load: <strong>{totalAssigned}</strong> / {totalCapacity}T
Gross: <strong>{totalAssigned}</strong> / {totalCapacity}T
</Text>
) : null}
</Group>
@@ -90,8 +101,9 @@ export function WagonPlanGrid({
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md">
{wagonPlan.map((wagon) => {
const seq = wagon.sequenceNo;
const capacity = wagon.capacityTons;
const assigned = wagon.assignedWeightTons;
const tare = Number(wagon.tareWeightTons) || 0;
const capacity = round1(wagon.capacityTons + tare);
const assigned = round1(wagon.assignedWeightTons + tare);
const allocations = wagon.allocations ?? [];
const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0;
const label = slotLabel(wagon, freightType);
@@ -149,7 +161,7 @@ export function WagonPlanGrid({
</Text>
{label === "BULK" ? (
<Text size="xs" c="dimmed">
{alloc.allocatedWeightTons}T
{alloc.allocatedWeightTons}T cargo
</Text>
) : null}
</Group>

View File

@@ -132,7 +132,7 @@ export const BookingDetailModal = ({
/>
<InfoRow
icon={<Weight size={15} />}
label="Weight"
label="Gross weight"
value={
<Text size="sm" fw={700}>
{booking.weightTons != null ? `${booking.weightTons.toFixed(1)} T` : "—"}

View File

@@ -161,8 +161,11 @@ function WagonCar({
const allocation = wagon.allocations?.[0];
const isEmpty = !allocation;
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
const assigned = allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0;
const capacity = wagon.capacityTons ?? 0;
// GROSS on both sides: cargo + tare vs rated payload + tare.
const tare = wagon.tareWeightTons ?? 0;
const assigned =
(allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0) + tare;
const capacity = (wagon.capacityTons ?? 0) + tare;
const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0;
const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan";
const accentVar = `var(--mantine-color-${accent}-6)`;

View File

@@ -21,6 +21,8 @@ export const RemoveBookingModal = ({
if (!wagon || !wagon.allocations?.[0]) return null;
const allocation = wagon.allocations[0];
// GROSS: allocated cargo + the tare of the wagon it sits on.
const grossTons = (allocation.allocatedWeightTons ?? 0) + (wagon.tareWeightTons ?? 0);
return (
<Modal opened={opened} onClose={onClose} title="Confirm Booking Removal" centered>
@@ -40,7 +42,7 @@ export const RemoveBookingModal = ({
</Badge>
</Text>
<Text size="sm">
<strong>Weight:</strong> {allocation.allocatedWeightTons?.toFixed(2) || 0} T
<strong>Gross weight:</strong> {grossTons.toFixed(2)} T
</Text>
<Text size="sm">
<strong>Wagon Slot:</strong> #{wagon.sequenceNo}

View File

@@ -93,10 +93,14 @@ export const TrainConsistView = ({
}
};
const weightUsed = wagons.reduce(
(sum, w) => sum + (w.allocations?.[0]?.allocatedWeightTons ?? 0),
// GROSS: cargo on every allocation + the tare of every wagon in the consist.
// maxPullWeightTons is a gross limit, so the numerator must be gross too.
const cargoUsed = wagons.reduce(
(sum, w) => sum + (w.allocations ?? []).reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0),
0,
);
const tareUsed = wagons.reduce((sum, w) => sum + (w.tareWeightTons ?? 0), 0);
const weightUsed = cargoUsed + tareUsed;
const lengthUsed = wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0);
return (

View File

@@ -98,7 +98,7 @@ export const TrainStatsBar = ({
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="lg">
<StatTile
icon={<Weight size={15} />}
label="Weight"
label="Gross weight"
pct={weightPct}
current={weightUsed.toFixed(1)}
max={weightMax?.toFixed(1) ?? "∞"}

View File

@@ -130,7 +130,9 @@ export const UnassignedBookingsPanel = ({
{bookings.map((booking) => {
const isActive = selectedBookingId === booking.id;
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
// GROSS (cargo + wagon tare) so this badge shares the axis every other
// weight on the page uses — cargo-only here read ~25% light.
const weight = Number(booking.grossWeightTons ?? booking.cargoTotalWeightVgm ?? 0);
const fits = booking.canAssign;
const blockReason = booking.blockReason;

View File

@@ -36,8 +36,12 @@ export const WagonCard = ({
const hasAllocations = Boolean(allocation);
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
const weightUsed = allocation?.allocatedWeightTons ?? 0;
const weightMax = wagon.capacityTons ?? 0;
// GROSS on both sides: loaded cargo + wagon tare, against the wagon's max
// gross (rated payload + tare). Keeps the wagon axis identical to the train
// axis in TrainStatsBar.
const tare = wagon.tareWeightTons ?? 0;
const weightUsed = (allocation?.allocatedWeightTons ?? 0) + tare;
const weightMax = (wagon.capacityTons ?? 0) + tare;
const weightPercent = weightMax ? (weightUsed / weightMax) * 100 : 0;
const wagonType = wagon.wagonType?.code || "UNKNOWN";

View File

@@ -134,7 +134,11 @@ export interface TrainSchedulePreviewResponse {
deferredBookings?: DeferredBookingRow[];
summary: {
totalBookings: number;
/** Cargo VGM only — display gross instead. */
totalWeightTons: number;
/** GROSS: cargo + the tare of every wagon in the plan. */
grossWeightTons: number;
totalTareTons: number;
wagonType: string;
wagonsNeeded: number;
totalLengthMeters: number;
@@ -845,6 +849,8 @@ export interface CompositionUnassignedBooking {
freightType: FreightType | null;
priorityScore: number;
cargoTotalWeightVgm: number;
/** GROSS: cargo VGM + tare of every wagon the booking occupies. */
grossWeightTons: number;
status: string | null;
schedulingStatus: SchedulingStatus | null;
wagonsRequired: number;

View File

@@ -59,8 +59,6 @@ import {
StepCard,
StepHeader,
StepLabel,
ToggleRow,
UnitCountToggles,
fieldStyles,
} from "./new-contract-form/shared";
import { formatRateUnit } from "./new-contract-form/unit-rates";
@@ -362,6 +360,11 @@ function NewShipmentBookingForm({
containerNumber: u.containerNumber,
sealNumber: u.sealNumber || undefined,
vgmTons: Number(u.vgmTons),
// Per-container handling — the server rolls these up into the
// line counts and bills each surcharge on the ticked containers.
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
...(withReturnService ? { isReturn: Boolean(u.isReturn) } : {}),
})),
})),
}
@@ -1133,7 +1136,7 @@ function CargoStep({
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
units: [emptyUnit()],
})),
{ shouldValidate: false },
);
@@ -1177,7 +1180,7 @@ function CargoStep({
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
units: [emptyUnit()],
}
);
}
@@ -1187,10 +1190,15 @@ function CargoStep({
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length),
returnQuantity: String(imported.filter((r) => r.withReturn).length),
// The spreadsheet already marks handling per row — carry it onto the
// container it belongs to rather than collapsing it to a line count.
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
vgmTons: r.vgmTons,
isHazardous: Boolean(r.hazardous),
isReefer: Boolean(r.reefer),
isReturn: Boolean(r.withReturn),
})),
};
});
@@ -1511,6 +1519,16 @@ function NotesSection({ form }: { form: ShipmentForm }) {
);
}
/** A blank container row — handling switches start off. */
const emptyUnit = () => ({
containerNumber: "",
sealNumber: "",
vgmTons: "",
isHazardous: false,
isReefer: false,
isReturn: false,
});
function ContainerLineEditor({
form,
index,
@@ -1536,76 +1554,81 @@ function ContainerLineEditor({
const current = form.getValues(`containers.${index}.units`) ?? [];
const next = [...current];
while (next.length < qty)
next.push({ containerNumber: "", sealNumber: "", vgmTons: "" });
next.push({
containerNumber: "",
sealNumber: "",
vgmTons: "",
isHazardous: false,
isReefer: false,
isReturn: false,
});
next.length = Math.max(0, qty);
form.setValue(`containers.${index}.units`, next, { shouldValidate: false });
syncHandlingCounts(next);
};
// Lowering the line quantity must pull every cargo-handling count back within
// it, or a stale count silently exceeds the line and fails validation on a
// field the customer can no longer see a cause for.
const clampHandlingCounts = (qty: number) => {
(["hazardousQuantity", "reeferQuantity", "returnQuantity"] as const).forEach(
(key) => {
const path = `containers.${index}.${key}` as const;
const current = Number(form.getValues(path) || 0);
if (current > qty)
form.setValue(path, String(Math.max(0, qty)), {
shouldDirty: true,
shouldValidate: true,
});
},
);
/**
* Line totals are a roll-up of the per-container switches — the count is
* however many containers ticked each service. Kept in form state so the
* price estimate and the submitted payload stay in step with the switches.
*/
const syncHandlingCounts = (
units: Array<{ isHazardous?: boolean; isReefer?: boolean; isReturn?: boolean }>,
) => {
const set = (
key: "hazardousQuantity" | "reeferQuantity" | "returnQuantity",
count: number,
) =>
form.setValue(`containers.${index}.${key}`, String(count), {
shouldDirty: true,
shouldValidate: true,
});
set("hazardousQuantity", units.filter((u) => u.isHazardous).length);
set("reeferQuantity", units.filter((u) => u.isReefer).length);
set("returnQuantity", units.filter((u) => u.isReturn).length);
};
/** Switch state is derived from the count — a line is hazardous iff qty > 0. */
const handlingToggle = (
key: "hazardousQuantity" | "reeferQuantity" | "returnQuantity",
opts: {
icon: ReactNode;
iconBg: string;
iconColor: string;
title: string;
description: string;
pickLabel: string;
activeBg: string;
activeBorder: string;
activeColor: string;
/** Flip one container's handling switch, then re-roll the line totals. */
const toggleUnitHandling = (
unitIndex: number,
key: "isHazardous" | "isReefer" | "isReturn",
on: boolean,
) => {
form.setValue(`containers.${index}.units.${unitIndex}.${key}`, on, {
shouldDirty: true,
});
syncHandlingCounts(form.getValues(`containers.${index}.units`) ?? []);
};
/**
* The handling columns offered on each container row — only the services this
* contract was created with, since the server rejects quantities for the others.
*/
const handlingColumns = [
isHazardous && {
key: "isHazardous" as const,
label: "Hazardous",
icon: <Flame size={14} />,
color: "#C0392B",
},
) => (
<Controller
name={`containers.${index}.${key}`}
control={form.control}
render={({ field, fieldState }) => (
<ToggleRow
icon={opts.icon}
iconBg={opts.iconBg}
iconColor={opts.iconColor}
title={opts.title}
description={opts.description}
checked={Number(field.value || 0) > 0}
onChange={(on) => field.onChange(on ? "1" : "0")}
>
<div>
<UnitCountToggles
total={quantity}
value={field.value ?? "0"}
onChange={field.onChange}
label={opts.pickLabel}
activeBg={opts.activeBg}
activeBorder={opts.activeBorder}
activeColor={opts.activeColor}
/>
{fieldState.error?.message ? (
<Text fz={11} c="red.7" mt={4}>
{fieldState.error.message}
</Text>
) : null}
</div>
</ToggleRow>
)}
/>
);
isReefer && {
key: "isReefer" as const,
label: "Refrigerated",
icon: <Snowflake size={14} />,
color: "#2E5B96",
},
withReturnService && {
key: "isReturn" as const,
label: "With return",
icon: <Repeat size={14} />,
color: "#0A6F4D",
},
].filter(Boolean) as Array<{
key: "isHazardous" | "isReefer" | "isReturn";
label: string;
icon: ReactNode;
color: string;
}>;
return (
<Box
@@ -1640,54 +1663,13 @@ function ContainerLineEditor({
/>
</Box>
{/* Cargo handling — only the services this contract was created with are
offered, since the server rejects quantities for the others. Each
switch reveals a bounded picker: tap the containers it applies to. */}
{(isHazardous || isReefer || withReturnService) && quantity > 0 && (
<>
<StepLabel>Cargo handling</StepLabel>
<div className="grid gap-3 sm:grid-cols-2" style={{ marginBottom: 14 }}>
{isHazardous &&
handlingToggle("hazardousQuantity", {
icon: <Flame size={18} />,
iconBg: "#FBEAE7",
iconColor: "#C0392B",
title: "Hazardous",
description: "Some of these containers carry hazardous cargo.",
pickLabel: "Tap the hazardous containers",
activeBg: "#FBEAE7",
activeBorder: "#E4A69B",
activeColor: "#C0392B",
})}
{isReefer &&
handlingToggle("reeferQuantity", {
icon: <Snowflake size={18} />,
iconBg: "#E9F0F8",
iconColor: "#2E5B96",
title: "Refrigerated",
description: "Some of these containers need reefer transport.",
pickLabel: "Tap the refrigerated containers",
activeBg: "#E9F0F8",
activeBorder: "#A9C2E0",
activeColor: "#2E5B96",
})}
{withReturnService &&
handlingToggle("returnQuantity", {
icon: <Repeat size={18} />,
iconBg: "#ECF6F1",
iconColor: "#0A6F4D",
title: "With return",
description: "Some of these containers come back to EDR empty.",
pickLabel: "Tap the containers EDR returns",
activeBg: "#ECF6F1",
activeBorder: "#A9D6C2",
activeColor: "#0A6F4D",
})}
</div>
</>
)}
<StepLabel>Per-container details</StepLabel>
{handlingColumns.length > 0 && quantity > 0 ? (
<Text fz={11} c="#5B6B7B" mt={4}>
Tick the services each individual container needs charges apply only
to the containers you tick.
</Text>
) : null}
<Stack gap={10} mt={8}>
{Array.from({ length: Math.max(quantity, units.length) }).map((_, u) => (
<Group key={u} gap={10} grow align="flex-start">
@@ -1739,6 +1721,37 @@ function ContainerLineEditor({
/>
)}
/>
{handlingColumns.map((col) => (
<Controller
key={col.key}
name={`containers.${index}.units.${u}.${col.key}`}
control={form.control}
render={({ field }) => (
<Switch
checked={Boolean(field.value)}
aria-label={`${col.label} — container ${u + 1}`}
onChange={(e) =>
toggleUnitHandling(u, col.key, e.currentTarget.checked)
}
label={
u === 0 ? (
<Group gap={4} wrap="nowrap">
<span style={{ color: col.color, display: "flex" }}>
{col.icon}
</span>
<Text fz={12} fw={600} c="#10202F">
{col.label}
</Text>
</Group>
) : undefined
}
labelPosition="right"
size="sm"
mt={u === 0 ? 26 : 6}
/>
)}
/>
))}
</Group>
))}
</Stack>

View File

@@ -48,6 +48,11 @@ const containerUnitSchema = z.object({
.string()
.refine((v) => v.trim().length > 0, "VGM is required.")
.refine((v) => !Number.isNaN(Number(v)) && Number(v) > 0, "Enter a valid VGM."),
// Handling is per physical container, recorded next to its VGM. The line
// totals below are derived from these.
isHazardous: z.boolean().default(false),
isReefer: z.boolean().default(false),
isReturn: z.boolean().default(false),
});
const containerLineSchema = z.object({

View File

@@ -729,14 +729,22 @@ export interface CreateContainerUnitDto {
containerNumber: string;
sealNumber?: string;
vgmTons: number;
/** Per-container handling opt-ins, entered alongside this container's VGM. */
isHazardous?: boolean;
isReefer?: boolean;
/** This container ships back empty (equipment return). */
isReturn?: boolean;
}
export interface CreateBookingContainerLineDto {
/** "20ft" | "40ft" — must be in the contract's cargo scope. */
containerSize: string;
quantity: number;
/**
* Line totals, derived from the per-unit switches above. The API recomputes
* them from `units` whenever any unit carries a flag, so they are only
* authoritative for callers that don't send per-unit flags.
*/
hazardousQuantity?: number;
reeferQuantity?: number;
/**