diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index cf4c37b2d..f13c596d2 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -1,6 +1,7 @@ import "reflect-metadata"; import * as dotenv from "dotenv"; dotenv.config(); +import { createRequire } from "node:module"; import { NestFactory } from "@nestjs/core"; import type { NestExpressApplication } from "@nestjs/platform-express"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; @@ -20,6 +21,62 @@ import { AppModule } from "./app.module"; */ const JSON_BODY_LIMIT = '20mb'; +/** + * Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as + * `host=ip,host2=ip2`. Some internal hosts (MinIO) resolve only inside the + * deployment network, so dev machines get ENOTFOUND on every upload. Patching + * `dns.lookup` keeps the real hostname on the wire — the IP is used for the + * connection only — so TLS still validates against the certificate's CN. + * + * The module is loaded through `createRequire`, NOT `import * as dns`: an ESM + * namespace object is frozen, so assigning to it is silently dropped and the + * patch becomes a no-op. `require` returns the live module object every other + * caller (minio's http agent included) reads `lookup` off. + */ +function applyDnsHostOverrides(): void { + const raw = process.env.DNS_HOST_OVERRIDES?.trim(); + if (!raw) return; + + const overrides = new Map(); + for (const entry of raw.split(",")) { + const [host, ip] = entry.split("=").map((part) => part?.trim()); + if (host && ip) overrides.set(host.toLowerCase(), ip); + } + if (overrides.size === 0) return; + + const dns = createRequire(__filename)("node:dns") as typeof import("node:dns"); + const originalLookup = dns.lookup.bind(dns); + // `dns.lookup` is overloaded (options optional, all/family variants); the + // cast keeps that surface intact while we intercept only mapped hostnames. + (dns as { lookup: unknown }).lookup = (( + hostname: string, + options: unknown, + callback?: unknown, + ) => { + const ip = overrides.get(hostname?.toLowerCase?.()); + if (!ip) return (originalLookup as Function)(hostname, options, callback); + + const done = (typeof options === "function" ? options : callback) as ( + err: NodeJS.ErrnoException | null, + address: string | { address: string; family: number }[], + family?: number, + ) => void; + const family = ip.includes(":") ? 6 : 4; + const wantsAll = + typeof options === "object" && options !== null && (options as { all?: boolean }).all; + + process.nextTick(() => + wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family), + ); + }) as typeof dns.lookup; + + console.log( + `[DNS] Host overrides active: ${[...overrides].map(([h, ip]) => `${h}->${ip}`).join(", ")}`, + ); +} + +applyDnsHostOverrides(); + async function bootstrap() { const app = await NestFactory.create(AppModule); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index cc9ced8a2..27e1597c5 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -9,6 +9,7 @@ import { import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useMutation, useQuery } from "@tanstack/react-query"; import { + ActionIcon, Alert, Box, Button, @@ -665,6 +666,17 @@ export default function GlCreateBookingForm() { ), ); + // Drop one container row and shrink quantity to match — the inverse of + // syncUnits growing the array when quantity goes up. + const removeUnit = (lineIdx: number, unitIdx: number) => + setContainerLines((prev) => + prev.map((l, i) => { + if (i !== lineIdx) return l; + const units = l.units.filter((_, j) => j !== unitIdx); + return withDerivedCounts({ ...l, quantity: String(units.length), units }); + }), + ); + // Same client-side validation as the customer portal shipment form // (new-shipment-form/schema.ts): ISO container numbers unique within the // shipment, positive VGM per unit, hazardous/reefer counts bounded by the @@ -1362,6 +1374,8 @@ export default function GlCreateBookingForm() { } onChange={(e) => { patchLine(lineIdx, { quantity: e.currentTarget.value }); + }} + onBlur={(e) => { syncUnits(lineIdx, Number(e.currentTarget.value || 0)); }} radius={10} @@ -1495,6 +1509,14 @@ export default function GlCreateBookingForm() { /> ))} + removeUnit(lineIdx, unitIdx)} + > + + ))} 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 9d9116bab..ed019bbff 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,6 +1,6 @@ import { Group, Tabs } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { CreditCard, FileText, LayoutGrid } from "lucide-react"; +import { Clock, CreditCard, FileText, LayoutGrid, Truck } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -223,15 +223,27 @@ export function ReadonlyBookingView({ }> Overview + }> + Logistics + + }> + Activity + }> Documents @@ -254,23 +266,9 @@ export function ReadonlyBookingView({ - - - - {canAssignCustomerTruck && ( - {})} - /> - )} - - - - - } right={ @@ -295,6 +293,34 @@ export function ReadonlyBookingView({ + +
+ + + + {canAssignCustomerTruck && ( + {})} + /> + )} + + + + } + right={} + /> +
+
+ + +
+ +
+
+ diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx index fc7935962..ab11da3a0 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx @@ -1,10 +1,18 @@ import { Box, Group, Text } from "@mantine/core"; -import type { ReactNode } from "react"; +import { MapPin } from "lucide-react"; +import { type ReactNode, useState } from "react"; import { bookingStatusLabel } from "@/pages/bookings/booking-display"; +import { ShipmentTrackingModal } from "@/pages/bookings/tracking/ShipmentTrackingModal"; import type { BookingDetail } from "../booking-detail-types"; -import { fmtDate, isDraftLike, isNegative, serviceTypeLabel } from "../utils"; +import { + fmtDate, + isDraftLike, + isNegative, + serviceTypeLabel, + yardLabel, +} from "../utils"; import { CardTitle, SectionCard } from "./layout"; type Row = { label: string; value: ReactNode; muted?: boolean }; @@ -53,14 +61,39 @@ export function ScheduleCard({ title: string; consignment?: boolean; }) { + const [trackingOpen, setTrackingOpen] = useState(false); const service = serviceTypeLabel(booking); const equipmentReturn = booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return"; - const assignedTrain: Row = { - label: "Assigned train", - value: booking.trainId ?? "Not yet assigned", - muted: !booking.trainId, - }; + const assignedTrain: Row = booking.trainScheduleId + ? { + label: "Assigned train", + value: ( + + ), + } + : { + label: "Assigned train", + value: "Not yet assigned", + muted: true, + }; const statusRow: Row = { label: "Status", @@ -115,6 +148,17 @@ export function ScheduleCard({ ))} + + {booking.trainScheduleId && ( + setTrackingOpen(false)} + bookingId={booking.id} + bookingReference={booking.reference} + originLabel={yardLabel(booking.originYard)} + destinationLabel={yardLabel(booking.destinationYard)} + /> + )} ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx index 8051e9986..a2a48c7dc 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx @@ -69,10 +69,7 @@ export function ShipmentDetailsCard({ booking }: { booking: BookingDetail }) { ], ["Scheduled date", fmtDate(booking.scheduledDate)], ], - [ - ["Shipping line", shippingLineLabel(booking)], - ["Assigned train", booking.trainId ?? "Not yet assigned"], - ], + [["Shipping line", shippingLineLabel(booking)]], ]; const badges: string[] = []; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx index 3f20c79e4..4a9ef757c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx @@ -77,6 +77,7 @@ const STATUS_FILTERS = [ key: "all", label: "All bookings", statuses: undefined as string | undefined, + assignedToSchedule: undefined as "true" | "false" | undefined, }, { key: "active", @@ -89,9 +90,16 @@ const STATUS_FILTERS = [ key: "payment", label: "Awaiting payment", statuses: - "SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED", + "SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS", }, { key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT,ARRIVED" }, + { + key: "allocated", + label: "Allocated to a train", + statuses: undefined as string | undefined, + assignedToSchedule: "true" as const, + }, + { key: "expired", label: "Expired", statuses: "EXPIRED" }, { key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" }, { key: "closed", @@ -348,7 +356,10 @@ export default function BookingsListPage() { const [trackingBooking, setTrackingBooking] = useState(null); - const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses; + const activeFilter = STATUS_FILTERS.find((t) => t.key === statusFilter); + const statuses = activeFilter?.statuses; + const assignedToSchedule = + "assignedToSchedule" in activeFilter! ? activeFilter.assignedToSchedule : undefined; const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"]; const resetPage = () => @@ -372,6 +383,7 @@ export default function BookingsListPage() { const filter: BookingListFilter = useMemo( () => ({ statuses, + assignedToSchedule, bookingType: typeFilter ?? undefined, freightType: freightFilter ?? undefined, createdFrom: createdFrom || undefined, @@ -386,6 +398,7 @@ export default function BookingsListPage() { }), [ statuses, + assignedToSchedule, typeFilter, freightFilter, createdFrom, @@ -423,6 +436,8 @@ export default function BookingsListPage() { draft: draftCount, done: doneCount, transit: undefined, + allocated: undefined, + expired: undefined, closed: undefined, }; @@ -557,7 +572,12 @@ export default function BookingsListPage() { size: 130, meta: hMeta, header: () => , - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, { id: "scheduling", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx b/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx index 24d9d421e..ec5abad1b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx @@ -144,17 +144,31 @@ export function paymentStatusLabel(status?: string | null): string { return PAYMENT_LABELS[status] ?? titleCaseStatus(status); } -/** Payment status pill. */ -export function PaymentBadge({ status }: { status?: string | null }) { - if (!status) return ; +/** + * Payment status pill. Once the booking's own lifecycle status has moved past + * payment (PAID or later — stage ≥ 3 in STATUS_CONFIG), payment is a settled + * fact: show "Paid" even if a stale/lagging `paymentStatus` value says + * otherwise, rather than surface a contradictory "Paid booking, pending + * payment" row. + */ +export function PaymentBadge({ + status, + bookingStatus, +}: { + status?: string | null; + bookingStatus?: string | null; +}) { + const settled = bookingStatus ? (STATUS_CONFIG[bookingStatus]?.stage ?? 0) >= 3 : false; + const effective = settled ? "PAID" : status; + if (!effective) return ; return ( - {PAYMENT_LABELS[status] ?? titleCaseStatus(status)} + {PAYMENT_LABELS[effective] ?? titleCaseStatus(effective)} ); } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 79b904f47..6ceb1fb69 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -1316,7 +1316,10 @@ export default function ContractDetailPage() { )} - + 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 99eef8bdf..c7206b215 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -11,6 +11,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "react-router-dom"; import { + ActionIcon, Alert, Box, Button, @@ -515,7 +516,9 @@ function NewShipmentBookingForm({ routes={routes} completeBookingId={completeBookingId ?? null} /> - + {/* Notes are captured when the booking is initiated — completing + a bare booking does not re-ask for them. */} + {!completeBookingId && } @@ -1694,6 +1697,18 @@ function ContainerLineEditor({ syncHandlingCounts(next); }; + // Drop one container row and shrink quantity to match — the inverse of + // syncUnits growing the array when quantity goes up. + const removeUnit = (unitIdx: number) => { + const current = form.getValues(`containers.${index}.units`) ?? []; + const next = current.filter((_, j) => j !== unitIdx); + form.setValue(`containers.${index}.units`, next, { shouldValidate: false }); + form.setValue(`containers.${index}.quantity`, String(next.length), { + shouldValidate: true, + }); + syncHandlingCounts(next); + }; + /** * Line totals are a roll-up of the per-container switches — the count is * however many containers ticked each service. Kept in form state so the @@ -1781,8 +1796,10 @@ function ContainerLineEditor({ styles={fieldStyles} onChange={(e) => { field.onChange(e.currentTarget.value); - const qty = Number(e.currentTarget.value || 0); - syncUnits(qty); + }} + onBlur={(e) => { + field.onBlur(); + syncUnits(Number(e.currentTarget.value || 0)); }} /> )} @@ -1906,6 +1923,14 @@ function ContainerLineEditor({ )} /> ))} + removeUnit(u)} + > + + ))} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx index 5aca3fc81..93f5c23c5 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx @@ -1,4 +1,4 @@ -import { Controller, type UseFormReturn } from "react-hook-form"; +import { type UseFormReturn } from "react-hook-form"; import { Badge, Box, @@ -7,13 +7,11 @@ import { Paper, Stack, Text, - Textarea, } from "@mantine/core"; import { CheckCircle2, Circle, ClipboardCheck, - Coins, FileText, MapPin, Package, @@ -220,13 +218,13 @@ export function Step8Review({ // not of the stored form flag — a stale draft flag must not misreport it. // Without bundling, the customer may still name their own clearing agent. const ownAgent = values.customsClearingAgent?.trim(); - const customsValue = isIntercity - ? "Not applicable — domestic transport" + const customsTag: { label: string; color: string } = isIntercity + ? { label: "Not applicable · domestic", color: "gray" } : serviceType?.includesCustoms || values.customsClearingEnabled - ? "Included — Global Logistics" + ? { label: "EDR handles it · Global Logistics", color: "edr-green" } : ownAgent - ? `Own agent — ${ownAgent}` - : "Not requested"; + ? { label: `Own agent · ${ownAgent}`, color: "blue" } + : { label: "Not requested", color: "gray" }; // Mirror the step-2 gating: imports never truck the first mile, exports never // truck the last mile, and a service that doesn't bundle a mile can't have it. @@ -351,27 +349,26 @@ export function Step8Review({ label="Service" value={serviceType?.serviceName ?? "—"} /> - } - label="Quotation currency" - value={ - <> - USD - - You choose the billing currency on each shipment. - - - } - /> } label="Route" - value={`${originYardName} → ${destinationYardName}`} - /> - } - label="Trade direction" - value={directionLabel} + value={ + + + {originYardName} → {destinationYardName} + + } + style={{ flexShrink: 0 }} + > + {directionLabel} + + + } /> } @@ -420,7 +417,16 @@ export function Step8Review({ } label="Customs clearing" - value={customsValue} + value={ + + {customsTag.label} + + } /> {/* Step-3 toggles appear only when the customer selected them — an off toggle is left off the summary entirely. */} @@ -478,20 +484,6 @@ export function Step8Review({ {documentsEditor} )} - - ( -