diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 39b6f4145..4996f45a9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -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 line’s 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 3908dbbac..ec2fc5f2e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -530,14 +530,6 @@ export class BookingPricingService { const usedRatesMap = new Map(); 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 { + 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 diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index 76203edef..b3ee54d20 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -14,7 +14,8 @@ export interface IRatesRepository { findLiveRatesDetailed(): Promise; 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; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index bdec72e46..abd10cd98 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -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 { 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 { diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts new file mode 100644 index 000000000..23d96da89 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts @@ -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 => + ({ + 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', + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 37166f544..d8ceb97ab 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -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, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts index 95b677c1d..9131196c7 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts @@ -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; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index 2ad4e1f28..831e1bc1b 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -116,8 +116,8 @@ const FreightDashboardHeader = ({ {/* 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. */} diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index cd56ad79d..01bb5ceea 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -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) => { diff --git a/apps/edr-freight-web/backoffice/src/features/bookingWindows/DocReviewAlertButton.tsx b/apps/edr-freight-web/backoffice/src/features/bookingWindows/DocReviewAlertButton.tsx index 16af5b6ab..058617d3b 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookingWindows/DocReviewAlertButton.tsx +++ b/apps/edr-freight-web/backoffice/src/features/bookingWindows/DocReviewAlertButton.tsx @@ -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() { 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. */} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx index 7687b62f0..bb2e14036 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx @@ -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[] = [ { id: "contract", + size: COLUMN_WIDTH, + meta: COLUMN_META, header: () => Customer, cell: ({ row }) => { const c = row.original; @@ -269,10 +278,8 @@ export default function ContractRequestsPage() {
-

- {c.customerLabel} -

-

+

{c.customerLabel}

+

{c.reference}

@@ -283,17 +290,17 @@ export default function ContractRequestsPage() { }, { id: "route", + size: COLUMN_WIDTH, + meta: COLUMN_META, header: () => Route, cell: ({ row }) => { const c = row.original; return (
- {c.originLabel} + {c.originLabel} - - {c.destinationLabel} - + {c.destinationLabel}
Status, cell: ({ row }) => (
@@ -326,18 +333,18 @@ export default function ContractRequestsPage() { />
), - meta: { - headerClassName: "min-w-[11rem]", - cellClassName: "min-w-[11rem]", - }, }, { id: "approval", + size: COLUMN_WIDTH, + meta: COLUMN_META, header: () => Approval, cell: ({ row }) => , }, { id: "validity", + size: COLUMN_WIDTH, + meta: COLUMN_META, header: () => Validity, cell: ({ row }) => { const c = row.original; @@ -362,6 +369,8 @@ export default function ContractRequestsPage() { }, { id: "kind", + size: COLUMN_WIDTH, + meta: COLUMN_META, header: () => Kind, 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: () => Action, 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} /> diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts index c778385fd..7fd35c174 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts @@ -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);