From 49c453b82a44f5be77b9ab70ccc206b9c7f3fdb8 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 5 Aug 2026 07:26:41 +0000 Subject: [PATCH 1/2] always-on calendar, currency cards --- .../bookings/booking-pricing.service.spec.ts | 27 +++++++++++++++++++ .../bookings/booking-pricing.service.ts | 25 ++++++++--------- .../contracts/contract-pricing.service.ts | 3 ++- 3 files changed, 42 insertions(+), 13 deletions(-) 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 4996f45a9..75a179b5c 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 @@ -87,6 +87,33 @@ describe('BookingPricingService — domestic corridor', () => { expect(result.lineItems[0].amount).toBe(Math.round(35 * 120 * MOCK_CBE_RATE)); }); + it('keeps the exchange rate decimals — ETB amounts round to cents, not whole birr', async () => { + exchangeService.getRate.mockResolvedValue(162.2132); + const booking = { + id: 'b-1-frac', + freightType: 'BULK', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'ETB', + cargoTotalWeightVgm: 120, + originYardId: MOJO, + destinationYardId: DIRE, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { containers: [] }, + ) => Promise<{ lineItems: Array<{ amount: number }> }>; + } + ).computeBaseRailLinesWithRates(booking, { containers: [] }); + + // 35 × 120 × 162.2132 = 681,295.44 — the .44 must survive (whole-birr + // rounding here billed with the integer part of the rate, in effect). + expect(result.lineItems[0].amount).toBe(681295.44); + }); + it('prices domestic bulk in USD using INTERCITY_BULK USD rate directly', async () => { const booking = { id: 'b-1-usd', 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 9bbe6bed5..522bac47e 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 @@ -6,6 +6,7 @@ import { RatesService } from '../rule-engine/services/rates.service'; import { Rate } from '../rule-engine/entities/rate.entity'; import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; +import { round2 } from '../billing/invoice-settlement.util'; import { ExchangeService } from '@edr/api-common'; import { AppliedCargoModifier, @@ -205,14 +206,14 @@ export class BookingPricingService { const unitAmount = frozen ? Number(frozen.unitPrice) : isEtbBooking - ? Math.round(unitUsd * usdToEtb) + ? round2(unitUsd * usdToEtb) : unitUsd; const convertedAmount = frozen ? isEtbBooking - ? Math.round(unitAmount * quantity) + ? round2(unitAmount * quantity) : unitAmount * quantity : isEtbBooking - ? Math.round(usdAmount * usdToEtb) + ? round2(usdAmount * usdToEtb) : usdAmount; const item: PriceLineItemDto = { @@ -583,8 +584,8 @@ export class BookingPricingService { } else { const unitUsd = Number(rate!.rateValue); const usdAmount = this.amountForRate(rate!, container.quantity, lineWagons); - amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; - unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; + amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount; + unitAmount = isEtbBooking ? round2(unitUsd * usdToEtb) : unitUsd; } if (rate) usedRatesMap.set(rate.id, rate); lines.push({ @@ -652,8 +653,8 @@ export class BookingPricingService { ); } else { const usdAmount = this.amountForRate(fallback, quantity, wagonCount); - amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; - unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; + amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount; + unitAmount = isEtbBooking ? round2(unitUsd * usdToEtb) : unitUsd; } lines.push({ code: rateType, @@ -763,12 +764,12 @@ export class BookingPricingService { if (frozen) { unitAmount = Number(frozen.unitPrice); amount = isEtbBooking - ? Math.round(unitAmount * quantity) + ? round2(unitAmount * quantity) : unitAmount * quantity; } else { const usdAmount = value * quantity; - amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; - unitAmount = isEtbBooking ? Math.round(value * usdToEtb) : value; + amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount; + unitAmount = isEtbBooking ? round2(value * usdToEtb) : value; } // Skip legs that resolve to nothing (zero rate, or zero km / count / tons). if (!(amount > 0)) continue; @@ -971,7 +972,7 @@ export class BookingPricingService { if (!(usdToEtb > 0)) return null; const converted = snap.currency === 'USD' && bookingCurrency === 'ETB' - ? Math.round(unitPrice * usdToEtb) + ? round2(unitPrice * usdToEtb) : snap.currency === 'ETB' && bookingCurrency === 'USD' ? unitPrice / usdToEtb : null; @@ -1027,7 +1028,7 @@ export class BookingPricingService { const currency = booking.paymentCurrency; const isEtb = currency === 'ETB'; const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; - const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd); + const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd); const onLeg = liveRates.filter( (r) => diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index d54b0b544..3f395c369 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -2,6 +2,7 @@ import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { RatesService } from '../rule-engine/services/rates.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; +import { round2 } from '../billing/invoice-settlement.util'; import { ExchangeService } from '@edr/api-common'; import { ContractsRepository } from './contracts.repository'; import { Contract } from './entities/contract.entity'; @@ -79,7 +80,7 @@ export class ContractPricingService { const currency = contract.paymentCurrency; const isEtb = currency === 'ETB'; const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; - const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd); + const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd); const lineItems: ContractUnitRateLineItem[] = []; const baseType = this.baseRateType(contract); From ceabe163e79b8feb9fd73af70d4d57b161d10e2d Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 5 Aug 2026 09:46:39 +0000 Subject: [PATCH 2/2] fix issue --- apps/edr-freight-web/backoffice/src/App.tsx | 16 + .../backoffice/src/pages/SettingsPage.tsx | 3 - .../BookingDetailPage/ReadonlyBookingView.tsx | 17 +- .../BookingDetailPage/components/CargoTab.tsx | 465 ++++++++++++++++++ .../src/pages/bookings/NewBookingPage.tsx | 7 +- .../src/pages/contracts/NewShipmentPage.tsx | 18 +- 6 files changed, 507 insertions(+), 19 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CargoTab.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index d4892d198..4c1b346e9 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -115,6 +115,7 @@ import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2De import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import TradeAccessPage from "./pages/configuration/TradeAccessPage"; +import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard"; import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage"; import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; @@ -600,6 +601,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/configuration/trade-access", permission: FREIGHT_PERMS.admin, }, + { + label: "Exchange rate", + href: "/dashboard/configuration/exchange-rate", + permission: FREIGHT_PERMS.admin, + }, ], }, { @@ -1574,6 +1580,16 @@ const App = () => { } /> + +
+ +
+ + } + /> {/* - - diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index ac211b5be..147262472 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -1,5 +1,12 @@ import { Group, Tabs } from "@mantine/core"; -import { Clock, CreditCard, FileText, LayoutGrid, Truck } from "lucide-react"; +import { + Clock, + CreditCard, + FileText, + LayoutGrid, + Package, + Truck, +} from "lucide-react"; import { useNavigate } from "react-router-dom"; import { useFileViewer } from "@/hooks/useFileViewer"; @@ -9,6 +16,7 @@ import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton"; import { ActivityCard } from "./components/ActivityCard"; import { ClearanceCard } from "./components/ClearanceCard"; import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner"; +import { CargoTab } from "./components/CargoTab"; import { DocumentsTab } from "./components/DocumentsTab"; import { CompanyInfoCard } from "./components/CompanyInfoCard"; import { ContainersCard } from "./components/ContainersCard"; @@ -199,6 +207,9 @@ export function ReadonlyBookingView({ }> Overview + }> + Cargo + }> Logistics @@ -254,6 +265,10 @@ export function ReadonlyBookingView({ + + + +
+ {icon} + {label} + + ); +} + +function StatTile({ + icon, + label, + value, + sub, +}: { + icon: ReactNode; + label: string; + value: string; + sub?: string; +}) { + return ( + + + {icon} + + {label} + + + + {value} + + {sub && ( + + {sub} + + )} + + ); +} + +function DetailRow({ label, value }: { label: string; value: ReactNode }) { + return ( + + + {label} + + + {value} + + + ); +} + +const th = { color: "#9AA8B5", fontSize: 11 } as const; + +// ─── Containers ─────────────────────────────────────────────────────────────── + +function lineTypeLabel(line: BookingContainerLineDetail): string { + const t = line.containerType; + if (t?.label) return t.label; + if (t?.sizeFt) return `${t.sizeFt}ft${t.isReefer ? " Reefer" : ""} container`; + return t?.code ?? "Container"; +} + +function UnitRow({ unit }: { unit: BookingContainerUnitDetail }) { + return ( + + + + {unit.containerNumber} + + + + + {unit.sealNumber || "—"} + + + + + {Number(unit.vgmTons || 0) ? fmtWeight(Number(unit.vgmTons)) : "—"} + + + + + {unit.isHazardous && ( + } label="Hazardous" /> + )} + {unit.isReefer && ( + } label="Reefer" /> + )} + {unit.isReturn && } label="Return" />} + {!unit.isHazardous && !unit.isReefer && !unit.isReturn && ( + + — + + )} + + + + + {unit.grnNumber || "—"} + + + + {unit.receivedToPort ? ( + + + Received + + {unit.receivedAt && ( + + {fmtDate(unit.receivedAt)} + + )} + + ) : ( + + Pending + + )} + + + ); +} + +function ContainerLineCard({ line, index }: { line: BookingContainerLineDetail; index: number }) { + const units = line.units ?? []; + return ( + + + + + + + + + {lineTypeLabel(line)} + + + Line {index + 1} · {line.quantity} unit{line.quantity !== 1 ? "s" : ""} + + + + + {line.isOverweight && ( + } + label={ + Number(line.overweightExcessTons || 0) + ? `Overweight +${Number(line.overweightExcessTons)} t` + : "Overweight" + } + /> + )} + {!!line.hazardousQuantity && ( + } label={`${line.hazardousQuantity} hazardous`} /> + )} + {!!line.reeferQuantity && ( + } label={`${line.reeferQuantity} reefer`} /> + )} + {!!line.returnQuantity && ( + } label={`${line.returnQuantity} return`} /> + )} + + + + + } + label="Quantity" + value={`${line.quantity}`} + sub={`container${line.quantity !== 1 ? "s" : ""}`} + /> + } + label="VGM / unit" + value={fmtWeight(Number(line.vgmPerUnitTons || 0))} + /> + } + label="Line total VGM" + value={fmtWeight(Number(line.totalVgmTons || 0))} + /> + + + {units.length > 0 && ( + + + + + Container no. + Seal no. + VGM + Flags + GRN + Port status + + + + {units.map((u) => ( + + ))} + +
+
+ )} + {units.length === 0 && ( + + Container numbers will appear here once the physical units are assigned. + + )} +
+ ); +} + +// ─── Bulk ───────────────────────────────────────────────────────────────────── + +function BulkCargoCard({ booking }: { booking: BookingDetail }) { + const unit = booking.cargoType?.unitOfMeasure; + const isPerItem = unit === "PER_ITEM"; + // Break-bulk (PER_ITEM): cargoTotalWeightVgm holds the ITEM COUNT and the + // real tonnage lives in bulkTotalWeightTons; PER_TON stores tons directly. + const quantity = Number(booking.cargoTotalWeightVgm || 0); + const tons = totalVgmTons(booking); + + return ( + + + + + + + + {commodityLabel(booking)} + + + Bulk cargo{booking.cargoType?.code ? ` · ${booking.cargoType.code}` : ""} + + + + + + {isPerItem && ( + } + label="Items" + value={quantity ? quantity.toLocaleString() : "—"} + sub="declared item count" + /> + )} + } + label="Total weight" + value={fmtWeight(tons)} + sub={isPerItem ? "actual tonnage" : "declared tonnage"} + /> + } + label="Billing unit" + value={isPerItem ? "Per item" : "Per ton"} + /> + + + + + {booking.cargoType?.code && ( + + )} + {booking.cargoFreeText && booking.cargoType?.cargoTypeName && ( + + )} + + + + + + + ); +} + +// ─── Tab ────────────────────────────────────────────────────────────────────── + +/** + * Dedicated cargo breakdown tab: bulk bookings get the full bulk declaration + * (commodity, unit of measure, item count vs tonnage, hazardous/reefer + * quantities); container bookings get one card per container line with its + * per-unit numbers, seals, VGM, GRN and port-arrival status. + */ +export function CargoTab({ booking }: { booking: BookingDetail }) { + const isBulk = booking.freightType === "BULK"; + const lines = booking.bookingContainers ?? []; + const totalUnits = lines.reduce((s, c) => s + Number(c.quantity || 0), 0); + const totalVgm = totalVgmTons(booking); + const receivedCount = lines + .flatMap((l) => l.units ?? []) + .filter((u) => u.receivedToPort).length; + + return ( +
+ + Cargo summary + + : } + label="Freight type" + value={isBulk ? "Bulk" : "Container"} + /> + } + label="Commodity" + value={commodityLabel(booking)} + /> + } + label="Total weight" + value={fmtWeight(totalVgm)} + /> + {isBulk ? ( + } + label="Special handling" + value={ + [ + booking.isHazardous && "Hazardous", + booking.isRefrigerated && "Reefer", + ] + .filter(Boolean) + .join(" · ") || "None" + } + /> + ) : ( + } + label="Containers" + value={`${totalUnits}`} + sub={`${receivedCount} received at port`} + /> + )} + + + + {isBulk ? ( + + ) : lines.length > 0 ? ( + lines.map((line, i) => ( + + )) + ) : ( + + + No container details recorded for this booking yet. + + + )} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 5a8705610..6b80ebd32 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -292,7 +292,10 @@ export default function NewBookingPage() { // physically carry the selected cargo/container type. Quantity is NOT part // of this gate — an oversized booking is accepted and gets a partial split // offer later. Only selectable days reach the UI; no capacity counts. - const gateContainerTypeIds = useMemo(() => { + // Computed per render on purpose — NOT useMemo. react-hook-form mutates the + // watched containers array in place on nested edits (containers.0.containerType), + // so a reference-based dep list never sees per-line changes. + const gateContainerTypeIds = (() => { if (watchedCargoKind !== "container") return []; const groups = referenceData?.containers ?? []; const ids = new Set(); @@ -304,7 +307,7 @@ export default function NewBookingPage() { } } return [...ids]; - }, [watchedCargoKind, watchedContainers, referenceData]); + })(); const gateCargoTypeId = watchedCargoKind === "bulk" ? watchedCargoTypePath?.[1] : undefined; const gateReady = diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 4d423abe8..54f678049 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -1006,9 +1006,11 @@ function ScheduleStep({ const isContainer = contract.freightType === "CONTAINER"; const containerLines = form.watch("containers"); const cargoWeightTons = form.watch("cargoWeightTons"); - const itemCount = form.watch("itemCount"); - const cargoQuery = useMemo(() => { + // Computed per render on purpose — NOT useMemo. react-hook-form mutates the + // watched containers array in place on nested edits (containers.0.quantity), + // so a reference-based dep list never sees manual quantity changes. + const cargoQuery = ((): Freight.AvailableDaysForCargoQuery | null => { if (!route?.originYardId || !route?.destinationYardId) return null; if (isContainer) { const containers = (containerLines ?? []) @@ -1036,17 +1038,7 @@ function ScheduleStep({ ?.cargoTypeCode ?? undefined, totalWeightTons: tons, }; - // Tonnage is the sizing input the day-feasibility endpoint takes, and - // PER_ITEM cargo now captures it too — itemCount stays in the deps so the - // query still refreshes when only the item count changes. - }, [ - route, - isContainer, - containerLines, - cargoWeightTons, - itemCount, - contract.pricingBreakdown, - ]); + })(); const isIntercity = contract.tradeDirection === "DOMESTIC"; const { data: availableDays, isLoading } = useQuery({