mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
Enhance booking and signature functionalities
- Updated MySignaturePage title to Signature
This commit is contained in:
@@ -3131,6 +3131,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
async intercityCapacity(scheduleId: string): Promise<{
|
||||
budget: CorridorBudget;
|
||||
needFor: (booking: Booking) => Capacity;
|
||||
/**
|
||||
* Per-wagon-type split of `needFor(booking).wagons`, against THIS
|
||||
* schedule's own wagon stock — so the same booking reads differently on a
|
||||
* different train. Empty when the stock can't be resolved.
|
||||
*/
|
||||
breakdownFor: (
|
||||
booking: Booking,
|
||||
) => Array<{ wagonTypeId: string; code: string; wagons: number }>;
|
||||
} | null> {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
@@ -3145,7 +3153,24 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// still accepts ride-alongs on its empty legs — that is the whole point
|
||||
// of the ride-along flow.
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
||||
// Physical stock of THIS schedule's train (built consist, or the yard fleet
|
||||
// it will draw from) — what makes the breakdown train-specific.
|
||||
const stock = await this.trainSchedulingService.wagonStockForSchedule(
|
||||
schedule.id,
|
||||
schedule.originStationId,
|
||||
budget.stops,
|
||||
);
|
||||
return {
|
||||
budget,
|
||||
needFor: (booking) => this.needFor(booking, wagonDims),
|
||||
breakdownFor: (booking) =>
|
||||
this.wagonBreakdownFor(
|
||||
booking,
|
||||
wagonDims,
|
||||
stock.remainingByTypeId,
|
||||
stock.codesByTypeId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4094,6 +4119,78 @@ export class BookingBatchService implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The wagon count of {@link wagonsFor}, split across the wagon TYPES this
|
||||
* particular train stocks — "3 × N35 + 1 × PW2" rather than a bare 4.
|
||||
*
|
||||
* `wagonsFor` sizes the booking on ONE representative type (the first the
|
||||
* cargo type allows), which is all the abstract budget needs. Staff placing a
|
||||
* ride-along need the physical picture: how many of each type this schedule
|
||||
* must actually give up. So each allowed type is sized on its OWN capacity and
|
||||
* items-fit, then filled greedily from the type with the largest per-wagon
|
||||
* take, bounded by what the schedule has left of it.
|
||||
*
|
||||
* Because the stock is per-schedule, the same booking breaks down differently
|
||||
* on a train stocking 60T N35s than on one stocking 40T PW2s. Returns [] when
|
||||
* the booking's types are unconfigured or the train stocks none of them — the
|
||||
* caller then shows the plain total.
|
||||
*/
|
||||
private wagonBreakdownFor(
|
||||
booking: Booking,
|
||||
wagonDims: WagonDims,
|
||||
stockByTypeId: Map<string, number>,
|
||||
codesByTypeId: Map<string, string>,
|
||||
): Array<{ wagonTypeId: string; code: string; wagons: number }> {
|
||||
const total = this.wagonsFor(booking, wagonDims);
|
||||
if (total <= 0) return [];
|
||||
|
||||
// Per-wagon take of each allowed type ON THIS TRAIN, largest first: a type
|
||||
// that swallows more of the booking per wagon needs fewer wagons.
|
||||
const options = this.allowedDimsWithTypes(booking, wagonDims)
|
||||
.filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0)
|
||||
.map((o) => {
|
||||
const wagonTypeId = o.wagonTypeId as string;
|
||||
const wagonsIfAlone = Math.max(
|
||||
1,
|
||||
bulkItemWagonsRequired(
|
||||
booking,
|
||||
o.dims.capacityTons,
|
||||
bulkItemsFitFor(booking.cargoType, wagonTypeId),
|
||||
) ||
|
||||
(o.dims.capacityTons > 0
|
||||
? Math.ceil(bookingCargoTons(booking) / o.dims.capacityTons)
|
||||
: total),
|
||||
);
|
||||
return {
|
||||
wagonTypeId,
|
||||
code: codesByTypeId.get(wagonTypeId) ?? '—',
|
||||
available: stockByTypeId.get(wagonTypeId) ?? 0,
|
||||
// Share of the whole booking one wagon of this type carries.
|
||||
takePerWagon: 1 / wagonsIfAlone,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.takePerWagon - a.takePerWagon);
|
||||
if (!options.length) return [];
|
||||
|
||||
// Fill greedily by take, capped by stock; `remaining` is the fraction of the
|
||||
// booking still unplaced, so a wagon of any type covers `takePerWagon` of it.
|
||||
const out: Array<{ wagonTypeId: string; code: string; wagons: number }> = [];
|
||||
let remaining = 1;
|
||||
for (const option of options) {
|
||||
if (remaining <= 1e-9) break;
|
||||
const wagons = Math.min(
|
||||
option.available,
|
||||
Math.ceil(remaining / option.takePerWagon),
|
||||
);
|
||||
if (wagons <= 0) continue;
|
||||
out.push({ wagonTypeId: option.wagonTypeId, code: option.code, wagons });
|
||||
remaining -= wagons * option.takePerWagon;
|
||||
}
|
||||
// The train cannot hold the whole booking in the types it stocks — the
|
||||
// `fits` check already fails it; report only what it CAN take.
|
||||
return out;
|
||||
}
|
||||
|
||||
private fits(need: Capacity, budget: Capacity): boolean {
|
||||
return (
|
||||
need.wagons <= budget.wagons &&
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
/**
|
||||
* The intercity ride-along board shows WHICH wagon types a booking takes from
|
||||
* the train it is being placed on ("3 × N35 + 1 × PW2"), not just how many
|
||||
* wagons. Because the split is drawn against that schedule's own stock, the
|
||||
* same booking must read differently on a different train.
|
||||
*/
|
||||
describe('BookingBatchService — intercity wagon breakdown', () => {
|
||||
const N35 = 'wagon-type-n35';
|
||||
const PW2 = 'wagon-type-pw2';
|
||||
|
||||
const dims = (capacityTons: number) => ({
|
||||
capacityTons,
|
||||
lengthMeters: 14,
|
||||
tareWeightTons: 20,
|
||||
});
|
||||
|
||||
const wagonDims = {
|
||||
bulk: dims(60),
|
||||
container: dims(60),
|
||||
byWagonTypeId: new Map([
|
||||
[N35, dims(60)],
|
||||
[PW2, dims(20)],
|
||||
]),
|
||||
};
|
||||
|
||||
const codes = new Map([
|
||||
[N35, 'N35'],
|
||||
[PW2, 'PW2'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* 400 break-bulk items weighing 800t — 2t per item. On a 60t N35 that is 30
|
||||
* items per wagon (14 wagons); on a 20t PW2, 10 items (40 wagons).
|
||||
*/
|
||||
const perItemBooking = {
|
||||
id: 'booking-1',
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: 400,
|
||||
bulkTotalWeightTons: 800,
|
||||
bookingContainers: [],
|
||||
cargoType: {
|
||||
wagonTypes: [{ id: N35 }, { id: PW2 }],
|
||||
itemsPerWagonMap: {},
|
||||
},
|
||||
} as unknown as Booking;
|
||||
|
||||
const service = Object.create(
|
||||
BookingBatchService.prototype,
|
||||
) as BookingBatchService;
|
||||
|
||||
const breakdown = (
|
||||
booking: Booking,
|
||||
stock: Map<string, number>,
|
||||
): Array<{ code: string; wagons: number }> =>
|
||||
(
|
||||
service as unknown as {
|
||||
wagonBreakdownFor: (
|
||||
b: Booking,
|
||||
d: typeof wagonDims,
|
||||
s: Map<string, number>,
|
||||
c: Map<string, string>,
|
||||
) => Array<{ code: string; wagons: number }>;
|
||||
}
|
||||
)
|
||||
.wagonBreakdownFor(booking, wagonDims, stock, codes)
|
||||
.map(({ code, wagons }) => ({ code, wagons }));
|
||||
|
||||
it('takes the highest-capacity type first when the train stocks plenty', () => {
|
||||
const rows = breakdown(perItemBooking, new Map([[N35, 50], [PW2, 50]]));
|
||||
expect(rows).toEqual([{ code: 'N35', wagons: 14 }]);
|
||||
});
|
||||
|
||||
it('falls back to the smaller type for the remainder when the big one runs short', () => {
|
||||
// Only 10 of the 14 N35s the booking wants — the rest rides PW2s. Ten N35s
|
||||
// carry 10/14 of the booking, leaving 4/14, which needs ceil(40 × 4/14) PW2s.
|
||||
const rows = breakdown(perItemBooking, new Map([[N35, 10], [PW2, 50]]));
|
||||
expect(rows[0]).toEqual({ code: 'N35', wagons: 10 });
|
||||
expect(rows[1].code).toBe('PW2');
|
||||
expect(rows[1].wagons).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('reads differently on a train that stocks only the small type', () => {
|
||||
const rows = breakdown(perItemBooking, new Map([[PW2, 60]]));
|
||||
expect(rows).toEqual([{ code: 'PW2', wagons: 40 }]);
|
||||
});
|
||||
|
||||
it('honours the configured items-per-wagon fit over raw tonnage', () => {
|
||||
// Floor space binds before weight: an N35 physically holds 20 of these
|
||||
// items even though 30 would fit by weight → 20 wagons, not 14.
|
||||
const floorBound = {
|
||||
...perItemBooking,
|
||||
cargoType: {
|
||||
wagonTypes: [{ id: N35 }],
|
||||
itemsPerWagonMap: { [N35]: 20 },
|
||||
},
|
||||
} as unknown as Booking;
|
||||
expect(breakdown(floorBound, new Map([[N35, 50]]))).toEqual([
|
||||
{ code: 'N35', wagons: 20 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns nothing when the train stocks none of the allowed types', () => {
|
||||
expect(breakdown(perItemBooking, new Map([['other-type', 30]]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -155,12 +155,17 @@ export class IntercityService {
|
||||
return {
|
||||
...this.mapBooking(booking, need),
|
||||
need,
|
||||
wagonBreakdown: capacity?.breakdownFor(booking) ?? [],
|
||||
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
|
||||
};
|
||||
}),
|
||||
accepted: accepted.map((booking) => {
|
||||
const need = capacity?.needFor(booking) ?? null;
|
||||
return { ...this.mapBooking(booking, need), need };
|
||||
return {
|
||||
...this.mapBooking(booking, need),
|
||||
need,
|
||||
wagonBreakdown: capacity?.breakdownFor(booking) ?? [],
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -200,7 +205,9 @@ export class IntercityService {
|
||||
where: { id: bookingId },
|
||||
relations: {
|
||||
bookingContainers: { containerType: true },
|
||||
cargoType: true,
|
||||
// wagonTypes drives the break-bulk items-per-wagon fit — the accept
|
||||
// check must size the booking exactly as the candidate list did.
|
||||
cargoType: { wagonTypes: true },
|
||||
},
|
||||
});
|
||||
if (!booking) {
|
||||
@@ -326,6 +333,10 @@ export class IntercityService {
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||
// The allowed wagon-type list is what sizes a break-bulk (PER_ITEM)
|
||||
// booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and
|
||||
// the wagon count silently degrades to tonnage-only.
|
||||
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType')
|
||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.where(`booking.trade_direction = 'DOMESTIC'`)
|
||||
@@ -355,6 +366,10 @@ export class IntercityService {
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||
// The allowed wagon-type list is what sizes a break-bulk (PER_ITEM)
|
||||
// booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and
|
||||
// the wagon count silently degrades to tonnage-only.
|
||||
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType')
|
||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.where(`booking.trade_direction = 'DOMESTIC'`)
|
||||
|
||||
@@ -1435,4 +1435,44 @@ describe('TrainSchedulingService', () => {
|
||||
).rejects.toThrow(/free only 5/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveWagonsRequired', () => {
|
||||
const effective = (booking: unknown): number =>
|
||||
(service as never as { effectiveWagonsRequired(b: unknown): number })
|
||||
.effectiveWagonsRequired(booking);
|
||||
|
||||
// 20-item / 100T break-bulk on 70T wagons with a 4-items-per-wagon fit:
|
||||
// ceil(20/4) = 5 wagons.
|
||||
const perItemBooking = (wagonsRequired: number | null) => ({
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: 20,
|
||||
bulkTotalWeightTons: 100,
|
||||
wagonsRequired,
|
||||
cargoType: {
|
||||
wagonTypes: [{ id: 'wt-nw5', capacityTons: 70 }],
|
||||
itemsPerWagonMap: { 'wt-nw5': 4 },
|
||||
},
|
||||
});
|
||||
|
||||
it('overrides a stale too-small stamp with the item-aware recompute', () => {
|
||||
// Stamped 1 by old code that read the PER_ITEM count (20) as tons.
|
||||
expect(effective(perItemBooking(1))).toBe(5);
|
||||
});
|
||||
|
||||
it('keeps a stored stamp that is at least the recompute', () => {
|
||||
expect(effective(perItemBooking(7))).toBe(7);
|
||||
});
|
||||
|
||||
it('trusts the stamp when BULK cargo relations are not loaded', () => {
|
||||
expect(
|
||||
effective({
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: 20,
|
||||
bulkTotalWeightTons: 100,
|
||||
wagonsRequired: 5,
|
||||
cargoType: null,
|
||||
}),
|
||||
).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7819,7 +7819,7 @@ export class TrainSchedulingService {
|
||||
*/
|
||||
private effectiveWagonsRequired(booking: Booking): number {
|
||||
const stored = Number(booking.wagonsRequired);
|
||||
if (stored > 0) return Math.ceil(stored);
|
||||
const storedCeil = stored > 0 ? Math.ceil(stored) : 0;
|
||||
const bulkCapacities = (booking.cargoType?.wagonTypes ?? [])
|
||||
.map((wt) => Number(wt.capacityTons))
|
||||
.filter((c) => c > 0);
|
||||
@@ -7827,7 +7827,15 @@ export class TrainSchedulingService {
|
||||
booking.freightType === 'BULK' && bulkCapacities.length
|
||||
? Math.max(...bulkCapacities)
|
||||
: undefined;
|
||||
return wagonsRequiredForBooking(booking, bulkCapacity);
|
||||
// BULK with no cargo relations loaded: recomputing would size against a
|
||||
// 1T capacity and read a PER_ITEM item count as tons — trust the stamp.
|
||||
if (booking.freightType === 'BULK' && bulkCapacity === undefined && storedCeil > 0) {
|
||||
return storedCeil;
|
||||
}
|
||||
// Stored is a candidate, never an early return (batch parity): rows
|
||||
// stamped while BULK sizing read the PER_ITEM item count as tons carry a
|
||||
// too-small footprint — a 20-item/100T booking was stamped 1 wagon.
|
||||
return Math.max(storedCeil, wagonsRequiredForBooking(booking, bulkCapacity));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Package } from "lucide-react";
|
||||
import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { MetricTile } from "./MetricTile";
|
||||
@@ -13,6 +14,7 @@ export interface BookingCargoCardProps {
|
||||
/** Cargo specs + container manifest table. */
|
||||
export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
const { tons, items } = cargoTonsAndItems(booking);
|
||||
|
||||
return (
|
||||
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
|
||||
@@ -21,7 +23,8 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
label="Cargo type"
|
||||
value={booking.cargoType?.label ?? booking.freightType}
|
||||
/>
|
||||
<MetricTile label="Total VGM" value={`${booking.cargoTotalWeightVgm} tons`} />
|
||||
<MetricTile label="Total VGM" value={`${tons} tons`} />
|
||||
{items != null && <MetricTile label="Items" value={`${items}`} />}
|
||||
<MetricTile
|
||||
label="Hazardous"
|
||||
value={booking.isHazardous ? "Yes" : "No"}
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { ReactNode } from "react";
|
||||
import { Hash, Package, Ship, Weight, Clock } from "lucide-react";
|
||||
import { Group, Stack, Text, Divider } from "@mantine/core";
|
||||
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { formatDate, type BookingDetailView } from "./booking-detail.styles";
|
||||
|
||||
@@ -38,7 +40,14 @@ export function BookingFactsCard({ booking }: BookingFactsCardProps) {
|
||||
{ icon: Hash, label: "PNR Code", value: booking.pnrCode || "—" },
|
||||
{ icon: Package, label: "Cargo Type", value: booking.cargoType?.label ?? "—" },
|
||||
{ icon: Ship, label: "Shipping Line", value: booking.shippingLine?.label ?? "—" },
|
||||
{ icon: Weight, label: "VGM Weight", value: `${booking.cargoTotalWeightVgm} tons` },
|
||||
{
|
||||
icon: Weight,
|
||||
label: "VGM Weight",
|
||||
value: (() => {
|
||||
const { tons, items } = cargoTonsAndItems(booking);
|
||||
return items != null ? `${tons} tons (${items} items)` : `${tons} tons`;
|
||||
})(),
|
||||
},
|
||||
{ icon: Clock, label: "Last Updated", value: formatDate(booking.updatedAt) },
|
||||
];
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
||||
@@ -52,7 +53,7 @@ export function BookingRequestHero({
|
||||
(sum, c) => sum + Number(c.quantity ?? 0),
|
||||
0,
|
||||
);
|
||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -162,7 +163,7 @@ export function BookingRequestHero({
|
||||
icon={Weight}
|
||||
label="Cargo weight"
|
||||
value={`${weight} T`}
|
||||
hint="VGM total"
|
||||
hint={itemCount != null ? `${itemCount} items` : "VGM total"}
|
||||
accent="blue"
|
||||
/>
|
||||
<HeroTile
|
||||
|
||||
@@ -79,7 +79,7 @@ export function MySignatureCard() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileSignature className="size-4" />
|
||||
My signature
|
||||
Signature & Stamp
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
This signature can be reused to sign booking contracts.
|
||||
|
||||
@@ -38,6 +38,7 @@ import { formatRouteLabel } from "@/services/routes.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
import type {
|
||||
ContainerPlacement,
|
||||
FreightType,
|
||||
@@ -416,7 +417,7 @@ export function AllocateBookingWizard({
|
||||
const amount = Number(booking.totalAmount);
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
|
||||
const holdCountdown = formatCountdown(booking.holdExpiresAt);
|
||||
|
||||
const containerComplete =
|
||||
@@ -945,7 +946,13 @@ export function AllocateBookingWizard({
|
||||
})}`}
|
||||
hint={booking.paymentStatus}
|
||||
/>
|
||||
<StatTile onDark icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" />
|
||||
<StatTile
|
||||
onDark
|
||||
icon={Weight}
|
||||
label="Cargo weight"
|
||||
value={`${weight} T`}
|
||||
hint={itemCount != null ? `${itemCount} items` : "VGM total"}
|
||||
/>
|
||||
<StatTile
|
||||
onDark
|
||||
icon={ContainerIcon}
|
||||
|
||||
@@ -68,11 +68,27 @@ function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NeedCells({ need }: { need: IntercityCapacity | null }) {
|
||||
function NeedCells({ row }: { row: IntercityBookingRow }) {
|
||||
const { need, wagonBreakdown } = row;
|
||||
if (!need) return <Table.Td colSpan={3}>—</Table.Td>;
|
||||
return (
|
||||
<>
|
||||
<Table.Td>{fmt(need.wagons)}</Table.Td>
|
||||
<Table.Td>
|
||||
{wagonBreakdown?.length ? (
|
||||
// Which wagon TYPES this train gives up, not just how many wagons —
|
||||
// a break-bulk booking's count depends on each type's capacity and
|
||||
// its configured items-per-wagon fit, so it differs per train.
|
||||
<Stack gap={2}>
|
||||
{wagonBreakdown.map((entry) => (
|
||||
<Text key={entry.wagonTypeId} size="sm">
|
||||
{entry.wagons} × {entry.code}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
fmt(need.wagons)
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{fmt(need.weightTons)} t</Table.Td>
|
||||
<Table.Td>{fmt(need.lengthMeters)} m</Table.Td>
|
||||
</>
|
||||
@@ -285,7 +301,7 @@ export function IntercityRideAlongPanel({
|
||||
<Table.Td>
|
||||
<CorridorCell row={row} />
|
||||
</Table.Td>
|
||||
<NeedCells need={row.need} />
|
||||
<NeedCells row={row} />
|
||||
<Table.Td>
|
||||
{row.fits ? (
|
||||
<Badge size="sm" variant="light" color="teal">
|
||||
|
||||
@@ -12,6 +12,7 @@ import toast from "react-hot-toast";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import { StampUpload } from "@/components/contracts/StampUpload";
|
||||
import { bookingSurface } from "@/components/bookings/booking-ui.styles";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
|
||||
@@ -40,6 +41,9 @@ export default function BookingContractPage() {
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
// Company stamp: prefilled from the profile, or uploaded here when none is
|
||||
// saved yet.
|
||||
const [stampData, setStampData] = useState<string | null>(null);
|
||||
// When the user has a saved signature we offer it for approval first; they
|
||||
// can switch to drawing a fresh one.
|
||||
const [drawNew, setDrawNew] = useState(false);
|
||||
@@ -55,6 +59,7 @@ export default function BookingContractPage() {
|
||||
|
||||
const savedSignature = data?.savedSignature ?? null;
|
||||
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
||||
const savedStampImage = savedSignature?.stampImageUrl ?? null;
|
||||
// Show the approval view only while a saved signature exists and the user
|
||||
// hasn't opted to draw a new one.
|
||||
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
||||
@@ -98,6 +103,8 @@ export default function BookingContractPage() {
|
||||
// approve it; otherwise start with an empty pad.
|
||||
setSignerName(savedSignature?.signerDisplayName ?? "");
|
||||
setSignatureData(null);
|
||||
// Prefill with the reusable stamp saved on the profile; still replaceable.
|
||||
setStampData(savedStampImage);
|
||||
setDrawNew(false);
|
||||
setSignOpen(true);
|
||||
};
|
||||
@@ -106,10 +113,12 @@ export default function BookingContractPage() {
|
||||
if (!canSign || !signerName.trim()) return;
|
||||
// Approve the saved signature, or submit the freshly drawn one.
|
||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||
if (!image) return;
|
||||
// The API rejects a STAFF signature without a stamp.
|
||||
if (!image || !stampData) return;
|
||||
signMutation.mutate({
|
||||
role: "STAFF",
|
||||
signatureImageBase64: image,
|
||||
stampImageBase64: stampData,
|
||||
signerDisplayName: signerName.trim(),
|
||||
consentText: "I agree to the terms of this contract.",
|
||||
});
|
||||
@@ -234,6 +243,15 @@ export default function BookingContractPage() {
|
||||
) : (
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
)}
|
||||
<StampUpload
|
||||
value={stampData}
|
||||
onChange={setStampData}
|
||||
description={
|
||||
savedStampImage
|
||||
? "Your saved company stamp — replace it for this contract if needed."
|
||||
: "Required. Attach your official company stamp or seal."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
||||
@@ -243,6 +261,7 @@ export default function BookingContractPage() {
|
||||
disabled={
|
||||
signMutation.isPending ||
|
||||
(!usingSaved && !signatureData) ||
|
||||
!stampData ||
|
||||
!signerName.trim()
|
||||
}
|
||||
onClick={confirmSign}
|
||||
|
||||
@@ -92,6 +92,7 @@ export interface ContractView {
|
||||
savedSignature?: {
|
||||
signerDisplayName: string;
|
||||
signatureImageUrl?: string | null;
|
||||
stampImageUrl?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -113,6 +114,8 @@ export interface ConsolidationDetails {
|
||||
export interface SignContractPayload {
|
||||
role: "CUSTOMER" | "STAFF";
|
||||
signatureImageBase64: string;
|
||||
/** Company stamp/seal image; the API requires one for CUSTOMER and STAFF. */
|
||||
stampImageBase64?: string;
|
||||
signerDisplayName: string;
|
||||
consentText?: string;
|
||||
}
|
||||
|
||||
@@ -171,6 +171,8 @@ export interface BookingDetail {
|
||||
/** What the containers carry / bulk commodity label — entered at booking time. */
|
||||
cargoFreeText?: string | null;
|
||||
cargoTotalWeightVgm: number;
|
||||
/** Break-bulk (PER_ITEM) only: real total tons — cargoTotalWeightVgm then holds the item count. */
|
||||
bulkTotalWeightTons?: number | null;
|
||||
isHazardous: boolean;
|
||||
consolidationPartnerId?: string | null;
|
||||
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
|
||||
|
||||
@@ -959,6 +959,14 @@ export interface IntercityCapacity {
|
||||
lengthMeters: number | null;
|
||||
}
|
||||
|
||||
/** One wagon type this train must give up, and how many of it. */
|
||||
export interface IntercityWagonBreakdownEntry {
|
||||
wagonTypeId: string;
|
||||
/** Wagon-type code as marshalled, e.g. "N35" / "PW2". */
|
||||
code: string;
|
||||
wagons: number;
|
||||
}
|
||||
|
||||
export interface IntercityBookingRow {
|
||||
id: string;
|
||||
reference: string | null;
|
||||
@@ -973,6 +981,12 @@ export interface IntercityBookingRow {
|
||||
weightTons: number;
|
||||
paymentDeadline: string | null;
|
||||
need: IntercityCapacity | null;
|
||||
/**
|
||||
* `need.wagons` split across the wagon types THIS schedule stocks — the same
|
||||
* booking reads differently on a train of 60T wagons than on one of 40T.
|
||||
* Empty when the stock or the cargo type's wagon list is unresolved.
|
||||
*/
|
||||
wagonBreakdown?: IntercityWagonBreakdownEntry[];
|
||||
}
|
||||
|
||||
export interface IntercityCandidateRow extends IntercityBookingRow {
|
||||
|
||||
18
apps/edr-freight-web/backoffice/src/utils/cargoWeight.ts
Normal file
18
apps/edr-freight-web/backoffice/src/utils/cargoWeight.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Break-bulk (PER_ITEM) bulk bookings overload `cargoTotalWeightVgm` with the
|
||||
* ITEM COUNT; their real tonnage lives in `bulkTotalWeightTons`. Every other
|
||||
* booking stores tons in `cargoTotalWeightVgm` directly. Rendering the raw
|
||||
* VGM column showed a 20-item / 100T booking as "20 tons".
|
||||
*/
|
||||
export function cargoTonsAndItems(booking: {
|
||||
freightType?: string | null;
|
||||
cargoTotalWeightVgm?: number | string | null;
|
||||
bulkTotalWeightTons?: number | string | null;
|
||||
}): { tons: number; items: number | null } {
|
||||
const bulkTons = Number(booking.bulkTotalWeightTons ?? 0);
|
||||
const vgm = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
if (booking.freightType === "BULK" && bulkTons > 0) {
|
||||
return { tons: bulkTons, items: vgm > 0 ? vgm : null };
|
||||
}
|
||||
return { tons: vgm, items: null };
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export function MySignatureCard() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileSignature className="size-5 text-primary" />
|
||||
My signature
|
||||
Signature & Stamp
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Reused to approve and sign booking contracts.
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function MySignaturePage() {
|
||||
<div className="mx-auto flex max-w-md flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black tracking-tight text-foreground">
|
||||
My signature
|
||||
Signature & Stamp
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Saved and reused to approve and sign booking contracts.
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import { StampUpload } from "@/components/contracts/StampUpload";
|
||||
import {
|
||||
bookingsService,
|
||||
type SignContractPayload,
|
||||
@@ -25,6 +26,9 @@ export default function BookingContractPage() {
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
// Company stamp: prefilled from the profile, or uploaded here when the
|
||||
// customer has never saved one.
|
||||
const [stampData, setStampData] = useState<string | null>(null);
|
||||
// When a saved signature exists we offer it for approval first; the customer
|
||||
// can switch to drawing a fresh one.
|
||||
const [drawNew, setDrawNew] = useState(false);
|
||||
@@ -37,12 +41,14 @@ export default function BookingContractPage() {
|
||||
|
||||
const savedSignature = data?.savedSignature ?? null;
|
||||
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
||||
const savedStampImage = savedSignature?.stampImageUrl ?? null;
|
||||
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
||||
|
||||
const openSign = () => {
|
||||
// Prefill from the saved signature so the customer only has to approve it.
|
||||
setSignerName(savedSignature?.signerDisplayName ?? "");
|
||||
setSignatureData(null);
|
||||
setStampData(savedStampImage);
|
||||
setDrawNew(false);
|
||||
setSignOpen(true);
|
||||
};
|
||||
@@ -51,10 +57,12 @@ export default function BookingContractPage() {
|
||||
if (!signerName.trim()) return;
|
||||
// Approve the saved signature, or submit the freshly drawn one.
|
||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||
if (!image) return;
|
||||
// The API rejects a CUSTOMER signature without a stamp.
|
||||
if (!image || !stampData) return;
|
||||
signMutation.mutate({
|
||||
role: "CUSTOMER",
|
||||
signatureImageBase64: image,
|
||||
stampImageBase64: stampData,
|
||||
signerDisplayName: signerName.trim(),
|
||||
consentText: "I agree to the terms of this contract.",
|
||||
});
|
||||
@@ -191,6 +199,15 @@ export default function BookingContractPage() {
|
||||
) : (
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
)}
|
||||
<StampUpload
|
||||
value={stampData}
|
||||
onChange={setStampData}
|
||||
description={
|
||||
savedStampImage
|
||||
? "Your saved company stamp — replace it for this contract if needed."
|
||||
: "Required. Attach your official company stamp or seal."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
||||
@@ -200,6 +217,7 @@ export default function BookingContractPage() {
|
||||
disabled={
|
||||
signMutation.isPending ||
|
||||
(!usingSaved && !signatureData) ||
|
||||
!stampData ||
|
||||
!signerName.trim()
|
||||
}
|
||||
onClick={confirmSign}
|
||||
|
||||
@@ -83,6 +83,10 @@ export function totalVgmTons(b: BookingDetail): number {
|
||||
);
|
||||
if (sum > 0) return sum;
|
||||
}
|
||||
// Break-bulk (PER_ITEM): cargoTotalWeightVgm holds the ITEM COUNT — the
|
||||
// real tonnage lives in bulkTotalWeightTons.
|
||||
const bulkTons = Number(b.bulkTotalWeightTons || 0);
|
||||
if (bulkTons > 0) return bulkTons;
|
||||
return Number(b.cargoTotalWeightVgm || 0);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user