Merge pull request #968 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-27 09:00:59 +03:00
committed by GitHub
12 changed files with 434 additions and 48 deletions

View File

@@ -594,3 +594,126 @@ describe('BookingPricingService — bulk base freight units', () => {
expect(result.hardBlocked.some((m) => m.includes('rate is configured'))).toBe(true);
});
});
/**
* A PER_WAGON container rate bills the wagons the LINE occupies — two 20ft share
* one wagon, a 40ft takes a whole one. Regression cases taken from real
* bookings on Doraleh → Gelan, where the 20ft line was being charged for the
* 40ft line's wagons as well.
*/
describe('BookingPricingService — PER_WAGON container freight', () => {
const DJ = 'yard-dj-w';
const ET = 'yard-et-w';
const perWagon20: Rate = {
id: 'rate-20-wagon',
rateType: 'CONTAINER_IMPORT',
currency: 'USD',
rateValue: 1690,
rateUnit: 'PER_WAGON',
status: 'LIVE',
containerTypeId: 'ct-20',
originYardId: DJ,
destinationYardId: ET,
} as Rate;
const perContainer40: Rate = {
...perWagon20,
id: 'rate-40-container',
rateValue: 1676,
rateUnit: 'PER_CONTAINER',
containerTypeId: 'ct-40',
} as Rate;
const makeService = () =>
new BookingPricingService(
{
// Booking-wide aggregate — deliberately larger than any single line, so
// a regression that reads it instead of the line's own wagons shows up.
calculateWagonCount: jest.fn().mockResolvedValue(5),
findContractRateSnapshots: jest.fn().mockResolvedValue([]),
} as never,
{
evaluate: jest.fn().mockResolvedValue({
priorityScore: 0,
appliedModifiers: [],
containerWeightResults: [],
warnings: [],
hardBlocked: [],
requiresDirectorApproval: false,
}),
} as never,
{
findById: jest.fn(async (id: string) => ({
id,
sizeFt: id === 'ct-40' ? 40 : 20,
isReefer: false,
code: id === 'ct-40' ? 'C40' : 'C20',
label: id === 'ct-40' ? 'C40' : 'C20',
})),
} as never,
{ findLiveRates: jest.fn().mockResolvedValue([perWagon20, perContainer40]) } as never,
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{ findById: jest.fn() } as never,
);
const booking = (
lines: Array<{ containerTypeId: string; quantity: number }>,
) =>
({
id: 'b-wagon',
freightType: 'CONTAINER',
tradeDirection: 'IMPORT',
paymentCurrency: 'USD',
originYardId: DJ,
destinationYardId: ET,
bookingContainers: lines.map((l) => ({
containerTypeId: l.containerTypeId,
quantity: l.quantity,
vgmPerUnitTons: 10,
})),
}) as unknown as Booking;
const price = async (
lines: Array<{ containerTypeId: string; quantity: number }>,
) => {
const service = makeService();
const result = await service.computePriceForBooking(booking(lines));
return result.lineItems.filter((l) => l.code === 'CONTAINER_IMPORT');
};
it('bills 2× 20ft as one wagon', async () => {
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 2 }]);
expect(line.unit).toBe('PER_WAGON');
expect(line.quantity).toBe(1);
expect(line.amount).toBe(1690);
});
it('bills 10× 20ft as five wagons', async () => {
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 10 }]);
expect(line.quantity).toBe(5);
expect(line.amount).toBe(5 * 1690);
});
it('does not charge the 20ft line for the 40ft lines wagons', async () => {
const lines = await price([
{ containerTypeId: 'ct-20', quantity: 4 },
{ containerTypeId: 'ct-40', quantity: 1 },
]);
const twenty = lines.find((l) => l.description.startsWith('C20'))!;
const forty = lines.find((l) => l.description.startsWith('C40'))!;
// 4× 20ft = 2 wagons, NOT the booking-wide 3.
expect(twenty.quantity).toBe(2);
expect(twenty.amount).toBe(2 * 1690);
// The 40ft line keeps billing per container.
expect(forty.quantity).toBe(1);
expect(forty.amount).toBe(1676);
});
it('rounds an odd 20ft count up to a whole wagon', async () => {
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 5 }]);
expect(line.quantity).toBe(3);
expect(line.amount).toBe(3 * 1690);
});
});

View File

@@ -530,14 +530,6 @@ export class BookingPricingService {
const usedRatesMap = new Map<string, Rate>();
const warnings: string[] = [];
const blocked: string[] = [];
// Bulk bookings carry no container lines, so the container-based wagon
// aggregate is 0 for them — a PER_WAGON bulk rate would bill nothing. Use
// the tonnage-derived estimate instead (the eval input already carries it
// for saved bookings; a preview derives it here).
const wagonCount = isBulk
? Number(evalInput.bulkWagons ?? 0) || (await this.bulkWagonCount(booking)) || 0
: await this.resolveWagonCount(booking);
for (const container of evalInput.containers) {
const rate = this.pickRate(
liveRates,
@@ -573,6 +565,10 @@ export class BookingPricingService {
}
const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER';
// A PER_WAGON line bills the wagons THIS line occupies (two 20ft share
// one), never the booking-wide count — otherwise a booking with a 20ft
// and a 40ft line charges each line for the other's wagons too.
const lineWagons = await this.lineWagonCount(container);
let amount: number;
let unitAmount: number;
if (frozen) {
@@ -581,11 +577,11 @@ export class BookingPricingService {
rateUnit,
unitAmount,
container.quantity,
wagonCount,
lineWagons,
);
} else {
const unitUsd = Number(rate!.rateValue);
const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount);
const usdAmount = this.amountForRate(rate!, container.quantity, lineWagons);
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
}
@@ -596,7 +592,7 @@ export class BookingPricingService {
amount,
unitAmount,
unit: rateUnit,
quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount),
quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, lineWagons),
currency: paymentCurrency,
});
}
@@ -625,6 +621,14 @@ export class BookingPricingService {
: undefined) ?? onLeg.find((r) => !r.cargoTypeId);
if (fallback) {
usedRatesMap.set(fallback.id, fallback);
// Bulk has no container lines to count wagons from, so a PER_WAGON bulk
// rate bills the tonnage-derived estimate for the WHOLE booking (there
// is only ever this one line).
const wagonCount = isBulk
? Number(evalInput.bulkWagons ?? 0) ||
(await this.bulkWagonCount(booking)) ||
0
: await this.resolveWagonCount(booking);
// Bulk quantity is stored in the commodity's own unit — tonnes for a
// PER_TON commodity, item count for a PER_ITEM one.
const bulkQuantity = Number(booking.cargoTotalWeightVgm ?? 0);
@@ -783,6 +787,34 @@ export class BookingPricingService {
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
}
/**
* Wagons ONE container line occupies: two 20ft share a wagon, a 40ft takes a
* whole one. This — not the booking-wide total — is what a PER_WAGON base
* freight line bills, so a booking of 4×20ft + 1×40ft charges the 20ft line
* for 2 wagons and the 40ft line for its own 1, instead of billing each line
* for all 3.
*/
private async lineWagonCount(container: {
containerTypeId: string;
quantity: number;
wagonsPerUnit?: number;
}): Promise<number> {
let perUnit = container.wagonsPerUnit;
if (perUnit == null) {
// Preview bookings build their eval input without the fraction — read it
// off the container type instead of assuming one wagon per box.
try {
const ct = await this.containerTypesService.findById(
container.containerTypeId,
);
perUnit = wagonsPerUnitForSize(Number(ct.sizeFt));
} catch {
perUnit = 1; // unknown type: never under-bill
}
}
return Math.max(1, Math.ceil(container.quantity * perUnit));
}
/**
* Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate;
* an unsaved preview booking (no id) sums the wagonsRequired already computed

View File

@@ -14,7 +14,8 @@ export interface IRatesRepository {
findLiveRatesDetailed(): Promise<Rate[]>;
findByPattern(pattern: {
rateType: string;
rateUnit: string;
/** Omitted for singly-resolved rates — see the repository implementation. */
rateUnit?: string;
containerTypeId?: string | null;
cargoTypeId?: string | null;
tradeDirection?: string | null;

View File

@@ -18,10 +18,17 @@ export class RatesRepository implements IRatesRepository {
return this.repo.findOne({ where: { id } });
}
/**
* Every LIVE rate, newest first. The ordering is load-bearing: pricing picks
* the first match for a pattern, so without it Postgres heap order decided
* which of two overlapping rates a booking was billed at. Newest-first also
* means the most recent configuration wins where legacy overlaps still exist.
*/
findLiveRates(): Promise<Rate[]> {
return this.repo
.createQueryBuilder('rate')
.where('rate.status = :status', { status: 'LIVE' })
.orderBy('rate.created_at', 'DESC')
.getMany();
}
@@ -47,9 +54,21 @@ export class RatesRepository implements IRatesRepository {
* insert so the admin gets a friendly error instead of a raw constraint fault.
* NULL scope columns are matched with IS NULL, mirroring the COALESCE index.
*/
/**
* The live/draft rate already covering a pricing pattern, if any.
*
* `rateUnit` is optional on purpose. Where pricing resolves ONE rate for a
* lane (base freight, customs, lashing, empty return) the unit is not part of
* the identity — a per-container and a per-wagon row for the same lane are
* two answers to one question and the engine picks whichever came back first,
* so the caller omits it and the second row is rejected. Additive surcharges
* (hazard, reefer, demurrage…) are the opposite: the engine bills every
* matching rate by its own unit, so one per freight shape is the design and
* the caller passes the unit to keep them apart.
*/
findByPattern(pattern: {
rateType: string;
rateUnit: string;
rateUnit?: string;
containerTypeId?: string | null;
cargoTypeId?: string | null;
tradeDirection?: string | null;
@@ -59,9 +78,12 @@ export class RatesRepository implements IRatesRepository {
const qb = this.repo
.createQueryBuilder('rate')
.where('rate.rate_type = :rateType', { rateType: pattern.rateType })
.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit })
.andWhere('rate.status <> :superseded', { superseded: 'SUPERSEDED' });
if (pattern.rateUnit) {
qb.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit });
}
if (pattern.containerTypeId) {
qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId });
} else {

View File

@@ -0,0 +1,138 @@
import { ConflictException } from '@nestjs/common';
import { RatesService } from './rates.service';
import type { Rate } from '../entities/rate.entity';
/**
* One rate per lane + scope, whatever the unit.
*
* Pricing resolves a single rate for a (type, container type, leg) and then
* applies whatever unit it carries — it has no way to choose between a
* per-container and a per-wagon row for the same 20ft lane, and used to bill
* whichever the database happened to return first. So the unit is NOT part of a
* rate's identity: changing how a lane is billed means editing its rate.
*/
describe('RatesService — one rate per pattern', () => {
const DJ = '11111111-1111-4000-8000-000000000001';
const ET = '11111111-1111-4000-8000-000000000002';
const CT20 = '11111111-1111-4000-8000-000000000003';
const existing = (over: Partial<Rate> = {}): Rate =>
({
id: 'rate-existing',
rateType: 'CONTAINER_IMPORT',
rateUnit: 'PER_WAGON',
rateValue: 1690,
containerTypeId: CT20,
cargoTypeId: null,
tradeDirection: 'IMPORT',
originYardId: DJ,
destinationYardId: ET,
status: 'LIVE',
...over,
}) as Rate;
const dto = {
appliesTo: 'CONTAINER',
trigger: 'ALWAYS',
tradeDirection: 'IMPORT',
containerTypeId: CT20,
originYardId: DJ,
destinationYardId: ET,
rateValue: 845,
rateUnit: 'PER_CONTAINER',
};
let repository: { findByPattern: jest.Mock; create: jest.Mock };
let service: RatesService;
beforeEach(() => {
repository = {
findByPattern: jest.fn().mockResolvedValue(null),
create: jest.fn(async (r) => ({ id: 'rate-new', ...r })),
};
service = new RatesService(
repository as never,
{
findById: jest.fn(async (id: string) => ({
id,
country: id === DJ ? 'Djibouti' : 'Ethiopia',
label: id === DJ ? 'Doraleh' : 'Gelan',
})),
} as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
);
});
it('refuses a second rate on the same lane that only differs by unit', async () => {
repository.findByPattern.mockResolvedValue(existing());
await expect(service.create(dto as never, 'staff-1')).rejects.toBeInstanceOf(
ConflictException,
);
expect(repository.create).not.toHaveBeenCalled();
});
it('looks the pattern up without the unit, so either order collides', async () => {
await service.create(dto as never, 'staff-1');
const pattern = repository.findByPattern.mock.calls[0][0];
expect(pattern).not.toHaveProperty('rateUnit');
expect(pattern).toMatchObject({
rateType: 'CONTAINER_IMPORT',
containerTypeId: CT20,
originYardId: DJ,
destinationYardId: ET,
});
});
it('still allows the same unit on a different lane', async () => {
await service.create(dto as never, 'staff-1');
expect(repository.create).toHaveBeenCalledWith(
expect.objectContaining({
rateUnit: 'PER_CONTAINER',
rateValue: 845,
status: 'DRAFT',
}),
);
});
/**
* Additive surcharges are billed per matching rate, each by its own unit, so
* hazard is legitimately per-container for boxes AND per-ton for bulk. The
* unit stays part of their identity or the second one could never be created.
*/
it('keeps the unit in the key for an additive surcharge', async () => {
await service.create(
{
appliesTo: 'OTHER',
trigger: 'HAZARDOUS',
rateValue: 300,
rateUnit: 'PER_CONTAINER',
} as never,
'staff-1',
);
expect(repository.findByPattern.mock.calls[0][0]).toMatchObject({
rateType: 'HAZARD_SURCHARGE',
rateUnit: 'PER_CONTAINER',
});
});
it('treats lashing as singly resolved — one unit per direction', async () => {
await service.create(
{
appliesTo: 'OTHER',
trigger: 'LASHING',
tradeDirection: 'IMPORT',
rateValue: 40,
rateUnit: 'PER_TON',
} as never,
'staff-1',
);
expect(repository.findByPattern.mock.calls[0][0]).not.toHaveProperty(
'rateUnit',
);
});
});

View File

@@ -132,6 +132,24 @@ export class RatesService {
);
}
/**
* True when pricing resolves exactly ONE rate for this shape (base freight,
* customs clearance, lashing, empty-container return — all `find()`-based
* lookups). For those the unit is not part of the rate's identity: two rows
* for the same lane differing only by unit are a duplicate the engine cannot
* choose between.
*
* The additive surcharges are the opposite — the engine bills EVERY matching
* rate by its own unit, which is how hazard can be per-container for boxes
* and per-ton for bulk at the same time — so their unit stays part of the key.
*/
private resolvesSingleRate(
appliesTo: Rate['appliesTo'],
trigger: Rate['trigger'],
): boolean {
return this.isRouteScoped(appliesTo, trigger) || trigger === 'LASHING';
}
/**
* Which country each end of the leg must sit in, given what the rate is for.
* The railway only sells three shapes: import lands at the Djibouti ports and
@@ -326,10 +344,17 @@ export class RatesService {
* Reject a second rate with the same identity pattern (rateType + scope). With
* effective-date windows gone, two LIVE/DRAFT rates for the same pattern would
* make pricing ambiguous — so we allow exactly one per pattern.
*
* The UNIT is not part of that identity. Pricing resolves one rate per lane +
* scope and then applies whatever unit it carries; a per-container and a
* per-wagon row for the same 20ft lane are two answers to one question, and
* the engine silently picked one of them. Changing how a lane is billed means
* editing its rate, not adding a second.
*/
private async assertNoDuplicatePattern(pattern: {
rateType: string;
rateUnit: string;
/** Passed only for additive surcharges — see {@link resolvesSingleRate}. */
rateUnit?: string;
containerTypeId: string | null;
cargoTypeId: string | null;
tradeDirection: string | null;
@@ -415,7 +440,7 @@ export class RatesService {
await this.assertNoDuplicatePattern({
rateType,
rateUnit,
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
containerTypeId,
cargoTypeId,
tradeDirection,
@@ -600,7 +625,7 @@ export class RatesService {
// Guard the pattern uniqueness for the new identity, ignoring this row.
await this.assertNoDuplicatePattern({
rateType,
rateUnit,
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
containerTypeId: updates.containerTypeId,
cargoTypeId: updates.cargoTypeId,
tradeDirection: updates.tradeDirection,

View File

@@ -72,12 +72,19 @@ export function computeGlShipmentTotal(
(i) => i.containerSize === line.containerSize && !i.isClearance,
);
if (rate) {
// A per-wagon rate bills the wagons this line occupies — two 20ft share
// one — not the box count. Mirrors the API's base-freight line so the
// quote matches the invoice.
const billedQty =
rate.unit === "per_wagon"
? Math.ceil(qty * (line.containerSize === "40ft" ? 1 : 0.5))
: qty;
lines.push({
label: rate.label,
unitPrice: rate.unitPrice,
unit: rate.unit,
quantity: qty,
amount: rate.unitPrice * qty,
quantity: billedQty,
amount: rate.unitPrice * billedQty,
});
}
hazardTotalQty += line.hazardousQuantity;

View File

@@ -116,8 +116,8 @@ const FreightDashboardHeader = ({
</Group>
{/* Right: actions + avatar. The doc-review alarm leads the group — it
only renders in the last half of a review phase that still has
undecided requests, so it never competes for space otherwise. */}
renders only during a review phase that still has undecided
requests, so it never competes for space otherwise. */}
<Group gap={10} wrap="nowrap" align="center">
<DocReviewAlertButton />

View File

@@ -391,6 +391,12 @@ const RuleEngineFormDialog = ({
// display order) is a non-negative magnitude — reject negatives outright
// rather than letting a typed "-" reach the API.
min={isNumber ? 0 : undefined}
// Without an explicit step the browser assumes 1 and refuses to submit
// anything fractional — which blocked decimal rates, tonnages and
// distances. The API is the one that decides which of these are whole
// numbers (@IsInt on points/sizes/order, @IsNumber on money, tons, km),
// so let the field carry decimals and let a 400 catch the rest.
step={isNumber ? "any" : undefined}
disabled={field.disabled || computed !== undefined}
value={String((computed !== undefined ? computed : values[field.name]) ?? "")}
onChange={(e) => {

View File

@@ -36,11 +36,12 @@ function formatRemaining(ms: number): string {
}
/**
* Header alarm for the document-review deadline. Appears only once the review
* phase is half spent AND requests are still undecided — everything still
* pending when the clock runs out is expired automatically, so this is the last
* call to accept or reject. Clicking opens the booking requests already
* filtered to those undecided import requests.
* Header alarm for the document-review deadline. Runs for the whole review
* phase — from the moment it opens until the clock runs out — whenever requests
* are still undecided. Everything left pending at the deadline is expired
* automatically, so staff get the full window to accept or reject rather than
* only its back half. Clicking opens the booking requests already filtered to
* those undecided requests.
*/
export default function DocReviewAlertButton() {
const navigate = useNavigate();
@@ -67,10 +68,12 @@ export default function DocReviewAlertButton() {
}, [deadlineMs]);
if (!alert) return null;
// Half the review phase has to be gone before staff are alarmed — a 30-minute
// review warns with 15 minutes left.
const halfMs = (Math.max(alert.docReviewMinutes, 1) * 60_000) / 2;
if (remaining <= 0 || remaining > halfMs) return null;
// Alarm for the WHOLE review phase, from the moment it opens: anything still
// undecided when the clock runs out is expired automatically, so staff need
// the full window to act, not the back half of it. The endpoint only returns
// a schedule that is in DOC_REVIEW with undecided requests behind it, so the
// remaining check just hides the pill once the deadline passes.
if (remaining <= 0) return null;
const requestLabel = alert.pendingCount === 1 ? "request" : "requests";
@@ -85,7 +88,16 @@ export default function DocReviewAlertButton() {
<UnstyledButton
onClick={() => navigate(pendingRequestsHref(alert.tradeDirection))}
aria-label={`${alert.pendingCount} import booking ${requestLabel} awaiting a decision — document review ends in ${formatRemaining(remaining)}`}
className="group flex h-9 shrink-0 items-center gap-2 rounded-full border border-red-600/60 bg-red-600 pl-2.5 pr-2 text-white shadow-[0_2px_10px_rgba(220,38,38,0.35)] transition-transform hover:scale-[1.02] hover:bg-red-700"
className="group flex h-9 shrink-0 items-center gap-2 rounded-full pl-2.5 pr-2 transition-transform hover:scale-[1.02]"
// Inline, not Tailwind: Mantine's UnstyledButton resets the background
// in unlayered CSS, which beats a `@layer utilities` class whatever its
// specificity — `bg-red-600` alone renders the pill white.
style={{
background: "var(--mantine-color-red-6)",
border: "1px solid var(--mantine-color-red-7)",
color: "#fff",
boxShadow: "0 2px 10px rgba(220, 38, 38, 0.35)",
}}
>
{/* Live dot: a ping ring behind a solid core, so the pill reads as
active without animating the whole chip. */}

View File

@@ -112,6 +112,13 @@ const SORT_OPTIONS = [
{ value: "contractValidUntil:DESC", label: "Expiring latest" },
];
/** Every column is 120px wide and wraps its content instead of truncating. */
const COLUMN_WIDTH = 120;
const COLUMN_META = {
headerClassName: "whitespace-normal break-words",
cellClassName: "whitespace-normal break-words align-top",
};
/** Local start-of-day → ISO, for inclusive "from" date filters. */
function startOfDayIso(d: Date): string {
const x = new Date(d);
@@ -260,6 +267,8 @@ export default function ContractRequestsPage() {
const columns: ColumnDef<ContractListRow>[] = [
{
id: "contract",
size: COLUMN_WIDTH,
meta: COLUMN_META,
header: () => <span className={bookingTable.headerCell}>Customer</span>,
cell: ({ row }) => {
const c = row.original;
@@ -269,10 +278,8 @@ export default function ContractRequestsPage() {
<User className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{c.customerLabel}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<p className="font-medium text-foreground">{c.customerLabel}</p>
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<FileText className="size-3 shrink-0 opacity-70" />
{c.reference}
</p>
@@ -283,17 +290,17 @@ export default function ContractRequestsPage() {
},
{
id: "route",
size: COLUMN_WIDTH,
meta: COLUMN_META,
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => {
const c = row.original;
return (
<div className="space-y-1 py-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<span className="max-w-[8rem] truncate">{c.originLabel}</span>
<span>{c.originLabel}</span>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span className="max-w-[8rem] truncate">
{c.destinationLabel}
</span>
<span>{c.destinationLabel}</span>
</div>
<div className="flex gap-1.5">
<Badge
@@ -315,8 +322,8 @@ export default function ContractRequestsPage() {
},
{
id: "status",
size: 200,
minSize: 180,
size: COLUMN_WIDTH,
meta: COLUMN_META,
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<div className="py-1">
@@ -326,18 +333,18 @@ export default function ContractRequestsPage() {
/>
</div>
),
meta: {
headerClassName: "min-w-[11rem]",
cellClassName: "min-w-[11rem]",
},
},
{
id: "approval",
size: COLUMN_WIDTH,
meta: COLUMN_META,
header: () => <span className={bookingTable.headerCell}>Approval</span>,
cell: ({ row }) => <ContractApprovalProgressCell row={row.original} />,
},
{
id: "validity",
size: COLUMN_WIDTH,
meta: COLUMN_META,
header: () => <span className={bookingTable.headerCell}>Validity</span>,
cell: ({ row }) => {
const c = row.original;
@@ -362,6 +369,8 @@ export default function ContractRequestsPage() {
},
{
id: "kind",
size: COLUMN_WIDTH,
meta: COLUMN_META,
header: () => <span className={bookingTable.headerCell}>Kind</span>,
cell: ({ row }) => {
const isGeneral = row.original.contractKind === "GENERAL";
@@ -383,6 +392,8 @@ export default function ContractRequestsPage() {
},
{
id: "actions",
size: COLUMN_WIDTH,
meta: COLUMN_META,
header: () => <span className={bookingTable.headerCell}>Action</span>,
cell: ({ row }) => {
const action = getStaffRowAction(row.original);
@@ -653,7 +664,9 @@ export default function ContractRequestsPage() {
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
// table-fixed makes the per-column 120px widths stick; without
// it auto-layout re-widens columns once cells wrap.
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[840px]"
footer={DataTableFooter}
/>
</Box>

View File

@@ -55,12 +55,19 @@ export function computeShipmentTotal(
(i) => i.containerSize === line.containerSize && !i.isClearance,
);
if (rate) {
// A per-wagon rate bills the wagons this line occupies — two 20ft share
// one — not the box count. Mirrors the API's base-freight line so the
// quote matches the invoice.
const billedQty =
rate.unit === "per_wagon"
? Math.ceil(qty * (line.containerSize === "40ft" ? 1 : 0.5))
: qty;
lines.push({
label: rate.label,
unitPrice: rate.unitPrice,
unit: rate.unit,
quantity: qty,
amount: rate.unitPrice * qty,
quantity: billedQty,
amount: rate.unitPrice * billedQty,
});
}
hazardTotalQty += Number(line.hazardousQuantity || 0);