From ceabe163e79b8feb9fd73af70d4d57b161d10e2d Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 5 Aug 2026 09:46:39 +0000 Subject: [PATCH] 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({