From c977deb460895567ece5d593394dac6d3eda410c Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 14:01:50 +0000 Subject: [PATCH 01/90] modify booking request page --- .../modules/bookings/bookings.repository.ts | 6 + .../src/modules/bookings/bookings.service.ts | 1 + .../bookings/entities/booking.entity.ts | 4 + apps/edr-freight-web/backoffice/src/App.tsx | 20 +- .../pages/bookings/BookingRequestsPage.tsx | 249 +++++++++--------- .../src/services/bookings.service.ts | 4 + 6 files changed, 156 insertions(+), 128 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 3f696bc6b..846fd2c9a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -35,6 +35,7 @@ export interface BookingListFilterOptions { serviceTypeId?: string; cargoTypeId?: string; freightType?: string; + bookingType?: string; tradeDirection?: string; paymentCurrency?: string; paymentStatus?: string; @@ -737,6 +738,11 @@ export class BookingsRepository extends BaseRepository { freightType: options.freightType, }); } + if (options.bookingType) { + qb.andWhere('booking.bookingType = :bookingType', { + bookingType: options.bookingType, + }); + } if (options.createdFrom) { qb.andWhere('booking.created_at >= :createdFrom', { createdFrom: options.createdFrom, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index ac639a4bd..e3d882baa 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1001,6 +1001,7 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, + bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 4b033bab5..cf0ca76f6 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -157,6 +157,10 @@ export class Booking extends BaseEntity { @Column({ name: 'contract_route_id', type: 'uuid', nullable: true }) contractRouteId?: string | null; + /** Booking origin: ONE_TIME (single-shipment) or GENERAL_CONTRACT (drawdown). */ + @Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' }) + bookingType!: string; + /** Denormalized contract kind (ONE_TIME | GENERAL) for the single-active-booking index. */ @Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true }) contractKind?: string | null; diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index e7e567c7a..1e7d1e280 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -52,8 +52,9 @@ import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPa import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage"; import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage"; import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage"; -import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage"; -import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage"; +// Hidden for now — Shipment Requests pages disabled (imports kept commented). +// import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage"; +// import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage"; import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm"; import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; @@ -178,12 +179,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ FREIGHT_PERMS.contracts.clearanceEtActions, ], }, - { - label: "Shipment Requests", - href: "/dashboard/shipment-requests", - icon: , - permission: FREIGHT_PERMS.contracts.createBooking, - }, + // Hidden for now — Shipment Requests nav item disabled. + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // permission: FREIGHT_PERMS.contracts.createBooking, + // }, { label: "GL Djibouti Clearance", href: "/dashboard/gl-djibouti/clearance", @@ -672,6 +674,7 @@ const App = () => { } /> + {/* Hidden for now — Shipment Requests pages disabled. { } /> + */} {/* GL (Path B) contract clearance review hub */} t.key === tab); - if (!match?.statuses?.length) return undefined; - return match.statuses.join(","); -} +/** The two booking-kind tabs: one-time vs general-contract bookings. */ +type BookingKindTab = "ONE_TIME" | "GENERAL_CONTRACT"; + +const BOOKING_KIND_TABS: { value: BookingKindTab; label: string }[] = [ + { value: "ONE_TIME", label: "One-time booking" }, + { value: "GENERAL_CONTRACT", label: "General booking" }, +]; + +/** Status options for the filter select — built from the shared status styles. */ +const STATUS_OPTIONS = Object.entries(BOOKING_STATUS_STYLES).map( + ([value, { label }]) => ({ value, label }), +); + +const TRADE_DIRECTION_OPTIONS = [ + { value: "IMPORT", label: "Import" }, + { value: "EXPORT", label: "Export" }, + { value: "DOMESTIC", label: "Domestic" }, +]; + +const FREIGHT_TYPE_OPTIONS = [ + { value: "CONTAINER", label: "Container" }, + { value: "BULK", label: "Bulk" }, +]; function formatDate(value: string | null | undefined): string { if (!value) return "—"; @@ -75,14 +89,16 @@ function formatDate(value: string | null | undefined): string { }); } -type OperationsSubTab = "ready" | "scheduled"; - export default function BookingRequestsPage() { const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); - const [activeTab, setActiveTab] = useState("all"); - const [operationsSubTab, setOperationsSubTab] = useState("ready"); + // Booking-kind tabs (one-time vs general contract) replace the old status tabs. + const [kindTab, setKindTab] = useState("ONE_TIME"); + // Per-tab filter selects (each nullable = "all"). + const [statusFilter, setStatusFilter] = useState(null); + const [directionFilter, setDirectionFilter] = useState(null); + const [freightTypeFilter, setFreightTypeFilter] = useState(null); const [allocateOpen, setAllocateOpen] = useState(false); const [allocateIds, setAllocateIds] = useState([]); const suppressRowClickRef = useRef(false); @@ -93,47 +109,26 @@ export default function BookingRequestsPage() { }, 400); }, []); - const tabStatuses = getStatusesForTab(activeTab); - const isOperationsTab = activeTab === "operations"; - const filter: BookingListFilter = useMemo(() => { - if (isOperationsTab) { - if (operationsSubTab === "ready") { - return { - page: 1, - pageSize: 100, - statuses: "PAID", - assignedToSchedule: "false", - sortBy: "createdAt", - sortOrder: "DESC", - tab: activeTab, - }; - } - return { - page: 1, - pageSize: 100, - statuses: "PAID", - schedulingStatuses: "SCHEDULED,DISPATCHED", - sortBy: "scheduledDate", - sortOrder: "ASC", - tab: activeTab, - }; - } return { page: pagination.pageIndex + 1, pageSize: pagination.pageSize, sortBy: "createdAt", sortOrder: "DESC", - tab: activeTab, - ...(tabStatuses ? { statuses: tabStatuses } : {}), + // React Query cache key per kind tab. + tab: kindTab, + bookingType: kindTab, + ...(statusFilter ? { statuses: statusFilter } : {}), + ...(directionFilter ? { tradeDirection: directionFilter } : {}), + ...(freightTypeFilter ? { freightType: freightTypeFilter } : {}), }; }, [ - isOperationsTab, - operationsSubTab, pagination.pageIndex, pagination.pageSize, - activeTab, - tabStatuses, + kindTab, + statusFilter, + directionFilter, + freightTypeFilter, ]); const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter); @@ -171,18 +166,6 @@ export default function BookingRequestsPage() { void refetchSummary(); }, [refetch, refetchSummary]); - const handleAllocateFromQueue = useCallback( - (ids: string[]) => { - const selected = rows.filter((b) => ids.includes(b.id)); - const sorted = [...selected].sort( - (a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0), - ); - setAllocateIds(sorted.map((b) => b.id)); - setAllocateOpen(true); - }, - [rows], - ); - const handleRowClick = useCallback( (row: BookingListRow) => { if (suppressRowClickRef.current) return; @@ -356,6 +339,8 @@ export default function BookingRequestsPage() { ]} /> + {/* Status tabs replaced by booking-kind tabs (one-time / general). The + old BookingStatusTabs is commented out — status is now a filter select. { @@ -364,73 +349,97 @@ export default function BookingRequestsPage() { }} counts={tabCounts} /> + */} + + { + setKindTab((value as BookingKindTab) ?? "ONE_TIME"); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + > + + {BOOKING_KIND_TABS.map((t) => ( + + {t.label} + + ))} + + - - } - value={query} - onChange={(e) => setQuery(e.target.value)} - rightSection={ - query && ( - setQuery("")} - > - - - ) - } - style={{ flex: 1, minWidth: "200px" }} - radius="lg" - /> - - {total} record{total !== 1 ? "s" : ""} - - + + + } + value={query} + onChange={(e) => setQuery(e.target.value)} + rightSection={ + query && ( + setQuery("")} + > + + + ) + } + style={{ flex: 1, minWidth: "200px" }} + radius="lg" + /> + + {total} record{total !== 1 ? "s" : ""} + + + + { + setDirectionFilter(v); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + clearable + radius="lg" + style={{ minWidth: 170 }} + /> + + + + + + + + + ); +} + +// ── Sub-components ─────────────────────────────────────────────────────────── + +function PanelColumn({ + title, + hint, + count, + accent, + loading, + emptyIcon: EmptyIcon, + emptyText, + children, +}: { + title: string; + hint: string; + count: number; + accent: string; + loading?: boolean; + emptyIcon: typeof Inbox; + emptyText: string; + children: React.ReactNode; +}) { + const isEmpty = !loading && count === 0; + return ( + + + + + + {title} + + + {count} + + + + {hint} + + + + {isEmpty ? ( + + + + {emptyText} + + + ) : ( + + + {loading ? ( + + Loading… + + ) : ( + children + )} + + + )} + + ); +} + +function BookingCard({ + reference, + customer, + weightTons, + status, + right, +}: { + reference: string; + customer?: string | null; + weightTons?: number | null; + status?: string | null; + right?: React.ReactNode; +}) { + return ( + { + e.currentTarget.style.borderColor = GREEN; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)"; + }} + > + + + + + {reference} + + {status ? : null} + + + + {customer ?? "—"} + + {weightTons != null ? ( + + + + {Number(weightTons).toFixed(1)}T + + + ) : null} + + + {right ? {right} : null} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 9319e6a01..8ff192ef6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -8,6 +8,7 @@ import { Paper, RingProgress, Stack, + Tabs, Text, Textarea, TextInput, @@ -25,10 +26,12 @@ import { LayoutGrid, Navigation, Package, + PackageCheck, Route as RouteIcon, Send, Train, Weight, + Workflow as WorkflowIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; @@ -45,6 +48,7 @@ import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvai import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel"; import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep"; +import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel"; import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { RouteCorridor, @@ -1028,6 +1032,18 @@ export default function TrainScheduleV2DetailPage() { ) : null} + + + }> + Workflow + + }> + Workspace + + + + + {/* Workflow header with ring progress */} @@ -1085,7 +1101,20 @@ export default function TrainScheduleV2DetailPage() { - + + + + + + { + autoPreviewedRef.current = false; + void detailQuery.refetch(); + }} + /> + + {scheduleId ? ( ({ ...current, maxTrainLengthMeters: value })) } min={1} - clampBehavior="strict" disabled={loading} /> ({ ...current, maxTrainWeightTons: value })) } min={1} - clampBehavior="strict" disabled={loading} /> ({ ...current, maxWagonsPerTrain: value })) } min={1} - clampBehavior="strict" disabled={loading} /> @@ -158,7 +153,6 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, importWindowLeadDays: value })) } min={0} - clampBehavior="strict" disabled={loading} /> ({ ...current, exportBookingLeadHours: value })) } min={1} - clampBehavior="strict" disabled={loading} /> ({ ...current, docReviewMinutes: value })) } min={0} - clampBehavior="strict" disabled={loading} /> ({ ...current, paymentWindowMinutes: value })) } min={1} - clampBehavior="strict" disabled={loading} /> ({ ...current, reopenDelayMinutes: value })) } min={1} - clampBehavior="strict" disabled={loading} /> From 4ec38bf0289fd2c25534d4ff2efa37b0bac61dd6 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 15:56:28 +0000 Subject: [PATCH 08/90] fix --- .../modules/first-mile/first-mile.service.ts | 28 +++++++++++ .../modules/last-mile/last-mile.service.ts | 46 ++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 43f02442a..20048c94d 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -550,6 +550,34 @@ export class FirstMileService { previousVehicleIds.filter((id) => !vehicleIds.has(id)), ); + // History: one event per vehicle actually added or removed by this + // multi-car (re)allocation, so reassignments show on every timeline. + const prevSet = new Set(previousVehicleIds); + const bookingRef = await this.resolveBookingRef(firstMile); + for (const vehicleId of vehicleIds) { + if (prevSet.has(vehicleId)) continue; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId, + firstMileId, + driverId: info.driverId, + label: firstMile.status, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + for (const vehicleId of previousVehicleIds) { + if (vehicleIds.has(vehicleId)) continue; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + firstMileId, + driverId: info.driverId, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + return { success: true, allocated: allocations.length, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 1e952b1ce..811ddd36b 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { DataSource, FindOptionsWhere } from 'typeorm'; +import { DataSource, FindOptionsWhere, In } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; @@ -348,6 +348,21 @@ export class LastMileService { throw new NotFoundException(`Last-mile record ${lastMileId} not found`); } + // Capture the vehicles currently on these containers so a reallocation can + // be diffed into assigned/released history events below. + const previousAllocations = await this.dataSource.manager.find( + LastMileContainerAllocation, + { + where: { + lastMileId, + containerId: In(allocations.map((a) => a.containerId)), + }, + }, + ); + const previousVehicleIds = previousAllocations + .map((a) => a.vehicleId) + .filter((id): id is string => Boolean(id)); + await this.dataSource.transaction(async (manager) => { for (const allocation of allocations) { await manager.delete(LastMileContainerAllocation, { @@ -364,6 +379,35 @@ export class LastMileService { } }); + // History: one event per vehicle actually added or removed by this + // multi-car (re)allocation, so reassignments show on every timeline. + const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); + const prevSet = new Set(previousVehicleIds); + const bookingRef = await this.resolveBookingRef(lastMile); + for (const vehicleId of vehicleIds) { + if (prevSet.has(vehicleId)) continue; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId, + lastMileId, + driverId: info.driverId, + label: lastMile.status, + metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + for (const vehicleId of previousVehicleIds) { + if (vehicleIds.has(vehicleId)) continue; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + lastMileId, + driverId: info.driverId, + metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + return { success: true, allocated: allocations.length, From c61bb787f2e95143b532890df247d3175b482a06 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:01:59 +0000 Subject: [PATCH 09/90] fix --- .../modules/last-mile/last-mile.service.ts | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 811ddd36b..ade7402a9 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -5,6 +5,7 @@ import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; +import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; @@ -182,6 +183,7 @@ export class LastMileService { }); if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); const info = await this.vehicleInfo(dto.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, @@ -240,6 +242,14 @@ export class LastMileService { // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. const bookingRef = await this.resolveBookingRef(existing); if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { + // Keep vehicle availability in sync: new vehicle goes BUSY, replaced one + // is freed if no other active trip still holds it. + if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + } + if (existing.vehicleId) { + await this.vehiclesService.releaseIfUnused([existing.vehicleId]); + } if (existing.vehicleId) { const info = await this.vehicleInfo(existing.vehicleId); await this.history.record({ @@ -292,9 +302,32 @@ export class LastMileService { }); } + // Delivery finished — free the vehicles this trip was holding. + if (dto.status === 'DELIVERED' && existing.status !== 'DELIVERED') { + await this.releaseVehicles(updated); + } + return updated; } + /** + * Free every vehicle held by this record (direct assignment + container + * allocations), unless still in use by another active trip. + */ + private async releaseVehicles(record: LastMile): Promise { + const recordAllocations = await this.dataSource.manager.find( + LastMileContainerAllocation, + { where: { lastMileId: record.id } }, + ); + const vehicleIds = recordAllocations + .map((a) => a.vehicleId) + .filter((id): id is string => Boolean(id)); + if (record.vehicleId) { + vehicleIds.push(record.vehicleId); + } + await this.vehiclesService.releaseIfUnused(vehicleIds); + } + private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); @@ -379,9 +412,20 @@ export class LastMileService { } }); + // Keep vehicle availability in sync: newly-allocated cars go BUSY, cars no + // longer on any of these containers are freed if unused elsewhere. + const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); + await Promise.all( + [...vehicleIds].map((id) => + this.vehiclesService.setAvailability(id, VehicleAvailability.BUSY), + ), + ); + await this.vehiclesService.releaseIfUnused( + previousVehicleIds.filter((id) => !vehicleIds.has(id)), + ); + // History: one event per vehicle actually added or removed by this // multi-car (re)allocation, so reassignments show on every timeline. - const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); const prevSet = new Set(previousVehicleIds); const bookingRef = await this.resolveBookingRef(lastMile); for (const vehicleId of vehicleIds) { From ed388042afe5627c5a85f574cbdc48631d51ccfc Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:14:33 +0000 Subject: [PATCH 10/90] fix --- .../src/modules/first-mile/first-mile-invoice.service.ts | 2 +- .../src/modules/first-mile/first-mile.controller.ts | 5 +++-- .../src/modules/last-mile/last-mile-invoice.service.ts | 2 +- .../src/modules/last-mile/last-mile.controller.ts | 7 ++++--- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts index 22520aac1..f7d9ee11f 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts @@ -71,7 +71,7 @@ export class FirstMileInvoiceService { type: 'DELIVERY_FEE', companyId: fm.booking!.companyId, companyProfileId: fm.booking!.companyProfileId || '', - currency: 'ETB', + currency: fm.booking!.paymentCurrency || 'ETB', lines: [ { chargeType: 'DELIVERY', diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index d5f53ae0c..444cbee87 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -92,6 +92,7 @@ export class FirstMileController { const record = await this.firstMileService.update(id, dto); // Auto-generate invoice if distance or payment was updated const booking = await this.bookingsService.findById(record.bookingId); + const currency = booking.paymentCurrency || "ETB"; if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { await this.billingService.generateInvoice({ source: Freight.InvoiceSource.FirstMile, @@ -99,7 +100,7 @@ export class FirstMileController { type: "FIRST_MILE", companyId: booking.companyId, companyProfileId: booking.companyProfileId, - currency: "ETB", + currency, lines: [ { @@ -108,7 +109,7 @@ export class FirstMileController { quantity: 1, unitRate: record.remainingPayment, amount: record.remainingPayment, - currency: "ETB", + currency, }, ], diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts index c304a89e8..e40e94509 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts @@ -61,7 +61,7 @@ export class LastMileInvoiceService { type: 'DELIVERY_FEE', companyId: lm.booking!.companyId, companyProfileId: lm.booking!.companyProfileId || '', - currency: 'ETB', + currency: lm.booking!.paymentCurrency || 'ETB', lines: [ { chargeType: 'DELIVERY', diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 88a96e6c2..9ad5a00bf 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -86,6 +86,7 @@ export class LastMileController { const record = await this.lastMileService.update(id, dto); // Auto-generate invoice if distance or payment was updated const booking = await this.bookingsService.findById(record.bookingId); + const currency = booking.paymentCurrency || "ETB"; if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { await this.billingService.generateInvoice({ source: Freight.InvoiceSource.LastMile, @@ -93,8 +94,8 @@ export class LastMileController { type: "LAST_MILE", companyId: booking.companyId, companyProfileId: booking.companyProfileId, - currency: "ETB", - + currency, + lines: [ { chargeType: "LAST_MILE", @@ -102,7 +103,7 @@ export class LastMileController { quantity: 1, unitRate: record.remainingPayment, amount: record.remainingPayment, - currency: "ETB", + currency, }, ], From 86615863017be36b99219b0abb22778e3ce8213b Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:21:07 +0000 Subject: [PATCH 11/90] fix --- .../modules/first-mile/first-mile.service.ts | 38 ++++++++++++++++++- .../modules/last-mile/last-mile.service.ts | 29 +++++++++++++- .../src/pages/operations/FirstMilePage.tsx | 8 +++- .../src/pages/operations/LastMilePage.tsx | 8 +++- 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 20048c94d..1a7db810d 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,5 +1,5 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere, In } from 'typeorm'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; @@ -66,6 +66,19 @@ export class FirstMileService { } } + /** A leg counts as having a vehicle if it has a direct assignment or at least + * one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */ + private async hasAssignedVehicle( + recordId: string, + directVehicleId?: string | null, + ): Promise { + if (directVehicleId) return true; + const count = await this.dataSource.manager.count(FirstMileContainerAllocation, { + where: { firstMileId: recordId, vehicleId: Not(IsNull()) }, + }); + return count > 0; + } + /** Human booking reference for a first-mile record, for the history timeline. */ private async resolveBookingRef(record: FirstMile): Promise { const loaded = (record as FirstMile & { booking?: { reference?: string } }) @@ -297,6 +310,18 @@ export class FirstMileService { async update(id: string, dto: UpdateFirstMileDto): Promise { const existing = await this.findById(id); + // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle + // assigned in this same request). + if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + const vehicleId = + dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId; + if (!(await this.hasAssignedVehicle(id, vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this first-mile leg in transit', + ); + } + } + const dtoAny = dto as any; const updated = await this.firstMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), @@ -396,6 +421,15 @@ export class FirstMileService { async updateStatus(id: string, status: FirstMileStatus): Promise { const existing = await this.findById(id); + + if (status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + if (!(await this.hasAssignedVehicle(id, existing.vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this first-mile leg in transit', + ); + } + } + const updated = await this.firstMileRepository.update(id, { status }); if (!updated) { diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index ade7402a9..884a02aae 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,5 +1,5 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { DataSource, FindOptionsWhere, In } from 'typeorm'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; @@ -65,6 +65,19 @@ export class LastMileService { } } + /** A leg counts as having a vehicle if it has a direct assignment or at least + * one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */ + private async hasAssignedVehicle( + recordId: string, + directVehicleId?: string | null, + ): Promise { + if (directVehicleId) return true; + const count = await this.dataSource.manager.count(LastMileContainerAllocation, { + where: { lastMileId: recordId, vehicleId: Not(IsNull()) }, + }); + return count > 0; + } + /** Human booking reference for a last-mile record, for the history timeline. * Uses the already-loaded relation when present, else looks it up. */ private async resolveBookingRef( @@ -218,6 +231,18 @@ export class LastMileService { async update(id: string, dto: UpdateLastMileDto): Promise { const existing = await this.findById(id); + // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle + // assigned in this same request). + if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + const vehicleId = + dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId; + if (!(await this.hasAssignedVehicle(id, vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this last-mile leg in transit', + ); + } + } + const dtoAny = dto as any; const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index cec4d4351..bbd5acc0c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -879,10 +879,14 @@ const FirstMilePage = () => { } - disabled={!nextStatus} + disabled={!nextStatus || (nextStatus === "IN_TRANSIT" && !assigned)} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label} + {nextStatus === "IN_TRANSIT" && !assigned + ? "Assign a vehicle first" + : nextStatus + ? `Mark ${STATUS_META[nextStatus].label}` + : STATUS_META[row.original.status].label} { } - disabled={!nextStatus} + disabled={!nextStatus || (nextStatus === "IN_TRANSIT" && !assigned)} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label} + {nextStatus === "IN_TRANSIT" && !assigned + ? "Assign a vehicle first" + : nextStatus + ? `Mark ${STATUS_META[nextStatus].label}` + : STATUS_META[row.original.status].label} Date: Fri, 3 Jul 2026 16:34:06 +0000 Subject: [PATCH 12/90] fix --- .../components/operations/LastMileSteps.tsx | 93 +++++++++++++++++++ .../src/pages/operations/LastMilePage.tsx | 71 ++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/components/operations/LastMileSteps.tsx diff --git a/apps/edr-freight-web/backoffice/src/components/operations/LastMileSteps.tsx b/apps/edr-freight-web/backoffice/src/components/operations/LastMileSteps.tsx new file mode 100644 index 000000000..d4860a414 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/operations/LastMileSteps.tsx @@ -0,0 +1,93 @@ +import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core"; +import { Check } from "lucide-react"; + +/** One stage of the last-mile delivery workflow. */ +export interface LastMileStepState { + label: string; + done: boolean; + active: boolean; + /** Optional stamp/value shown next to the step (plate, time, distance…). */ + detail?: string | null; +} + +/** + * Compact 6-dot progress bar for a table row — filled = done, ringed = current, + * hollow = pending. Hover a dot for its label + stamp. + */ +export function LastMileStepBar({ steps }: { steps: LastMileStepState[] }) { + return ( + + {steps.map((s, i) => { + const color = s.done + ? "var(--mantine-color-green-6)" + : s.active + ? "var(--mantine-color-blue-5)" + : "var(--mantine-color-gray-4)"; + return ( + + + + ); + })} + + ); +} + +/** + * Vertical stepper for the detail view — completed steps bulleted + green, the + * current step highlighted, each showing its stamp/value when known. + */ +export function LastMileStepper({ steps }: { steps: LastMileStepState[] }) { + const activeIndex = steps.findIndex((s) => s.active); + // Timeline highlights items with index < `active`; count of done steps drives it. + const doneCount = steps.filter((s) => s.done).length; + return ( + + {steps.map((s, i) => ( + : undefined} + title={ + + {s.label} + + } + lineVariant={s.done ? "solid" : "dashed"} + > + + + {s.done ? "Done" : s.active ? "Current step" : "Pending"} + + {s.detail && ( + + {s.detail} + + )} + + + ))} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 0a40e9d08..56fbcc3fc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -49,6 +49,7 @@ import { driversService, type Driver } from "@/services/drivers.service"; import { ratesService } from "@/services/rates.service"; import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable"; import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal"; +import { LastMileStepBar, LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; import { api } from "@/auth/http"; const formatPrice = (amount: number) => @@ -92,6 +93,50 @@ const vehicleLabel = (record: LastMileRecord) => { const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId); +const fmtStamp = (iso?: string | null) => { + if (!iso) return null; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d.toLocaleString(); +}; + +/** + * Derive the 6-step last-mile workflow state for a record. Step completion is + * read from the record + its pickup-ready (warehouse release) row: + * assign→vehicleId, arrived→release order issued, leave→releaseDate, + * in-transit/delivered→status, distance→exactKm. + */ +const computeLastMileSteps = ( + record: LastMileRecord, + releaseRow?: ImportUnloadedItem, +): LastMileStepState[] => { + const exactKm = (record as { exactKm?: number | null }).exactKm; + const flags = [ + Boolean(record.vehicleId), + Boolean(releaseRow?.releaseOrderReference), + Boolean(releaseRow?.releaseDate), + record.status === "IN_TRANSIT" || record.status === "DELIVERED", + exactKm != null, + record.status === "DELIVERED", + ]; + // Current step = earliest incomplete one. + const activeIdx = flags.findIndex((f) => !f); + const labels = ["Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Delivered"]; + const details: (string | null)[] = [ + record.vehicle?.plateNumber ?? null, + releaseRow?.releaseOrderReference ?? null, + fmtStamp(releaseRow?.releaseDate), + null, + exactKm != null ? `${exactKm} KM` : null, + fmtStamp(releaseRow?.deliveredAt), + ]; + return labels.map((label, i) => ({ + label, + done: flags[i], + active: i === activeIdx, + detail: details[i], + })); +}; + const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId; const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—"; const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—"; @@ -943,6 +988,20 @@ const LastMilePage = () => { ), }, + { + id: "progress", + header: "Progress", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => ( + + ), + }, { id: "actions", header: "Actions", @@ -1324,6 +1383,18 @@ const LastMilePage = () => { > {activeRecord && } + {activeRecord && ( + + Delivery steps + + + )} From a5c46505e372610e5e2dc6ff196e8231cb047465 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 16:37:09 +0000 Subject: [PATCH 13/90] Add migration to widen window_duration_hours precision and update related components for duration handling --- ...00000-WidenWindowDurationHoursPrecision.ts | 28 ++++ ...pdate-train-scheduling-global-rules.dto.ts | 4 +- .../train-scheduling-global-rules.entity.ts | 6 +- .../detail/ContractDetailTabCards.tsx | 10 +- .../trainScheduling/DurationField.tsx | 123 ++++++++++++++++++ .../backoffice/src/hooks/use-toast.ts | 7 +- .../contracts/ContractRequestDetailPage.tsx | 52 +++++++- .../TrainSchedulingGlobalRulesPage.tsx | 74 ++++++++--- 8 files changed, 274 insertions(+), 30 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx diff --git a/apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts b/apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts new file mode 100644 index 000000000..194c0d056 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Widen train_scheduling_global_rules.window_duration_hours from numeric(4,2) + * to numeric(6,4). The UI now lets staff enter the booking-window duration in + * minutes / hours / days and converts to the column's native hours unit; a + * 4-minute window is 0.0667h, which numeric(4,2) rounds to 0.07 (≈3.96 min). + * Four decimals store sub-minute durations exactly (0.0667h → 4.00 min). + */ +export class WidenWindowDurationHoursPrecision1910000000000 + implements MigrationInterface +{ + name = "WidenWindowDurationHoursPrecision1910000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ALTER COLUMN window_duration_hours TYPE numeric(6, 4); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ALTER COLUMN window_duration_hours TYPE numeric(4, 2); + `); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index 1171b0c90..2e82feb6a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -60,11 +60,13 @@ export class UpdateTrainSchedulingGlobalRulesDto { @Max(23) windowOpenHour?: number; + // Stored in hours. The UI enters this in minutes/hours/days and converts to + // hours before sending, so the floor is 1 minute (0.0166h) — not 15 min. @ApiPropertyOptional({ example: 3 }) @IsOptional() @Type(() => Number) @IsNumber() - @Min(0.25) + @Min(0.0166) @Max(12) windowDurationHours?: number; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index 7b8d9b26a..1a67bb791 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -54,11 +54,13 @@ export class TrainSchedulingGlobalRules extends BaseEntity { @Column({ name: 'window_open_hour', type: 'int', default: 8 }) windowOpenHour!: number; + // Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h) + // are exact. See WidenWindowDurationHoursPrecision migration. @Column({ name: 'window_duration_hours', type: 'numeric', - precision: 4, - scale: 2, + precision: 6, + scale: 4, default: 3, }) windowDurationHours!: number; diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx index 44ece975d..de8ff1953 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx @@ -208,6 +208,10 @@ function codeLabel(code?: string | null): string | null { export interface ContractDocumentsCardProps { files: ContractFile[]; + /** Card heading. Defaults to "Documents". */ + title?: string; + /** Message shown when there are no files. */ + emptyText?: string; /** Open the file inline in a viewer modal. */ onView?: (file: ContractFile) => void; /** Download the file to disk. */ @@ -217,13 +221,15 @@ export interface ContractDocumentsCardProps { /** Rich list of the contract's attached documents: type, size, view + download. */ export function ContractDocumentsCard({ files, + title = "Documents", + emptyText = "No documents attached to this contract.", onView, onDownload, }: ContractDocumentsCardProps) { return ( @@ -233,7 +239,7 @@ export function ContractDocumentsCard({ > {files.length === 0 ? ( - No documents attached to this contract. + {emptyText} ) : ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx new file mode 100644 index 000000000..06c9013d4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx @@ -0,0 +1,123 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Group, NumberInput, Select, Stack } from "@mantine/core"; + +export type DurationUnit = "minutes" | "hours" | "days"; + +const UNIT_MINUTES: Record = { + minutes: 1, + hours: 60, + days: 1440, +}; + +const UNIT_OPTIONS: { value: DurationUnit; label: string }[] = [ + { value: "minutes", label: "min" }, + { value: "hours", label: "hr" }, + { value: "days", label: "day" }, +]; + +/** Convert a value expressed in `from` units to `to` units. */ +function convert(value: number, from: DurationUnit, to: DurationUnit): number { + return (value * UNIT_MINUTES[from]) / UNIT_MINUTES[to]; +} + +/** Pick the largest unit that keeps a value a clean-ish whole number, so a + * stored 0.0667h loads back as "4 min" rather than "0.0667 hr". */ +function bestDisplayUnit(minutes: number): DurationUnit { + if (minutes <= 0) return "minutes"; + if (minutes % 1440 === 0) return "days"; + if (minutes % 60 === 0) return "hours"; + return "minutes"; +} + +export interface DurationFieldProps { + label: string; + description?: string; + /** Current value, expressed in `nativeUnit` (what the API/DB stores). */ + value: number | string; + /** The unit the parent stores/sends. The field converts to this on change. */ + nativeUnit: DurationUnit; + /** Called with the value converted back to `nativeUnit` (or "" when blank). */ + onChange: (nativeValue: number | "") => void; + /** Smallest allowed value, in `nativeUnit`. */ + min?: number; + disabled?: boolean; +} + +export default function DurationField({ + label, + description, + value, + nativeUnit, + onChange, + min, + disabled, +}: DurationFieldProps) { + const nativeMinutes = useMemo(() => { + const num = value === "" || value == null ? NaN : Number(value); + return Number.isFinite(num) ? num * UNIT_MINUTES[nativeUnit] : NaN; + }, [value, nativeUnit]); + + // Display unit is user-driven; seed it from the incoming value once. + const [unit, setUnit] = useState(() => + Number.isFinite(nativeMinutes) ? bestDisplayUnit(nativeMinutes) : nativeUnit, + ); + + // The value usually arrives async (after the initial "" render), so the + // useState seed above runs before it exists. Re-pick the friendliest display + // unit the first time a real value shows up — but never again, so the user's + // manual unit choice sticks. + const seeded = useRef(false); + useEffect(() => { + if (!seeded.current && Number.isFinite(nativeMinutes)) { + seeded.current = true; + setUnit(bestDisplayUnit(nativeMinutes)); + } + }, [nativeMinutes]); + + const displayValue: number | "" = Number.isFinite(nativeMinutes) + ? Number(convert(nativeMinutes, "minutes", unit).toFixed(4)) + : ""; + + const emitNative = (display: number | "", displayUnit: DurationUnit) => { + if (display === "" || !Number.isFinite(Number(display))) { + onChange(""); + return; + } + const native = convert(Number(display), displayUnit, nativeUnit); + onChange(Number(native.toFixed(6))); + }; + + return ( + + + + emitNative(v === "" ? "" : Number(v), unit) + } + clampBehavior="none" + allowDecimal + min={min != null ? convert(min, nativeUnit, unit) : 0} + disabled={disabled} + style={{ flex: 1 }} + /> + @@ -1368,29 +1404,82 @@ const LastMilePage = () => { ) : ( No unassigned deliveries available. )} + {!bulkMode && activeRecord && requiredVehicles(activeRecord) > 0 && (() => { + const containers = containerCount(activeRecord); + const needed = requiredVehicles(activeRecord); + const picked = vehicleValues.filter(Boolean).length; + const ok = picked === needed; + return ( + + One truck (with trailer) carries {CONTAINERS_PER_VEHICLE} containers. + {picked > 0 && !ok && + ` You've selected ${picked} — ${picked < needed ? "add more" : "that's more than needed"}.`} + + ); + })()} - o.value === val || !vehicleValues.includes(o.value), + )} + value={val} + onChange={(v) => + setVehicleValues((prev) => prev.map((x, idx) => (idx === i ? v : x))) + } + searchable + clearable + disabled={assignVehicleOptions.length === 0} + /> + {vehicleValues.length > 1 && ( + setVehicleValues((prev) => prev.filter((_, idx) => idx !== i))} + > + + + )} + + ))} + + diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index 27f47cc7a..8648fb94b 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -22,6 +22,8 @@ export interface LastMileBooking { originYard?: { id: string; name?: string; label?: string } | null; destinationYard?: { id: string; name?: string; label?: string } | null; cargoType?: { id: string; name?: string; label?: string; cargoTypeName?: string } | null; + /** Container lines — total container count drives how many trucks are needed. */ + bookingContainers?: Array<{ id: string; quantity: number }>; } export interface LastMileVehicle { @@ -48,6 +50,8 @@ export interface LastMileRecord { vehicleId?: string | null; booking?: LastMileBooking | null; vehicle?: LastMileVehicle | null; + /** Full set of vehicles serving this delivery (multi-truck). */ + vehicleAssignments?: Array<{ id: string; vehicleId: string; vehicle?: LastMileVehicle | null }>; createdAt: string; updatedAt: string; } @@ -69,4 +73,6 @@ export const lastMileService = { api.post(LM.ACCEPT(encodeURIComponent(bookingReference))), remove: (id: string) => api.delete(LM.BY_ID(id)), + setVehicles: (id: string, vehicleIds: string[]) => + api.post(`${LM.BASE}/${id}/vehicles`, { vehicleIds }), }; From 8d17d9111e5974fe58e70ab9c28c1193d3c7f386 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 19:44:27 +0000 Subject: [PATCH 25/90] mile --- .../last-mile/dto/allocate-containers.dto.ts | 9 ++ .../modules/last-mile/last-mile.service.ts | 4 +- .../LastMileContainerAllocationTable.tsx | 38 ++++-- .../src/pages/operations/LastMilePage.tsx | 126 ++++++++++++++---- .../src/services/last-mile.service.ts | 8 +- 5 files changed, 151 insertions(+), 34 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts index de86ac883..7fff8247e 100644 --- a/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts @@ -1,8 +1,17 @@ +import { IsArray, IsUUID, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + export class LastMileContainerAllocationDto { + @IsUUID() containerId!: string; + + @IsUUID() vehicleId!: string; } export class AllocateLastMileContainersDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => LastMileContainerAllocationDto) allocations!: LastMileContainerAllocationDto[]; } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 744aa7df1..201692fdb 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -150,7 +150,7 @@ export class LastMileService { const [data, total] = await this.lastMileRepository.findAndCount({ where, relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: true }, + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true } }, vehicle: true, vehicleAssignments: { vehicle: true }, }, @@ -173,7 +173,7 @@ export class LastMileService { async findById(id: string): Promise { const record = await this.lastMileRepository.findById(id, { relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: true }, + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true } }, vehicle: true, vehicleAssignments: { vehicle: true }, }, diff --git a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx index cc619cb9a..02b62ec4c 100644 --- a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx @@ -22,8 +22,10 @@ export interface LastMileContainerRow { qty: number; } +/** One vehicle (with trailer) carries at most this many containers. */ +const CONTAINERS_PER_VEHICLE = 2; + export interface LastMileContainerAllocationTableProps { - lastMileId: string; containers: LastMileContainerRow[]; onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; } @@ -33,7 +35,6 @@ export interface LastMileContainerAllocationTableProps { * Displays containers with type/qty, vehicle dropdown per row, and save action. */ export function LastMileContainerAllocationTable({ - lastMileId, containers, onSave, }: LastMileContainerAllocationTableProps) { @@ -43,7 +44,8 @@ export function LastMileContainerAllocationTable({ const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ queryKey: ["vehicles", "free"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), + queryFn: () => + vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }).then((r) => r.data), }); const vehicleOptions = useMemo( @@ -85,13 +87,32 @@ export function LastMileContainerAllocationTable({ }); const allocatedCount = Object.values(allocations).filter(Boolean).length; - const allAllocated = allocatedCount === containers.length; + + // Containers already loaded onto each vehicle, to enforce the 2-per-vehicle cap. + const loadByVehicle = useMemo(() => { + const map: Record = {}; + for (const c of containers) { + const v = allocations[c.id]; + if (v) map[v] = (map[v] ?? 0) + (c.qty || 1); + } + return map; + }, [allocations, containers]); + + /** Options for a given row: a vehicle is disabled if assigning this container + * to it would exceed its 2-container capacity. */ + const optionsForRow = (row: LastMileContainerRow) => + vehicleOptions.map((o) => { + const already = loadByVehicle[o.value] ?? 0; + const selfHere = allocations[row.id] === o.value ? row.qty || 1 : 0; + const over = already - selfHere + (row.qty || 1) > CONTAINERS_PER_VEHICLE; + return { ...o, disabled: over }; + }); if (vehiclesLoading) { return ( - + - + ); } @@ -126,7 +147,7 @@ export function LastMileContainerAllocationTable({ o.value === val || !vehicleValues.includes(o.value), + (o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value), )} - value={val} + value={row.vehicleId} onChange={(v) => - setVehicleValues((prev) => prev.map((x, idx) => (idx === i ? v : x))) + setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x))) } searchable clearable disabled={assignVehicleOptions.length === 0} /> - {vehicleValues.length > 1 && ( + + setVehicleRows((prev) => + prev.map((x, idx) => (idx === i ? { ...x, containerNumber: e.currentTarget.value } : x)), + ) + } + /> + {vehicleRows.length > 1 && ( setVehicleValues((prev) => prev.filter((_, idx) => idx !== i))} + onClick={() => setVehicleRows((prev) => prev.filter((_, idx) => idx !== i))} > @@ -1542,11 +1574,20 @@ const LastMilePage = () => { variant="light" size="xs" leftSection={} - onClick={() => setVehicleValues((prev) => [...prev, null])} + onClick={() => + setVehicleRows((prev) => [ + ...prev, + { + vehicleId: null, + containerNumber: + (activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "", + }, + ]) + } disabled={ assignVehicleOptions.length === 0 || - vehicleValues.some((v) => !v) || - vehicleValues.filter(Boolean).length >= assignVehicleOptions.length + vehicleRows.some((r) => !r.vehicleId) || + vehicleRows.filter((r) => r.vehicleId).length >= assignVehicleOptions.length } style={{ alignSelf: "flex-start" }} > diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index 4dad0ae0d..a50778143 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -57,7 +57,12 @@ export interface LastMileRecord { booking?: LastMileBooking | null; vehicle?: LastMileVehicle | null; /** Full set of vehicles serving this delivery (multi-truck). */ - vehicleAssignments?: Array<{ id: string; vehicleId: string; vehicle?: LastMileVehicle | null }>; + vehicleAssignments?: Array<{ + id: string; + vehicleId: string; + containerNumber?: string | null; + vehicle?: LastMileVehicle | null; + }>; createdAt: string; updatedAt: string; } @@ -79,6 +84,8 @@ export const lastMileService = { api.post(LM.ACCEPT(encodeURIComponent(bookingReference))), remove: (id: string) => api.delete(LM.BY_ID(id)), - setVehicles: (id: string, vehicleIds: string[]) => - api.post(`${LM.BASE}/${id}/vehicles`, { vehicleIds }), + setVehicles: ( + id: string, + vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>, + ) => api.post(`${LM.BASE}/${id}/vehicles`, { vehicles }), }; From 5d1a2aa3195521e4fee6429dad70242f6a4b21d6 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 20:11:30 +0000 Subject: [PATCH 28/90] mile --- .../src/pages/operations/LastMilePage.tsx | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index a66dda5de..fa3f71e9c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1552,11 +1552,12 @@ const LastMilePage = () => { label={i === 0 ? "Container no." : undefined} placeholder="Container number" value={row.containerNumber} - onChange={(e) => + onChange={(e) => { + const value = e.currentTarget.value; setVehicleRows((prev) => - prev.map((x, idx) => (idx === i ? { ...x, containerNumber: e.currentTarget.value } : x)), - ) - } + prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)), + ); + }} /> {vehicleRows.length > 1 && ( { > {activeRecord && } + {activeRecord && (activeRecord.vehicleAssignments?.length ?? 0) > 0 && ( + + Assigned vehicles + + {activeRecord.vehicleAssignments!.map((a) => { + const v = a.vehicle; + const label = v + ? [v.code, v.plateNumber].filter(Boolean).join(" · ") + : a.vehicleId; + return ( + + {label} + {a.containerNumber ? ( + + {a.containerNumber} + + ) : ( + No container no. + )} + + ); + })} + + + )} {activeRecord && ( Delivery steps From c3f06b6aa9f6b19d8377a597431424c445f219e1 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 20:15:31 +0000 Subject: [PATCH 29/90] Add contractKind field to BookingWindowRow and MyBookingWindow interfaces --- .../modules/train-scheduling/train-scheduling.service.ts | 6 ++++-- .../MyPortalPage/components/UpcomingWindowsSection.tsx | 5 ++++- .../edr-freight-web/portal/src/services/bookings.service.ts | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 8768b16b1..199be696a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -176,6 +176,7 @@ const DEFAULT_TRAIN_LIMITS: Required = { interface BookingWindowRow { schedule_id: string; contract_id: string | null; + contract_kind: string | null; direction: string | null; window_phase: string | null; window_opens_at: Date | null; @@ -3129,6 +3130,7 @@ export class TrainSchedulingService { const rows: Array = await this.dataSource.query( `SELECT DISTINCT ts.id AS schedule_id, cr.contract_id AS contract_id, + c.contract_kind AS contract_kind, ts.direction, ts.window_phase, ts.window_opens_at, @@ -3149,7 +3151,6 @@ export class TrainSchedulingService { ON c.id = cr.contract_id AND c.company_id = $1 AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') - AND c.contract_kind = 'GENERAL' AND c.deleted_at IS NULL LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id @@ -3173,6 +3174,7 @@ export class TrainSchedulingService { const rows: Array = await this.dataSource.query( `SELECT DISTINCT ts.id AS schedule_id, cr.contract_id AS contract_id, + c.contract_kind AS contract_kind, ts.direction, ts.window_phase, ts.window_opens_at, @@ -3192,7 +3194,6 @@ export class TrainSchedulingService { AND cr.deleted_at IS NULL JOIN freight.contracts c ON c.id = cr.contract_id - AND c.contract_kind = 'GENERAL' AND c.deleted_at IS NULL LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id @@ -3211,6 +3212,7 @@ export class TrainSchedulingService { return { scheduleId: r.schedule_id, contractId: r.contract_id, + contractKind: r.contract_kind, direction: r.direction, windowPhase: r.window_phase, isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN', diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx index 00351761f..bb5b629de 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx @@ -221,7 +221,10 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ - {w.isOpenNow && ( + {/* ONE_TIME contracts book via their own single-shipment flow, + not window drawdown — show the window + countdown but no + "Book now" entry. */} + {w.isOpenNow && w.contractKind !== "ONE_TIME" && ( diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index a50778143..9eb6d03a5 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -61,6 +61,7 @@ export interface LastMileRecord { id: string; vehicleId: string; containerNumber?: string | null; + distanceKm?: number | null; vehicle?: LastMileVehicle | null; }>; createdAt: string; @@ -88,4 +89,11 @@ export const lastMileService = { id: string, vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>, ) => api.post(`${LM.BASE}/${id}/vehicles`, { vehicles }), + setDistances: ( + id: string, + distances: Array<{ vehicleId: string; distanceKm: number }>, + remainingPayment?: number, + ) => api.post(`${LM.BASE}/${id}/distances`, { distances, remainingPayment }), + generateInvoice: (id: string) => + api.post(`${LM.BASE}/${id}/invoice`), }; From 05f7fc254ead239731bf9beb4e802fc0d9139658 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 20:25:53 +0000 Subject: [PATCH 31/90] mile --- .../src/pages/operations/LastMilePage.tsx | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 06ac6d60a..21b57f0d5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -48,6 +48,7 @@ import { LAST_MILE_STATUSES, type LastMileApiStatus, type LastMileRecord, + type LastMileVehicle, lastMileService, } from "@/services/last-mile.service"; import { vehiclesService } from "@/services/vehicles.service"; @@ -797,17 +798,29 @@ const LastMilePage = () => { const assignVehicleOptions = useMemo(() => { const opts = [...vehicleOptions]; const seen = new Set(opts.map((o) => o.value)); - const current = [ - ...(activeRecord?.vehicleAssignments?.map((a) => a.vehicle) ?? []), - activeRecord?.vehicle, - ]; - for (const v of current) { + const pushVehicle = (v?: LastMileVehicle | null) => { if (v && !seen.has(v.id)) { seen.add(v.id); const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber]; if (v.code) parts.unshift(v.code); opts.push({ value: v.id, label: parts.join(" · ") }); } + }; + for (const a of activeRecord?.vehicleAssignments ?? []) pushVehicle(a.vehicle); + pushVehicle(activeRecord?.vehicle); + // Fallback: an assigned vehicle whose relation didn't load still needs an + // option so the reassign Select can render it as selected (not blank). + for (const a of activeRecord?.vehicleAssignments ?? []) { + if (!seen.has(a.vehicleId)) { + seen.add(a.vehicleId); + opts.push({ + value: a.vehicleId, + label: a.containerNumber ? `Assigned · ${a.containerNumber}` : "Assigned vehicle", + }); + } + } + if (activeRecord?.vehicleId && !seen.has(activeRecord.vehicleId)) { + opts.push({ value: activeRecord.vehicleId, label: "Assigned vehicle" }); } return opts; }, [vehicleOptions, activeRecord]); From f5e68f5a61eb189833ee092040e0ea4e4152701f Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 20:32:39 +0000 Subject: [PATCH 32/90] mile --- .../modules/last-mile/last-mile.controller.ts | 42 ++----------------- 1 file changed, 3 insertions(+), 39 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 9d2a82d0b..3ed6a8ede 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -23,9 +23,6 @@ import { SetDistancesDto } from './dto/set-distances.dto'; import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileService } from './last-mile.service'; import { LastMileInvoiceService } from './last-mile-invoice.service'; -import { Freight } from '@edr/types'; -import { BillingService } from '../billing/billing.service'; -import { BookingsService } from '../bookings/bookings.service'; @ApiTags('last-mile') @ApiBearerAuth() @@ -35,8 +32,6 @@ export class LastMileController { constructor( private readonly lastMileService: LastMileService, private readonly lastMileInvoiceService: LastMileInvoiceService, - private readonly billingService: BillingService, - private readonly bookingsService: BookingsService ) {} @Get() @@ -85,40 +80,9 @@ export class LastMileController { @TrainSchedulingManage() @ApiOperation({ summary: 'Update a last-mile leg' }) async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) { - const record = await this.lastMileService.update(id, dto); - // Auto-generate invoice if distance or payment was updated - const booking = await this.bookingsService.findById(record.bookingId); - const currency = booking.paymentCurrency || "ETB"; - if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { - await this.billingService.generateInvoice({ - source: Freight.InvoiceSource.LastMile, - sourceId: record.id, - type: "LAST_MILE", - companyId: booking.companyId, - companyProfileId: booking.companyProfileId, - currency, - - lines: [ - { - chargeType: "LAST_MILE", - description: "Last Mile Transportation Service", - quantity: 1, - unitRate: record.remainingPayment, - amount: record.remainingPayment, - currency, - }, - ], - - subtotalAmount: record.remainingPayment, - taxAmount: 0, // Replace if VAT/tax applies - totalAmount: record.remainingPayment, - - dueInDays: 7, - status: Freight.InvoiceStatus.Pending, - }); - await this.lastMileInvoiceService.ensureInvoiceFor(record); - } - return record; + // No invoice side-effects here — invoices are generated only via the + // explicit POST :id/invoice endpoint (the "Generate Invoice" action). + return this.lastMileService.update(id, dto); } @Delete(':id') From 4ddf03e3572555bc75d7fed49b13e25450fa11eb Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 20:43:26 +0000 Subject: [PATCH 33/90] mile --- .../src/modules/billing/billing.service.ts | 10 +++++ .../modules/last-mile/last-mile.service.ts | 26 +++++++++++- .../src/pages/operations/LastMilePage.tsx | 42 +++++++------------ .../src/services/last-mile.service.ts | 2 + 4 files changed, 53 insertions(+), 27 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 536129122..fdd230d33 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -307,6 +307,16 @@ export class BillingService { }); } + /** Invoices for a batch of source records (e.g. many last-mile legs), so a + * list can show which records already have an invoice without N+1 queries. */ + findBySourceIds(source: string, sourceIds: string[]): Promise { + if (!sourceIds.length) return Promise.resolve([]); + return this.invoices.findAll({ + where: { source, sourceId: In(sourceIds) }, + order: { createdAt: "DESC" }, + }); + } + /** Invoices for the signed-in customer; empty when they have no company. */ async findForUser( userId: string, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index fdc8a4c48..b5341b0a1 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -12,7 +12,7 @@ import { LastMile, LastMileStatus } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity'; import { LastMileRepository } from './last-mile.repository'; -import { InvoiceEventPayload } from '../billing/billing.service'; +import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; import { OnEvent } from '@nestjs/event-emitter'; import { FleetHistoryService } from '../fleet-history/fleet-history.service'; import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; @@ -46,8 +46,28 @@ export class LastMileService { private readonly smsClient: SmsClientService, private readonly dataSource: DataSource, private readonly history: FleetHistoryService, + private readonly billing: BillingService, ) {} + /** Attach real invoice info (number/status) to records so the UI can show an + * invoice link only when one actually exists — NOT merely because distance + * was entered. Batched to avoid N+1. */ + private async attachInvoices(records: LastMile[]): Promise { + const invoices = await this.billing.findBySourceIds( + 'last_mile', + records.map((r) => r.id), + ); + const byId = new Map(); + for (const inv of invoices) { + if (!byId.has(inv.sourceId)) { + byId.set(inv.sourceId, { number: inv.invoiceNumber, status: String(inv.status) }); + } + } + for (const r of records) { + (r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; + } + } + /** Resolve a vehicle's driver + human labels, for stamping mile events onto * the driver's timeline and naming the vehicle. Best-effort — never throws. */ private async vehicleInfo( @@ -159,6 +179,8 @@ export class LastMileService { take: pageSize, }); + await this.attachInvoices(data); + return { data, meta: { @@ -183,6 +205,8 @@ export class LastMileService { throw new NotFoundException(`Last-mile record ${id} not found`); } + await this.attachInvoices([record]); + return record; } diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 21b57f0d5..3ba71148e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1086,35 +1086,25 @@ const LastMilePage = () => { header: "Invoice", meta: { headerClassName, cellClassName }, cell: ({ row }) => { - const hasDistance = row.original.exactKm != null && row.original.exactKm > 0; - const isPaid = (row.original as any).paid; - if (!hasDistance) { + // Only show an invoice once it's actually been generated — NOT merely + // because distance was entered. + const invoice = row.original.invoice; + if (!invoice) { return ; } - if (isPaid) { - return ( - - openInvoice(row.original)} - c="blue" - fw={500} - style={{ textDecoration: "underline", cursor: "pointer" }} - > - #345 - - Paid - - ); - } + const isPaid = (row.original as any).paid || invoice.status === "Paid"; return ( - openInvoice(row.original)} - c="blue" - fw={500} - style={{ textDecoration: "underline", cursor: "pointer" }} - > - #345 - + + openInvoice(row.original)} + c="blue" + fw={500} + style={{ textDecoration: "underline", cursor: "pointer" }} + > + {invoice.number} + + {isPaid && Paid} + ); }, }, diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index 9eb6d03a5..d800c2331 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -64,6 +64,8 @@ export interface LastMileRecord { distanceKm?: number | null; vehicle?: LastMileVehicle | null; }>; + /** Present only when an invoice has actually been generated (not on distance). */ + invoice?: { number: string; status: string } | null; createdAt: string; updatedAt: string; } From 90ab1bd0422601396dcbb3782499073387aa9ba1 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 20:51:32 +0000 Subject: [PATCH 34/90] mile --- .../modules/last-mile/last-mile.service.ts | 40 ++++++++++- .../src/pages/operations/LastMilePage.tsx | 71 +++++++++++++++++-- 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index b5341b0a1..0a0fed154 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -539,8 +539,46 @@ export class LastMileService { } async remove(id: string): Promise { - await this.findById(id); + const existing = await this.findById(id); + + // Every vehicle this delivery holds — junction + legacy + container rows. + const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, { + where: { lastMileId: id }, + }); + const allocations = await this.dataSource.manager.find(LastMileContainerAllocation, { + where: { lastMileId: id }, + }); + const vehicleIds = [ + ...new Set( + [ + ...assignments.map((a) => a.vehicleId), + ...allocations.map((a) => a.vehicleId), + existing.vehicleId ?? null, + ].filter((v): v is string => Boolean(v)), + ), + ]; + await this.lastMileRepository.softDelete(id); + if (assignments.length) { + await this.dataSource.manager.softDelete(LastMileVehicleAssignment, { lastMileId: id }); + } + + // Free every vehicle no longer held by another active trip (releaseIfUnused + // ignores this now soft-deleted record) and audit the release. + if (vehicleIds.length) { + await this.vehiclesService.releaseIfUnused(vehicleIds); + const bookingRef = await this.resolveBookingRef(existing); + for (const vehicleId of vehicleIds) { + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + lastMileId: id, + driverId: info.driverId, + metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + } } async allocateContainers( diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 3ba71148e..f44c8e3a3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -497,6 +497,8 @@ const LastMilePage = () => { const [distanceRows, setDistanceRows] = useState>({}); const [invoiceOpen, setInvoiceOpen] = useState(false); const [invoiceRecord, setInvoiceRecord] = useState(null); + // Record pending invoice-generation confirmation (shows a summary first). + const [invoiceConfirm, setInvoiceConfirm] = useState(null); const [allocationOpen, setAllocationOpen] = useState(false); const [allocationContainers, setAllocationContainers] = useState([]); @@ -1234,11 +1236,7 @@ const LastMilePage = () => { } disabled={!(row.original.exactKm != null && row.original.exactKm > 0)} - onClick={() => - generateInvoiceMutation.mutate(row.original.id, { - onSuccess: () => openInvoice(row.original), - }) - } + onClick={() => setInvoiceConfirm(row.original)} > Generate Invoice @@ -1858,6 +1856,69 @@ const LastMilePage = () => { + {/* Generate Invoice — confirmation summary */} + setInvoiceConfirm(null)} + title={Generate Invoice} + radius="lg" + centered + > + {invoiceConfirm && ( + + + {bookingRef(invoiceConfirm)} + {customerName(invoiceConfirm)} + + + + {(invoiceConfirm.vehicleAssignments ?? []).map((a) => { + const v = a.vehicle; + const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return ( + + + {label} + {a.containerNumber ? ` · ${a.containerNumber}` : ""} + + {a.distanceKm != null ? `${a.distanceKm} km` : "—"} + + ); + })} + + + + Total distance + {invoiceConfirm.exactKm ?? 0} km + + + Invoice amount + {formatPrice(invoiceConfirm.remainingPayment)} + + + This creates the delivery-fee invoice. Confirm the distances and amount are correct. + + + + + + + )} + + {/* Container Allocation modal */} Date: Fri, 3 Jul 2026 20:59:21 +0000 Subject: [PATCH 35/90] mile --- .../modules/last-mile/last-mile.service.ts | 4 +- .../src/pages/operations/LastMilePage.tsx | 102 ++---------------- .../src/services/last-mile.service.ts | 4 +- 3 files changed, 10 insertions(+), 100 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 0a0fed154..09db565f9 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -57,10 +57,10 @@ export class LastMileService { 'last_mile', records.map((r) => r.id), ); - const byId = new Map(); + const byId = new Map(); for (const inv of invoices) { if (!byId.has(inv.sourceId)) { - byId.set(inv.sourceId, { number: inv.invoiceNumber, status: String(inv.status) }); + byId.set(inv.sourceId, { id: inv.id, number: inv.invoiceNumber, status: String(inv.status) }); } } for (const r of records) { diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index f44c8e3a3..19a8fa290 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -14,6 +14,7 @@ import { X, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { @@ -467,6 +468,7 @@ const buildTripSlipHtml = (record: LastMileRecord) => { const LastMilePage = () => { const { toast } = useToast(); const qc = useQueryClient(); + const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [search, setSearch] = useState(""); @@ -495,8 +497,6 @@ const LastMilePage = () => { const [distanceOpen, setDistanceOpen] = useState(false); // Per-vehicle actual distance, keyed by vehicleId. const [distanceRows, setDistanceRows] = useState>({}); - const [invoiceOpen, setInvoiceOpen] = useState(false); - const [invoiceRecord, setInvoiceRecord] = useState(null); // Record pending invoice-generation confirmation (shows a summary first). const [invoiceConfirm, setInvoiceConfirm] = useState(null); @@ -743,15 +743,6 @@ const LastMilePage = () => { setDistanceRows({}); }; - const openInvoice = (record: LastMileRecord) => { - setInvoiceRecord(record); - setInvoiceOpen(true); - }; - - const closeInvoice = () => { - setInvoiceOpen(false); - setInvoiceRecord(null); - }; const openAllocation = (id: string, containers?: LastMileContainerRow[]) => { setActiveId(id); @@ -1098,7 +1089,7 @@ const LastMilePage = () => { return ( openInvoice(row.original)} + onClick={() => navigate(`/dashboard/invoices/${invoice.id}`)} c="blue" fw={500} style={{ textDecoration: "underline", cursor: "pointer" }} @@ -1775,87 +1766,6 @@ const LastMilePage = () => { - {/* Invoice modal */} - Invoice #345} - size="lg" - radius="lg" - centered - > - - {invoiceRecord && ( - <> - - - - EDR Freight - Invoice #345 - - - - - - - - - - - - - - - - - Post Payment - {formatPrice(invoiceRecord.remainingPayment)} - - - Advanced Payment - {formatPrice(invoiceRecord.advancedPayment)} - - - {(() => { - const postPayment = parseFloat(String(invoiceRecord.remainingPayment)); - const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment)); - const difference = postPayment - advancedPayment; - - if (difference > 0) { - return ( - - Remaining to Pay - {formatPrice(difference)} - - ); - } else if (difference < 0) { - return ( - - Refund - {formatPrice(Math.abs(difference))} - - ); - } else { - return ( - - Status - Settled - - ); - } - })()} - - - - - - )} - - - - - - {/* Generate Invoice — confirmation summary */} { loading={generateInvoiceMutation.isPending} onClick={() => generateInvoiceMutation.mutate(invoiceConfirm.id, { - onSuccess: () => { - const rec = invoiceConfirm; + onSuccess: (res) => { setInvoiceConfirm(null); - openInvoice(rec); + const invoiceId = res?.data?.id; + if (invoiceId) navigate(`/dashboard/invoices/${invoiceId}`); }, }) } diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index d800c2331..c68c5a9a2 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -65,7 +65,7 @@ export interface LastMileRecord { vehicle?: LastMileVehicle | null; }>; /** Present only when an invoice has actually been generated (not on distance). */ - invoice?: { number: string; status: string } | null; + invoice?: { id: string; number: string; status: string } | null; createdAt: string; updatedAt: string; } @@ -97,5 +97,5 @@ export const lastMileService = { remainingPayment?: number, ) => api.post(`${LM.BASE}/${id}/distances`, { distances, remainingPayment }), generateInvoice: (id: string) => - api.post(`${LM.BASE}/${id}/invoice`), + api.post<{ id: string; invoiceNumber?: string } | null>(`${LM.BASE}/${id}/invoice`), }; From 85c2fd142882ccee88aeac25ca2011fea86740af Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 21:04:46 +0000 Subject: [PATCH 36/90] feat: enhance contract clearance process with linked booking details - Added and to for better visibility of GL-created shipment bookings. - Implemented method in to fetch the latest clearance phase for contracts, improving list responses. - Introduced property in the entity to store the latest clearance cycle's phase. - Updated to surface linked booking information in the clearance view. - Created component to display detailed container information in booking details. - Refactored booking actions to remove contract-related actions from the booking request page. - Enhanced the component to reflect the current phase of clearance actions. - Updated UI components to provide clearer messaging regarding the status of clearance and linked bookings. - Adjusted action handling in to include duty payment actions. - Improved the to show hints for each phase of the clearance process. --- .../modules/bookings/bookings.repository.ts | 2 + .../entities/booking-container.entity.ts | 7 +- .../modules/companies/companies.controller.ts | 22 +- .../contracts/contract-clearance.service.ts | 24 ++- .../modules/contracts/contracts.repository.ts | 25 +++ .../contracts/entities/contract.entity.ts | 6 + .../src/modules/files/files.service.ts | 10 + .../bookings/BookingActionsToolbar.tsx | 35 +-- .../detail/BookingContainerUnitsCard.tsx | 202 ++++++++++++++++++ .../src/components/bookings/detail/index.ts | 1 + .../bookings/booking-actions.config.ts | 40 +--- .../bookings/BookingRequestDetailPage.tsx | 65 +----- .../backoffice/src/types/booking.ts | 13 ++ .../ContractClearanceAction.tsx | 19 +- .../ContractCustomerAction.tsx | 2 + .../deriveContractCustomerAction.ts | 62 +++++- .../portal/src/pages/MyPortalPage/actions.ts | 2 +- .../components/ActionNeededSection.tsx | 37 +++- .../pages/contracts/ClearancePhaseStepper.tsx | 122 +++++++---- .../ContractClearanceWorkflowBanner.tsx | 33 ++- .../new-contract-form/step1-contract-type.tsx | 3 +- packages/types/src/freight/contracts.ts | 9 + 22 files changed, 537 insertions(+), 204 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContainerUnitsCard.tsx diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 846fd2c9a..913565f70 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -90,6 +90,7 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.bookingContainers', 'bc') .leftJoinAndSelect('bc.containerType', 'ct') + .leftJoinAndSelect('bc.units', 'bcu') .leftJoinAndSelect('booking.company', 'company') // .leftJoinAndSelect('booking.customer', 'customer') .leftJoinAndSelect('booking.train', 'train') @@ -104,6 +105,7 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.reviewNotes', 'reviewNotes') .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') .where('booking.id = :id', { id }) + .addOrderBy('bcu.sort_order', 'ASC') .leftJoinAndMapMany( 'booking.files', FileRecord, diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts index db9746c09..182ff153d 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts @@ -1,8 +1,9 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { ContainerType } from '../../rule-engine/entities/container-type.entity'; import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity'; import { Booking } from './booking.entity'; +import { BookingContainerUnit } from './booking-container-unit.entity'; @Entity({ schema: 'freight', name: 'booking_container' }) @Index(['bookingId']) @@ -61,4 +62,8 @@ export class BookingContainer extends BaseEntity { @Column({ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) overweightExcessTons?: number | null; + + /** The physical containers under this line — each with its own number + VGM. */ + @OneToMany(() => BookingContainerUnit, (u) => u.bookingContainer) + units?: BookingContainerUnit[]; } diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 4bcc3252a..b1761b35f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -334,15 +334,19 @@ export class CompaniesController { @Param("companyId", ParseUUIDPipe) companyId: string, ) { const files = await this.filesService.findByResource(companyId, "companies"); - return files.map((f) => ({ - id: f.id, - name: f.name, - code: f.code, - mimeType: f.mimeType, - size: f.size, - uploadedAt: f.createdAt, - url: f.url, - })); + return Promise.all( + files.map(async (f) => ({ + id: f.id, + name: f.name, + code: f.code, + mimeType: f.mimeType, + size: f.size, + uploadedAt: f.createdAt, + // Raw `f.url` is an un-signed MinIO path the browser can't open — sign + // it so the file previews/downloads in the client. + url: f.url ? await this.filesService.signUrl(f.url) : f.url, + })), + ); } @Post(":companyId/documents") diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 73a4fe117..532d26359 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -77,6 +77,9 @@ export interface ContractClearanceView { /** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */ exportClearanceFinalized?: boolean; linkedBookingId?: string | null; + /** Reference + status of the GL-created shipment booking, once it exists. */ + linkedBookingReference?: string | null; + linkedBookingStatus?: string | null; dutyAdvice?: { amount: number; currency: string; @@ -284,13 +287,22 @@ export class ContractClearanceService { ); let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); - if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { + // Once GL creates the shipment booking, surface its reference + status so the + // customer sees the concrete booking instead of a stale "will be created + // shortly" message. Reuse the export booking load; fetch for import too. + let linkedBookingReference: string | null = null; + let linkedBookingStatus: string | null = null; + if (cycle?.bookingId) { const booking = await this.bookingsService.findById(cycle.bookingId); if (booking) { - nextAction = this.workflowService.computeNextActionForBooking( - booking, - bookingMilestones, - ); + linkedBookingReference = booking.reference ?? null; + linkedBookingStatus = booking.status ?? null; + if (contract.tradeDirection === 'EXPORT') { + nextAction = this.workflowService.computeNextActionForBooking( + booking, + bookingMilestones, + ); + } } } @@ -326,6 +338,8 @@ export class ContractClearanceService { preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt), exportClearanceFinalized: Boolean(cycle?.completedAt), linkedBookingId: cycle?.bookingId ?? null, + linkedBookingReference, + linkedBookingStatus, dutyAdvice, workflowFiles, t1, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 9e464db51..34a958fd2 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -135,6 +135,7 @@ export class ContractsRepository extends BaseRepository { // Attach the generated contract PDF to each row so list/home can offer a // direct download. Loaded separately to keep pagination counts correct. await this.attachContractFiles(items); + await this.attachClearancePhases(items); const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0; return { @@ -173,6 +174,30 @@ export class ContractsRepository extends BaseRepository { } } + /** + * Attach each contract's persisted clearance phase (latest cycle's + * current_phase) so list consumers can show step-accurate customer actions + * ("Pay duty & upload slip" vs generic "Update clearance") without a + * per-contract clearance-view request. One query per page, like + * `attachContractFiles`. + */ + private async attachClearancePhases(contracts: Contract[]): Promise { + if (contracts.length === 0) return; + const ids = contracts.map((c) => c.id); + const rows: Array<{ contract_id: string; current_phase: string | null }> = + await this.dataSource.query( + `SELECT DISTINCT ON (contract_id) contract_id, current_phase + FROM freight.contract_clearance_cycles + WHERE contract_id = ANY($1) + ORDER BY contract_id, cycle_number DESC`, + [ids], + ); + const byContract = new Map(rows.map((r) => [r.contract_id, r.current_phase])); + for (const contract of contracts) { + contract.clearancePhase = byContract.get(contract.id) ?? null; + } + } + async getStatusCounts(): Promise> { const rows = await this.repository .createQueryBuilder('contract') diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index 0461d3736..07d08d3c0 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -254,4 +254,10 @@ export class Contract extends BaseEntity { createForeignKeyConstraints: false, }) files?: FileRecord[]; + + /** + * Latest clearance cycle's current_phase, attached by + * ContractsRepository.attachClearancePhases for list responses. Not a column. + */ + clearancePhase?: string | null; } diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index ec1fb6fa9..a5c641dd7 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -123,6 +123,16 @@ export class FilesService { return this.filesRepository.findByResource(resourceId, resource); } + /** + * Short-lived signed URL for a stored file's raw MinIO URL. The persisted + * `url` is an un-signed object path that a browser cannot fetch directly; + * callers that expose files for preview/download must sign them first. + */ + async signUrl(rawUrl: string, expirySeconds = 300): Promise { + const objectName = this.minioService.getObjectNameFromUrl(rawUrl); + return this.minioService.getSignedUrl(objectName, expirySeconds); + } + async findByCode( resourceId: string, resource: string, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx index e451f2d2c..349d68f32 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx @@ -1,5 +1,5 @@ -import { Download, Zap, FileText, Clock } from "lucide-react"; -import { Stack, Text, Button } from "@mantine/core"; +import { Zap, Clock } from "lucide-react"; +import { Stack, Text } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; import { BookingActionsMenu } from "./BookingActionsMenu"; @@ -14,21 +14,11 @@ interface BookingActionsToolbarProps { mutations: Mutations; } -/** Detail-page actions: primary toolbar + downloads. */ -export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) { +/** Detail-page actions: primary staff-action toolbar. */ +export function BookingActionsToolbar({ booking }: BookingActionsToolbarProps) { const row = toBookingListRow(booking); const { status } = booking; - const downloadBlob = async (fn: () => Promise, filename: string) => { - const blob = await fn(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - }; - if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") { return null; } @@ -101,23 +91,6 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool - - {status === "CONTRACT_READY" && ( - - - - )} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContainerUnitsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContainerUnitsCard.tsx new file mode 100644 index 000000000..cd925c03c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContainerUnitsCard.tsx @@ -0,0 +1,202 @@ +import { useMemo } from "react"; +import { Boxes, Container as ContainerIcon, Snowflake, Flame } from "lucide-react"; +import { Badge, Box, Group, Stack, Table, Text, ThemeIcon } from "@mantine/core"; + +import type { BookingDetail } from "@/types/booking"; +import { SectionCard } from "./SectionCard"; + +export interface BookingContainerUnitsCardProps { + booking: BookingDetail; +} + +interface FlatUnit { + id: string; + containerNumber: string; + sealNumber?: string | null; + vgmTons: number; + isHazardous?: boolean; + isReefer?: boolean; + typeLabel: string; + sizeFt?: number; +} + +/** + * The physical container manifest: one row per container with its number, type, + * seal, and weight (VGM). Per-unit numbers are only captured for contract-drawdown + * bookings — when a line has no units the card falls back to the aggregate + * type/qty/weight so it still renders something for plain bookings. + */ +export function BookingContainerUnitsCard({ booking }: BookingContainerUnitsCardProps) { + const lines = booking.bookingContainers ?? []; + + const units: FlatUnit[] = useMemo( + () => + lines.flatMap((line) => + (line.units ?? []).map((u) => ({ + id: u.id, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber, + vgmTons: Number(u.vgmTons) || 0, + isHazardous: u.isHazardous, + isReefer: u.isReefer, + typeLabel: line.containerType?.label ?? line.containerType?.code ?? "—", + sizeFt: line.containerType?.sizeFt, + })), + ), + [lines], + ); + + // Container bookings only — bulk has no container manifest. + if (booking.freightType === "BULK" || lines.length === 0) return null; + + const totalUnits = units.length; + const totalVgm = units.reduce((sum, u) => sum + u.vgmTons, 0); + + return ( + 0 + ? "Each physical container with its number and weight" + : "Per-container numbers were not captured for this booking" + } + accent="teal" + extra={ + totalUnits > 0 ? ( + + {totalUnits} container{totalUnits === 1 ? "" : "s"} + + ) : ( + + {lines.length} line{lines.length === 1 ? "" : "s"} + + ) + } + > + {totalUnits > 0 ? ( + + + + + + # + Container No. + Type + Seal + Weight (VGM) + + + + {units.map((u, i) => ( + + + + {i + 1} + + + + + + + + + {u.containerNumber} + + {u.isReefer ? ( + + + + ) : null} + {u.isHazardous ? ( + + + + ) : null} + + + + + {u.typeLabel} + {u.sizeFt ? ( + + {u.sizeFt}FT + + ) : null} + + + + + {u.sealNumber || "—"} + + + + + {u.vgmTons.toFixed(3)} t + + + + ))} + +
+
+ + + + Total weight (VGM) + + + {totalVgm.toFixed(3)} t + + +
+ ) : ( + // Fallback: no per-unit numbers — show the aggregate lines. + + + + + Type + Qty + VGM / unit + Total VGM + + + + {lines.map((line) => { + const perUnit = Number(line.vgmPerUnitTons) || 0; + return ( + + + + + {line.containerType?.label ?? line.containerType?.code ?? "—"} + + {line.containerType?.sizeFt ? ( + + {line.containerType.sizeFt}FT + + ) : null} + + + {line.quantity} + {perUnit.toFixed(3)} t + + + {(line.quantity * perUnit).toFixed(3)} t + + + + ); + })} + +
+
+ )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index a023fafda..003f3d4de 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -8,6 +8,7 @@ export * from "./BookingDetailHeader"; export * from "./BookingLifecycleStepper"; export * from "./BookingRouteCard"; export * from "./BookingContainersCard"; +export * from "./BookingContainerUnitsCard"; export * from "./BookingApprovalCard"; export * from "./BookingReviewNotesCard"; export * from "./BookingPaymentCard"; diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts index 66bb9b1ea..818a82c52 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts @@ -2,7 +2,6 @@ import type { LucideIcon } from "lucide-react"; import { Ban, Check, - FileSignature, MessageSquareWarning, Play, ShieldCheck, @@ -211,29 +210,6 @@ const CANCEL_ACTION: BookingActionDef = { inputPlaceholder: "Reason for cancellation…", }; -const VIEW_CONTRACT_ACTION: BookingActionDef = { - id: "viewContract", - label: "View contract", - shortLabel: "Contract", - description: "Open contract document and signatures", - confirmTitle: "", - confirmDescription: "", - variant: "outline", - icon: FileSignature, -}; - -const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = { - id: "signContractStaff", - label: "Sign contract", - shortLabel: "Sign", - description: "Open contract page and apply staff counter-signature", - confirmTitle: "", - confirmDescription: "", - variant: "default", - icon: FileSignature, - primary: true, -}; - // Opens the booking detail straight on the Clearance tab so Marketing can // review the customer's clearance documents (non-customs bookings only). const REVIEW_CLEARANCE_ACTION: BookingActionDef = { @@ -340,22 +316,14 @@ export function getBookingActions( actions = withCancel(approvalActions(approvalSteps)); break; case "APPROVED": - actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }, CANCEL_ACTION]; + actions = [CANCEL_ACTION]; break; case "CONTRACT_READY": - actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }]; - break; case "SIGNED_CUSTOMER": - actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION]; - break; case "FULLY_EXECUTED": - actions = [ - { - ...VIEW_CONTRACT_ACTION, - label: "View executed contract", - primary: true, - }, - ]; + // Contract view/sign/executed buttons intentionally removed from the + // booking-request page. + actions = []; break; case "AWAITING_DOCUMENTS": case "DOCUMENTS_UNDER_REVIEW": diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index ebfe22cb7..d1830b45a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -1,7 +1,6 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { ArrowLeft, - FileSignature, Layers, LayoutGrid, Milestone, @@ -36,31 +35,19 @@ import { BookingCargoCard, BookingCompanyCard, BookingContractSummaryCard, - BookingDocumentsCard, + BookingContainerUnitsCard, ClearanceReviewSection, ContractOrdersPanel, - type BookingFileView, } from "@/components/bookings/detail"; import { WarehouseInfoCard } from "@/components/warehouses"; import { getStatusMeta } from "@/features/bookings/booking-status.config"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import type { BookingDetail } from "@/types/booking"; -import { downloadBookingFile } from "@/services/files.service"; import { useBookingDetail, useBookingMutations, } from "@/hooks/bookings/useBookings"; import { useScrollToHash } from "@/hooks/useScrollToHash"; -import toast from "react-hot-toast"; - -// Signature / generated-contract files are surfaced on the contract page, not -// in the booking's Documents list. -const SIGNATURE_FILE_CODES = new Set([ - "signature", - "signature_customer", - "signature_staff", - "contract", -]); export default function BookingRequestDetailPage() { const { id } = useParams<{ id: string }>(); @@ -77,14 +64,6 @@ export default function BookingRequestDetailPage() { } = useBookingDetail(id); const mutations = useBookingMutations(id ?? ""); - const handleDownloadFile = async (file: BookingFileView) => { - try { - await downloadBookingFile(file.id, file.name); - } catch { - toast.error("Could not download file."); - } - }; - if (isLoading) { return ( @@ -149,11 +128,6 @@ export default function BookingRequestDetailPage() { const row = toBookingListRow(booking); const statusMeta = getStatusMeta(booking.status); - const showContractButton = [ - "CONTRACT_READY", - "SIGNED_CUSTOMER", - "FULLY_EXECUTED", - ].includes(booking.status); const showApprovalCard = booking.status === "PENDING_APPROVAL" || booking.status === "APPROVED_PENDING_SIGNATURE"; @@ -246,11 +220,7 @@ export default function BookingRequestDetailPage() { - + {isGeneralContract && ( @@ -270,11 +240,7 @@ export default function BookingRequestDetailPage() { )} ) : ( - + )} @@ -306,20 +272,6 @@ export default function BookingRequestDetailPage() { View document clearance )} - {showContractButton && ( - - )} {showApprovalCard && ( )} @@ -332,15 +284,13 @@ export default function BookingRequestDetailPage() { ); } -/** The booking's primary detail cards — route, services, cargo, contract, docs. */ +/** The booking's primary detail cards — route, services, cargo, containers. */ function OverviewPanel({ booking, row, - onDownload, }: { booking: BookingDetail; row: ReturnType; - onDownload: (file: BookingFileView) => void; }) { return ( @@ -351,15 +301,10 @@ function OverviewPanel({ /> + {booking.contractSummary && ( )} - !SIGNATURE_FILE_CODES.has(f.code ?? ""), - )} - onDownload={onDownload} - /> ); } diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 298e749c7..a89e10378 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -69,6 +69,17 @@ export interface BookingCompany { website?: string | null; } +/** One physical container under a line — its own number + verified gross mass. */ +export interface BookingContainerUnit { + id: string; + containerNumber: string; + sealNumber?: string | null; + vgmTons: number; + isHazardous?: boolean; + isReefer?: boolean; + sortOrder?: number; +} + export interface BookingContainerLine { id: string; containerTypeId: string; @@ -80,6 +91,8 @@ export interface BookingContainerLine { label?: string; sizeFt?: number; }; + /** Per-physical-container rows (number + weight). Empty when not captured. */ + units?: BookingContainerUnit[]; } export interface BookingApprovalStep { diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/ContractClearanceAction.tsx b/apps/edr-freight-web/portal/src/components/customer-actions/ContractClearanceAction.tsx index 58fef65e3..4aa131c23 100644 --- a/apps/edr-freight-web/portal/src/components/customer-actions/ContractClearanceAction.tsx +++ b/apps/edr-freight-web/portal/src/components/customer-actions/ContractClearanceAction.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { Button, Modal, Text, type ButtonProps } from "@mantine/core"; -import { AlertCircle, Upload } from "lucide-react"; +import { AlertCircle, Upload, type LucideIcon } from "lucide-react"; import { api } from "@/services/api"; import { ContractClearancePanel } from "@/pages/contracts/ContractClearancePanel"; @@ -13,6 +13,10 @@ interface ContractClearanceActionProps { label?: string; size?: ButtonProps["size"]; urgent?: boolean; + /** GL's turn — render as a calm status button, not a call to action. */ + waiting?: boolean; + /** Icon override from the phase-aware action derivation. */ + icon?: LucideIcon; } export function ContractClearanceAction({ @@ -20,6 +24,8 @@ export function ContractClearanceAction({ label: labelProp, size = "xs", urgent = false, + waiting = false, + icon: iconProp, }: ContractClearanceActionProps) { const [opened, { open, close }] = useDisclosure(false); @@ -38,7 +44,13 @@ export function ContractClearanceAction({ return urgent ? "Upload clearance" : "Manage clearance"; }, [labelProp, clearance, urgent]); - const Icon = urgent || label.includes("Update") ? AlertCircle : Upload; + const Icon = + iconProp ?? (urgent || label.includes("Update") ? AlertCircle : Upload); + + // Urgent (customer's turn) = filled orange so it stands out among the green + // actions; waiting (GL's turn) = calm subtle gray; default = brand green. + const color = urgent ? "orange" : waiting ? "gray" : "edr-green"; + const variant = waiting ? "light" : "filled"; return ( @@ -47,7 +59,8 @@ export function ContractClearanceAction({ radius="md" fw={700} fz={13} - color="edr-green" + color={color} + variant={variant} leftSection={} onClick={(e) => { e.stopPropagation(); diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx index e91f897b4..9d1b6718e 100644 --- a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx +++ b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx @@ -46,6 +46,8 @@ export function ContractCustomerAction({ label={action.label} size={size} urgent={action.urgent} + waiting={action.waiting} + icon={action.icon} /> ); } diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/deriveContractCustomerAction.ts b/apps/edr-freight-web/portal/src/components/customer-actions/deriveContractCustomerAction.ts index 48fb19943..4de7d2545 100644 --- a/apps/edr-freight-web/portal/src/components/customer-actions/deriveContractCustomerAction.ts +++ b/apps/edr-freight-web/portal/src/components/customer-actions/deriveContractCustomerAction.ts @@ -4,8 +4,10 @@ import { CreditCard, Eye, FileSignature, + Hourglass, PackagePlus, PencilLine, + Receipt, RotateCcw, Upload, } from "lucide-react"; @@ -61,6 +63,8 @@ export type ContractCustomerAction = primary: boolean; icon: LucideIcon; urgent: boolean; + /** True when it's GL's turn — render calm/informational, not a call to action. */ + waiting?: boolean; } | { type: "pay"; @@ -126,14 +130,56 @@ export function deriveContractCustomerAction( const clr = contractNeedsClearanceAction(contract); if (clr.show) { - return { - type: "clearance", - contractId: id, - label: clr.urgent ? "Upload clearance" : "Update clearance", - primary: true, - icon: Upload, - urgent: clr.urgent, - }; + // Refine the generic clearance action by the persisted clearance phase so + // the button says what the customer actually has to do right now (e.g. + // "Pay duty & upload slip" during CUSTOMER_DUTY, not "Update clearance"). + const phase = contract.clearancePhase ?? null; + switch (phase) { + case "CUSTOMER_INTAKE": + return { + type: "clearance", + contractId: id, + label: "Upload clearance documents", + primary: true, + icon: Upload, + urgent: true, + }; + case "CUSTOMER_DUTY": + return { + type: "clearance", + contractId: id, + label: "Pay duty & upload slip", + primary: true, + icon: Receipt, + urgent: true, + }; + case "GL_ET_REVIEW": + case "GL_DJ_COLLECTION": + case "GL_ET_OUTPUT": + case "GL_ET_POST_CLEARANCE": + case "GL_DJ_LOADING": + case "POST_TRANSIT": + // GL's turn — nothing for the customer to do; show a calm status. + return { + type: "clearance", + contractId: id, + label: "Clearance in progress", + primary: false, + icon: Hourglass, + urgent: false, + waiting: true, + }; + default: + // No persisted phase (legacy / early cycles) — keep the status-derived label. + return { + type: "clearance", + contractId: id, + label: clr.urgent ? "Upload clearance" : "Update clearance", + primary: true, + icon: Upload, + urgent: clr.urgent, + }; + } } if ( diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts index 833aa6ce7..de4b387b1 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts @@ -6,7 +6,7 @@ import { contractNeedsClearanceAction } from "@/components/customer-actions/deri export interface ActionItem { id: string; /** What the customer must do — drives the icon, label and modal. */ - kind: "clearance" | "sign" | "book" | "pay"; + kind: "clearance" | "duty" | "sign" | "book" | "pay"; /** The contract/booking reference for display. */ reference: string; /** Short human description of the action. */ diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx index a68a72280..64f320602 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx @@ -8,6 +8,7 @@ import { FilePlus2, FileSignature, PackagePlus, + Receipt, Upload, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -34,6 +35,7 @@ const KIND_META: Record< { icon: typeof Upload; label: string; color: string } > = { clearance: { icon: Upload, label: "Clearance", color: "edr-green" }, + duty: { icon: Receipt, label: "Duty / tax", color: "orange" }, sign: { icon: FileSignature, label: "Sign", color: "blue" }, book: { icon: PackagePlus, label: "Book", color: "violet" }, pay: { icon: CreditCard, label: "Payment", color: "orange" }, @@ -96,6 +98,19 @@ export function ActionNeededSection({ const awaiting = c.status === "AWAITING_CLEARANCE_DOCUMENTS" || view?.clearanceStatus === "AWAITING_DOCUMENTS"; + // Duty phase: the customer's task is paying duty/tax and uploading the + // slip — a distinct, money action, not a generic document upload. + if (view?.phase === "CUSTOMER_DUTY") { + out.push({ + id: `duty-${c.id}`, + kind: "duty", + reference: c.reference, + description: "Duty / tax payment due — pay and upload the slip", + targetId: c.id, + urgent: true, + }); + return; + } // Only surface when there's something the customer can do: a query, or the // contract is awaiting their (re)upload. if (queried === 0 && !awaiting) return; @@ -162,6 +177,10 @@ export function ActionNeededSection({ case "clearance": setClearanceId(item.targetId); break; + case "duty": + // Duty advice + payment-slip upload live on the contract detail page. + navigate(`/contracts/${item.targetId}`); + break; case "pay": setPayItem(item); break; @@ -249,6 +268,8 @@ export function ActionNeededSection({ leftSection={ item.kind === "clearance" ? ( + ) : item.kind === "duty" ? ( + ) : ( ) @@ -256,13 +277,15 @@ export function ActionNeededSection({ > {item.kind === "pay" ? "Pay now" - : item.kind === "sign" - ? "Sign" - : item.kind === "book" - ? "Book" - : item.urgent - ? "Upload documents" - : "Upload"} + : item.kind === "duty" + ? "Pay duty & upload slip" + : item.kind === "sign" + ? "Sign" + : item.kind === "book" + ? "Book" + : item.urgent + ? "Upload documents" + : "Upload"} ); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ClearancePhaseStepper.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ClearancePhaseStepper.tsx index f9429a3e6..eb534f224 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ClearancePhaseStepper.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ClearancePhaseStepper.tsx @@ -15,6 +15,18 @@ const PHASE_LABELS: Record = { POST_TRANSIT: "Transit", }; +/** One-line hint under each phase label, for the vertical layout. */ +const PHASE_HINTS: Record = { + CUSTOMER_INTAKE: "You upload the required clearance documents", + GL_ET_REVIEW: "Global Logistics reviews your documents in Ethiopia", + GL_DJ_COLLECTION: "Delivery order collected in Djibouti", + GL_ET_OUTPUT: "Customs declaration prepared", + CUSTOMER_DUTY: "You pay the assessed duty / tax", + GL_ET_POST_CLEARANCE: "Transit cleared and paperwork finalised", + GL_DJ_LOADING: "Cargo loaded for departure", + POST_TRANSIT: "In transit", +}; + const IMPORT_PHASES = [ "CUSTOMER_INTAKE", "GL_ET_REVIEW", @@ -51,62 +63,92 @@ export function ClearancePhaseStepper({ const current = clearance?.phase ?? phases[0]; const activeIdx = phaseIndex(phases, current); + const dot = compact ? 26 : 30; + const rowGap = compact ? 18 : 24; + + // Vertical timeline: every phase is a row, so all steps stay visible on any + // width without horizontal scrolling. The connector runs down between dots. return ( - + {phases.map((phase, index) => { const isComplete = index < activeIdx; const isActive = index === activeIdx; const isLast = index === phases.length - 1; + const doneOrActive = isComplete || isActive; return ( - - - - - {isComplete ? : null} - - - {PHASE_LABELS[phase] ?? phase} - - + + {/* Dot + connector column */} + + + {isComplete ? ( + + ) : ( + + {index + 1} + + )} + {!isLast && ( )} - - + + + {/* Label + hint */} + + + {PHASE_LABELS[phase] ?? phase} + + {PHASE_HINTS[phase] && ( + + {PHASE_HINTS[phase]} + + )} + + ); })} - + ); } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx index 736cc67e4..cc5318cf9 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { Alert, Box, Button, Group, Paper, Stack, Text } from "@mantine/core"; -import { AlertTriangle, Download, Receipt, Upload } from "lucide-react"; +import { AlertTriangle, ArrowRight, Download, PackageCheck, Receipt, Upload } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import toast from "react-hot-toast"; import type { Freight } from "@edr/types"; @@ -87,7 +87,36 @@ export function ContractClearanceWorkflowBanner({ onDownload={downloadWorkflowFile} /> - {clearance.bookingReady ? ( + {clearance.linkedBookingId ? ( + }> + + + Shipment booking created + {clearance.linkedBookingReference + ? ` · ${clearance.linkedBookingReference}` + : ""} + + + Global Logistics has created your shipment booking + {clearance.linkedBookingStatus + ? ` (${clearance.linkedBookingStatus.replace(/_/g, " ").toLowerCase()})` + : ""} + . Track its progress from the booking. + + + + + ) : clearance.bookingReady ? ( Clearance is complete. Global Logistics will create your shipment booking shortly. diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx index c86e17fc5..eec7e896b 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx @@ -15,7 +15,8 @@ import { AlertBox, AsyncComboboxField, fieldStyles } from "./shared"; const CONTRACT_TYPE_OPTIONS = [ { value: "new", label: "New Contract" }, - { value: "renewal", label: "Contract Renewal" }, + // Renewal is disabled for now — not yet available to customers. + { value: "renewal", label: "Contract Renewal (coming soon)", disabled: true }, ]; type ContractForm = UseFormReturn< diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index ef893e68f..e62b410ba 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -356,6 +356,9 @@ export interface ContractClearanceView { /** Export post-booking clearance finalized after transit permit upload. */ exportClearanceFinalized?: boolean; linkedBookingId?: string | null; + /** Reference + status of the GL-created shipment booking, once it exists. */ + linkedBookingReference?: string | null; + linkedBookingStatus?: string | null; dutyAdvice?: { amount: number; currency: string; @@ -577,6 +580,12 @@ export interface IContract extends BaseEntity { status: ContractStatus; clearanceStatus: ContractClearanceStatus; clearanceCycleNumber: number; + /** + * Latest clearance cycle's current phase (list responses only). Lets list + * consumers show step-accurate customer actions without fetching the full + * clearance view per contract. + */ + clearancePhase?: ContractDocPhase | string | null; pricingBreakdown?: ContractPricingBreakdown | null; pricingDisplayMode?: "UNIT_RATES"; From 1f3368dcc3ac21738fb1cf87675cdfb033ba65ba Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 21:17:12 +0000 Subject: [PATCH 37/90] refactor: remove unused variable assignment in ClearancePhaseStepper --- .../portal/src/pages/contracts/ClearancePhaseStepper.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ClearancePhaseStepper.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ClearancePhaseStepper.tsx index eb534f224..10b64c79a 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ClearancePhaseStepper.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ClearancePhaseStepper.tsx @@ -74,7 +74,7 @@ export function ClearancePhaseStepper({ const isComplete = index < activeIdx; const isActive = index === activeIdx; const isLast = index === phases.length - 1; - const doneOrActive = isComplete || isActive; + // const doneOrActive = isComplete || isActive; return ( From 6bf8da2934a741e53464c2064b59ab8cb80216b6 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 21:21:42 +0000 Subject: [PATCH 38/90] mile --- .../last-mile/dto/allocate-containers.dto.ts | 17 -- .../modules/last-mile/last-mile.controller.ts | 10 - .../modules/last-mile/last-mile.service.ts | 112 +++-------- .../LastMileContainerAllocationTable.tsx | 186 ------------------ .../src/pages/operations/LastMilePage.tsx | 157 +++------------ 5 files changed, 51 insertions(+), 431 deletions(-) delete mode 100644 apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts delete mode 100644 apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts deleted file mode 100644 index 7fff8247e..000000000 --- a/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IsArray, IsUUID, ValidateNested } from 'class-validator'; -import { Type } from 'class-transformer'; - -export class LastMileContainerAllocationDto { - @IsUUID() - containerId!: string; - - @IsUUID() - vehicleId!: string; -} - -export class AllocateLastMileContainersDto { - @IsArray() - @ValidateNested({ each: true }) - @Type(() => LastMileContainerAllocationDto) - allocations!: LastMileContainerAllocationDto[]; -} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 3ed6a8ede..0b2ec1dbe 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -17,7 +17,6 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; -import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto'; import { SetVehiclesDto } from './dto/set-vehicles.dto'; import { SetDistancesDto } from './dto/set-distances.dto'; import { LastMileStatus } from './entities/last-mile.entity'; @@ -93,15 +92,6 @@ export class LastMileController { return this.lastMileService.remove(id); } - @Post(':id/allocate-containers') - @TrainSchedulingManage() - @ApiOperation({ summary: 'Allocate containers to vehicles' }) - async allocateContainers( - @Param('id', ParseUUIDPipe) id: string, - @Body() dto: AllocateLastMileContainersDto, - ) { - return this.lastMileService.allocateContainers(id, dto.allocations); - } @Post(':id/vehicles') @TrainSchedulingManage() diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 09db565f9..0a6aaa56e 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -243,14 +243,16 @@ export class LastMileService { return record; } - @OnEvent("lastmile.invoice.paid") + @OnEvent("last_mile.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { try { - await this.lastMileRepository.update(payload.sourceId, { paid: true } as any); - this.logger.log(`Marked last-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`); + // Invoice paid → the delivery is complete. Route through update() so it + // also frees the trucks + records history (same as "Mark Delivered"). + await this.update(payload.sourceId, { status: 'DELIVERED', paid: true } as unknown as UpdateLastMileDto); + this.logger.log(`Last-mile ${payload.sourceId} marked DELIVERED on invoice ${payload.invoiceId} payment`); } catch (err) { this.logger.error( - `Failed to update last-mile payment status for record ${payload.sourceId}: ${String(err)}`, + `Failed to deliver last-mile ${payload.sourceId} on payment: ${String(err)}`, ); } } @@ -484,6 +486,15 @@ export class LastMileService { remainingPayment?: number, ): Promise { await this.findById(id); + + // Distances are locked once the invoice exists. + const invoices = await this.billing.findBySourceIds('last_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Distances cannot be changed after the invoice is generated', + ); + } + for (const d of distances) { await this.dataSource.manager.update( LastMileVehicleAssignment, @@ -541,6 +552,14 @@ export class LastMileService { async remove(id: string): Promise { const existing = await this.findById(id); + // Can't delete once billed. + const invoices = await this.billing.findBySourceIds('last_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Cannot delete a last-mile delivery after its invoice is generated', + ); + } + // Every vehicle this delivery holds — junction + legacy + container rows. const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, { where: { lastMileId: id }, @@ -581,89 +600,4 @@ export class LastMileService { } } - async allocateContainers( - lastMileId: string, - allocations: Array<{ containerId: string; vehicleId: string }>, - ) { - const lastMile = await this.findById(lastMileId); - if (!lastMile) { - throw new NotFoundException(`Last-mile record ${lastMileId} not found`); - } - - // Capture the vehicles currently on these containers so a reallocation can - // be diffed into assigned/released history events below. - const previousAllocations = await this.dataSource.manager.find( - LastMileContainerAllocation, - { - where: { - lastMileId, - containerId: In(allocations.map((a) => a.containerId)), - }, - }, - ); - const previousVehicleIds = previousAllocations - .map((a) => a.vehicleId) - .filter((id): id is string => Boolean(id)); - - await this.dataSource.transaction(async (manager) => { - for (const allocation of allocations) { - await manager.delete(LastMileContainerAllocation, { - lastMileId, - containerId: allocation.containerId, - }); - await manager.insert(LastMileContainerAllocation, { - lastMileId, - containerId: allocation.containerId, - vehicleId: allocation.vehicleId, - containerType: 'CONTAINER', - quantity: 1, - }); - } - }); - - // Keep vehicle availability in sync: newly-allocated cars go BUSY, cars no - // longer on any of these containers are freed if unused elsewhere. - const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); - await Promise.all( - [...vehicleIds].map((id) => - this.vehiclesService.setAvailability(id, VehicleAvailability.BUSY), - ), - ); - await this.vehiclesService.releaseIfUnused( - previousVehicleIds.filter((id) => !vehicleIds.has(id)), - ); - - // History: one event per vehicle actually added or removed by this - // multi-car (re)allocation, so reassignments show on every timeline. - const prevSet = new Set(previousVehicleIds); - const bookingRef = await this.resolveBookingRef(lastMile); - for (const vehicleId of vehicleIds) { - if (prevSet.has(vehicleId)) continue; - const info = await this.vehicleInfo(vehicleId); - await this.history.record({ - eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, - vehicleId, - lastMileId, - driverId: info.driverId, - label: lastMile.status, - metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, - }); - } - for (const vehicleId of previousVehicleIds) { - if (vehicleIds.has(vehicleId)) continue; - const info = await this.vehicleInfo(vehicleId); - await this.history.record({ - eventType: FleetEventType.MILE_VEHICLE_RELEASED, - vehicleId, - lastMileId, - driverId: info.driverId, - metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, - }); - } - - return { - success: true, - allocated: allocations.length, - }; - } } diff --git a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx deleted file mode 100644 index 02b62ec4c..000000000 --- a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { useState, useMemo } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { - Box, - Button, - Group, - Loader, - Select, - Stack, - Table, - Text, - Alert, -} from "@mantine/core"; -import { AlertCircle } from "lucide-react"; -import toast from "react-hot-toast"; - -import { vehiclesService } from "@/services/vehicles.service"; - -export interface LastMileContainerRow { - id: string; - type: string; - qty: number; -} - -/** One vehicle (with trailer) carries at most this many containers. */ -const CONTAINERS_PER_VEHICLE = 2; - -export interface LastMileContainerAllocationTableProps { - containers: LastMileContainerRow[]; - onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; -} - -/** - * Manual container-to-vehicle allocation table for last-mile deliveries. - * Displays containers with type/qty, vehicle dropdown per row, and save action. - */ -export function LastMileContainerAllocationTable({ - containers, - onSave, -}: LastMileContainerAllocationTableProps) { - const [allocations, setAllocations] = useState>( - () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), - ); - - const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "free"], - queryFn: () => - vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }).then((r) => r.data), - }); - - const vehicleOptions = useMemo( - () => - vehicles.map((v) => ({ - value: v.id, - label: `${v.plateNumber} (${v.vehicleType})`, - description: `${v.model} · ${v.manufacturer}`, - })), - [vehicles], - ); - - const saveAllocation = useMutation({ - mutationFn: async () => { - const mappings = containers - .filter((c) => allocations[c.id]) - .map((c) => ({ - containerId: c.id, - vehicleId: allocations[c.id]!, - })); - - if (mappings.length === 0) { - throw new Error("No containers allocated to vehicles"); - } - - await onSave(mappings); - }, - onSuccess: () => { - toast.success("Container allocations saved"); - setAllocations( - containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), - ); - }, - onError: (error) => { - toast.error( - error instanceof Error ? error.message : "Failed to save allocations", - ); - }, - }); - - const allocatedCount = Object.values(allocations).filter(Boolean).length; - - // Containers already loaded onto each vehicle, to enforce the 2-per-vehicle cap. - const loadByVehicle = useMemo(() => { - const map: Record = {}; - for (const c of containers) { - const v = allocations[c.id]; - if (v) map[v] = (map[v] ?? 0) + (c.qty || 1); - } - return map; - }, [allocations, containers]); - - /** Options for a given row: a vehicle is disabled if assigning this container - * to it would exceed its 2-container capacity. */ - const optionsForRow = (row: LastMileContainerRow) => - vehicleOptions.map((o) => { - const already = loadByVehicle[o.value] ?? 0; - const selfHere = allocations[row.id] === o.value ? row.qty || 1 : 0; - const over = already - selfHere + (row.qty || 1) > CONTAINERS_PER_VEHICLE; - return { ...o, disabled: over }; - }); - - if (vehiclesLoading) { - return ( - - - - ); - } - - return ( - - {vehicles.length === 0 && ( - } color="yellow"> - No free vehicles available. Free up or add vehicles before allocating containers. - - )} - - - - - - Container ID - Type - Qty - Assigned Vehicle - - - - {containers.map((container) => ( - - - - {container.id} - - - {container.type} - {container.qty} - -
-
- - - - {allocatedCount} of {containers.length} containers allocated · max{" "} - {CONTAINERS_PER_VEHICLE} per vehicle - - - -
- ); -} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 19a8fa290..062577977 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1,7 +1,6 @@ import { type ReactNode, useMemo, useState } from "react"; import { ArrowRight, - Boxes, Eye, MoreHorizontal, Plus, @@ -55,10 +54,8 @@ import { import { vehiclesService } from "@/services/vehicles.service"; import { driversService, type Driver } from "@/services/drivers.service"; import { ratesService } from "@/services/rates.service"; -import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable"; import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal"; import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; -import { api } from "@/auth/http"; const formatPrice = (amount: number) => `ETB ${amount.toLocaleString("en-US", { @@ -118,19 +115,6 @@ const bookingContainerNumbers = (record: LastMileRecord): string[] => .map((c) => c.containerNumber) .filter((n): n is string => Boolean(n)); -/** Container rows for the per-container→vehicle allocation table. */ -const allocationRowsFor = (record: LastMileRecord): LastMileContainerRow[] => - (record.booking?.bookingContainers ?? []).map((c) => ({ - id: c.id, - type: - c.containerNumber ?? - c.containerType?.code ?? - c.containerType?.label ?? - c.containerType?.name ?? - (c.containerSize || "Container"), - qty: c.quantity || 1, - })); - /** Container badges for a booking: the container number when known, else the * type × quantity. */ const containerLabels = (record: LastMileRecord): string[] => { @@ -500,8 +484,6 @@ const LastMilePage = () => { // Record pending invoice-generation confirmation (shows a summary first). const [invoiceConfirm, setInvoiceConfirm] = useState(null); - const [allocationOpen, setAllocationOpen] = useState(false); - const [allocationContainers, setAllocationContainers] = useState([]); const [releaseItem, setReleaseItem] = useState(null); const [releaseTruckPrefill, setReleaseTruckPrefill] = useState(null); @@ -639,19 +621,6 @@ const LastMilePage = () => { }, }); - const allocateMutation = useMutation({ - mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) => - api.post(`/last-mile/${activeId}/allocate-containers`, { allocations: data }), - onSuccess: () => { - toast({ title: "Containers allocated", variant: "default" }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); - void qc.invalidateQueries({ queryKey: ["vehicles"] }); - closeAllocation(); - }, - onError: () => { - toast({ title: "Allocation failed", variant: "destructive" }); - }, - }); const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({ queryKey: ["warehouse-inventory", "arrival-queue"], @@ -744,17 +713,6 @@ const LastMilePage = () => { }; - const openAllocation = (id: string, containers?: LastMileContainerRow[]) => { - setActiveId(id); - setAllocationContainers(containers ?? []); - setAllocationOpen(true); - }; - - const closeAllocation = () => { - setAllocationOpen(false); - setActiveId(null); - setAllocationContainers([]); - }; const handleSaveDistance = () => { const distances = Object.entries(distanceRows) @@ -984,6 +942,15 @@ const LastMilePage = () => { setReleaseItem(toReleaseInventoryItem(row)); }; + // Truck leaving the warehouse = the leg is now in transit. Advance the status + // (same as "Mark In Transit") alongside the warehouse exit-weighing flow. + const handleTruckLeaving = (record: LastMileRecord) => { + openTruckArrival(record); + if (record.status === "READY_TO_TRANSIT") { + updateMutation.mutate({ id: record.id, data: { status: "IN_TRANSIT" } }); + } + }; + const closeTruckArrival = () => { setReleaseItem(null); setReleaseTruckPrefill(null); @@ -1089,7 +1056,11 @@ const LastMilePage = () => { return ( navigate(`/dashboard/invoices/${invoice.id}`)} + onClick={() => + invoice.id + ? navigate(`/dashboard/invoices/${invoice.id}`) + : toast({ title: "Invoice link unavailable", description: "Refresh after the API restart.", variant: "destructive" }) + } c="blue" fw={500} style={{ textDecoration: "underline", cursor: "pointer" }} @@ -1135,11 +1106,12 @@ const LastMilePage = () => { (status === "IN_TRANSIT" && hasDistance); const canAssignStep = !assigned && status !== "DELIVERED"; const canDistance = status === "IN_TRANSIT"; - // Truck arrival/leaving are independent — each driven only by its own - // warehouse state: arrive once assigned & not arrived, leave once - // arrived & not departed. - const canArrive = assigned && !releaseRow?.releaseOrderReference; - const canLeave = Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate; + // Truck arrival/leaving are independent — each driven by its own + // warehouse state — but both are done once the leg is IN_TRANSIT/DELIVERED. + const pastTransit = status === "IN_TRANSIT" || status === "DELIVERED"; + const canArrive = assigned && !releaseRow?.releaseOrderReference && !pastTransit; + const canLeave = + Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate && !pastTransit; return ( @@ -1189,13 +1161,6 @@ const LastMilePage = () => { > Unassign - } - disabled={allocationRowsFor(row.original).length === 0 || delivered} - onClick={() => openAllocation(row.original.id, allocationRowsFor(row.original))} - > - Allocate to trucks - } disabled={!canArrive} @@ -1206,7 +1171,7 @@ const LastMilePage = () => { } disabled={!canLeave} - onClick={() => openTruckArrival(row.original)} + onClick={() => handleTruckLeaving(row.original)} > Truck Leaving @@ -1219,17 +1184,20 @@ const LastMilePage = () => { } - disabled={!canDistance} + disabled={!canDistance || Boolean(row.original.invoice)} onClick={() => openDistance(row.original.id)} > Add distance } - disabled={!(row.original.exactKm != null && row.original.exactKm > 0)} + disabled={ + !(row.original.exactKm != null && row.original.exactKm > 0) || + Boolean(row.original.invoice) + } onClick={() => setInvoiceConfirm(row.original)} > - Generate Invoice + {row.original.invoice ? "Invoice generated" : "Generate Invoice"} {canPrint && ( { } color="red" - disabled={delivered} + disabled={delivered || Boolean(row.original.invoice)} onClick={() => { if (confirm(`Delete last-mile record ${bookingRef(row.original)}?`)) { deleteMutation.mutate(row.original.id); @@ -1829,75 +1797,6 @@ const LastMilePage = () => { )} - {/* Container Allocation modal */} - Allocate Containers to Vehicles} - size="xl" - radius="lg" - centered - > - - {activeRecord && ( - <> - - - - - {bookingRef(activeRecord)} - {customerName(activeRecord)} - - - Cargo Type - {activeRecord.booking?.cargoType?.label ?? activeRecord.booking?.cargoType?.name ?? "—"} - - - - - - {/* Capacity logic based on cargo type */} - {activeRecord.booking?.cargoType?.name === "BULK" ? ( - - - - Smart Capacity Allocation - - - Capacity: TBD - - TODO: add vehicle capacity_tons to vehicle API if missing - - - TODO: add container weight to booking if missing - - - - Select multiple containers per vehicle based on capacity - - - - ) : ( - - Up to 2 containers per vehicle (trailer) - - )} - - )} - - { - await allocateMutation.mutateAsync(mappings); - }} - /> - - - - - - - Date: Fri, 3 Jul 2026 21:25:49 +0000 Subject: [PATCH 39/90] mile --- .../last-mile/entities/last-mile-container-allocation.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts index 8a61c73bf..187d9aea1 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts @@ -24,7 +24,7 @@ export class LastMileContainerAllocation extends BaseEntity { @Column('uuid', { name: 'vehicle_id', nullable: true }) vehicleId?: string | null; - @Column('text') + @Column('text', { name: 'container_type' }) containerType!: string; @Column('integer', { default: 1 }) From 9422e5588e8259023b0809f7bdd27417a2c07954 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 21:45:54 +0000 Subject: [PATCH 40/90] mile --- .../modules/last-mile/last-mile.service.ts | 32 ++-- .../src/pages/operations/LastMilePage.tsx | 175 ++++++++++++++---- 2 files changed, 158 insertions(+), 49 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 0a6aaa56e..a42ce289a 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -365,20 +365,28 @@ export class LastMileService { } /** - * Free every vehicle held by this record (direct assignment + container - * allocations), unless still in use by another active trip. + * Free every vehicle held by this record — junction assignments, the legacy + * direct vehicle, and container allocations — unless still used by another + * active trip. */ private async releaseVehicles(record: LastMile): Promise { - const recordAllocations = await this.dataSource.manager.find( - LastMileContainerAllocation, - { where: { lastMileId: record.id } }, - ); - const vehicleIds = recordAllocations - .map((a) => a.vehicleId) - .filter((id): id is string => Boolean(id)); - if (record.vehicleId) { - vehicleIds.push(record.vehicleId); - } + const [assignments, recordAllocations] = await Promise.all([ + this.dataSource.manager.find(LastMileVehicleAssignment, { + where: { lastMileId: record.id }, + }), + this.dataSource.manager.find(LastMileContainerAllocation, { + where: { lastMileId: record.id }, + }), + ]); + const vehicleIds = [ + ...new Set( + [ + ...assignments.map((a) => a.vehicleId), + ...recordAllocations.map((a) => a.vehicleId), + record.vehicleId ?? null, + ].filter((id): id is string => Boolean(id)), + ), + ]; await this.vehiclesService.releaseIfUnused(vehicleIds); } diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 062577977..c3c59ca58 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -36,6 +36,7 @@ import { Stack, Text, TextInput, + Tooltip, UnstyledButton, } from "@mantine/core"; import type { ArrivalQueueItem, ImportUnloadedItem, WarehouseInventoryItem } from "@/types/warehouse"; @@ -306,21 +307,43 @@ const BookingInfo = ({ record }: { record: LastMileRecord }) => { ); }; -const tripSlipRows = (record: LastMileRecord): [string, string][] => [ - ["Customer", customerName(record)], - ["Service", serviceTypeName(record)], - ["Pickup (origin yard)", originYardName(record)], - ["Destination", deliveryLocation(record)], - ["Cargo", cargoDesc(record)], - ["Advanced Payment", formatPrice(record.advancedPayment)], - ["Post Payment", formatPrice(record.remainingPayment)], - ["Vehicle", vehicleLabel(record) ?? "Unassigned"], - ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], - ["Requested date", requestedDate(record)], - ["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"], - ["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"], - ["Status", STATUS_META[record.status].label], -]; +type TripSlipVehicle = NonNullable[number]; + +const tripSlipRows = ( + record: LastMileRecord, + vehicle?: TripSlipVehicle | null, +): [string, string][] => { + // Per-vehicle block when a specific truck is chosen (its own driver, container(s) + // and distance); else fall back to the record-level vehicle summary. + const vehicleRows: [string, string][] = vehicle + ? [ + [ + "Vehicle", + vehicle.vehicle + ? [vehicle.vehicle.code, vehicle.vehicle.plateNumber].filter(Boolean).join(" · ") + : vehicle.vehicleId, + ], + ["Driver", vehicle.vehicle?.assignedDriverName || "—"], + ["Container(s)", vehicle.containerNumber || "—"], + ["Distance (KM)", vehicle.distanceKm != null ? String(vehicle.distanceKm) : "—"], + ] + : [ + ["Vehicle", vehicleLabel(record) ?? "Unassigned"], + ["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"], + ]; + return [ + ["Customer", customerName(record)], + ["Service", serviceTypeName(record)], + ["Pickup (origin yard)", originYardName(record)], + ["Destination", deliveryLocation(record)], + ["Cargo", cargoDesc(record)], + ["Post Payment", formatPrice(record.remainingPayment)], + ...vehicleRows, + ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], + ["Requested date", requestedDate(record)], + ["Status", STATUS_META[record.status].label], + ]; +}; const SampleStamp = () => ( @@ -378,7 +401,13 @@ const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) ); -const TripSlipDocument = ({ record }: { record: LastMileRecord }) => ( +const TripSlipDocument = ({ + record, + vehicle, +}: { + record: LastMileRecord; + vehicle?: TripSlipVehicle | null; +}) => ( EDR Freight @@ -390,7 +419,7 @@ const TripSlipDocument = ({ record }: { record: LastMileRecord }) => ( - {tripSlipRows(record).map(([label, value]) => ( + {tripSlipRows(record, vehicle).map(([label, value]) => ( ))} @@ -405,8 +434,8 @@ const TripSlipDocument = ({ record }: { record: LastMileRecord }) => ( const escapeHtml = (v: string) => v.replace(/&/g, "&").replace(//g, ">"); -const buildTripSlipHtml = (record: LastMileRecord) => { - const rows = tripSlipRows(record) +const buildTripSlipHtml = (record: LastMileRecord, vehicle?: TripSlipVehicle | null) => { + const rows = tripSlipRows(record, vehicle) .map(([l, v]) => `${escapeHtml(l)}${escapeHtml(v)}`) .join(""); const sig = (title: string, withStamp: boolean) => ` @@ -465,6 +494,9 @@ const LastMilePage = () => { const [detailOpen, setDetailOpen] = useState(false); const [tripSlipOpen, setTripSlipOpen] = useState(false); const [tripSlipRecord, setTripSlipRecord] = useState(null); + // Which vehicle the trip slip is for (per-truck), + the pre-print picker. + const [tripSlipVehicleId, setTripSlipVehicleId] = useState(null); + const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false); const [activeId, setActiveId] = useState(null); // Multi-vehicle assign: one row per truck — vehicle + the container it carries. const [vehicleRows, setVehicleRows] = useState< @@ -915,6 +947,20 @@ const LastMilePage = () => { const handlePrintTripSlip = (record: LastMileRecord) => { setTripSlipRecord(record); + const assigns = record.vehicleAssignments ?? []; + if (assigns.length > 1) { + // Multiple trucks → let the operator pick which one to print. + setTripSlipVehicleId(null); + setTripSlipSelectOpen(true); + } else { + setTripSlipVehicleId(assigns[0]?.vehicleId ?? null); + setTripSlipOpen(true); + } + }; + + const chooseTripSlipVehicle = (vehicleId: string) => { + setTripSlipVehicleId(vehicleId); + setTripSlipSelectOpen(false); setTripSlipOpen(true); }; @@ -958,6 +1004,9 @@ const LastMilePage = () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); }; + const tripSlipVehicle = + tripSlipRecord?.vehicleAssignments?.find((a) => a.vehicleId === tripSlipVehicleId) ?? null; + const printTripSlip = () => { if (!tripSlipRecord) return; const win = window.open("", "_blank", "width=820,height=920"); @@ -965,7 +1014,7 @@ const LastMilePage = () => { toast({ title: "Pop-up blocked", description: "Allow pop-ups to print the trip slip.", variant: "destructive" }); return; } - win.document.write(buildTripSlipHtml(tripSlipRecord)); + win.document.write(buildTripSlipHtml(tripSlipRecord, tripSlipVehicle)); win.document.close(); }; @@ -1019,17 +1068,32 @@ const LastMilePage = () => { cell: ({ row }) => { const assigns = row.original.vehicleAssignments ?? []; if (assigns.length > 1) { - const first = assigns[0]?.vehicle; - const firstLabel = first - ? [first.code, first.plateNumber].filter(Boolean).join(" · ") - : "Vehicle"; + const labelFor = (a: (typeof assigns)[number]) => { + const v = a.vehicle; + const l = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return a.containerNumber ? `${l} · ${a.containerNumber}` : l; + }; return ( - - {firstLabel} - - +{assigns.length - 1} - - + + {assigns.map(labelFor).join("\n")} + + } + > + + + {assigns[0].vehicle + ? [assigns[0].vehicle.code, assigns[0].vehicle.plateNumber].filter(Boolean).join(" · ") + : assigns[0].vehicleId} + + + +{assigns.length - 1} + + + ); } return vehicleLabel(row.original) ?? Unassigned; @@ -1114,7 +1178,12 @@ const LastMilePage = () => { Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate && !pastTransit; return ( - + @@ -1661,7 +1730,7 @@ const LastMilePage = () => { centered > - {tripSlipRecord && } + {tripSlipRecord && } @@ -1670,6 +1739,42 @@ const LastMilePage = () => { + {/* Trip slip — pick a vehicle (multi-truck) */} + setTripSlipSelectOpen(false)} + title={Print trip slip — select vehicle} + radius="lg" + centered + > + + + {tripSlipRecord ? bookingRef(tripSlipRecord) : ""} has multiple trucks — choose one. + + {(tripSlipRecord?.vehicleAssignments ?? []).map((a) => { + const v = a.vehicle; + const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return ( + + ); + })} + + + {/* Add Actual Distance modal */} { loading={generateInvoiceMutation.isPending} onClick={() => generateInvoiceMutation.mutate(invoiceConfirm.id, { - onSuccess: (res) => { - setInvoiceConfirm(null); - const invoiceId = res?.data?.id; - if (invoiceId) navigate(`/dashboard/invoices/${invoiceId}`); - }, + onSuccess: () => setInvoiceConfirm(null), }) } > From cad4b84b8c8c3f4905103c0e16ff26031756899f Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 21:57:53 +0000 Subject: [PATCH 41/90] mile --- .../src/pages/operations/LastMilePage.tsx | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index c3c59ca58..f3f290304 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -324,7 +324,10 @@ const tripSlipRows = ( : vehicle.vehicleId, ], ["Driver", vehicle.vehicle?.assignedDriverName || "—"], - ["Container(s)", vehicle.containerNumber || "—"], + [ + "Container(s)", + vehicle.containerNumber || bookingContainerNumbers(record).join(", ") || "—", + ], ["Distance (KM)", vehicle.distanceKm != null ? String(vehicle.distanceKm) : "—"], ] : [ @@ -469,7 +472,7 @@ const buildTripSlipHtml = (record: LastMileRecord, vehicle?: TripSlipVehicle | n .ring-inner span { font-size: 9px; font-weight: 700; letter-spacing: 1px; } .ring-inner strong { font-size: 13px; font-weight: 800; } - +

EDR Freight

Last Mile Trip Slip

${escapeHtml(bookingRef(record))}${escapeHtml(requestedDate(record))}
${rows}
@@ -946,16 +949,16 @@ const LastMilePage = () => { }; const handlePrintTripSlip = (record: LastMileRecord) => { + // Always open the picker so the operator chooses which truck to print. setTripSlipRecord(record); - const assigns = record.vehicleAssignments ?? []; - if (assigns.length > 1) { - // Multiple trucks → let the operator pick which one to print. - setTripSlipVehicleId(null); - setTripSlipSelectOpen(true); - } else { - setTripSlipVehicleId(assigns[0]?.vehicleId ?? null); - setTripSlipOpen(true); - } + setTripSlipVehicleId(null); + setTripSlipSelectOpen(true); + }; + + const printBookingSlip = () => { + setTripSlipVehicleId(null); + setTripSlipSelectOpen(false); + setTripSlipOpen(true); }; const chooseTripSlipVehicle = (vehicleId: string) => { @@ -1016,6 +1019,15 @@ const LastMilePage = () => { } win.document.write(buildTripSlipHtml(tripSlipRecord, tripSlipVehicle)); win.document.close(); + win.focus(); + // Explicit print after the doc paints (onload can miss with document.write). + setTimeout(() => { + try { + win.print(); + } catch { + /* window may have been closed */ + } + }, 250); }; const columns = useMemo((): ColumnDef[] => { @@ -1749,7 +1761,8 @@ const LastMilePage = () => { > - {tripSlipRecord ? bookingRef(tripSlipRecord) : ""} has multiple trucks — choose one. + {tripSlipRecord ? bookingRef(tripSlipRecord) : ""} — choose a truck to print its slip + (vehicle, driver, container). {(tripSlipRecord?.vehicleAssignments ?? []).map((a) => { const v = a.vehicle; @@ -1772,6 +1785,12 @@ const LastMilePage = () => { ); })} + {(tripSlipRecord?.vehicleAssignments?.length ?? 0) === 0 && ( + No vehicles assigned yet. + )} +
From 1d8f09583167f8c742da585605046e58b8268f35 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 22:43:45 +0000 Subject: [PATCH 42/90] fix --- .../first-mile/dto/allocate-containers.dto.ts | 8 - .../first-mile/first-mile.controller.ts | 59 +---- .../modules/first-mile/first-mile.service.ts | 117 +++------ .../FirstMileContainerAllocationTable.tsx | 164 ------------ .../src/pages/operations/FirstMilePage.tsx | 240 +++--------------- .../src/pages/operations/LastMilePage.tsx | 44 ++-- .../src/services/first-mile.service.ts | 4 + .../backoffice/src/types/booking.ts | 2 +- 8 files changed, 120 insertions(+), 518 deletions(-) delete mode 100644 apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts delete mode 100644 apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts deleted file mode 100644 index b750f1147..000000000 --- a/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -export class FirstMileContainerAllocationDto { - containerId!: string; - vehicleId!: string; -} - -export class AllocateFirstMileContainersDto { - allocations!: FirstMileContainerAllocationDto[]; -} diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 444cbee87..928882a7f 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -17,13 +17,9 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; -import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto'; import { FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileService } from './first-mile.service'; import { FirstMileInvoiceService } from './first-mile-invoice.service'; -import { BillingService } from '../billing/billing.service'; -import { BookingsService } from '../bookings/bookings.service'; -import { Freight } from '@edr/types'; @ApiTags('first-mile') @ApiBearerAuth() @@ -33,8 +29,6 @@ export class FirstMileController { constructor( private readonly firstMileService: FirstMileService, private readonly firstMileInvoiceService: FirstMileInvoiceService, - private readonly billingService: BillingService, - private readonly bookingsService: BookingsService ) { } @Get() @@ -89,40 +83,17 @@ export class FirstMileController { @TrainSchedulingManage() @ApiOperation({ summary: 'Update a first-mile leg' }) async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) { - const record = await this.firstMileService.update(id, dto); - // Auto-generate invoice if distance or payment was updated - const booking = await this.bookingsService.findById(record.bookingId); - const currency = booking.paymentCurrency || "ETB"; - if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { - await this.billingService.generateInvoice({ - source: Freight.InvoiceSource.FirstMile, - sourceId: record.id, - type: "FIRST_MILE", - companyId: booking.companyId, - companyProfileId: booking.companyProfileId, - currency, + // No invoice side-effects — invoices are generated only via the explicit + // POST :id/invoice endpoint (the "Generate Invoice" action). + return this.firstMileService.update(id, dto); + } - lines: [ - { - chargeType: "FIRST_MILE", - description: "First Mile Transportation Service", - quantity: 1, - unitRate: record.remainingPayment, - amount: record.remainingPayment, - currency, - }, - ], - - subtotalAmount: record.remainingPayment, - taxAmount: 0, // Replace if VAT/tax applies - totalAmount: record.remainingPayment, - - dueInDays: 7, - status: Freight.InvoiceStatus.Pending, - }); - await this.firstMileInvoiceService.ensureInvoiceFor(record); - } - return record; + @Post(':id/invoice') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Generate the first-mile delivery-fee invoice' }) + async generateInvoice(@Param('id', ParseUUIDPipe) id: string) { + const record = await this.firstMileService.findById(id); + return this.firstMileInvoiceService.ensureInvoiceFor(record); } @Delete(':id') @@ -132,14 +103,4 @@ export class FirstMileController { remove(@Param('id', ParseUUIDPipe) id: string) { return this.firstMileService.remove(id); } - - @Post(':firstMileId/allocate-containers') - @TrainSchedulingManage() - @ApiOperation({ summary: 'Allocate containers to vehicles for a first-mile leg' }) - allocateContainers( - @Param('firstMileId', ParseUUIDPipe) firstMileId: string, - @Body() dto: AllocateFirstMileContainersDto, - ) { - return this.firstMileService.allocateContainers(firstMileId, dto.allocations); - } } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 1a7db810d..dba5e48c7 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; +import { FindOptionsWhere, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; @@ -13,7 +13,7 @@ import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity"; import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity"; import { FirstMileRepository } from "./first-mile.repository"; import { OnEvent } from "@nestjs/event-emitter"; -import { InvoiceEventPayload } from "../billing/billing.service"; +import { BillingService, InvoiceEventPayload } from "../billing/billing.service"; import { FleetHistoryService } from "../fleet-history/fleet-history.service"; import { FleetEventType } from "../fleet-history/entities/fleet-event.entity"; @@ -46,8 +46,27 @@ export class FirstMileService { private readonly driversService: DriversService, private readonly smsClient: SmsClientService, private readonly history: FleetHistoryService, + private readonly billing: BillingService, ) { } + /** Attach real invoice info so the UI shows an invoice link only when one + * exists — not merely because distance was entered. Batched (no N+1). */ + private async attachInvoices(records: FirstMile[]): Promise { + const invoices = await this.billing.findBySourceIds( + 'first_mile', + records.map((r) => r.id), + ); + const byId = new Map(); + for (const inv of invoices) { + if (!byId.has(inv.sourceId)) { + byId.set(inv.sourceId, { id: inv.id, number: inv.invoiceNumber, status: String(inv.status) }); + } + } + for (const r of records) { + (r as FirstMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; + } + } + /** Resolve a vehicle's driver + human labels, for stamping mile events onto * the driver's timeline and naming the vehicle. Best-effort — never throws. */ private async vehicleInfo( @@ -191,6 +210,8 @@ export class FirstMileService { take: pageSize, }); + await this.attachInvoices(data); + return { data, meta: { @@ -236,6 +257,8 @@ export class FirstMileService { throw new NotFoundException(`First-mile record ${id} not found`); } + await this.attachInvoices([record]); + return record; } @@ -537,84 +560,18 @@ export class FirstMileService { } async remove(id: string): Promise { - await this.findById(id); + const existing = await this.findById(id); + + // Can't delete once billed. + const invoices = await this.billing.findBySourceIds('first_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Cannot delete a first-mile leg after its invoice is generated', + ); + } + await this.firstMileRepository.softDelete(id); - } - - async allocateContainers( - firstMileId: string, - allocations: Array<{ containerId: string; vehicleId: string }>, - ) { - const firstMile = await this.findById(firstMileId); - if (!firstMile) { - throw new NotFoundException(`First-mile record ${firstMileId} not found`); - } - - const previousAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, { - where: { - firstMileId, - containerId: In(allocations.map((a) => a.containerId)), - }, - }); - const previousVehicleIds = previousAllocations - .map((a) => a.vehicleId) - .filter((id): id is string => Boolean(id)); - - await this.dataSource.transaction(async (manager) => { - for (const allocation of allocations) { - await manager.delete(FirstMileContainerAllocation, { - firstMileId, - containerId: allocation.containerId, - }); - await manager.insert(FirstMileContainerAllocation, { - firstMileId, - containerId: allocation.containerId, - vehicleId: allocation.vehicleId, - containerType: "CONTAINER", - quantity: 1, - }); - } - }); - - const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); - await Promise.all( - [...vehicleIds].map((vehicleId) => this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY)), - ); - await this.vehiclesService.releaseIfUnused( - previousVehicleIds.filter((id) => !vehicleIds.has(id)), - ); - - // History: one event per vehicle actually added or removed by this - // multi-car (re)allocation, so reassignments show on every timeline. - const prevSet = new Set(previousVehicleIds); - const bookingRef = await this.resolveBookingRef(firstMile); - for (const vehicleId of vehicleIds) { - if (prevSet.has(vehicleId)) continue; - const info = await this.vehicleInfo(vehicleId); - await this.history.record({ - eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, - vehicleId, - firstMileId, - driverId: info.driverId, - label: firstMile.status, - metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, - }); - } - for (const vehicleId of previousVehicleIds) { - if (vehicleIds.has(vehicleId)) continue; - const info = await this.vehicleInfo(vehicleId); - await this.history.record({ - eventType: FleetEventType.MILE_VEHICLE_RELEASED, - vehicleId, - firstMileId, - driverId: info.driverId, - metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, - }); - } - - return { - success: true, - allocated: allocations.length, - }; + // Free the trucks it was holding (direct + container), unless still in use. + await this.releaseVehicles(existing); } } diff --git a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx deleted file mode 100644 index 78c5160ca..000000000 --- a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useState, useMemo } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { - Box, - Button, - Group, - Loader, - Select, - Stack, - Table, - Text, - Alert, -} from "@mantine/core"; -import { AlertCircle } from "lucide-react"; -import toast from "react-hot-toast"; - -import { vehiclesService } from "@/services/vehicles.service"; - -export interface ContainerAllocationRow { - id: string; - type: string; - qty: number; -} - -export interface FirstMileContainerAllocationTableProps { - firstMileId: string; - containers: ContainerAllocationRow[]; - onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; -} - -/** - * Manual container-to-vehicle allocation table for first-mile pickups. - * Displays containers with type/qty, vehicle dropdown per row, and save action. - */ -export function FirstMileContainerAllocationTable({ - firstMileId, - containers, - onSave, -}: FirstMileContainerAllocationTableProps) { - const [allocations, setAllocations] = useState>( - () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), - ); - - const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "free"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), - }); - - const vehicleOptions = useMemo( - () => - vehicles.map((v) => ({ - value: v.id, - label: `${v.plateNumber} (${v.vehicleType})`, - description: `${v.model} · ${v.manufacturer}`, - })), - [vehicles], - ); - - const saveAllocation = useMutation({ - mutationFn: async () => { - const mappings = containers - .filter((c) => allocations[c.id]) - .map((c) => ({ - containerId: c.id, - vehicleId: allocations[c.id]!, - })); - - if (mappings.length === 0) { - throw new Error("No containers allocated to vehicles"); - } - - await onSave(mappings); - }, - onSuccess: () => { - toast.success("Container allocations saved"); - setAllocations( - containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), - ); - }, - onError: (error) => { - toast.error( - error instanceof Error ? error.message : "Failed to save allocations", - ); - }, - }); - - const allocatedCount = Object.values(allocations).filter(Boolean).length; - const allAllocated = allocatedCount === containers.length; - - if (vehiclesLoading) { - return ( - - - - ); - } - - return ( - - {vehicles.length === 0 && ( - } color="yellow"> - No free vehicles available. Free up or add vehicles before allocating containers. - - )} - - - - - - Container ID - Type - Qty - Assigned Vehicle - - - - {containers.map((container) => ( - - - - {container.id} - - - {container.type} - {container.qty} - -
-
- - - - {allocatedCount} of {containers.length} containers allocated - - - -
- ); -} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 5f1790f42..182ff7cac 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -13,6 +13,7 @@ import { Truck, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { @@ -33,11 +34,9 @@ import { Text, TextInput, UnstyledButton, - Alert, } from "@mantine/core"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; -import { FirstMileContainerAllocationTable } from "@/components/FirstMileContainerAllocationTable"; import { ReceiveInventoryModal } from "@/components/warehouses/ReceiveInventoryModal"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { useToast } from "@/hooks/use-toast"; @@ -50,7 +49,6 @@ import { import { bookingsService } from "@/services/bookings.service"; import { vehiclesService } from "@/services/vehicles.service"; import { ratesService } from "@/services/rates.service"; -import { api } from "@/auth/http"; import type { BookingDetail } from "@/types/booking"; const formatPrice = (amount: number) => @@ -320,6 +318,7 @@ const buildTripSlipHtml = (record: FirstMileRecord) => { const FirstMilePage = () => { const { toast } = useToast(); const qc = useQueryClient(); + const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [search, setSearch] = useState(""); @@ -343,13 +342,9 @@ const FirstMilePage = () => { const [distanceOpen, setDistanceOpen] = useState(false); const [distanceValue, setDistanceValue] = useState(""); - const [invoiceOpen, setInvoiceOpen] = useState(false); - const [invoiceRecord, setInvoiceRecord] = useState(null); const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false); const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState(null); - const [containerAllocationOpen, setContainerAllocationOpen] = useState(false); - const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = useState(null); const { data: listData, isLoading } = useQuery({ queryKey: QUERY_KEYS.FIRST_MILE.list(), @@ -438,6 +433,17 @@ const FirstMilePage = () => { }, }); + const generateInvoiceMutation = useMutation({ + mutationFn: (id: string) => firstMileService.generateInvoice(id), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); + toast({ title: "Invoice generated" }); + }, + onError: () => { + toast({ title: "Invoice generation failed", variant: "destructive" }); + }, + }); + const acceptMutation = useMutation({ mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => { const res = await firstMileService.accept(reference); @@ -462,20 +468,6 @@ const FirstMilePage = () => { }, }); - const allocateMutation = useMutation({ - mutationFn: (data) => api.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data), - onSuccess: () => { - toast({ title: "Containers allocated" }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.byId(containerAllocationFirstMileId ?? "") }); - void qc.invalidateQueries({ queryKey: ["vehicles"] }); - setContainerAllocationOpen(false); - setContainerAllocationFirstMileId(null); - }, - onError: () => { - toast({ title: "Allocation failed", variant: "destructive" }); - }, - }); - const activeRecord = useMemo( () => records.find((r) => r.id === activeId) ?? null, [records, activeId], @@ -545,15 +537,6 @@ const FirstMilePage = () => { setDistanceValue(""); }; - const openInvoice = (record: FirstMileRecord) => { - setInvoiceRecord(record); - setInvoiceOpen(true); - }; - - const closeInvoice = () => { - setInvoiceOpen(false); - setInvoiceRecord(null); - }; const openWarehouseReceive = (record: FirstMileRecord) => { setWarehouseReceiveRecord(record); @@ -565,16 +548,6 @@ const FirstMilePage = () => { setWarehouseReceiveRecord(null); }; - const openContainerAllocation = (firstMileId: string) => { - setContainerAllocationFirstMileId(firstMileId); - setContainerAllocationOpen(true); - }; - - const closeContainerAllocation = () => { - setContainerAllocationOpen(false); - setContainerAllocationFirstMileId(null); - }; - const handleSaveDistance = () => { const distance = parseFloat(distanceValue); if (!activeId || isNaN(distance) || distance < 0) { @@ -779,35 +752,28 @@ const FirstMilePage = () => { header: "Invoice", meta: { headerClassName, cellClassName }, cell: ({ row }) => { - const hasDistance = row.original.exactKm != null && row.original.exactKm > 0; - const isPaid = (row.original as any).paid; - if (!hasDistance) { + // Only show once actually generated — not merely on distance. + const invoice = row.original.invoice; + if (!invoice) { return ; } - if (isPaid) { - return ( - - openInvoice(row.original)} - c="blue" - fw={500} - style={{ textDecoration: "underline", cursor: "pointer" }} - > - #345 - - Paid - - ); - } + const isPaid = (row.original as any).paid || invoice.status === "Paid"; return ( - openInvoice(row.original)} - c="blue" - fw={500} - style={{ textDecoration: "underline", cursor: "pointer" }} - > - #345 - + + + invoice.id + ? navigate(`/dashboard/invoices/${invoice.id}`) + : toast({ title: "Invoice link unavailable", description: "Refresh after the API restart.", variant: "destructive" }) + } + c="blue" + fw={500} + style={{ textDecoration: "underline", cursor: "pointer" }} + > + {invoice.number} + + {isPaid && Paid} + ); }, }, @@ -881,16 +847,20 @@ const FirstMilePage = () => { } + disabled={Boolean(row.original.invoice)} onClick={() => openDistance(row.original.id)} > Add distance } - disabled={!(row.original.exactKm != null && row.original.exactKm > 0)} - onClick={() => openInvoice(row.original)} + disabled={ + !(row.original.exactKm != null && row.original.exactKm > 0) || + Boolean(row.original.invoice) + } + onClick={() => generateInvoiceMutation.mutate(row.original.id)} > - Generate Invoice + {row.original.invoice ? "Invoice generated" : "Generate Invoice"} {canPrint && ( { } color="red" + disabled={Boolean(row.original.invoice)} onClick={() => { if (confirm(`Delete first-mile record ${bookingRef(row.original)}?`)) { deleteMutation.mutate(row.original.id); @@ -1295,137 +1266,6 @@ const FirstMilePage = () => { - - {/* Invoice modal */} - Invoice #345} - size="lg" - radius="lg" - centered - > - - {invoiceRecord && ( - <> - - - - EDR Freight - Invoice #345 - - - - - - - - - - - - - - - - - Post Payment - {formatPrice(invoiceRecord.remainingPayment)} - - - Advanced Payment - {formatPrice(invoiceRecord.advancedPayment)} - - - {(() => { - const postPayment = parseFloat(String(invoiceRecord.remainingPayment)); - const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment)); - const difference = postPayment - advancedPayment; - - if (difference > 0) { - return ( - - Remaining to Pay - {formatPrice(difference)} - - ); - } else if (difference < 0) { - return ( - - Refund - {formatPrice(Math.abs(difference))} - - ); - } else { - return ( - - Status - Settled - - ); - } - })()} - - - - - - )} - - - - - - - {/* Container Allocation modal */} - Allocate Containers to Vehicles} - size="xl" - radius="lg" - centered - > - - {activeRecord && ( - <> - {/* Capacity guidance */} - {activeRecord.booking?.cargoType?.label === "BULK" ? ( - - - Select multiple containers per vehicle based on capacity. Each vehicle can carry multiple containers if capacity allows. - - - Capacity: TBD — TODO: add vehicle capacity_tons to vehicle API if missing - - - ) : ( - - - One vehicle per container. Each container will be assigned to a single vehicle. - - - )} - - - {/* Container table */} - { - await allocateMutation.mutateAsync(allocations); - }} - /> - - )} - - - - - ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index f3f290304..0267bd1ce 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1761,34 +1761,46 @@ const LastMilePage = () => { > - {tripSlipRecord ? bookingRef(tripSlipRecord) : ""} — choose a truck to print its slip - (vehicle, driver, container). + {tripSlipRecord ? bookingRef(tripSlipRecord) : ""} — pick a truck to print its slip. {(tripSlipRecord?.vehicleAssignments ?? []).map((a) => { const v = a.vehicle; const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; return ( - + + + + + {label} + + + Driver: {v?.assignedDriverName || "—"} + + + Container: {a.containerNumber || "—"} + + + + + + + ); })} {(tripSlipRecord?.vehicleAssignments?.length ?? 0) === 0 && ( - No vehicles assigned yet. + No vehicles assigned yet. )} - diff --git a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts index 1c81f5dc5..1418cb636 100644 --- a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts @@ -45,6 +45,8 @@ export interface FirstMileRecord { vehicleId?: string | null; booking?: FirstMileBooking | null; vehicle?: FirstMileVehicle | null; + /** Present only when an invoice has actually been generated (not on distance). */ + invoice?: { id: string; number: string; status: string } | null; createdAt: string; updatedAt: string; } @@ -66,4 +68,6 @@ export const firstMileService = { api.post(FM.ACCEPT(bookingReference)), remove: (id: string) => api.delete(FM.BY_ID(id)), + generateInvoice: (id: string) => + api.post<{ id: string; invoiceNumber?: string } | null>(`${FM.BASE}/${id}/invoice`), }; diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 298e749c7..c1be65fa6 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -188,7 +188,7 @@ export interface BookingDetail { company?: BookingNamedRef & Partial; originYard?: BookingNamedRef; destinationYard?: BookingNamedRef; - serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean }; + serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean }; cargoType?: BookingNamedRef; shippingLine?: BookingNamedRef; bookingContainers?: BookingContainerLine[]; From c2649dae3c131fdd88fd610a49711fd9a2be0072 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 23:11:04 +0000 Subject: [PATCH 43/90] enhance phase countdown and booking window descriptions for clarity --- .../components/UpcomingWindowsSection.tsx | 53 +++++++++++++++---- .../portal/src/services/bookings.service.ts | 9 ++-- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx index bb5b629de..300ff643f 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx @@ -45,22 +45,50 @@ function windowLabel(w: MyBookingWindow): string { } /** - * The deadline + label for whichever phase the window is currently in. Phases - * run: window open (closes at windowClosesAt) → document review (docReviewEndsAt) - * → payment (paymentPhaseEndsAt). Returns null when no phase is timing down. + * The countdown for whichever phase the window is currently in. Phases run: + * pre-window (opens at windowOpensAt) → open (closes at windowClosesAt) → + * document review (docReviewEndsAt) → payment (paymentPhaseEndsAt). + * + * `label` describes the deadline being counted down to; `expiredText` names the + * NEXT step so that when a deadline lapses between the 60s refetches the row + * announces what comes next ("Booking opening now…", "Review starting…") rather + * than the bare word "Expired". Returns null when no phase is timing down. */ function phaseCountdown( w: MyBookingWindow, -): { label: string; deadline: string } | null { +): { label: string; deadline: string; expiredText: string } | null { switch (w.windowPhase) { + case "PRE_WINDOW": + if (w.windowOpensAt) + return { + label: "Booking opens in", + deadline: w.windowOpensAt, + expiredText: "Booking opening now…", + }; + return null; case "OPEN": - if (w.windowClosesAt) return { label: "Window closes in", deadline: w.windowClosesAt }; + if (w.windowClosesAt) + return { + label: "Window closes in", + deadline: w.windowClosesAt, + expiredText: "Document review starting…", + }; return null; case "DOC_REVIEW": - if (w.docReviewEndsAt) return { label: "Document review ends in", deadline: w.docReviewEndsAt }; + if (w.docReviewEndsAt) + return { + label: "Document review ends in", + deadline: w.docReviewEndsAt, + expiredText: "Payment starting…", + }; return null; case "PAYMENT": - if (w.paymentPhaseEndsAt) return { label: "Payment due in", deadline: w.paymentPhaseEndsAt }; + if (w.paymentPhaseEndsAt) + return { + label: "Payment due in", + deadline: w.paymentPhaseEndsAt, + expiredText: "Payment window closing…", + }; return null; default: return null; @@ -141,9 +169,11 @@ interface UpcomingWindowsSectionProps { } /** - * The customer's upcoming/open booking windows on their active-contract - * lanes. Import trains open a window on one booking day; export trains open - * 24h before departure. Hidden entirely when there is nothing to show. + * All announced upcoming/open booking windows, shown to every customer + * regardless of whether they hold a contract on the lane. Import trains open a + * window on one booking day; export trains open 24h before departure. Rows on a + * lane the customer has an active contract for carry a "Book now" action; + * others route to the contract list. Hidden entirely when nothing is announced. */ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ windows, @@ -162,7 +192,7 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ Booking Windows - Upcoming and open booking windows on your contract lanes + Upcoming and open booking windows across all lanes @@ -211,6 +241,7 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 79676cab1..c2729bebb 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -47,13 +47,16 @@ export interface PriceLineItem { } /** - * An upcoming/open booking window on one of the signed-in customer's - * active-contract lanes. Import trains open a window on one booking day; + * An announced upcoming/open booking window, shown to every signed-in customer + * regardless of contract. Import trains open a window on one booking day; * export trains open 24h before departure (first come, first served). */ export interface MyBookingWindow { scheduleId: string; - /** Contract whose route this window belongs to, when the row carries it. */ + /** + * The customer's active contract on this lane, when they hold one — enables + * "Book now" to target it. Null for lanes they have no contract on. + */ contractId: string | null; /** ONE_TIME contracts can't draw down against a window — button is hidden. */ contractKind: "ONE_TIME" | "GENERAL" | null; From 18926c32cba8d2c44974699d00591285c0c2cead Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 23:24:41 +0000 Subject: [PATCH 44/90] enhance phase countdown and booking window descriptions for clarity --- apps/edr-freight-web/backoffice/src/App.tsx | 18 + .../components/fleet/FleetRecordActions.tsx | 19 + .../src/pages/fleet/DriverDetailPage.tsx | 264 ++++++++++ .../src/pages/fleet/VehicleDetailPage.tsx | 453 ++++++++++++++++++ .../src/pages/fleet/config/drivers.ts | 1 + .../src/pages/fleet/config/vehicles.ts | 1 + 6 files changed, 756 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 4c74f6993..70b080d6d 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -83,6 +83,8 @@ import UsersPage from "./pages/dashboard/user-management/UsersPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import VehicleDetailPage from "./pages/fleet/VehicleDetailPage"; +import DriverDetailPage from "./pages/fleet/DriverDetailPage"; import RoutesPage from "./pages/fleet/RoutesPage"; import FuelPurchasePage from "./pages/fleet/FuelPurchasePage"; import FuelStatsPage from "./pages/fleet/FuelStatsPage"; @@ -926,6 +928,14 @@ const App = () => { } /> + + + + } + /> { } /> + + + + } + /> void; onAssignDriver?: (record: FleetRecord) => void; onHistory?: (record: FleetRecord) => void; + onViewDetail?: (record: FleetRecord) => void; layout?: "row" | "compact"; } @@ -22,11 +23,13 @@ const FleetRecordActions = ({ onRemove, onAssignDriver, onHistory, + onViewDetail, layout = "row", }: FleetRecordActionsProps) => { const navigate = useNavigate(); const removeLabel = config.removeActionLabel ?? "Delete"; const showDetail = Boolean(config.detailPath && "id" in record); + const showViewDetail = Boolean(onViewDetail); const isVehicle = config.slug === "vehicles"; const showHistory = Boolean(onHistory) && @@ -62,6 +65,14 @@ const FleetRecordActions = ({ > Edit + {showViewDetail ? ( + onViewDetail?.(record)} + leftSection={} + > + View detail + + ) : null} {showHistory ? ( onHistory?.(record)} @@ -114,6 +125,14 @@ const FleetRecordActions = ({ > Edit + {showViewDetail ? ( + onViewDetail?.(record)} + leftSection={} + > + View detail + + ) : null} {showDetail ? ( { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString(); +}; +const fmtDateTime = (iso?: string | null) => { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString(); +}; +const meta = (e: FleetHistoryEvent, k: string) => { + const v = e.metadata?.[k]; + return typeof v === "string" && v ? v : null; +}; + +const InfoRow = ({ label, value }: { label: string; value: React.ReactNode }) => ( + + {label} + {value} + +); +const Loading = () => ( +
+); + +const DriverDetailPage = () => { + const { id = "" } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const { data: driver, isLoading } = useQuery({ + queryKey: ["driver", id], + queryFn: () => driversService.getById(id).then((r) => r.data), + enabled: Boolean(id), + }); + + const name = driver ? `${driver.firstName ?? ""} ${driver.lastName ?? ""}`.trim() : ""; + const licenseExpired = + driver?.licenseExpiryDate && new Date(driver.licenseExpiryDate) < new Date(); + + return ( + + + navigate("/dashboard/drivers")} aria-label="Back"> + + + + + {name || "Driver"} + {driver && ( + + {driver.status} + {driver.faydaVerified && ( + }> + Fayda verified + + )} + {licenseExpired && ( + License expired + )} + {driver.licenseNumber} + + )} + + + + {isLoading ? ( + + ) : !driver ? ( + Driver not found. + ) : ( + + + }>Overview + }>Vehicles + }>History + }>Trips + + + + + + + + + + + {fmtDate(driver.licenseExpiryDate)} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + )} + + ); +}; + +const useDriverHistory = (driverId: string) => + useQuery({ + queryKey: ["driver-history", driverId], + queryFn: () => fleetHistoryService.driver(driverId), + }); + +const VehiclesTab = ({ driverId }: { driverId: string }) => { + const { data = [], isLoading } = useDriverHistory(driverId); + const rows = data + .filter((e) => e.eventType === "DRIVER_ASSIGNED") + .map((e) => ({ id: e.id, plate: meta(e, "vehiclePlate") || e.vehicleId || "Vehicle", at: e.createdAt })); + if (isLoading) return ; + return ( + + Vehicles driven ({rows.length}) + {rows.length === 0 ? ( + No vehicle assignments recorded. + ) : ( + + + VehicleAssigned + + + {rows.map((r) => ( + + {r.plate} + {fmtDateTime(r.at)} + + ))} + +
+ )} +
+ ); +}; + +const HistoryTab = ({ driverId }: { driverId: string }) => { + const { data = [], isLoading } = useDriverHistory(driverId); + if (isLoading) return ; + if (!data.length) return No activity recorded yet.; + return ( + + {data.map((e) => ( + {e.eventType.replaceAll("_", " ")}}> + {(meta(e, "vehiclePlate") || meta(e, "bookingRef") || e.label) && ( + + {[meta(e, "vehiclePlate"), meta(e, "bookingRef") && `Booking ${meta(e, "bookingRef")}`, e.label] + .filter(Boolean) + .join(" · ")} + + )} + {fmtDateTime(e.createdAt)} + + ))} + + ); +}; + +const TripsTab = ({ driverId }: { driverId: string }) => { + const { data = [], isLoading } = useDriverHistory(driverId); + const trips = useMemo( + () => + data + .filter((e) => e.eventType === "MILE_VEHICLE_ASSIGNED") + .map((e) => ({ + id: e.id, + mile: meta(e, "mile") === "LAST" ? "Last-mile" : "First-mile", + booking: meta(e, "bookingRef") ?? "—", + vehicle: meta(e, "vehiclePlate") ?? "—", + status: e.label ?? "—", + at: e.createdAt, + })), + [data], + ); + if (isLoading) return ; + return ( + + Trips assigned ({trips.length}) + {trips.length === 0 ? ( + No trips recorded. + ) : ( + + + + + MileBooking + VehicleStatusWhen + + + + {trips.map((t) => ( + + {t.mile} + {t.booking} + {t.vehicle} + {t.status} + {fmtDateTime(t.at)} + + ))} + +
+
+ )} +
+ ); +}; + +export default DriverDetailPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx new file mode 100644 index 000000000..76665f385 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx @@ -0,0 +1,453 @@ +import { useMemo } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { + ActionIcon, + Badge, + Card, + Center, + Container, + Group, + Loader, + SimpleGrid, + Stack, + Table, + Tabs, + Text, + Timeline, + Title, +} from "@mantine/core"; +import { + ArrowLeft, + Fuel, + History, + Route, + Truck, + User, + Wrench, +} from "lucide-react"; + +import { api } from "@/auth/http"; +import { vehiclesService } from "@/services/vehicles.service"; +import { driversService } from "@/services/drivers.service"; +import { fleetHistoryService } from "@/services/fleet-history.service"; + +interface MaintenanceCost { + id: string; + incurredDate: string; + costAmount: number; + costType: string; + description?: string | null; + serviceProvider?: string | null; + invoiceNumber?: string | null; +} +interface FuelPurchase { + id: string; + purchaseDate: string; + liters: number; + costPerLiter: number; + totalCost: number; + fuelStation?: string | null; + odometerReading?: number | null; +} +interface MileRecord { + id: string; + status: string; + exactKm?: number | null; + estimatedKm?: number | null; + remainingPayment?: number | null; + advancedPayment?: number | null; + booking?: { reference?: string } | null; + bookingId: string; +} + +const fmtDate = (iso?: string | null) => { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString(); +}; +const fmtDateTime = (iso?: string | null) => { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString(); +}; +const money = (n?: number | null) => + n == null ? "—" : `ETB ${Number(n).toLocaleString(undefined, { maximumFractionDigits: 2 })}`; + +const InfoRow = ({ label, value }: { label: string; value: React.ReactNode }) => ( + + {label} + {value} + +); + +const Loading = () => ( +
+); + +const VehicleDetailPage = () => { + const { id = "" } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const { data: vehicle, isLoading } = useQuery({ + queryKey: ["vehicle", id], + queryFn: () => vehiclesService.getById(id).then((r) => r.data), + enabled: Boolean(id), + }); + + const plate = vehicle + ? [vehicle.code, vehicle.plateNumber].filter(Boolean).join(" · ") + : ""; + + return ( + + + navigate("/dashboard/vehicles")} aria-label="Back"> + + + + + {plate || "Vehicle"} + {vehicle && ( + + {vehicle.status} + + {vehicle.availability} + + {vehicle.vehicleType && {vehicle.vehicleType}} + + )} + + + + {isLoading ? ( + + ) : !vehicle ? ( + Vehicle not found. + ) : ( + + + }>Overview + }>Driver + }>History + }>Maintenance + }>Fuel + }>First/Last mile + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + ); +}; + +const DriverTab = ({ + vehicleId, + driverId, + fallbackName, +}: { + vehicleId: string; + driverId?: string | null; + fallbackName?: string | null; +}) => { + const { data: driver } = useQuery({ + queryKey: ["driver", driverId], + queryFn: () => driversService.getById(driverId!).then((r) => r.data), + enabled: Boolean(driverId), + }); + // All drivers that have driven this vehicle, from the assignment history. + const { data: history = [], isLoading } = useQuery({ + queryKey: ["vehicle-history", vehicleId], + queryFn: () => fleetHistoryService.vehicle(vehicleId), + }); + const drivers = history + .filter((e) => e.eventType === "DRIVER_ASSIGNED") + .map((e) => ({ + id: e.id, + driverId: e.driverId, + name: (typeof e.metadata?.driverName === "string" && e.metadata.driverName) || e.label || "Driver", + at: e.createdAt, + })); + + return ( + + {driverId && driver ? ( + + Current driver + + + + + + + + + + ) : ( + {fallbackName ? `Assigned: ${fallbackName}` : "No driver currently assigned."} + )} + + + Driver history ({drivers.length}) + {isLoading ? ( + + ) : drivers.length === 0 ? ( + No driver assignments recorded. + ) : ( + + + + DriverAssigned + + + + {drivers.map((d) => ( + + {d.name} + {fmtDateTime(d.at)} + + ))} + +
+ )} +
+
+ ); +}; + +const HistoryTab = ({ vehicleId }: { vehicleId: string }) => { + const { data = [], isLoading } = useQuery({ + queryKey: ["vehicle-history", vehicleId], + queryFn: () => fleetHistoryService.vehicle(vehicleId), + }); + if (isLoading) return ; + if (!data.length) return No activity recorded yet.; + return ( + + {data.map((e) => ( + {e.eventType.replaceAll("_", " ")}}> + {(e.label || e.fromValue || e.toValue) && ( + + {[e.label, e.fromValue && e.toValue ? `${e.fromValue} → ${e.toValue}` : e.toValue] + .filter(Boolean) + .join(" · ")} + + )} + {fmtDateTime(e.createdAt)} + + ))} + + ); +}; + +const MaintenanceTab = ({ vehicleId }: { vehicleId: string }) => { + const { data = [], isLoading } = useQuery({ + queryKey: ["vehicle-maintenance", vehicleId], + queryFn: () => api.get(`/maintenance/history/${vehicleId}`).then((r) => r.data), + }); + const total = useMemo(() => data.reduce((s, m) => s + (Number(m.costAmount) || 0), 0), [data]); + if (isLoading) return ; + return ( + + + Last 12 months + Total: {money(total)} + + {data.length === 0 ? ( + No maintenance records. + ) : ( + + + + + DateTypeAmount + DescriptionProvider + + + + {data.map((m) => ( + + {fmtDate(m.incurredDate)} + {m.costType} + {money(m.costAmount)} + {m.description ?? "—"} + {m.serviceProvider ?? "—"} + + ))} + +
+
+ )} +
+ ); +}; + +const FuelTab = ({ vehicleId }: { vehicleId: string }) => { + const { data = [], isLoading } = useQuery({ + queryKey: ["vehicle-fuel", vehicleId], + queryFn: () => { + const end = new Date(); + const start = new Date(); + start.setMonth(start.getMonth() - 12); + const qs = `startDate=${start.toISOString()}&endDate=${end.toISOString()}`; + return api.get(`/fuel/purchases/${vehicleId}?${qs}`).then((r) => r.data); + }, + }); + const totals = useMemo( + () => ({ + liters: data.reduce((s, f) => s + (Number(f.liters) || 0), 0), + cost: data.reduce((s, f) => s + (Number(f.totalCost) || 0), 0), + }), + [data], + ); + if (isLoading) return ; + return ( + + + + Total litres + {totals.liters.toLocaleString(undefined, { maximumFractionDigits: 1 })} L + + + Total fuel cost + {money(totals.cost)} + + + Purchases + {data.length} + + + {data.length === 0 ? ( + No fuel purchases in the last 12 months. + ) : ( + + + + + DateLitresCost/L + TotalOdometerStation + + + + {data.map((f) => ( + + {fmtDate(f.purchaseDate)} + {f.liters} L + {money(f.costPerLiter)} + {money(f.totalCost)} + {f.odometerReading ?? "—"} + {f.fuelStation ?? "—"} + + ))} + +
+
+ )} +
+ ); +}; + +const mileTotal = (rows: MileRecord[]) => + rows.reduce((s, r) => s + (Number(r.remainingPayment) || 0), 0); + +const MileTable = ({ title, rows }: { title: string; rows: MileRecord[] }) => ( + + + {title} ({rows.length}) + Total: {money(mileTotal(rows))} + + {rows.length === 0 ? ( + None. + ) : ( + + + + BookingStatus + Distance (km)Cost + + + + {rows.map((r) => ( + + {r.booking?.reference ?? r.bookingId} + {r.status} + {r.exactKm ?? r.estimatedKm ?? "—"} + {money(r.remainingPayment)} + + ))} + +
+ )} +
+); + +const MileTab = ({ vehicleId }: { vehicleId: string }) => { + const first = useQuery({ + queryKey: ["vehicle-first-mile", vehicleId], + queryFn: () => + api.get<{ data: MileRecord[] }>(`/first-mile?vehicleId=${vehicleId}&pageSize=1000`).then((r) => r.data.data ?? []), + }); + const last = useQuery({ + queryKey: ["vehicle-last-mile", vehicleId], + queryFn: () => + api.get<{ data: MileRecord[] }>(`/last-mile?vehicleId=${vehicleId}&pageSize=1000`).then((r) => r.data.data ?? []), + }); + if (first.isLoading || last.isLoading) return ; + const firstRows = first.data ?? []; + const lastRows = last.data ?? []; + const grandTotal = mileTotal(firstRows) + mileTotal(lastRows); + return ( + + + + Total first + last mile revenue for this vehicle + {money(grandTotal)} + + + + + + ); +}; + +export default VehicleDetailPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts index f240bda09..fdfdd367d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts @@ -19,6 +19,7 @@ export const driversConfig: FleetResourceConfig = { label: "Drivers", subtitle: "Manage driver records and licenses", basePath: "/dashboard/drivers", + detailPath: "/dashboard/drivers/:id", addLabel: "Add Driver", entityLabel: "Driver", searchPlaceholder: "Search drivers…", diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index de6eb09c7..5e76584ed 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -34,6 +34,7 @@ export const vehiclesConfig: FleetResourceConfig = { label: "Vehicles", subtitle: "Manage vehicle master data for fleet operations", basePath: "/dashboard/vehicles", + detailPath: "/dashboard/vehicles/:id", addLabel: "Add Vehicle", entityLabel: "Vehicle", searchPlaceholder: "Search vehicles…", From f153f2936b70eefa508bfb9e121b290dae103308 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 23:30:05 +0000 Subject: [PATCH 45/90] fix --- .../src/pages/fleet/DriverDetailPage.tsx | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx index 190dc8b85..dfdf822e4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx @@ -27,6 +27,7 @@ import { } from "lucide-react"; import { driversService } from "@/services/drivers.service"; +import { vehiclesService } from "@/services/vehicles.service"; import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service"; const fmtDate = (iso?: string | null) => { @@ -160,11 +161,31 @@ const useDriverHistory = (driverId: string) => queryFn: () => fleetHistoryService.driver(driverId), }); +/** id → "code · plate" map so events without a stored plate still show a name. */ +const useVehicleMap = () => { + const { data } = useQuery({ + queryKey: ["vehicles-all"], + queryFn: () => vehiclesService.getAll({}).then((r) => r.data), + }); + return useMemo(() => { + const m = new Map(); + for (const v of data ?? []) { + m.set(v.id, [v.code, v.plateNumber].filter(Boolean).join(" · ") || v.id); + } + return m; + }, [data]); +}; + const VehiclesTab = ({ driverId }: { driverId: string }) => { const { data = [], isLoading } = useDriverHistory(driverId); + const vmap = useVehicleMap(); const rows = data .filter((e) => e.eventType === "DRIVER_ASSIGNED") - .map((e) => ({ id: e.id, plate: meta(e, "vehiclePlate") || e.vehicleId || "Vehicle", at: e.createdAt })); + .map((e) => ({ + id: e.id, + plate: meta(e, "vehiclePlate") || (e.vehicleId ? vmap.get(e.vehicleId) : null) || "Vehicle", + at: e.createdAt, + })); if (isLoading) return ; return ( @@ -214,6 +235,7 @@ const HistoryTab = ({ driverId }: { driverId: string }) => { const TripsTab = ({ driverId }: { driverId: string }) => { const { data = [], isLoading } = useDriverHistory(driverId); + const vmap = useVehicleMap(); const trips = useMemo( () => data @@ -222,11 +244,11 @@ const TripsTab = ({ driverId }: { driverId: string }) => { id: e.id, mile: meta(e, "mile") === "LAST" ? "Last-mile" : "First-mile", booking: meta(e, "bookingRef") ?? "—", - vehicle: meta(e, "vehiclePlate") ?? "—", + vehicle: meta(e, "vehiclePlate") || (e.vehicleId ? vmap.get(e.vehicleId) : null) || "—", status: e.label ?? "—", at: e.createdAt, })), - [data], + [data, vmap], ); if (isLoading) return ; return ( From 69818ab3f996db2a5f87db7de0744eca9758ed42 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 23:30:49 +0000 Subject: [PATCH 46/90] add booking window to gl --- .../train-scheduling.controller.ts | 5 +- .../train-scheduling.service.ts | 32 ++- .../contracts/GlUpcomingWindowsSection.tsx | 250 ++++++++++++++++++ .../contracts/ContractClearanceListPage.tsx | 3 + .../BatchScheduleDetailPage.tsx | 2 +- 5 files changed, 279 insertions(+), 13 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 2d591f2e0..22e322953 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -61,13 +61,14 @@ export class TrainSchedulingController { @Get("my-booking-windows") @ApiOperation({ summary: - "Upcoming/open booking windows on the signed-in customer's active contract lanes", + "Upcoming/open booking windows announced to the signed-in customer (all window-engine schedules; their own contract lanes carry a Book-now target)", }) async getMyBookingWindows(@CurrentUser() user: AuthUserPayload) { + // Every customer sees announced windows; companyId (when resolvable) just + // enriches lanes they hold a contract on so "Book now" can target it. const companyId = await this.billingService.resolveCompanyId( resolveAuthUserId(user), ); - if (!companyId) return []; return this.trainSchedulingService.getBookingWindowsForCompany(companyId); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 199be696a..bac226c0a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -3121,14 +3121,20 @@ export class TrainSchedulingService { } /** - * Upcoming/open booking windows for a customer's active-contract lanes — - * powers the portal home "booking windows" section. Only window-engine - * schedules (IMPORT cycle / EXPORT lead) are listed; DOMESTIC trains are - * always open and need no announcement. + * Upcoming/open booking windows announced on the portal home "booking + * windows" section. ALL window-engine schedules (IMPORT cycle / EXPORT lead) + * are listed so every customer sees what is opening — not just those on their + * contract lanes; DOMESTIC trains are always open and need no announcement. + * + * When `companyId` is given, a matching active contract on the lane is + * LEFT-JOINed in so the row carries `contractId`/`contractKind` (enabling + * "Book now"); customers with no covering contract still see the window with a + * null contract, and the portal routes them to the contract list to get one. */ - async getBookingWindowsForCompany(companyId: string) { + async getBookingWindowsForCompany(companyId: string | null) { const rows: Array = await this.dataSource.query( - `SELECT DISTINCT ts.id AS schedule_id, + `SELECT DISTINCT ON (ts.id) + ts.id AS schedule_id, cr.contract_id AS contract_id, c.contract_kind AS contract_kind, ts.direction, @@ -3143,11 +3149,11 @@ export class TrainSchedulingService { oy.label AS origin_label, oy.code AS origin_code, dy.label AS destination_label, dy.code AS destination_code FROM freight.train_schedules ts - JOIN freight.contract_routes cr + LEFT JOIN freight.contract_routes cr ON cr.origin_yard_id = ts.origin_station_id AND cr.destination_yard_id = ts.destination_station_id AND cr.deleted_at IS NULL - JOIN freight.contracts c + LEFT JOIN freight.contracts c ON c.id = cr.contract_id AND c.company_id = $1 AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') @@ -3159,10 +3165,16 @@ export class TrainSchedulingService { AND ts.window_phase IS NOT NULL AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') AND ts.scheduled_departure_date >= now() - ORDER BY ts.window_opens_at ASC NULLS LAST`, + ORDER BY ts.id, c.id NULLS LAST, ts.window_opens_at ASC NULLS LAST`, [companyId], ); - return rows.map((r) => this.mapBookingWindowRow(r)); + return rows + .map((r) => this.mapBookingWindowRow(r)) + .sort((a, b) => { + const ta = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity; + const tb = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity; + return ta - tb; + }); } /** diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx new file mode 100644 index 000000000..9d9573909 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -0,0 +1,250 @@ +import { useMemo } from "react"; +import { Badge, Box, Card, Group, ScrollArea, Skeleton, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowRight, CalendarClock } from "lucide-react"; +import { CountdownTimer } from "@edr/ui-common"; + +import { api } from "@/services/api"; +import type { BatchBoardSchedule } from "@/types/trainScheduling"; + +/** All window times are communicated in East Africa Time. */ +const TZ = "Africa/Addis_Ababa"; + +function fmtDay(iso: string): string { + return new Date(iso).toLocaleDateString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + timeZone: TZ, + }); +} + +function fmtTime(iso: string): string { + return new Date(iso).toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: TZ, + }); +} + +function windowLabel(w: BatchBoardSchedule): string { + if (w.windowOpensAt && w.windowClosesAt) { + return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} – ${fmtTime( + w.windowClosesAt, + )} EAT`; + } + if (w.windowOpensAt) { + return `Opens ${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} EAT`; + } + return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " "); +} + +/** + * The countdown for whichever phase the window is currently in, mirroring the + * customer portal. Phases run pre-window (opens at windowOpensAt) → open (closes + * at windowClosesAt) → document review (docReviewEndsAt) → payment + * (paymentPhaseEndsAt). `expiredText` names the NEXT step so a deadline that + * lapses between the 60s refetches announces what comes next rather than the + * bare word "Expired". Returns null when no phase is timing down. + */ +function phaseCountdown( + w: BatchBoardSchedule, +): { label: string; deadline: string; expiredText: string } | null { + switch (w.windowPhase) { + case "PRE_WINDOW": + return w.windowOpensAt + ? { + label: "Booking opens in", + deadline: w.windowOpensAt, + expiredText: "Booking opening now…", + } + : null; + case "OPEN": + return w.windowClosesAt + ? { + label: "Window closes in", + deadline: w.windowClosesAt, + expiredText: "Document review starting…", + } + : null; + case "DOC_REVIEW": + return w.docReviewEndsAt + ? { + label: "Document review ends in", + deadline: w.docReviewEndsAt, + expiredText: "Payment starting…", + } + : null; + case "PAYMENT": + return w.paymentPhaseEndsAt + ? { + label: "Payment window ends in", + deadline: w.paymentPhaseEndsAt, + expiredText: "Payment window closing…", + } + : null; + default: + return null; + } +} + +function isOpenNow(w: BatchBoardSchedule): boolean { + return w.windowPhase === "OPEN" && w.bookingWindowStatus === "OPEN"; +} + +/** Drop windows whose booking window (or the train itself) has already passed. */ +function isPast(w: BatchBoardSchedule): boolean { + const now = Date.now(); + const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null; + const departs = w.scheduleDate ? new Date(w.scheduleDate).getTime() : null; + // Still live while in a post-close staff phase (doc review / payment). + if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false; + if (departs != null && departs <= now) return true; + if (closes != null && closes <= now) return true; + return false; +} + +/** + * Upcoming / open import booking windows across all train schedules, shown to GL + * ET on the clearance queue so they can see which lanes are accepting bookings + * (mirrors the customer's portal "Booking Windows" card). Hidden when nothing is + * pending. Windows already past close/departure are dropped. + */ +export function GlUpcomingWindowsSection() { + const { data, isLoading } = useQuery( + api.trainScheduling.batchBoard.queryOptions({ refetchInterval: 60_000 }), + ); + + const windows = useMemo(() => { + const rows = (data ?? []).filter( + (w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w), + ); + // Open lanes first, then by opening time. + return rows.sort((a, b) => { + const openDiff = Number(isOpenNow(b)) - Number(isOpenNow(a)); + if (openDiff !== 0) return openDiff; + const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity; + const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity; + return at - bt; + }); + }, [data]); + + if (!isLoading && windows.length === 0) return null; + + return ( + + + + + + Booking windows + + + Upcoming and open import booking windows across all lanes (EAT) + + + + + {isLoading ? ( + + {[1, 2].map((i) => ( + + ))} + + ) : ( + + + {windows.map((w) => { + const open = isOpenNow(w); + const cd = phaseCountdown(w); + return ( + + + + + {w.origin ?? "—"} + + + + {w.destination ?? "—"} + + {w.trainNumber ? ( + + · {w.trainNumber} + + ) : null} + + + {windowLabel(w)} + {w.scheduleDate ? ` · Departs ${fmtDay(w.scheduleDate)}` : ""} + + {cd ? ( + + + + ) : null} + + + + {w.direction ? ( + + {w.direction === "IMPORT" ? "Import" : "Export"} + + ) : null} + + {open + ? "Open now" + : w.windowPhase === "PRE_WINDOW" && w.windowOpensAt + ? `Opens ${fmtTime(w.windowOpensAt)} EAT` + : (w.windowPhase ?? w.bookingWindowStatus).replace( + /_/g, + " ", + )} + + + + ); + })} + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx index 034958303..1121329fb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx @@ -50,6 +50,7 @@ import { useEtClearanceQueue, } from "@/hooks/contracts/useContracts"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection"; type ViewMode = "table" | "cards"; type QueueTab = "all" | "et"; @@ -446,6 +447,8 @@ export default function ContractClearanceListPage() { ]} /> + + {queueTabOptions.length > 1 ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index a4ebd531e..7e38ba932 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -40,7 +40,7 @@ import { XCircle, } from "lucide-react"; -import { DataTable, type ColumnDef } from "@edr/ui-common"; +import { CountdownTimer, DataTable, type ColumnDef } from "@edr/ui-common"; import { KpiStrip, PageContainer } from "@/components/page"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; From 61f70d54716a343ac8e444955528e0edf5bda9f8 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 23:37:11 +0000 Subject: [PATCH 47/90] add booking window to gl --- .../BatchScheduleDetailPage.tsx | 459 +++++++----------- 1 file changed, 166 insertions(+), 293 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 7e38ba932..de6ecfa9b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -1,8 +1,7 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Accordion, - ActionIcon, Alert, Badge, Box, @@ -24,9 +23,7 @@ import { Boxes, CalendarDays, CheckCircle2, - ChevronLeft, ClipboardCheck, - ChevronRight, Clock, FileSignature, Hourglass, @@ -431,101 +428,148 @@ function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) { } /** "05 Jun 2026 · 06:00 – 09:00 EAT" → "06:00 – 09:00 EAT" (date lives in the day header). */ -function timeLabelOf(label: string): string { - const idx = label.indexOf("·"); - return idx >= 0 ? label.slice(idx + 1).trim() : label; -} - const EAT_TZ = "Africa/Addis_Ababa"; -const dateKeyFmt = new Intl.DateTimeFormat("en-CA", { - timeZone: EAT_TZ, - year: "numeric", - month: "2-digit", - day: "2-digit", -}); const dateLabelFmt = new Intl.DateTimeFormat("en-GB", { timeZone: EAT_TZ, weekday: "short", day: "2-digit", month: "short", }); +const timeFmt = new Intl.DateTimeFormat("en-GB", { + timeZone: EAT_TZ, + hour: "2-digit", + minute: "2-digit", + hour12: false, +}); -/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */ -function windowDateKey(w: BatchWindowGroup): string { - if (w.date) return w.date; - if (w.start) return dateKeyFmt.format(new Date(w.start)); - return "undated"; +interface ScheduleWindow { + windowPhase: BatchBoardScheduleDetail["windowPhase"]; + bookingWindowStatus: string; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + bookingCycleNo?: number; } -/** Human day label for a window — prefers the API field, falls back to `start`. */ -function windowDateLabel(w: BatchWindowGroup): string { - if (w.dateLabel) return w.dateLabel; - if (w.start) return dateLabelFmt.format(new Date(w.start)); - return "Undated"; +/** + * Deadline + label for the phase the schedule's booking window is currently in — + * the SAME phases the customer sees on the portal: pre-window (opens) → open + * (closes) → document review → payment. `expiredText` names the next step so a + * lapsed deadline reads as a handover, not a bare "Expired". + */ +function windowPhaseCountdown( + w: ScheduleWindow, +): { label: string; deadline: string; expiredText: string } | null { + switch (w.windowPhase) { + case "PRE_WINDOW": + return w.windowOpensAt + ? { label: "Booking opens in", deadline: w.windowOpensAt, expiredText: "Booking opening now…" } + : null; + case "OPEN": + return w.windowClosesAt + ? { label: "Window closes in", deadline: w.windowClosesAt, expiredText: "Document review starting…" } + : null; + case "DOC_REVIEW": + return w.docReviewEndsAt + ? { label: "Document review ends in", deadline: w.docReviewEndsAt, expiredText: "Payment starting…" } + : null; + case "PAYMENT": + return w.paymentPhaseEndsAt + ? { label: "Payment window ends in", deadline: w.paymentPhaseEndsAt, expiredText: "Payment window closing…" } + : null; + default: + return null; + } } -function WindowAccordionItem({ window }: { window: BatchWindowGroup }) { - const total = window.bookings.length; - const hasIssues = window.bookings.some( - (b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED", +/** One phase row: label + its clock time (or "—" when unset). */ +function PhaseTimeRow({ + label, + iso, + active, +}: { + label: string; + iso: string | null; + active: boolean; +}) { + return ( + + + {label} + + + {iso ? `${timeFmt.format(new Date(iso))} EAT` : "—"} + + ); +} + +/** + * The schedule's REAL booking window — the exact same window the customer sees on + * the portal (frozen open/close from the schedule's own snapshot + the post-close + * document-review and payment phases), with a live countdown to the current phase. + * Replaces the old theoretical "3-hour windows across every day" projection. + */ +function ScheduleWindowPanel({ window: w }: { window: ScheduleWindow }) { + const phase = w.windowPhase; + const cd = windowPhaseCountdown(w); + const open = phase === "OPEN" && w.bookingWindowStatus === "OPEN"; + + const openDay = w.windowOpensAt + ? dateLabelFmt.format(new Date(w.windowOpensAt)) + : null; return ( - - - - - - - - - - {timeLabelOf(window.label)} - - - {total - ? `${total} booking${total === 1 ? "" : "s"}` - : "Empty window"} - - - - - {hasIssues ? ( - } - > - Issues - - ) : null} - - + + + + {phase ? ( + + ) : null} + - - - - - + {openDay ? ( + + Booking day · {openDay} + + ) : null} + + + {cd ? ( + + + + ) : null} + + + + + + + + ); } +/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */ + export default function BatchScheduleDetailPage() { const { scheduleId } = useParams<{ scheduleId: string }>(); const navigate = useNavigate(); @@ -577,6 +621,34 @@ export default function BatchScheduleDetailPage() { return [...byId.values()]; }, [data]); + // All bookings that fall inside the schedule's booking window (every window + // cycle, flattened) — the window is one booking day, so these belong to the + // single window panel above. + const windowBookings = useMemo( + () => (data?.windows ?? []).flatMap((w) => w.bookings), + [data?.windows], + ); + + const windowCounts = useMemo(() => { + const counts = { + allocated: 0, + selectedForBatch: 0, + ready: 0, + waiting: 0, + expired: 0, + pendingContract: 0, + }; + for (const b of windowBookings) { + if (b.state === "ALLOCATED") counts.allocated += 1; + else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1; + else if (b.state === "READY") counts.ready += 1; + else if (b.state === "WAITING") counts.waiting += 1; + else if (b.state === "EXPIRED") counts.expired += 1; + else counts.pendingContract += 1; + } + return counts; + }, [windowBookings]); + // Batch bookings by state for the composition side panel (payment / expired lists). const batchBookings = useMemo(() => { const all = allBookings; @@ -591,102 +663,10 @@ export default function BatchScheduleDetailPage() { [data?.status], ); - // Group the flat window list into per-day sections (one per EAT calendar date). - const dayGroups = useMemo(() => { - if (!data) return []; - const byDate = new Map< - string, - { - date: string; - dateLabel: string; - windows: BatchWindowGroup[]; - totalBookings: number; - counts: BatchWindowGroup["counts"]; - hasIssues: boolean; - } - >(); - for (const w of data.windows) { - const dateKey = windowDateKey(w); - let group = byDate.get(dateKey); - if (!group) { - group = { - date: dateKey, - dateLabel: windowDateLabel(w), - windows: [], - totalBookings: 0, - counts: { - allocated: 0, - selectedForBatch: 0, - ready: 0, - waiting: 0, - expired: 0, - pendingContract: 0, - }, - hasIssues: false, - }; - byDate.set(dateKey, group); - } - group.windows.push(w); - group.totalBookings += w.bookings.length; - group.counts.allocated += w.counts.allocated; - group.counts.selectedForBatch += w.counts.selectedForBatch; - group.counts.ready += w.counts.ready; - group.counts.waiting += w.counts.waiting; - group.counts.expired += w.counts.expired; - group.counts.pendingContract += w.counts.pendingContract; - group.hasIssues = - group.hasIssues || - w.bookings.some( - (b) => - b.allocationStatus === "FAILED" || - b.allocationStatus === "DEFERRED", - ); - } - return [...byDate.values()]; - }, [data]); - - // Windows with bookings open by default (inside an expanded day). - const openWindowKeys = useMemo( - () => - data - ? data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key) - : [], - [data], - ); - - const todayEat = useMemo( - () => - new Intl.DateTimeFormat("en-CA", { - timeZone: "Africa/Addis_Ababa", - year: "numeric", - month: "2-digit", - day: "2-digit", - }).format(new Date()), - [], - ); - - // Date-stepper: which day is currently shown. Default to today, else the first - // day with bookings, else the first day. Keep the selection if still valid. - const [selectedDate, setSelectedDate] = useState(null); const [activeTab, setActiveTab] = useState("overview"); const [selectedBookingId, setSelectedBookingId] = useState( null, ); - useEffect(() => { - if (!dayGroups.length) return; - if (selectedDate && dayGroups.some((d) => d.date === selectedDate)) return; - const preferred = - dayGroups.find((d) => d.date === todayEat) ?? - dayGroups.find((d) => d.totalBookings > 0) ?? - dayGroups[0]; - setSelectedDate(preferred.date); - }, [dayGroups, selectedDate, todayEat]); - - const selectedIndex = Math.max( - 0, - dayGroups.findIndex((d) => d.date === selectedDate), - ); - const selectedDay = dayGroups[selectedIndex]; const handleCompleteDocReview = () => { completeDocReview @@ -1012,137 +992,30 @@ export default function BatchScheduleDetailPage() { - Batch windows (EAT) + Booking window (EAT) - 3-hour windows for every day from when the booking window - opened through the departure date. Bookings appear under the - date their contract was signed — open a day to see its - windows. + The schedule's real booking window — the same window and + phase timings the customer sees on the portal. Bookings in + the window are listed below. - {dayGroups.length && selectedDay ? ( - <> - {/* Date stepper — page back/forward through each day in the range */} - - - setSelectedDate( - dayGroups[selectedIndex - 1]?.date ?? null, - ) - } - > - - + - - - - - {selectedDay.dateLabel} - - {selectedDay.date === todayEat ? ( - - Today - - ) : null} - - - {selectedDay.totalBookings - ? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows` - : `${selectedDay.windows.length} windows · no bookings`} - - - - = dayGroups.length - 1} - onClick={() => - setSelectedDate( - dayGroups[selectedIndex + 1]?.date ?? null, - ) - } - > - - - - - - - Day {selectedIndex + 1} of {dayGroups.length} + {windowBookings.length ? ( + + + + Bookings in this window - - {selectedDay.hasIssues ? ( - } - > - Issues - - ) : null} - - + - - - {selectedDay.windows.map((window) => ( - - ))} - - + + ) : ( - No batch windows for this schedule. + No bookings in this window yet. )} From 4994d140029354ec44f39a266353b93d63a6d125 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 00:23:40 +0000 Subject: [PATCH 48/90] fix --- .../src/modules/first-mile/first-mile.controller.ts | 9 ++++++++- .../src/modules/last-mile/last-mile.controller.ts | 9 ++++++++- apps/edr-freight-api/src/seed/pricing-data.seeder.ts | 3 +++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 928882a7f..49175a6f3 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -93,7 +94,13 @@ export class FirstMileController { @ApiOperation({ summary: 'Generate the first-mile delivery-fee invoice' }) async generateInvoice(@Param('id', ParseUUIDPipe) id: string) { const record = await this.firstMileService.findById(id); - return this.firstMileInvoiceService.ensureInvoiceFor(record); + const invoice = await this.firstMileInvoiceService.ensureInvoiceFor(record); + if (!invoice) { + throw new BadRequestException( + 'Cannot generate invoice: the leg has no billable amount. Add distance and ensure a FIRST_MILE rate is configured, and the booking has a company.', + ); + } + return invoice; } @Delete(':id') diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 0b2ec1dbe..2931a1f85 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -118,6 +119,12 @@ export class LastMileController { @ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' }) async generateInvoice(@Param('id', ParseUUIDPipe) id: string) { const record = await this.lastMileService.findById(id); - return this.lastMileInvoiceService.ensureInvoiceFor(record); + const invoice = await this.lastMileInvoiceService.ensureInvoiceFor(record); + if (!invoice) { + throw new BadRequestException( + 'Cannot generate invoice: the leg has no billable amount. Add distance and ensure a LAST_MILE rate is configured, and the booking has a company.', + ); + } + return invoice; } } diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 14ccb3efb..aa3f2c9bb 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -415,6 +415,9 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 2, rateUnit: "PER_TON" }, { appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, + // ── First/last-mile road haulage (per km) — drives the mile invoices ── + { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" }, + { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" }, ]; // Idempotent: insert each canonical rate only if no row with the same From 6bf737171667dafff7b0f94f7018ee6e0f5cd596 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 00:37:36 +0000 Subject: [PATCH 49/90] fix --- .../first-mile/first-mile-invoice.service.ts | 3 ++- .../last-mile/last-mile-invoice.service.ts | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts index f7d9ee11f..f4935a87b 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts @@ -57,7 +57,8 @@ export class FirstMileInvoiceService { return null; } - const totalAmount = record.remainingPayment || 0; + // numeric columns come back as strings — coerce before the finite/>0 check. + const totalAmount = Number(record.remainingPayment) || 0; if (!Number.isFinite(totalAmount) || totalAmount <= 0) { this.logger.warn( `Skipping invoice for first-mile record ${record.id}: no remaining payment.`, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts index e40e94509..7b14f6887 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts @@ -54,6 +54,15 @@ export class LastMileInvoiceService { return null; } + // numeric columns come back as strings — coerce before billing. + const totalAmount = Number(record.remainingPayment) || 0; + if (!Number.isFinite(totalAmount) || totalAmount <= 0) { + this.logger.warn( + `Skipping invoice for last-mile record ${record.id}: no remaining payment.`, + ); + return null; + } + // Generate invoice with remainingPayment as totalAmount const input: GenerateInvoiceInput = { source: 'last_mile' as Freight.InvoiceSource, @@ -67,11 +76,11 @@ export class LastMileInvoiceService { chargeType: 'DELIVERY', description: 'Last-mile delivery', quantity: 1, - unitRate: record.remainingPayment || 0, - amount: record.remainingPayment || 0, + unitRate: totalAmount, + amount: totalAmount, }, ], - totalAmount: record.remainingPayment || 0, + totalAmount, }; return this.billing.generateInvoice(input); From 97cc9d76b141c58e3bd4cfb5716535a1e05fbb8f Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 00:41:28 +0000 Subject: [PATCH 50/90] enhance booking windows section with pagination and improved UI --- .../bookings/booking-pricing.service.ts | 19 +- .../bookings/booking-transition.service.ts | 87 ++++- .../modules/bookings/bookings.repository.ts | 22 +- .../contracts/contract-booking.service.ts | 120 +++--- .../modules/contracts/contracts.controller.ts | 2 +- .../train-scheduling.controller.ts | 10 + .../train-scheduling.service.ts | 44 +++ .../contracts/GlCreateBookingForm.tsx | 135 ++++++- .../contracts/GlUpcomingWindowsSection.tsx | 352 +++++++++++------- .../backoffice/src/constants/URLS.ts | 2 + .../features/bookings/mapBookingListRow.ts | 1 + .../src/hooks/bookings/useBookings.ts | 8 +- .../pages/bookings/BookingRequestsPage.tsx | 21 +- .../backoffice/src/services/api.ts | 8 + .../src/services/contracts.service.ts | 45 +++ .../src/services/trainScheduling.service.ts | 8 + .../backoffice/src/types/booking.ts | 4 + .../backoffice/src/types/trainScheduling.ts | 21 ++ .../components/UpcomingWindowsSection.tsx | 106 ++++-- .../src/pages/contracts/NewShipmentPage.tsx | 50 ++- .../portal/src/services/contracts.service.ts | 25 +- 21 files changed, 805 insertions(+), 285 deletions(-) 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 e8469e627..d63106c2b 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 @@ -431,7 +431,7 @@ export class BookingPricingService { const lines: PriceLineItemDto[] = []; const usedRatesMap = new Map(); - const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); + const wagonCount = await this.resolveWagonCount(booking); for (const container of evalInput.containers) { const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD'); @@ -571,6 +571,23 @@ export class BookingPricingService { return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; } + /** + * Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate; + * an unsaved preview booking (no id) sums the wagonsRequired already computed + * on its in-memory container lines — same math, no DB row needed. + */ + private async resolveWagonCount(booking: Booking): Promise { + if (!booking.id) { + return Math.ceil( + (booking.bookingContainers ?? []).reduce( + (sum, bc) => sum + Number(bc.wagonsRequired ?? 0), + 0, + ), + ); + } + return this.bookingsRepository.calculateWagonCount(booking.id); + } + /** Friendly container-type label for the per-unit card; degrades to "Container". */ private async containerTypeLabel(containerTypeId: string): Promise { try { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 8423e9606..35fc7fd54 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -1051,7 +1051,22 @@ export class BookingTransitionService { // paid → auto-allocated by the settle/paid pipeline. Consolidated bookings // only reserve once both partners are FULLY_EXECUTED (handled inside). const fresh = await this.bookingsService.findById(booking.id); - await this.bookingBatchService.acceptExportBooking(fresh); + try { + await this.bookingBatchService.acceptExportBooking(fresh); + } catch (err) { + // The status update above already committed. Without compensation the + // client gets an error for a booking that reads as accepted after a + // refresh — half-applied state. Put the request back so staff can retry. + await this.bookingsRepository.update(booking.id, { + status: "OPERATION_REQUEST_PENDING", + fullyExecutedAt: null, + lockedAt: booking.lockedAt ?? null, + } as never); + this.logger.warn( + `Export accept failed post-commit for ${booking.reference}:${booking.id}; reverted to OPERATION_REQUEST_PENDING: ${(err as Error).message}`, + ); + throw err; + } } // IMPORT and DOMESTIC bookings wait for their booking-day window cycle — the // batch runs after the window closes + staff document review, never at accept @@ -1073,23 +1088,59 @@ export class BookingTransitionService { } | null; } > { - const note = await this.bookingsRepository.findLatestReviewNote( - booking.id, - "CHANGES_REQUESTED", - ); - const summary = - booking.contractSummary ?? - this.contractService.buildContractSummary(booking); - const nextPending = - booking.status === "PENDING_APPROVAL" || - booking.status === "APPROVED_PENDING_SIGNATURE" - ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) - : null; - const nextStep = computeNextStep(booking, nextPending); - const activeBatchOffer = - booking.status === "SELECTED_FOR_BATCH" - ? await this.bookingBatchService.getOpenOfferSummary(booking.id) - : null; + // This enrichment runs AFTER the transition has committed. A failure here + // must never 500 the response — the client would report "failed" for a + // transition that actually succeeded (visible only after a refresh). + // Degrade each fragile field to null instead. + let note: Awaited< + ReturnType + > = null; + try { + note = await this.bookingsRepository.findLatestReviewNote( + booking.id, + "CHANGES_REQUESTED", + ); + } catch (err) { + this.logger.warn( + `enrichBookingResponse: review-note lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } + let summary: string | null = booking.contractSummary ?? null; + try { + summary = + booking.contractSummary ?? + this.contractService.buildContractSummary(booking); + } catch (err) { + this.logger.warn( + `enrichBookingResponse: contract summary failed for ${booking.id}: ${(err as Error).message}`, + ); + } + let nextStep: BookingNextStep | null = null; + try { + const nextPending = + booking.status === "PENDING_APPROVAL" || + booking.status === "APPROVED_PENDING_SIGNATURE" + ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) + : null; + nextStep = computeNextStep(booking, nextPending); + } catch (err) { + this.logger.warn( + `enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } + let activeBatchOffer: Awaited< + ReturnType + > = null; + try { + activeBatchOffer = + booking.status === "SELECTED_FOR_BATCH" + ? await this.bookingBatchService.getOpenOfferSummary(booking.id) + : null; + } catch (err) { + this.logger.warn( + `enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } return { ...booking, latestChangeRequestNote: note?.note ?? null, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 913565f70..25cf4f875 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -587,6 +587,10 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.serviceType', 'serviceType') .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') + // Contract reference for the list column + search (no entity relation on + // Booking → contract, so join by id and select just the reference). + .leftJoin('freight.contracts', 'contract', 'contract.id = booking.contract_id') + .addSelect('contract.reference', 'contract_reference') .where('booking.deleted_at IS NULL'); this.applyListFilters(qb, options); @@ -605,10 +609,24 @@ export class BookingsRepository extends BaseRepository { qb.orderBy(sortField, options.sortOrder ?? 'DESC'); } - const [items, total] = await qb + const total = await qb.getCount(); + const { entities: items, raw } = await qb .skip((page - 1) * pageSize) .take(pageSize) - .getManyAndCount(); + .getRawAndEntities(); + + // The joined contract.reference comes back on the raw rows only (entity has no + // contract relation) — map it onto each booking by position. + const contractRefByBooking = new Map(); + for (const row of raw as Array<{ booking_id: string; contract_reference: string | null }>) { + if (row.booking_id && !contractRefByBooking.has(row.booking_id)) { + contractRefByBooking.set(row.booking_id, row.contract_reference ?? null); + } + } + for (const item of items) { + (item as Booking & { contractReference?: string | null }).contractReference = + contractRefByBooking.get(item.id) ?? null; + } if (items.length) { const links = await this.dataSource.getRepository(TrainScheduleBooking).find({ diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 01b35fc71..3ea19f778 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -8,13 +8,13 @@ import { forwardRef, } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { ExchangeService } from '@edr/api-common'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; +import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; @@ -66,7 +66,6 @@ export class ContractBookingService { private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly dataSource: DataSource, - private readonly exchangeService: ExchangeService, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, ) {} @@ -587,11 +586,14 @@ export class ContractBookingService { } /** - * Pre-create validation for the shipment form: run the overweight rule + the - * 20ft weight-pairing rule against the entered containers WITHOUT persisting a - * booking. The portal calls this from the price-confirm modal so the customer - * sees the overweight warning (+ surcharge basis) and is blocked on an - * un-pairable 20ft set before the booking is created. + * Pre-create validation + authoritative price preview for the shipment form: + * build an UNSAVED booking shaped exactly like {@link createUnderContract} + * would persist it and run the same BookingPricingService compute over it — + * base rail freight, first/last-mile trucking, and every rule-engine surcharge + * (overweight, hazard, reefer, consolidation, …). The portal and the GL + * backoffice form call this from the price-confirm modal, so the breakdown the + * user confirms is line-for-line what the booking will be charged. Also runs + * the 20ft weight-pairing rule, which hard-blocks creation. */ async validateShipment( contractId: string, @@ -606,22 +608,26 @@ export class ContractBookingService { overweightSurchargeAmount: number; currency: string | null; pairingErrors: string[]; + lineItems: PriceLineItemDto[]; + totalAmount: number; }> { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); const lines = dto.containers ?? []; - if (!lines.length) { + if (contract.freightType === 'CONTAINER' && !lines.length) { return { overweightLines: [], overweightSurchargeAmount: 0, currency: null, pairingErrors: [], + lineItems: [], + totalAmount: 0, }; } - // Resolve each line's container type + total VGM (sum of unit weights) so the - // rule engine can flag overweight per line (maxVgmTons × quantity vs total). + // Resolve each container line's type + total VGM (sum of unit weights) — + // mirrors persistContainers so the preview lines match the persisted ones. const resolved = await Promise.all( lines.map(async (line) => { const ct = await this.resolveContainerTypeForSize( @@ -636,46 +642,44 @@ export class ContractBookingService { }), ); - const ruleResult = await this.ruleEngineService.evaluate({ - freightType: 'CONTAINER', - cargoTypeId: null, - serviceTypeId: contract.serviceTypeId, - paymentCurrency: contract.paymentCurrency, + // The unsaved twin of the booking createUnderContract would write: same + // denormalized contract fields, same container-line math. No id → the + // pricing service derives wagon counts from the in-memory lines. + const route = await this.resolveRoute(contract, dto.contractRouteId); + const previewBooking = Object.assign(new Booking(), { + freightType: contract.freightType, tradeDirection: contract.tradeDirection, - isHazardous: false, - isReefer: contract.isReefer ?? false, - isGovernment: false, - allowConsolidation: false, + paymentCurrency: contract.paymentCurrency, + serviceTypeId: contract.serviceTypeId, + cargoTypeId: this.resolveCargoTypeId(contract, dto), + isHazardous: contract.isHazardous, + isReefer: contract.isReefer, + isGovernment: contract.isGovernment, shippingLineId: null, - totalWagons: 0, - bulkTons: 0, - containers: resolved.map((r) => ({ - containerTypeId: r.ct.id, - quantity: r.line.quantity, - vgmPerUnitTons: r.line.quantity ? r.totalVgmTons / r.line.quantity : 0, - totalVgmTons: r.totalVgmTons, - isReefer: r.ct.isReefer, - })), - } as never); + contractRouteId: route?.id ?? null, + cargoTotalWeightVgm: this.resolveBulkTons(dto), + firstMilePickupAddress: contract.firstMilePickupAddress ?? null, + lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, + bookingContainers: resolved.map(({ line, ct, totalVgmTons }) => + Object.assign(new BookingContainer(), { + containerTypeId: ct.id, + containerSize: line.containerSize, + quantity: line.quantity, + hazardousQuantity: line.hazardousQuantity ?? 0, + reeferQuantity: line.reeferQuantity ?? 0, + vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, + totalVgmTons, + wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)), + }), + ), + }) as Booking; - const overweightLines: Array<{ - containerTypeCode: string; - totalVgmTons: number; - maxAllowedTons: number; - excessTons: number; - }> = []; - for (let i = 0; i < ruleResult.containerWeightResults.length; i++) { - const wr = ruleResult.containerWeightResults[i]; - if (!wr?.isOverweight) continue; - const r = resolved[i]; - const excessTons = Number(wr.overweightExcessTons ?? 0); - overweightLines.push({ - containerTypeCode: r?.ct.code ?? r?.line.containerSize ?? '', - totalVgmTons: r?.totalVgmTons ?? 0, - maxAllowedTons: Math.max(0, (r?.totalVgmTons ?? 0) - excessTons), - excessTons, - }); - } + const computed = await this.bookingPricingService.computePriceForBooking(previewBooking); + + // The overweight surcharge line is already currency-converted; surface its + // amount separately so the warning alert can reference the exact charge. + const overweightSurchargeAmount = + computed.lineItems.find((li) => li.code === 'OVERWEIGHT_PER_TON')?.amount ?? 0; // 20ft weight-pairing: gather every 20ft unit weight and check the pair rule. const twentyFtUnits = resolved @@ -691,27 +695,13 @@ export class ContractBookingService { (v) => v.message, ); - // Real overweight surcharge (same rate the rule engine bills at booking-create - // time) so the confirm-modal total isn't missing the charge the warning refers to. - // Rates are stored in USD; convert to the contract's payment currency the same - // way BookingPricingService does so this preview matches the eventual booking total. - const overweightModifier = ruleResult.appliedModifiers.find( - (m) => m.surchargeCode === 'OVERWEIGHT_PER_TON', - ); - let overweightSurchargeAmount = 0; - if (overweightModifier) { - const isEtb = contract.paymentCurrency === 'ETB'; - const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; - overweightSurchargeAmount = isEtb - ? Math.round(overweightModifier.calculatedAmount * usdToEtb) - : overweightModifier.calculatedAmount; - } - return { - overweightLines, + overweightLines: computed.overweightLines, overweightSurchargeAmount, - currency: overweightLines.length ? contract.paymentCurrency : null, + currency: computed.currency, pairingErrors, + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 09e4a7ffd..06ac31d68 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -791,7 +791,7 @@ export class ContractsController { @Post(':id/validate-shipment') @ApiOperation({ summary: - 'Pre-create validation: overweight lines + 20ft weight-pairing errors for a shipment payload (no booking created).', + 'Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created).', }) validateShipment( @Param('id', ParseUUIDPipe) id: string, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 22e322953..773e4738a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -83,6 +83,16 @@ export class TrainSchedulingController { return this.trainSchedulingService.getBookingWindowsForContract(contractId); } + @Get("booking-windows") + @TrainSchedulingView() + @ApiOperation({ + summary: + "All announced booking windows across lanes (import cycle + export FCFS), for staff dashboards", + }) + listBookingWindows() { + return this.trainSchedulingService.listAllBookingWindows(); + } + @Get("global-rules") @TrainSchedulingView() @ApiOperation({ summary: "Get global train scheduling rules (singleton)" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index bac226c0a..93846d31d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -3220,6 +3220,50 @@ export class TrainSchedulingService { return rows.map((r) => this.mapBookingWindowRow(r)); } + /** + * All announced booking windows across every lane — import window cycles AND + * export FCFS lead windows — for staff dashboards (GL clearance queue). Same + * phase filter as the customer-facing lists, no contract scoping. + */ + async listAllBookingWindows() { + const rows: Array< + Omit & { + train_number: string | null; + } + > = await this.dataSource.query( + `SELECT ts.id AS schedule_id, + ts.train_number, + ts.direction, + ts.window_phase, + ts.window_opens_at, + ts.window_closes_at, + ts.doc_review_ends_at, + ts.payment_phase_ends_at, + ts.booking_window_status, + ts.booking_cycle_no, + ts.scheduled_departure_date, + oy.label AS origin_label, oy.code AS origin_code, + dy.label AS destination_label, dy.code AS destination_code + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.window_phase IS NOT NULL + AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') + AND ts.scheduled_departure_date >= now() + ORDER BY ts.window_opens_at ASC NULLS LAST`, + ); + return rows.map((r) => ({ + ...this.mapBookingWindowRow({ + ...r, + contract_id: null, + contract_kind: null, + }), + trainNumber: r.train_number, + })); + } + private mapBookingWindowRow(r: BookingWindowRow) { return { scheduleId: r.schedule_id, 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 ef9b2453f..d437d2904 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -4,7 +4,7 @@ import { useParams, useSearchParams, } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Alert, Box, @@ -25,6 +25,7 @@ import { } from "@mantine/core"; import { AlertCircle, + AlertTriangle, CalendarDays, CheckCircle2, ChevronLeft, @@ -365,8 +366,11 @@ export default function GlCreateBookingForm() { (!needsRouteSelect || Boolean(contractRouteId)) && (isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0); - const handleSubmit = () => { - if (!scheduledDate || !contract || !windowOpen) return; + /** The create-booking DTO from the current form state — shared by the + * authoritative price preview and the actual submit so what GL confirms is + * exactly what gets booked. */ + const buildPayload = (): Freight.CreateBookingUnderContractDto | null => { + if (!scheduledDate || !contract) return null; const payload: Freight.CreateBookingUnderContractDto = { scheduledDate, @@ -408,6 +412,56 @@ export default function GlCreateBookingForm() { })); } + return payload; + }; + + // Authoritative price preview (same pricing pass the booking persists at + // create): rail freight + first/last mile + overweight + every surcharge. + // Fired when the price modal opens; the modal falls back to the contract + // unit-rate estimate while it loads. + const validateShipmentMutation = useMutation({ + mutationFn: (dto: Freight.CreateBookingUnderContractDto) => + contractsService.validateShipment(id ?? "", dto), + }); + const validation = validateShipmentMutation.data ?? null; + + const serverTotal = useMemo(() => { + const items = validation?.lineItems; + if (!items?.length) return null; + return { + currency: validation?.currency ?? priceTotal?.currency ?? "ETB", + lines: items.map((li) => ({ + label: li.description, + unitPrice: li.unitAmount, + unit: li.unit.toLowerCase(), + quantity: li.quantity, + amount: li.amount, + })), + total: + validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), + }; + }, [validation, priceTotal]); + + const displayTotal = serverTotal ?? priceTotal; + const pairingErrors = validation?.pairingErrors ?? []; + const overweightLines = validation?.overweightLines ?? []; + + const openPriceModal = () => { + setPriceOpen(true); + const payload = buildPayload(); + if (payload) { + validateShipmentMutation.reset(); + validateShipmentMutation.mutate(payload); + } + }; + + const handleSubmit = () => { + if (!contract || !windowOpen) return; + // Never book past unresolved 20ft pairing hard-blocks. + if (pairingErrors.length > 0) return; + const payload = buildPayload(); + if (!payload) return; + mutations.createBooking.mutate(payload, { onSuccess: async (booking) => { if (requestId) { @@ -819,7 +873,7 @@ export default function GlCreateBookingForm() { radius="md" leftSection={} disabled={!canSubmit} - onClick={() => setPriceOpen(true)} + onClick={openPriceModal} > Review price & book @@ -850,11 +904,67 @@ export default function GlCreateBookingForm() { } > - {priceTotal ? ( + {displayTotal ? ( + {validateShipmentMutation.isPending && ( + + + + Computing the final price breakdown and checking container + weights… + + + )} + + {pairingErrors.length > 0 && ( + } + title="Cannot create booking — 20ft wagon pairing" + > + + {pairingErrors.map((msg, i) => ( + + {msg} + + ))} + + Adjust the 20ft container weights or quantities so pairs + differ by no more than 10 tons. + + + + )} + + {overweightLines.length > 0 && ( + } + title="Overweight containers" + > + + {overweightLines.map((line, i) => ( + + {line.containerTypeCode}: {line.totalVgmTons}t exceeds + limit {line.maxAllowedTons}t (+{line.excessTons}t + overweight) + + ))} + + An overweight surcharge applies (included in the total + below). + + + + )} + - {priceTotal.lines.map((line, i) => ( + {displayTotal.lines.map((line, i) => ( @@ -862,16 +972,16 @@ export default function GlCreateBookingForm() { {line.quantity.toLocaleString()} ×{" "} - {line.unitPrice.toLocaleString()} {priceTotal.currency} ·{" "} + {line.unitPrice.toLocaleString()} {displayTotal.currency} ·{" "} {formatRateUnit(line.unit)} - {line.amount.toLocaleString()} {priceTotal.currency} + {line.amount.toLocaleString()} {displayTotal.currency} ))} - {priceTotal.lines.length === 0 && ( + {displayTotal.lines.length === 0 && ( No priced lines — check the cargo details. @@ -889,9 +999,9 @@ export default function GlCreateBookingForm() { Total - {priceTotal.total.toLocaleString()}{" "} + {displayTotal.total.toLocaleString()}{" "} - {priceTotal.currency} + {displayTotal.currency} @@ -912,6 +1022,9 @@ export default function GlCreateBookingForm() { radius="md" leftSection={} loading={mutations.createBooking.isPending} + disabled={ + validateShipmentMutation.isPending || pairingErrors.length > 0 + } onClick={handleSubmit} > Confirm & book diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx index 9d9573909..e5e998721 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -1,14 +1,31 @@ -import { useMemo } from "react"; -import { Badge, Box, Card, Group, ScrollArea, Skeleton, Stack, Text } from "@mantine/core"; +import { useMemo, useState } from "react"; +import { + ActionIcon, + Badge, + Box, + Card, + Group, + SimpleGrid, + Skeleton, + Stack, + Text, +} from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; -import { ArrowRight, CalendarClock } from "lucide-react"; +import { + ArrowRight, + CalendarClock, + ChevronLeft, + ChevronRight, +} from "lucide-react"; import { CountdownTimer } from "@edr/ui-common"; import { api } from "@/services/api"; -import type { BatchBoardSchedule } from "@/types/trainScheduling"; +import type { StaffBookingWindow } from "@/types/trainScheduling"; /** All window times are communicated in East Africa Time. */ const TZ = "Africa/Addis_Ababa"; +/** Cards visible per carousel page. */ +const PER_PAGE = 3; function fmtDay(iso: string): string { return new Date(iso).toLocaleDateString("en-GB", { @@ -28,7 +45,7 @@ function fmtTime(iso: string): string { }); } -function windowLabel(w: BatchBoardSchedule): string { +function windowLabel(w: StaffBookingWindow): string { if (w.windowOpensAt && w.windowClosesAt) { return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} – ${fmtTime( w.windowClosesAt, @@ -42,36 +59,33 @@ function windowLabel(w: BatchBoardSchedule): string { /** * The countdown for whichever phase the window is currently in, mirroring the - * customer portal. Phases run pre-window (opens at windowOpensAt) → open (closes - * at windowClosesAt) → document review (docReviewEndsAt) → payment - * (paymentPhaseEndsAt). `expiredText` names the NEXT step so a deadline that - * lapses between the 60s refetches announces what comes next rather than the - * bare word "Expired". Returns null when no phase is timing down. + * customer portal. `expiredText` names the NEXT step so a deadline that lapses + * between refetches announces what comes next rather than the bare "Expired". */ function phaseCountdown( - w: BatchBoardSchedule, + w: StaffBookingWindow, ): { label: string; deadline: string; expiredText: string } | null { switch (w.windowPhase) { case "PRE_WINDOW": return w.windowOpensAt ? { - label: "Booking opens in", + label: "Opens in", deadline: w.windowOpensAt, - expiredText: "Booking opening now…", + expiredText: "Opening now…", } : null; case "OPEN": return w.windowClosesAt ? { - label: "Window closes in", + label: "Closes in", deadline: w.windowClosesAt, - expiredText: "Document review starting…", + expiredText: "Review starting…", } : null; case "DOC_REVIEW": return w.docReviewEndsAt ? { - label: "Document review ends in", + label: "Doc review ends in", deadline: w.docReviewEndsAt, expiredText: "Payment starting…", } @@ -79,9 +93,9 @@ function phaseCountdown( case "PAYMENT": return w.paymentPhaseEndsAt ? { - label: "Payment window ends in", + label: "Payment ends in", deadline: w.paymentPhaseEndsAt, - expiredText: "Payment window closing…", + expiredText: "Closing…", } : null; default: @@ -89,15 +103,11 @@ function phaseCountdown( } } -function isOpenNow(w: BatchBoardSchedule): boolean { - return w.windowPhase === "OPEN" && w.bookingWindowStatus === "OPEN"; -} - /** Drop windows whose booking window (or the train itself) has already passed. */ -function isPast(w: BatchBoardSchedule): boolean { +function isPast(w: StaffBookingWindow): boolean { const now = Date.now(); const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null; - const departs = w.scheduleDate ? new Date(w.scheduleDate).getTime() : null; + const departs = w.departureDate ? new Date(w.departureDate).getTime() : null; // Still live while in a post-close staff phase (doc review / payment). if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false; if (departs != null && departs <= now) return true; @@ -105,16 +115,121 @@ function isPast(w: BatchBoardSchedule): boolean { return false; } +function WindowCard({ w }: { w: StaffBookingWindow }) { + const cd = phaseCountdown(w); + const open = w.isOpenNow; + const isImport = w.direction === "IMPORT"; + + return ( + + + + + {w.direction ? ( + + {isImport ? "Import" : "Export"} + + ) : ( + + )} + + {open + ? "Open now" + : (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")} + + + + + + {w.origin ?? "—"} + + + + {w.destination ?? "—"} + + + {w.trainNumber ? ( + + Train {w.trainNumber} + + ) : null} + + + + + {windowLabel(w)} + + + {w.departureDate ? ( + + Departs {fmtDay(w.departureDate)} + + ) : null} + + + {cd ? ( + + + + ) : null} + + + ); +} + /** - * Upcoming / open import booking windows across all train schedules, shown to GL - * ET on the clearance queue so they can see which lanes are accepting bookings - * (mirrors the customer's portal "Booking Windows" card). Hidden when nothing is - * pending. Windows already past close/departure are dropped. + * All announced booking windows (import cycles + export FCFS) across every lane, + * shown to GL ET on the clearance queue as a paged carousel — three lanes per + * page, arrows to flip. Mirrors the customer's portal "Booking Windows" card. + * Hidden when nothing is pending. */ export function GlUpcomingWindowsSection() { const { data, isLoading } = useQuery( - api.trainScheduling.batchBoard.queryOptions({ refetchInterval: 60_000 }), + api.trainScheduling.allBookingWindows.queryOptions({ + refetchInterval: 60_000, + }), ); + const [page, setPage] = useState(0); const windows = useMemo(() => { const rows = (data ?? []).filter( @@ -122,7 +237,7 @@ export function GlUpcomingWindowsSection() { ); // Open lanes first, then by opening time. return rows.sort((a, b) => { - const openDiff = Number(isOpenNow(b)) - Number(isOpenNow(a)); + const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow); if (openDiff !== 0) return openDiff; const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity; const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity; @@ -130,120 +245,87 @@ export function GlUpcomingWindowsSection() { }); }, [data]); + const pageCount = Math.max(1, Math.ceil(windows.length / PER_PAGE)); + const safePage = Math.min(page, pageCount - 1); + const visible = windows.slice( + safePage * PER_PAGE, + safePage * PER_PAGE + PER_PAGE, + ); + if (!isLoading && windows.length === 0) return null; return ( - - - - - Booking windows - - - Upcoming and open import booking windows across all lanes (EAT) - - + + + + + + Booking windows + + + Import and export booking windows across all lanes (EAT) + + + + + {pageCount > 1 ? ( + + setPage((p) => Math.max(0, p - 1))} + > + + + + {Array.from({ length: pageCount }, (_, i) => ( + setPage(i)} + style={{ + width: i === safePage ? 18 : 7, + height: 7, + borderRadius: 999, + cursor: "pointer", + background: + i === safePage + ? "var(--mantine-color-edr-green-6)" + : "var(--mantine-color-gray-3)", + transition: "width 200ms ease, background 200ms ease", + }} + /> + ))} + + = pageCount - 1} + onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))} + > + + + + ) : null} {isLoading ? ( - - {[1, 2].map((i) => ( - + + {[1, 2, 3].map((i) => ( + ))} - + ) : ( - - - {windows.map((w) => { - const open = isOpenNow(w); - const cd = phaseCountdown(w); - return ( - - - - - {w.origin ?? "—"} - - - - {w.destination ?? "—"} - - {w.trainNumber ? ( - - · {w.trainNumber} - - ) : null} - - - {windowLabel(w)} - {w.scheduleDate ? ` · Departs ${fmtDay(w.scheduleDate)}` : ""} - - {cd ? ( - - - - ) : null} - - - - {w.direction ? ( - - {w.direction === "IMPORT" ? "Import" : "Export"} - - ) : null} - - {open - ? "Open now" - : w.windowPhase === "PRE_WINDOW" && w.windowOpensAt - ? `Opens ${fmtTime(w.windowOpensAt)} EAT` - : (w.windowPhase ?? w.bookingWindowStatus).replace( - /_/g, - " ", - )} - - - - ); - })} - - + + {visible.map((w) => ( + + ))} + )} ); diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 351c95e7e..d0f09f508 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -201,6 +201,7 @@ export const URL_CONSTANTS = { CLEARANCE_HISTORY: "/contracts/clearance/history", OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history", BOOKINGS: (id: string) => `/contracts/${id}/bookings`, + VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`, CAPACITY: (id: string) => `/contracts/${id}/capacity`, // Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking. BOOKING_REQUEST_QUEUE: "/contracts/booking-requests/queue", @@ -295,6 +296,7 @@ export const URL_CONSTANTS = { MOVE_BOOKING_SCHEDULE: (bookingId: string) => `/train-scheduling/bookings/${bookingId}/move-schedule`, GLOBAL_RULES: "/train-scheduling/global-rules", + BOOKING_WINDOWS: "/train-scheduling/booking-windows", PREVIEW: "/train-scheduling/preview", ASSIGN_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/assign-bookings`, diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts index ec4708868..a5bc34170 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts @@ -18,6 +18,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow { return { id: booking.id, reference: booking.reference, + contractReference: booking.contractReference ?? null, approvalSteps: booking.approvalSteps, customerLabel: booking.isGovernment ? (booking.governmentInstitution ?? "Government") diff --git a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts index 8ea6f6f27..1dc2b13ef 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts @@ -78,7 +78,13 @@ export function useBookingMutations(bookingId: string) { note?: string; }) => api.bookings.reviewOperation.call({ id: bookingId, ...payload }), onSuccess: (data) => onSuccess(data, "Operation request reviewed"), - onError: () => toast.error("Failed to review operation request"), + onError: (error) => { + toast.error(parseApiError(error, "Failed to review operation request")); + // The transition may have committed even when the response errored (e.g. + // a post-accept step failed). Refetch so the UI shows the true state + // instead of requiring a manual refresh. + void invalidateBookingDetail(qc, bookingId); + }, }); const approveStep = useMutation({ diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index e45d64c50..477900ee1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -149,7 +149,8 @@ export default function BookingRequestsPage() { return items.filter( (b) => b.reference.toLowerCase().includes(q) || - b.customerLabel.toLowerCase().includes(q), + b.customerLabel.toLowerCase().includes(q) || + (b.contractReference?.toLowerCase().includes(q) ?? false), ); }, [data?.items, query]); @@ -196,6 +197,22 @@ export default function BookingRequestsPage() { ); }, }, + { + id: "contract", + header: () => Contract, + cell: ({ row }) => { + const ref = row.original.contractReference; + return ( +
+ {ref ? ( + {ref} + ) : ( + + )} +
+ ); + }, + }, { id: "route", header: () => Route, @@ -373,7 +390,7 @@ export default function BookingRequestsPage() { } value={query} onChange={(e) => setQuery(e.target.value)} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 91685ef8a..7c6746ec7 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -54,6 +54,7 @@ import type { LocomotiveRecord, PinWagonsPayload, RecordCheckpointPayload, + StaffBookingWindow, TrainScheduleDetail, TrainScheduleFilters, TrainScheduleListItem, @@ -223,6 +224,13 @@ export const api = { () => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(), ), + allBookingWindows: endpoint( + "train-scheduling", + "all-booking-windows", + () => trainSchedulingService.getAllBookingWindows(), + () => ["train-scheduling", "all-booking-windows"], + ), + batchBoardDetail: endpoint< { scheduleId: string }, BatchBoardScheduleDetail diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index 369e53ea8..0c28a8ad7 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -27,6 +27,39 @@ export interface PaginatedContracts { total: number; } +/** One line of the server-priced booking breakdown (mirrors PriceLineItemDto). */ +export interface ShipmentPriceLine { + code: string; + description: string; + amount: number; + unitAmount: number; + /** Rate unit as stored: PER_CONTAINER | PER_WAGON | PER_TON | PER_KM | FLAT | … */ + unit: string; + quantity: number; + currency: string; +} + +/** + * Pre-create validation + authoritative price preview for a booking under a + * contract. `lineItems`/`totalAmount` are the full server-computed breakdown — + * the same pricing pass the booking persists at create (rail freight, + * first/last mile, overweight and every other surcharge). `pairingErrors` are + * HARD BLOCKS; `overweightLines` are warnings. + */ +export interface ShipmentValidation { + overweightLines: Array<{ + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; + }>; + overweightSurchargeAmount: number; + currency: string | null; + pairingErrors: string[]; + lineItems?: ShipmentPriceLine[]; + totalAmount?: number; +} + export interface ContractListSummaryMetrics { inQueue: number; needsAction: number; @@ -471,6 +504,18 @@ export const contractsService = { payload: Freight.CreateBookingUnderContractDto, ) => postContract<{ id: string; reference: string }>(C.BOOKINGS(id), payload), + /** + * Pre-create validation + authoritative price preview: the same + * BookingPricingService pass that prices the booking on create (rail + + * first/last mile + every surcharge), plus overweight warnings and 20ft + * pairing hard-blocks. Shown in the GL price-confirm modal. + */ + validateShipment: ( + id: string, + payload: Freight.CreateBookingUnderContractDto, + ) => + postContract(C.VALIDATE_SHIPMENT(id), payload), + /** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */ getCapacity: async (id: string): Promise => { const response = await client.get(C.CAPACITY(id)); diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index 8ec6a5783..aa178d4b7 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -21,6 +21,7 @@ import type { LocomotiveRecord, PinWagonsPayload, RecordCheckpointPayload, + StaffBookingWindow, TrainScheduleDetail, TrainScheduleFilters, TrainScheduleListItem, @@ -543,6 +544,13 @@ export const trainSchedulingService = { return unwrap(response.data); }, + getAllBookingWindows: async (): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WINDOWS, + ); + return unwrap(response.data); + }, + updateGlobalRules: async ( payload: Partial>, ): Promise => { diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index a89e10378..1caf29726 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -191,6 +191,9 @@ export interface BookingDetail { customsClearingEnabled?: boolean; customsClearingAgent?: string | null; contractKind?: "ONE_TIME" | "GENERAL" | null; + contractId?: string | null; + /** Reference of the contract this booking was created under (list column + search). */ + contractReference?: string | null; contractSummary?: string | null; latestChangeRequestNote?: string | null; nextStep?: BookingNextStep | null; @@ -218,6 +221,7 @@ export interface BookingDetail { export interface BookingListRow { id: string; reference: string; + contractReference?: string | null; customerLabel: string; approvalSteps?: BookingApprovalStep[]; status: BookingStatus; diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 3316117f7..d629311ee 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -226,6 +226,27 @@ export interface BatchBoardBooking { state: BatchBoardBookingState; } +/** + * An announced booking window on any lane (import cycle or export FCFS), for + * staff dashboards. Mirrors the customer portal's MyBookingWindow. + */ +export interface StaffBookingWindow { + scheduleId: string; + trainNumber: string | null; + direction: "IMPORT" | "EXPORT" | null; + windowPhase: BookingWindowPhase | string | null; + isOpenNow: boolean; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + bookingWindowStatus: string; + bookingCycleNo: number; + departureDate: string; + origin: string | null; + destination: string | null; +} + export interface BatchBoardSchedule { scheduleId: string; trainNumber: string | null; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx index 300ff643f..6dcece7ef 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx @@ -1,7 +1,11 @@ -import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core"; -import { memo } from "react"; -import { useNavigate } from "react-router-dom"; -import { ArrowRight, CalendarClock, PackagePlus } from "lucide-react"; +import { ActionIcon, Box, Group, Skeleton, Stack, Text } from "@mantine/core"; +import { memo, useMemo, useState } from "react"; +import { + ArrowRight, + CalendarClock, + ChevronLeft, + ChevronRight, +} from "lucide-react"; import { CountdownTimer } from "@edr/ui-common"; import type { MyBookingWindow } from "@/services/bookings.service"; import { Card } from "./Card"; @@ -175,11 +179,32 @@ interface UpcomingWindowsSectionProps { * lane the customer has an active contract for carry a "Book now" action; * others route to the contract list. Hidden entirely when nothing is announced. */ +/** Rows shown per carousel page. */ +const PER_PAGE = 3; + export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ windows, isLoading, }: UpcomingWindowsSectionProps) { - const navigate = useNavigate(); + const [page, setPage] = useState(0); + + // Open lanes first, then by opening time — the ones the customer can act on + // lead the carousel. + const sorted = useMemo( + () => + [...windows].sort((a, b) => { + const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow); + if (openDiff !== 0) return openDiff; + const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity; + const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity; + return at - bt; + }), + [windows], + ); + + const pageCount = Math.max(1, Math.ceil(sorted.length / PER_PAGE)); + const safePage = Math.min(page, pageCount - 1); + const visible = sorted.slice(safePage * PER_PAGE, safePage * PER_PAGE + PER_PAGE); // Nothing upcoming — keep the dashboard uncluttered. if (!isLoading && windows.length === 0) return null; @@ -195,17 +220,58 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ Upcoming and open booking windows across all lanes + + {pageCount > 1 ? ( + + setPage((p) => Math.max(0, p - 1))} + > + + + + {Array.from({ length: pageCount }, (_, i) => ( + setPage(i)} + style={{ + width: i === safePage ? 18 : 7, + height: 7, + borderRadius: 999, + cursor: "pointer", + background: i === safePage ? "#0A6F4D" : "#D8E2EB", + transition: "width 200ms ease, background 200ms ease", + }} + /> + ))} + + = pageCount - 1} + onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))} + > + + + + ) : null} {isLoading ? ( - {[1, 2].map((i) => ( + {[1, 2, 3].map((i) => ( ))} ) : ( - - {windows.map((w) => ( + + {visible.map((w) => ( + {/* Windows are informational here — booking is done from the + contract page while a window is open, not via a home CTA. */} - {/* ONE_TIME contracts book via their own single-shipment flow, - not window drawdown — show the window + countdown but no - "Book now" entry. */} - {w.isOpenNow && w.contractKind !== "ONE_TIME" && ( - - )} ))} 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 b2542881a..ddcff24b7 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -218,8 +218,6 @@ function NewShipmentBookingForm({ mode: "onChange", }); - const isContainerContract = contract.freightType === "CONTAINER"; - const submitMutation = useMutation({ mutationFn: (dto: Freight.CreateBookingUnderContractDto) => api.contracts.createBookingUnderContract.call({ id: contractId, dto }), @@ -287,15 +285,14 @@ function NewShipmentBookingForm({ } // Submit validates the whole form, then opens the price modal for - // confirmation. For container contracts we also run the server-side shipment - // validation (overweight warnings + 20ft pairing hard-blocks) so the modal - // can surface them before the booking is created. + // confirmation. The server-side shipment validation also returns the + // authoritative price breakdown (rail + first/last mile + every surcharge) — + // run it for every freight type; container contracts additionally get + // overweight warnings + 20ft pairing hard-blocks surfaced in the modal. const handleReview = form.handleSubmit((values) => { setPendingValues(values); - if (isContainerContract) { - validateMutation.reset(); - validateMutation.mutate(buildDto(values)); - } + validateMutation.reset(); + validateMutation.mutate(buildDto(values)); }); const handleConfirm = () => { @@ -449,12 +446,32 @@ function PriceConfirmModal({ const hasPairingBlock = pairingErrors.length > 0; const confirmDisabled = loading || validationLoading || hasPairingBlock; - // The contract's frozen unit rates (computeShipmentTotal) don't carry an - // overweight line — that surcharge only exists in the live rule engine. Fold - // the real amount from validateShipment into the displayed total so the - // customer sees the actual charge the overweight warning refers to, not just - // the warning text. + // Authoritative server breakdown — the SAME BookingPricingService pass that + // prices the booking on create, so it carries every line the booking will be + // charged: rail freight, first/last mile trucking, overweight, hazard/reefer + // and any other rule-engine surcharge. + const serverTotal = useMemo(() => { + const items = validation?.lineItems; + if (!items?.length) return null; + return { + currency: validation?.currency ?? baseTotal?.currency ?? "ETB", + lines: items.map((li) => ({ + label: li.description, + unitPrice: li.unitAmount, + unit: li.unit.toLowerCase(), + quantity: li.quantity, + amount: li.amount, + })), + total: + validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), + }; + }, [validation, baseTotal]); + + // Fallback while the server preview loads: the contract's frozen unit rates + // (container/bulk + hazard/reefer only) with the overweight surcharge folded + // in. Replaced by the full server breakdown the moment it arrives. const total = useMemo(() => { + if (serverTotal) return serverTotal; if (!baseTotal) return null; if (!(overweightSurchargeAmount > 0)) return baseTotal; return { @@ -471,7 +488,7 @@ function PriceConfirmModal({ ], total: baseTotal.total + overweightSurchargeAmount, }; - }, [baseTotal, overweightSurchargeAmount]); + }, [serverTotal, baseTotal, overweightSurchargeAmount]); return ( - Checking container weights and wagon pairing… + Computing the final price breakdown and checking container + weights… )} diff --git a/apps/edr-freight-web/portal/src/services/contracts.service.ts b/apps/edr-freight-web/portal/src/services/contracts.service.ts index 7c981eee2..0642e8061 100644 --- a/apps/edr-freight-web/portal/src/services/contracts.service.ts +++ b/apps/edr-freight-web/portal/src/services/contracts.service.ts @@ -42,18 +42,37 @@ export interface OverweightLine { } /** - * Pre-submit validation for a shipment booking under a CONTAINER contract. + * One line of the server-priced booking breakdown — the exact line the booking + * will persist at create time (rail freight, first/last mile, surcharges…). + */ +export interface ShipmentPriceLine { + code: string; + description: string; + amount: number; + unitAmount: number; + /** Rate unit as stored: PER_CONTAINER | PER_WAGON | PER_TON | PER_KM | FLAT | … */ + unit: string; + quantity: number; + currency: string; +} + +/** + * Pre-submit validation + authoritative price preview for a shipment booking. * `overweightLines` are WARNINGS only (an overweight surcharge applies — the * customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers * that cannot be balanced onto wagons) and must prevent booking. - * `overweightSurchargeAmount` is the real overweight charge (same rate the - * booking is billed at on submit) so the confirm-modal total can include it. + * `lineItems`/`totalAmount` are the full server-computed breakdown — the same + * BookingPricingService pass that prices the booking on create, so the confirm + * modal shows first/last mile, overweight, and every surcharge, not just the + * container estimate. */ export interface ShipmentValidation { overweightLines: OverweightLine[]; overweightSurchargeAmount: number; currency: string | null; pairingErrors: string[]; + lineItems?: ShipmentPriceLine[]; + totalAmount?: number; } export interface ContractListFilter { From 414ed92590ea9d3ccde235ae748f5d2cdcc34df7 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 00:58:14 +0000 Subject: [PATCH 51/90] fix --- .../src/pages/operations/FirstMilePage.tsx | 18 ++++++++++++++++-- .../src/pages/operations/LastMilePage.tsx | 18 ++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 182ff7cac..68dcf62f7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -64,6 +64,19 @@ const STATUS_META: Record RECEIVED_TO_PORT: { label: "Received to Port", color: "green" }, }; +// Invoice payment state → badge color, keyed by upper-cased status. +const INVOICE_STATUS_META: Record = { + PAID: { label: "Paid", color: "green" }, + PARTIALLY_PAID: { label: "Partially Paid", color: "teal" }, + PENDING: { label: "Pending", color: "yellow" }, + UNPAID: { label: "Unpaid", color: "yellow" }, + OPEN: { label: "Open", color: "yellow" }, + ISSUED: { label: "Issued", color: "blue" }, + OVERDUE: { label: "Overdue", color: "red" }, + CANCELLED: { label: "Cancelled", color: "gray" }, + VOID: { label: "Void", color: "gray" }, +}; + const NEXT_STATUS: Partial> = { PAYMENT_PENDING: "READY_TO_TRANSIT", READY_TO_TRANSIT: "IN_TRANSIT", @@ -757,7 +770,8 @@ const FirstMilePage = () => { if (!invoice) { return ; } - const isPaid = (row.original as any).paid || invoice.status === "Paid"; + const status = String((row.original as any).paid ? "PAID" : invoice.status || "").toUpperCase(); + const badge = INVOICE_STATUS_META[status] ?? { color: "gray", label: status || "—" }; return ( { > {invoice.number} - {isPaid && Paid} + {status && {badge.label}} ); }, diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 0267bd1ce..ace94f85d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -71,6 +71,19 @@ const STATUS_META: Record = DELIVERED: { label: "Delivered", color: "green" }, }; +// Invoice payment state → badge color, keyed by upper-cased status. +const INVOICE_STATUS_META: Record = { + PAID: { label: "Paid", color: "green" }, + PARTIALLY_PAID: { label: "Partially Paid", color: "teal" }, + PENDING: { label: "Pending", color: "yellow" }, + UNPAID: { label: "Unpaid", color: "yellow" }, + OPEN: { label: "Open", color: "yellow" }, + ISSUED: { label: "Issued", color: "blue" }, + OVERDUE: { label: "Overdue", color: "red" }, + CANCELLED: { label: "Cancelled", color: "gray" }, + VOID: { label: "Void", color: "gray" }, +}; + const NEXT_STATUS: Partial> = { PAYMENT_PENDING: "READY_TO_TRANSIT", READY_TO_TRANSIT: "IN_TRANSIT", @@ -1128,7 +1141,8 @@ const LastMilePage = () => { if (!invoice) { return ; } - const isPaid = (row.original as any).paid || invoice.status === "Paid"; + const status = String((row.original as any).paid ? "PAID" : invoice.status || "").toUpperCase(); + const badge = INVOICE_STATUS_META[status] ?? { color: "gray", label: status || "—" }; return ( { > {invoice.number} - {isPaid && Paid} + {status && {badge.label}} ); }, From 8ea2c8e95aff4e03c97a79626431644d91a2a35c Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 01:11:23 +0000 Subject: [PATCH 52/90] add hard capacity ceiling to weight limit rules --- ...000000-AddMaxCapacityToWeightLimitRules.ts | 28 ++++ .../modules/bookings/bookings.repository.ts | 4 +- .../contracts/contract-booking.service.ts | 56 +++++++ .../dto/create-weight-limit-rule.dto.ts | 15 +- .../entities/weight-limit-rule.entity.ts | 8 + .../rule-engine/rule-engine.service.ts | 38 +++++ .../services/weight-limit-rules.service.ts | 32 +++- .../train-scheduling.service.ts | 41 +++-- .../train-scheduling/wagon-plan.util.ts | 12 +- .../contracts/GlCreateBookingForm.tsx | 29 +++- .../ScheduleWorkspacePanel.tsx | 37 +++- .../ruleEngine/RuleEngineResourcePage.tsx | 4 + .../src/pages/ruleEngine/config/resources.ts | 14 ++ .../src/services/contracts.service.ts | 6 +- .../src/pages/bookings/NewBookingPage.tsx | 15 +- .../pages/bookings/new-booking-form/schema.ts | 8 +- .../bookings/new-booking-form/step4-route.tsx | 158 +----------------- .../src/pages/contracts/NewShipmentPage.tsx | 29 +++- .../portal/src/services/contracts.service.ts | 2 + 19 files changed, 340 insertions(+), 196 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1930000000000-AddMaxCapacityToWeightLimitRules.ts diff --git a/apps/edr-freight-api/src/migrations/1930000000000-AddMaxCapacityToWeightLimitRules.ts b/apps/edr-freight-api/src/migrations/1930000000000-AddMaxCapacityToWeightLimitRules.ts new file mode 100644 index 000000000..b1218a9b3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1930000000000-AddMaxCapacityToWeightLimitRules.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add a hard per-unit weight ceiling to weight limit rules. + * + * maxVgmTons stays the soft "overweight" threshold (surcharge + warning); + * max_capacity_tons is the absolute ceiling above which a booking cannot be + * created at all. Null means no ceiling (existing behavior). + */ +export class AddMaxCapacityToWeightLimitRules1930000000000 + implements MigrationInterface +{ + name = "AddMaxCapacityToWeightLimitRules1930000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.weight_limit_rules + ADD COLUMN IF NOT EXISTS max_capacity_tons numeric(8, 3); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.weight_limit_rules + DROP COLUMN IF EXISTS max_capacity_tons; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 25cf4f875..4803f988c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1077,7 +1077,9 @@ export class BookingsRepository extends BaseRepository { company: true, originYard: true, destinationYard: true, - bookingContainers: { containerType: true }, + // units carry the real per-container numbers entered at booking time — + // the wagon plan shows those instead of generated placeholders. + bookingContainers: { containerType: true, units: true }, cargoType: true, }, order: { priorityScore: 'DESC', createdAt: 'ASC' }, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 3ea19f778..c1eb4b4f8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -133,6 +133,13 @@ export class ContractBookingService { }); } + // Hard capacity gate: a container line whose total weight exceeds the + // container type's max capacity can never be booked — no surcharge path, + // no override. Checked before any row is written. + if (freightType === 'CONTAINER') { + await this.assertWithinMaxCapacity(contract, dto); + } + // Denormalize route/direction/freight onto the booking for the scheduling engine. const booking = await this.bookingsRepository.create({ reference, @@ -608,6 +615,7 @@ export class ContractBookingService { overweightSurchargeAmount: number; currency: string | null; pairingErrors: string[]; + capacityErrors: string[]; lineItems: PriceLineItemDto[]; totalAmount: number; }> { @@ -621,6 +629,7 @@ export class ContractBookingService { overweightSurchargeAmount: 0, currency: null, pairingErrors: [], + capacityErrors: [], lineItems: [], totalAmount: 0, }; @@ -695,16 +704,63 @@ export class ContractBookingService { (v) => v.message, ); + // Hard capacity ceiling — a non-empty result means the create call will be + // rejected, so the form can block submit up front. + const capacityErrors = await this.ruleEngineService.capacityViolations( + resolved.map(({ line, ct, totalVgmTons }) => ({ + containerTypeId: ct.id, + quantity: line.quantity, + totalVgmTons, + })), + contract.tradeDirection, + ); + return { overweightLines: computed.overweightLines, overweightSurchargeAmount, currency: computed.currency, pairingErrors, + capacityErrors, lineItems: computed.lineItems, totalAmount: computed.totalAmount, }; } + /** + * Throws when any container line's total weight exceeds the hard capacity + * ceiling of its weight limit rule. Mirrors validateShipment's line + * resolution so the gate matches what the form preview reported. + */ + private async assertWithinMaxCapacity( + contract: Contract, + dto: CreateBookingUnderContractDto, + ): Promise { + const lines = dto.containers ?? []; + if (!lines.length) return; + + const containers = await Promise.all( + lines.map(async (line) => { + const ct = await this.resolveContainerTypeForSize( + line.containerSize, + contract.isReefer || (line.reeferQuantity ?? 0) > 0, + ); + const totalVgmTons = (line.units ?? []).reduce( + (s, u) => s + Number(u.vgmTons ?? 0), + 0, + ); + return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons }; + }), + ); + + const violations = await this.ruleEngineService.capacityViolations( + containers, + contract.tradeDirection, + ); + if (violations.length) { + throw new BadRequestException(violations.join('; ')); + } + } + private async max20ftPairDiffTons(): Promise { const row = await this.dataSource .getRepository(TrainSchedulingGlobalRules) diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts index eea223ae3..d60a56944 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts @@ -1,6 +1,6 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsIn, IsNumber, IsUUID, Min } from 'class-validator'; +import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const; @@ -21,4 +21,15 @@ export class CreateWeightLimitRuleDto { @Min(0) @Transform(({ value }) => Number(value)) maxVgmTons!: number; + + @ApiPropertyOptional({ + description: + 'Hard per-unit weight ceiling in tons — above this the booking cannot be created. Null/omitted = no ceiling.', + minimum: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => (value === null || value === undefined || value === '' ? null : Number(value))) + maxCapacityTons?: number | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts index b6b87b285..7a30cc20d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts @@ -18,4 +18,12 @@ export class WeightLimitRule extends BaseEntity { @Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true }) maxVgmTons!: number; + + /** + * Absolute per-unit weight ceiling in tons. Weight above maxVgmTons but at or + * below this is "overweight" (surcharge + warning); weight above this hard- + * blocks booking creation entirely. Null = no ceiling (overweight only). + */ + @Column({ name: 'max_capacity_tons', type: 'numeric', precision: 8, scale: 3, nullable: true }) + maxCapacityTons!: number | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 4b082e5fd..0fee8e75a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -136,6 +136,10 @@ export class RuleEngineService { } } + hardBlocked.push( + ...(await this.capacityViolations(input.containers, input.tradeDirection)), + ); + for (const container of input.containers) { const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( container.containerTypeId, @@ -296,6 +300,40 @@ export class RuleEngineService { }; } + /** + * Messages for container lines whose total weight exceeds the hard capacity + * ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking + * must not be created at all. Overweight (above maxVgmTons but within + * capacity) is NOT reported here — that is a surcharge, not a block. + */ + async capacityViolations( + containers: Array<{ + containerTypeId: string; + quantity: number; + totalVgmTons: number; + }>, + tradeDirection: string, + ): Promise { + const violations: string[] = []; + for (const container of containers) { + const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( + container.containerTypeId, + tradeDirection, + ); + const rule = rules[0]; + if (!rule || rule.maxCapacityTons == null) continue; + const perUnit = Number(rule.maxCapacityTons); + const maxTotal = perUnit * container.quantity; + if (container.totalVgmTons > maxTotal) { + const label = rule.containerType?.code ?? container.containerTypeId; + violations.push( + `${label} total weight ${container.totalVgmTons}t exceeds the maximum capacity of ${maxTotal}t (${perUnit}t per unit) — the booking cannot be created; reduce the cargo weight`, + ); + } + } + return violations; + } + /** * Ensure ITMLS default approval chains exist (container + bulk). Idempotent. */ diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts index bbd042296..44f5332f2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts @@ -1,4 +1,10 @@ -import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; @@ -62,13 +68,31 @@ export class WeightLimitRulesService { } } + /** + * Capacity is the hard ceiling; the VGM limit is the soft overweight + * threshold. A ceiling below the threshold would make every overweight + * booking impossible to create, which is never what the operator means. + */ + private assertCapacityAboveVgmLimit( + maxVgmTons: number, + maxCapacityTons: number | null | undefined, + ): void { + if (maxCapacityTons != null && Number(maxCapacityTons) < Number(maxVgmTons)) { + throw new BadRequestException( + 'Max capacity must be greater than or equal to the max VGM limit.', + ); + } + } + /** Create a new weight limit rule. */ async create(dto: CreateWeightLimitRuleDto): Promise { await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection); + this.assertCapacityAboveVgmLimit(dto.maxVgmTons, dto.maxCapacityTons); return this.repository.create({ containerTypeId: dto.containerTypeId, tradeDirection: dto.tradeDirection, maxVgmTons: dto.maxVgmTons, + maxCapacityTons: dto.maxCapacityTons ?? null, }); } @@ -79,6 +103,12 @@ export class WeightLimitRulesService { if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId; if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection; if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons; + if (dto.maxCapacityTons !== undefined) patch.maxCapacityTons = dto.maxCapacityTons; + + this.assertCapacityAboveVgmLimit( + patch.maxVgmTons ?? Number(existing.maxVgmTons), + patch.maxCapacityTons !== undefined ? patch.maxCapacityTons : existing.maxCapacityTons, + ); // Re-check uniqueness when the identity (container/direction) changes. if (dto.containerTypeId !== undefined || dto.tradeDirection !== undefined) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 93846d31d..a10847c17 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -605,16 +605,22 @@ export class TrainSchedulingService { ); if (!validation.valid) { + // Put the violation detail in the message itself — global exception + // filters flatten the body, and "Booking validation failed" alone tells + // staff nothing (e.g. which wagon type is missing at the yard). throw new BadRequestException({ - message: 'Booking validation failed', + message: `Booking validation failed: ${validation.violations.join('; ')}`, violations: validation.violations, warnings: validation.warnings, }); } if (!validation.bookings.length) { + const shortfall = validation.deferredBookings + .map((d) => `${d.reference}: ${d.reason}`) + .join('; '); throw new BadRequestException({ - message: 'No bookings fit on available fleet wagons', + message: `No wagons available for the selected bookings${shortfall ? ` — ${shortfall}` : ''}`, violations: ['Insufficient fleet wagons for the selected bookings'], warnings: validation.warnings, deferredBookings: validation.deferredBookings, @@ -628,12 +634,14 @@ export class TrainSchedulingService { if (!limitLoco) { throw new BadRequestException('Schedule train set has no locomotives'); } - if (limitLoco.maxPullWeightTons < totalWeightTons) { + // forceAssign lets staff overload the locomotive set knowingly — the + // validator has already surfaced it as a warning in that case. + if (!dto.forceAssign && limitLoco.maxPullWeightTons < totalWeightTons) { throw new BadRequestException( `Train set locomotives cannot pull ${totalWeightTons}T`, ); } - if (limitLoco.maxTrainLengthMeters < totalLengthMeters) { + if (!dto.forceAssign && limitLoco.maxTrainLengthMeters < totalLengthMeters) { throw new BadRequestException( `Train set locomotives cannot support ${totalLengthMeters}m`, ); @@ -2251,9 +2259,16 @@ export class TrainSchedulingService { max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons, }; + // With forceAssign, capacity-shaped rules (train limits, total weight, + // locomotive capability) become warnings — staff owns the override. Physical + // impossibilities (no wagon of the required type at the yard, wrong route, + // wrong status) can never be forced and stay violations. + const pushLimit = (issues: string[]) => + forceAssign ? warnings.push(...issues) : violations.push(...issues); + if (resolvedMode === 'MIXED') { - violations.push( - ...validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits), + pushLimit( + validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits), ); if (requireContainerPlacements) { const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER'); @@ -2270,7 +2285,7 @@ export class TrainSchedulingService { ); } } else { - violations.push(...validateTrainLimits(wagonPlan, wagonType, trainLimits)); + pushLimit(validateTrainLimits(wagonPlan, wagonType, trainLimits)); if (requireContainerPlacements && resolvedMode === 'CONTAINER') { violations.push( @@ -2293,8 +2308,8 @@ export class TrainSchedulingService { ); if (totalWeightTons > trainLimits.maxWeightTons) { const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`; - if (!violations.includes(message)) { - violations.push(message); + if (!violations.includes(message) && !warnings.includes(message)) { + pushLimit([message]); } } @@ -2321,9 +2336,9 @@ export class TrainSchedulingService { (setLimits.maxPullWeightTons < totalWeightTons || setLimits.maxTrainLengthMeters < totalLengthMeters) ) { - violations.push( + pushLimit([ 'Assigned locomotives cannot support the total train weight and length', - ); + ]); } } else { const inServiceLocomotives = await this.locomotivesRepository.findAll({ @@ -2341,7 +2356,7 @@ export class TrainSchedulingService { Number(l.maxTrainLengthMeters) >= totalLengthMeters, ) ) { - violations.push('No locomotive can support the total train weight and length'); + pushLimit(['No locomotive can support the total train weight and length']); } } @@ -3740,7 +3755,7 @@ export class TrainSchedulingService { if (!validation.valid) { throw new BadRequestException({ - message: 'Booking validation failed', + message: `Booking validation failed: ${validation.violations.join('; ')}`, violations: validation.violations, warnings: validation.warnings, }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 8c3199461..35d5ce185 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -210,7 +210,14 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5)); const perWagon = containersPerWagonFromType(wagonsPerUnit); const teuSlots = teuSlotsForSizeFt(sizeFt); + // The REAL per-container numbers/weights entered at booking time. Unit i of + // the line maps to units[i] (sortOrder order); the line-level number is only + // a legacy fallback — never invent numbers here. + const units = [...(line.units ?? [])].sort( + (a, b) => Number(a.sortOrder ?? 0) - Number(b.sortOrder ?? 0), + ); for (let i = 0; i < qty; i += 1) { + const unit = units[i]; rows.push({ bookingId: booking.id, bookingReference: booking.reference, @@ -219,12 +226,13 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR containerTypeId: line.containerTypeId ?? '', containerTypeCode: code, label: `${booking.reference} · ${i + 1}/${qty} · ${code}`, - grossWeightTons: Number(line.vgmPerUnitTons), + grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons), sizeFt, wagonsPerUnit, containersPerWagon: perWagon, teuSlots, - containerNumber: line.containerNumber ?? null, + containerNumber: + unit?.containerNumber?.trim() || line.containerNumber || null, }); } } 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 d437d2904..d967ffc3f 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -444,6 +444,7 @@ export default function GlCreateBookingForm() { const displayTotal = serverTotal ?? priceTotal; const pairingErrors = validation?.pairingErrors ?? []; + const capacityErrors = validation?.capacityErrors ?? []; const overweightLines = validation?.overweightLines ?? []; const openPriceModal = () => { @@ -459,6 +460,8 @@ export default function GlCreateBookingForm() { if (!contract || !windowOpen) return; // Never book past unresolved 20ft pairing hard-blocks. if (pairingErrors.length > 0) return; + // A line above the container type's max capacity can never book. + if (capacityErrors.length > 0) return; const payload = buildPayload(); if (!payload) return; @@ -938,6 +941,28 @@ export default function GlCreateBookingForm() { )} + {capacityErrors.length > 0 && ( + } + title="Cannot create booking — over maximum capacity" + > + + {capacityErrors.map((msg, i) => ( + + {msg} + + ))} + + Reduce the cargo weight or split it across more containers + to book this shipment. + + + + )} + {overweightLines.length > 0 && ( } loading={mutations.createBooking.isPending} disabled={ - validateShipmentMutation.isPending || pairingErrors.length > 0 + validateShipmentMutation.isPending || + pairingErrors.length > 0 || + capacityErrors.length > 0 } onClick={handleSubmit} > diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index 4c2ccb57e..2a7689582 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from "react"; +import { isAxiosError } from "axios"; import { Badge, Box, @@ -47,6 +48,18 @@ interface ScheduleWorkspacePanelProps { const GREEN = "var(--mantine-color-edr-green-6)"; +/** Pull the API's violation detail out of an error (e.g. "No CW3 wagon available…"). */ +function apiErrorMessage(error: unknown, fallback: string): string { + if (isAxiosError(error)) { + const data = error.response?.data as Record | undefined; + const violations = data?.violations; + if (Array.isArray(violations) && violations.length) return violations.join(", "); + if (typeof data?.message === "string") return data.message; + if (Array.isArray(data?.message)) return (data.message as string[]).join(", "); + } + return fallback; +} + /** * Deadline + label for the window phase this schedule is currently in. * Phases run: window open (windowClosesAt) → document review (docReviewEndsAt) @@ -198,8 +211,12 @@ export function ScheduleWorkspacePanel({ onChanged(); void poolQuery.refetch(); }) - .catch(() => - toast({ title: "Could not add booking", variant: "destructive" }), + .catch((error) => + toast({ + title: "Could not add booking", + description: apiErrorMessage(error, "Validation failed — check capacity and status."), + variant: "destructive", + }), ); }; @@ -211,8 +228,12 @@ export function ScheduleWorkspacePanel({ onChanged(); void poolQuery.refetch(); }) - .catch(() => - toast({ title: "Could not remove booking", variant: "destructive" }), + .catch((error) => + toast({ + title: "Could not remove booking", + description: apiErrorMessage(error, "Please try again."), + variant: "destructive", + }), ); }; @@ -226,8 +247,12 @@ export function ScheduleWorkspacePanel({ onChanged(); void poolQuery.refetch(); }) - .catch(() => - toast({ title: "Could not reassign booking", variant: "destructive" }), + .catch((error) => + toast({ + title: "Could not reassign booking", + description: apiErrorMessage(error, "Target train may be closed or full."), + variant: "destructive", + }), ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 99260946f..297d67050 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -341,6 +341,10 @@ const RuleEngineResourcePage = () => { } else if (config.slug === "priority-configs") { // Label is required by the backend but hidden in the UI for now. payload = { ...values, label: String(Date.now()) }; + } else if (config.slug === "weight-limit-rules") { + // Empty max capacity means "no ceiling" — send null explicitly so an + // edit can clear a previously-set ceiling (omitting the key keeps it). + payload = { ...values, maxCapacityTons: values.maxCapacityTons ?? null }; } if (editing?.id) { diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index ff4a15868..faa26036b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -374,6 +374,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ }, { id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" }, { id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" }, + { + id: "maxCapacityTons", + header: "Max capacity (t)", + accessorKey: "maxCapacityTons", + format: "number", + }, ], formFields: [ { @@ -391,6 +397,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ options: TRADE_DIRECTIONS, }, { name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true }, + { + name: "maxCapacityTons", + label: "Max capacity (tons)", + type: "number", + optional: true, + description: + "Hard ceiling — a booking whose line weight exceeds this cannot be created at all. Leave empty for no ceiling (overweight surcharge only).", + }, ], }, { diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index 0c28a8ad7..8860df62a 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -43,8 +43,8 @@ export interface ShipmentPriceLine { * Pre-create validation + authoritative price preview for a booking under a * contract. `lineItems`/`totalAmount` are the full server-computed breakdown — * the same pricing pass the booking persists at create (rail freight, - * first/last mile, overweight and every other surcharge). `pairingErrors` are - * HARD BLOCKS; `overweightLines` are warnings. + * first/last mile, overweight and every other surcharge). `pairingErrors` and + * `capacityErrors` are HARD BLOCKS; `overweightLines` are warnings. */ export interface ShipmentValidation { overweightLines: Array<{ @@ -56,6 +56,8 @@ export interface ShipmentValidation { overweightSurchargeAmount: number; currency: string | null; pairingErrors: string[]; + /** Lines above the container type's hard max capacity — booking cannot be created. */ + capacityErrors?: string[]; lineItems?: ShipmentPriceLine[]; totalAmount?: number; } 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 80ed05eb0..afcd2f777 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -524,11 +524,10 @@ export default function NewBookingPage() { } : { customsClearingEnabled: false }), ...(cargoFreeText ? { cargoFreeText } : {}), - // Multi-route general contracts: routes are pure origin→destination lanes - // the contract covers — they carry NO quantity. Route #1 is the primary - // origin/destination; the rest come from the extra-routes step. The - // contracted quantity lives in a single shared pool (the container - // quantities / bulk total), drawn down per order against a chosen lane. + // A general contract covers exactly ONE route — the same single + // origin→destination pair as a one-time booking (multi-route on bookings + // was dropped). The contracted quantity lives in a single shared pool + // (container quantities / bulk total), drawn down per order. ...(isContract ? { routes: [ @@ -536,12 +535,6 @@ export default function NewBookingPage() { originYardId: data.originYard, destinationYardId: data.destinationYard, }, - ...(data.extraRoutes ?? []) - .filter((r) => r.originYard && r.destinationYard) - .map((r) => ({ - originYardId: r.originYard, - destinationYardId: r.destinationYard, - })), ], } : {}), diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 2b902bdc8..b137635ae 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -144,10 +144,9 @@ export const bookingFormSchema = z // The contracted quantity now comes from the cargo step (cargoWeight), the // same as a one-time booking, so per-route quantity is no longer entered. primaryRouteQuantity: z.string().default(""), - // Additional routes for a GENERAL contract (the primary origin/destination - // above is route #1). Each route is just an (origin, destination) pair — - // identical to the one-time route — so a contract can cover several routes. - // Ignored for one-time bookings. quantity/km kept for payload back-compat. + // LEGACY — multi-route general contracts were dropped; a contract booking + // now covers exactly one route, like a one-time booking. Field retained only + // so previously saved drafts still hydrate; never collected or sent anymore. extraRoutes: z .array( z.object({ @@ -434,7 +433,6 @@ export const stepFields: Record>> = { "originYard", "destinationYard", "primaryRouteQuantity", - "extraRoutes", // Estimated shipment date now lives in the Route step (one-time bookings only). "scheduledDate", ], diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index 783162a35..15b2c5a8e 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -1,26 +1,9 @@ import type { Freight } from "@edr/types"; -import { - Box, - Button, - Group, - Skeleton, - Stack, - Text, -} from "@mantine/core"; +import { Box, Skeleton, Stack } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; -import { - CalendarDays, - MapPin, - Plus, - Route as RouteIcon, - Trash2, -} from "lucide-react"; +import { CalendarDays, MapPin, Route as RouteIcon } from "lucide-react"; import { useCallback, useEffect, useMemo } from "react"; -import { - Controller, - useFieldArray, - type UseFormReturn, -} from "react-hook-form"; +import { Controller, type UseFormReturn } from "react-hook-form"; import { BookingFormInputValues, type BookingFormValues, @@ -70,16 +53,6 @@ export function Step4Route({ } }, [operationType]); - const { - fields: extraRoutes, - append: appendRoute, - remove: removeRoute, - } = useFieldArray({ control: form.control, name: "extraRoutes" }); - - // useFieldArray's `fields` don't re-render on value change, so watch the live - // route values to filter each row's yard options by what it has selected. - const watchedExtraRoutes = form.watch("extraRoutes") ?? []; - const yardOptions = useMemo(() => { if (!referenceData?.yard) return []; return referenceData.yard.map((y) => ({ value: y.id, label: y.name })); @@ -129,25 +102,6 @@ export function Step4Route({ } }, [destinationCountry, dest, form]); - // Same cleanup for the extra contract routes: when the operation type changes, - // clear any extra-route yard whose country no longer matches the required side - // so an added route can't contradict the operation either. - useEffect(() => { - watchedExtraRoutes.forEach((route, i) => { - const ro = referenceData?.yard.find((y) => y.id === route?.originYard); - if (originCountry && ro && ro.country !== originCountry) { - form.setValue(`extraRoutes.${i}.originYard`, ""); - } - const rd = referenceData?.yard.find( - (y) => y.id === route?.destinationYard, - ); - if (destinationCountry && rd && rd.country !== destinationCountry) { - form.setValue(`extraRoutes.${i}.destinationYard`, ""); - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [originCountry, destinationCountry, referenceData, form]); - const directionStyle: Record = { EXPORT: "bg-sky-50 text-sky-800 border-sky-200", IMPORT: "bg-amber-50 text-amber-800 border-amber-200", @@ -161,11 +115,10 @@ export function Step4Route({ const stationSelectDisabled = yardOptions.length === 0; - // A general contract can cover several routes, but each route is just an - // (origin, destination) pair — the same shape as the one-time route. The - // contracted quantity comes from the cargo step, so no per-route quantity or - // distance is collected here. Cargo handling (hazardous / refrigerated) also - // lives in the Cargo step now, not here. + // A general contract covers exactly ONE route — the same single + // origin/destination pair as a one-time booking. (Multi-route contracts were + // dropped; the multi-lane concept lives on the contracts module, not on + // bookings.) The contracted quantity comes from the cargo step. // Earliest selectable shipment date (today, local) for the date input's `min`. const todayISODate = useMemo(() => { @@ -256,103 +209,6 @@ export function Step4Route({ )} - {isGeneralContract && !isLoading && ( - - - Additional contract routes - - - - A general contract can cover several routes. The route above is your - primary route; add more origin–destination routes the contract should - cover. - - - {extraRoutes.map((rf, i) => { - // Each extra route is constrained by the SAME operation type as the - // primary route: its origin must sit in originCountry and its - // destination in destinationCountry. Watch this row's current values - // so each side also excludes the yard picked on the other side. - const rowOrigin = watchedExtraRoutes[i]?.originYard ?? ""; - const rowDestination = - watchedExtraRoutes[i]?.destinationYard ?? ""; - const rowOriginData = yardsForSide(originCountry, rowDestination); - const rowDestData = yardsForSide(destinationCountry, rowOrigin); - return ( - - - ( - - )} - /> - - - ( - - )} - /> - - - - ); - })} - - - )} - ); } 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 ddcff24b7..a2bc985c0 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -299,6 +299,8 @@ function NewShipmentBookingForm({ if (!pendingValues) return; // Guard: never let a booking with unresolved 20ft pairing errors submit. if ((validateMutation.data?.pairingErrors.length ?? 0) > 0) return; + // Guard: a line above the container type's max capacity can never book. + if ((validateMutation.data?.capacityErrors?.length ?? 0) > 0) return; submitMutation.mutate(buildDto(pendingValues)); }; @@ -444,7 +446,10 @@ function PriceConfirmModal({ const overweightSurchargeAmount = validation?.overweightSurchargeAmount ?? 0; const pairingErrors = validation?.pairingErrors ?? []; const hasPairingBlock = pairingErrors.length > 0; - const confirmDisabled = loading || validationLoading || hasPairingBlock; + const capacityErrors = validation?.capacityErrors ?? []; + const hasCapacityBlock = capacityErrors.length > 0; + const confirmDisabled = + loading || validationLoading || hasPairingBlock || hasCapacityBlock; // Authoritative server breakdown — the SAME BookingPricingService pass that // prices the booking on create, so it carries every line the booking will be @@ -550,6 +555,28 @@ function PriceConfirmModal({ )} + {hasCapacityBlock && ( + } + title="Cannot create booking — over maximum capacity" + > + + {capacityErrors.map((msg, i) => ( + + {msg} + + ))} + + Reduce the cargo weight or split it across more containers to + book this shipment. + + + + )} + {overweightLines.length > 0 && ( Date: Sat, 4 Jul 2026 01:13:47 +0000 Subject: [PATCH 53/90] fix --- .../src/pages/operations/FirstMilePage.tsx | 17 +++++++++++++++-- .../src/pages/operations/LastMilePage.tsx | 9 ++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 68dcf62f7..2da6e5bb8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -33,6 +33,7 @@ import { Stack, Text, TextInput, + Tooltip, UnstyledButton, } from "@mantine/core"; @@ -64,6 +65,11 @@ const STATUS_META: Record RECEIVED_TO_PORT: { label: "Received to Port", color: "green" }, }; +// Middle-truncate a long invoice number for the table (full value on hover). +// "INV-20260704-00002" → "INV-2…02" +const shortInvoiceNo = (n: string) => + n && n.length > 9 ? `${n.slice(0, 5)}…${n.slice(-2)}` : n; + // Invoice payment state → badge color, keyed by upper-cased status. const INVOICE_STATUS_META: Record = { PAID: { label: "Paid", color: "green" }, @@ -784,7 +790,9 @@ const FirstMilePage = () => { fw={500} style={{ textDecoration: "underline", cursor: "pointer" }} > - {invoice.number} + + {shortInvoiceNo(invoice.number)} + {status && {badge.label}} @@ -812,7 +820,12 @@ const FirstMilePage = () => { const canReceiveToWarehouse = row.original.status === "RECEIVED_TO_PORT"; return ( - + diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index ace94f85d..31b4a6cff 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -71,6 +71,11 @@ const STATUS_META: Record = DELIVERED: { label: "Delivered", color: "green" }, }; +// Middle-truncate a long invoice number for the table (full value on hover). +// "INV-20260704-00002" → "INV-2…02" +const shortInvoiceNo = (n: string) => + n && n.length > 9 ? `${n.slice(0, 5)}…${n.slice(-2)}` : n; + // Invoice payment state → badge color, keyed by upper-cased status. const INVOICE_STATUS_META: Record = { PAID: { label: "Paid", color: "green" }, @@ -1155,7 +1160,9 @@ const LastMilePage = () => { fw={500} style={{ textDecoration: "underline", cursor: "pointer" }} > - {invoice.number} + + {shortInvoiceNo(invoice.number)} + {status && {badge.label}} From 58c49d34b2a0e73f838ed48459b7fc6979b56d4d Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 01:31:08 +0000 Subject: [PATCH 54/90] fix --- .../src/pages/operations/FirstMilePage.tsx | 33 ++++++++++++----- .../src/pages/operations/LastMilePage.tsx | 36 +++++++++++++------ .../src/services/first-mile.service.ts | 1 + .../src/services/last-mile.service.ts | 1 + 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 2da6e5bb8..f7a4b82bc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -52,8 +52,8 @@ import { vehiclesService } from "@/services/vehicles.service"; import { ratesService } from "@/services/rates.service"; import type { BookingDetail } from "@/types/booking"; -const formatPrice = (amount: number) => - `ETB ${amount.toLocaleString("en-US", { +const formatPrice = (amount: number | string | null | undefined, currency = "ETB") => + `${currency || "ETB"} ${(Number(amount) || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2, })}`; @@ -111,8 +111,17 @@ const vehicleLabel = (record: FirstMileRecord) => { const isAssigned = (record: FirstMileRecord) => Boolean(record.vehicleId); +// Paid = record flag set OR its invoice reached PAID. +const isPaidRecord = (r: FirstMileRecord) => + Boolean((r as { paid?: boolean }).paid) || + (r.invoice?.status ?? "").toUpperCase() === "PAID"; +// Post payment pending = a post payment is owed but not yet paid. +const isPostPaymentPending = (r: FirstMileRecord) => + Number(r.remainingPayment) > 0 && !isPaidRecord(r); + // Map API record → display fields used in modals and trip slip const bookingRef = (r: FirstMileRecord) => r.booking?.reference ?? r.bookingId; +const currencyOf = (r: FirstMileRecord) => r.booking?.paymentCurrency ?? "ETB"; const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—"; const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—"; const cargoDesc = (r: FirstMileRecord) => { @@ -169,8 +178,8 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => { {hasPickupAddress && } - - + + @@ -189,8 +198,8 @@ const tripSlipRows = (record: FirstMileRecord): [string, string][] => [ ["Pickup location", pickupLocation(record)], ["Destination yard", destinationYardName(record)], ["Cargo", cargoDesc(record)], - ["Advanced Payment", formatPrice(record.advancedPayment)], - ["Post Payment", formatPrice(record.remainingPayment)], + ["Advanced Payment", formatPrice(record.advancedPayment, currencyOf(record))], + ["Post Payment", formatPrice(record.remainingPayment, currencyOf(record))], ["Vehicle", vehicleLabel(record) ?? "Unassigned"], ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], ["Requested date", requestedDate(record)], @@ -589,6 +598,7 @@ const FirstMilePage = () => { }; const matchesFilter = (r: FirstMileRecord) => { + if (filterPostPaymentPending && !isPostPaymentPending(r)) return false; switch (statusFilter) { case "ALL": return true; case "ASSIGNED": return isAssigned(r); @@ -615,6 +625,11 @@ const FirstMilePage = () => { return counts; }, [records]); + const postPaymentPendingCount = useMemo( + () => records.filter(isPostPaymentPending).length, + [records], + ); + const filteredRecords = useMemo(() => { const term = search.trim().toLowerCase(); return records.filter((r) => { @@ -751,7 +766,7 @@ const FirstMilePage = () => { id: "postPayment", header: "Post Payment", meta: { headerClassName, cellClassName }, - cell: ({ row }) => formatPrice(row.original.remainingPayment), + cell: ({ row }) => formatPrice(row.original.remainingPayment, currencyOf(row.original)), }, { id: "vehicle", @@ -853,7 +868,7 @@ const FirstMilePage = () => { } - disabled={!assigned} + disabled={!assigned || Boolean(row.original.invoice)} onClick={() => openAssign(row.original.id)} > Reassign @@ -975,7 +990,7 @@ const FirstMilePage = () => { setPagination((p) => ({ ...p, pageIndex: 0 })); }} > - Post Payment Pending + Post Payment Pending ({postPaymentPendingCount}) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 31b4a6cff..d7545fd7b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -58,8 +58,8 @@ import { ratesService } from "@/services/rates.service"; import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal"; import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; -const formatPrice = (amount: number) => - `ETB ${amount.toLocaleString("en-US", { +const formatPrice = (amount: number | string | null | undefined, currency = "ETB") => + `${currency || "ETB"} ${(Number(amount) || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2, })}`; @@ -156,6 +156,14 @@ const containerLabels = (record: LastMileRecord): string[] => { const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId); +// Paid = record flag set OR its invoice reached PAID. +const isPaidRecord = (r: LastMileRecord) => + Boolean((r as { paid?: boolean }).paid) || + (r.invoice?.status ?? "").toUpperCase() === "PAID"; +// Post payment pending = a post payment is owed but not yet paid. +const isPostPaymentPending = (r: LastMileRecord) => + Number(r.remainingPayment) > 0 && !isPaidRecord(r); + const fmtStamp = (iso?: string | null) => { if (!iso) return null; const d = new Date(iso); @@ -209,6 +217,7 @@ const computeLastMileSteps = ( }; const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId; +const currencyOf = (r: LastMileRecord) => r.booking?.paymentCurrency ?? "ETB"; const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—"; const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—"; const cargoDesc = (r: LastMileRecord) => { @@ -311,8 +320,8 @@ const BookingInfo = ({ record }: { record: LastMileRecord }) => { {hasDeliveryAddress && } - - + + @@ -358,7 +367,7 @@ const tripSlipRows = ( ["Pickup (origin yard)", originYardName(record)], ["Destination", deliveryLocation(record)], ["Cargo", cargoDesc(record)], - ["Post Payment", formatPrice(record.remainingPayment)], + ["Post Payment", formatPrice(record.remainingPayment, currencyOf(record))], ...vehicleRows, ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], ["Requested date", requestedDate(record)], @@ -870,11 +879,16 @@ const LastMilePage = () => { return counts; }, [records]); + const postPaymentPendingCount = useMemo( + () => records.filter(isPostPaymentPending).length, + [records], + ); + const filteredRecords = useMemo(() => { const term = search.trim().toLowerCase(); return records.filter((r) => { if (!matchesFilter(r)) return false; - if (filterPostPaymentPending && !(r.remainingPayment > 0)) return false; + if (filterPostPaymentPending && !isPostPaymentPending(r)) return false; if (!term) return true; return [bookingRef(r), customerName(r), deliveryLocation(r), cargoDesc(r)] .join(" ") @@ -1089,7 +1103,7 @@ const LastMilePage = () => { id: "postPayment", header: "Post Payment", meta: { headerClassName, cellClassName }, - cell: ({ row }) => formatPrice(row.original.remainingPayment), + cell: ({ row }) => formatPrice(row.original.remainingPayment, currencyOf(row.original)), }, { id: "vehicle", @@ -1242,7 +1256,7 @@ const LastMilePage = () => { } - disabled={!assigned || delivered} + disabled={!assigned || delivered || Boolean(row.original.invoice)} onClick={() => openAssign(row.original.id)} > Reassign @@ -1250,7 +1264,7 @@ const LastMilePage = () => { } - disabled={!assigned || delivered} + disabled={!assigned || delivered || Boolean(row.original.invoice)} onClick={() => setVehiclesMutation.mutate( { id: row.original.id, vehicles: [] }, @@ -1387,7 +1401,7 @@ const LastMilePage = () => { setPagination((p) => ({ ...p, pageIndex: 0 })); }} > - Post Payment Pending + Post Payment Pending ({postPaymentPendingCount}) @@ -1928,7 +1942,7 @@ const LastMilePage = () => { Invoice amount - {formatPrice(invoiceConfirm.remainingPayment)} + {formatPrice(invoiceConfirm.remainingPayment, currencyOf(invoiceConfirm))} This creates the delivery-fee invoice. Confirm the distances and amount are correct. diff --git a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts index 1418cb636..a1c2ec8e2 100644 --- a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts @@ -16,6 +16,7 @@ export interface FirstMileBooking { cargoFreeText?: string | null; cargoTotalWeightVgm: number; totalAmount: number; + paymentCurrency?: string | null; scheduledDate?: string | null; company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null; serviceType?: { id: string; label?: string } | null; diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index c68c5a9a2..2f8a1cec5 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -16,6 +16,7 @@ export interface LastMileBooking { cargoFreeText?: string | null; cargoTotalWeightVgm: number; totalAmount: number; + paymentCurrency?: string | null; scheduledDate?: string | null; company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null; serviceType?: { id: string; name?: string; label?: string } | null; From bd88424167b7a0dece7fbc959f183280725c2891 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 01:47:28 +0000 Subject: [PATCH 55/90] fix --- ...00000000-AddFirstMileVehicleAssignments.ts | 42 +++ .../first-mile/dto/set-distances.dto.ts | 24 ++ .../first-mile/dto/set-vehicles.dto.ts | 19 + .../first-mile-vehicle-assignment.entity.ts | 39 +++ .../first-mile/entities/first-mile.entity.ts | 4 + .../first-mile/first-mile.controller.ts | 22 ++ .../modules/first-mile/first-mile.module.ts | 3 +- .../modules/first-mile/first-mile.service.ts | 212 ++++++++++- .../src/pages/operations/FirstMilePage.tsx | 331 ++++++++++++++---- .../src/services/first-mile.service.ts | 28 ++ 10 files changed, 637 insertions(+), 87 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1940000000000-AddFirstMileVehicleAssignments.ts create mode 100644 apps/edr-freight-api/src/modules/first-mile/dto/set-distances.dto.ts create mode 100644 apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts create mode 100644 apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts diff --git a/apps/edr-freight-api/src/migrations/1940000000000-AddFirstMileVehicleAssignments.ts b/apps/edr-freight-api/src/migrations/1940000000000-AddFirstMileVehicleAssignments.ts new file mode 100644 index 000000000..42f2c4eba --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1940000000000-AddFirstMileVehicleAssignments.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Allow more than one vehicle per first-mile pickup. Junction table joins + * first_mile ⇄ vehicles, with each truck's container number + actual distance; + * existing single vehicle_id values are backfilled as the first assignment so + * nothing is lost. Mirrors the last-mile vehicle-assignment schema. + */ +export class AddFirstMileVehicleAssignments1940000000000 implements MigrationInterface { + name = "AddFirstMileVehicleAssignments1940000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.first_mile_vehicle_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + first_mile_id uuid NOT NULL REFERENCES freight.first_mile(id) ON DELETE CASCADE, + vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id), + container_number varchar, + distance_km numeric(10,2), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT "UQ_FIRST_MILE_VEHICLE" UNIQUE (first_mile_id, vehicle_id) + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FM_VEHICLE_ASSIGNMENTS_VEHICLE" + ON freight.first_mile_vehicle_assignments (vehicle_id) + `); + // Backfill: existing single-vehicle assignments become the first row + await queryRunner.query(` + INSERT INTO freight.first_mile_vehicle_assignments (first_mile_id, vehicle_id) + SELECT id, vehicle_id FROM freight.first_mile + WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL + ON CONFLICT (first_mile_id, vehicle_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.first_mile_vehicle_assignments`); + } +} diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/set-distances.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/set-distances.dto.ts new file mode 100644 index 000000000..84247708b --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/dto/set-distances.dto.ts @@ -0,0 +1,24 @@ +import { IsArray, IsNumber, IsOptional, IsUUID, Min, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class VehicleDistanceInput { + @IsUUID() + vehicleId!: string; + + @IsNumber() + @Min(0) + distanceKm!: number; +} + +/** Per-vehicle actual distances for a first-mile pickup (multi-truck). */ +export class SetDistancesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => VehicleDistanceInput) + distances!: VehicleDistanceInput[]; + + /** Recomputed remaining payment (total km × rate), from the client. */ + @IsOptional() + @IsNumber() + remainingPayment?: number; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts new file mode 100644 index 000000000..8656b2109 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts @@ -0,0 +1,19 @@ +import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class FirstMileVehicleInput { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsString() + containerNumber?: string; +} + +/** Replace the full set of vehicles (with their container numbers) on a pickup. */ +export class SetVehiclesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => FirstMileVehicleInput) + vehicles!: FirstMileVehicleInput[]; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts new file mode 100644 index 000000000..39bf51a50 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm'; + +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { FirstMile } from './first-mile.entity'; + +/** + * One row per vehicle assigned to a first-mile pickup. A pickup can be served + * by several vehicles at once (multi-truck bookings); the legacy + * `first_mile.vehicle_id` column keeps pointing at the first assignment for + * backward compatibility. + */ +@Entity({ name: 'first_mile_vehicle_assignments', schema: 'freight' }) +@Unique(['firstMileId', 'vehicleId']) +@Index(['vehicleId']) +export class FirstMileVehicleAssignment extends BaseEntity { + @Column({ name: 'first_mile_id', type: 'uuid' }) + firstMileId!: string; + + @ManyToOne(() => FirstMile, (fm) => fm.vehicleAssignments, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'first_mile_id' }) + firstMile?: FirstMile; + + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { nullable: false, eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle; + + /** Container this truck carries — auto-filled from the booking's container + * number when known, else entered manually at assignment time. */ + @Column({ name: 'container_number', type: 'varchar', nullable: true }) + containerNumber?: string | null; + + /** Actual distance driven by this truck (km), entered per vehicle. */ + @Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) + distanceKm?: number | null; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index 27dcfef87..45a051028 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity'; +import { FirstMileVehicleAssignment } from './first-mile-vehicle-assignment.entity'; export const FIRST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -61,4 +62,7 @@ export class FirstMile extends BaseEntity { { eager: false }, ) containerAllocations!: FirstMileContainerAllocation[]; + + @OneToMany(() => FirstMileVehicleAssignment, (va) => va.firstMile) + vehicleAssignments?: FirstMileVehicleAssignment[]; } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 49175a6f3..e43fbcae8 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -18,6 +18,8 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; +import { SetVehiclesDto } from './dto/set-vehicles.dto'; +import { SetDistancesDto } from './dto/set-distances.dto'; import { FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileService } from './first-mile.service'; import { FirstMileInvoiceService } from './first-mile-invoice.service'; @@ -103,6 +105,26 @@ export class FirstMileController { return invoice; } + @Post(':id/vehicles') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Set the vehicles assigned to a first-mile pickup (multi-truck)' }) + async setVehicles( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetVehiclesDto, + ) { + return this.firstMileService.setVehicles(id, dto.vehicles); + } + + @Post(':id/distances') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' }) + async setDistances( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetDistancesDto, + ) { + return this.firstMileService.setDistances(id, dto.distances, dto.remainingPayment); + } + @Delete(':id') @TrainSchedulingManage() @HttpCode(HttpStatus.NO_CONTENT) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index 799ae14e6..51f5ccf4e 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -8,6 +8,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; import { FirstMile } from './entities/first-mile.entity'; import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; +import { FirstMileVehicleAssignment } from './entities/first-mile-vehicle-assignment.entity'; import { FirstMileController } from './first-mile.controller'; import { FirstMileInvoiceService } from './first-mile-invoice.service'; import { FirstMileRepository } from './first-mile.repository'; @@ -15,7 +16,7 @@ import { FirstMileService } from './first-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]), + TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation, FirstMileVehicleAssignment]), forwardRef(() => BillingModule), forwardRef(() => BookingsModule), VehiclesModule, diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index dba5e48c7..7cdf6398b 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere, IsNull, Not } from 'typeorm'; +import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; @@ -11,6 +11,7 @@ import { CreateFirstMileDto } from "./dto/create-first-mile.dto"; import { UpdateFirstMileDto } from "./dto/update-first-mile.dto"; import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity"; import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity"; +import { FirstMileVehicleAssignment } from "./entities/first-mile-vehicle-assignment.entity"; import { FirstMileRepository } from "./first-mile.repository"; import { OnEvent } from "@nestjs/event-emitter"; import { BillingService, InvoiceEventPayload } from "../billing/billing.service"; @@ -92,10 +93,15 @@ export class FirstMileService { directVehicleId?: string | null, ): Promise { if (directVehicleId) return true; - const count = await this.dataSource.manager.count(FirstMileContainerAllocation, { - where: { firstMileId: recordId, vehicleId: Not(IsNull()) }, - }); - return count > 0; + const [junction, allocations] = await Promise.all([ + this.dataSource.manager.count(FirstMileVehicleAssignment, { + where: { firstMileId: recordId }, + }), + this.dataSource.manager.count(FirstMileContainerAllocation, { + where: { firstMileId: recordId, vehicleId: Not(IsNull()) }, + }), + ]); + return junction > 0 || allocations > 0; } /** Human booking reference for a first-mile record, for the history timeline. */ @@ -204,6 +210,7 @@ export class FirstMileService { cargoType: true, }, vehicle: true, + vehicleAssignments: { vehicle: true }, }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, @@ -250,6 +257,7 @@ export class FirstMileService { cargoType: true, }, vehicle: true, + vehicleAssignments: { vehicle: true }, }, }); @@ -490,18 +498,154 @@ export class FirstMileService { * allocations), unless still in use by another active trip. */ private async releaseVehicles(record: FirstMile): Promise { - const recordAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, { - where: { firstMileId: record.id }, - }); - const vehicleIds = recordAllocations - .map((a) => a.vehicleId) - .filter((id): id is string => Boolean(id)); - if (record.vehicleId) { - vehicleIds.push(record.vehicleId); - } + const [assignments, recordAllocations] = await Promise.all([ + this.dataSource.manager.find(FirstMileVehicleAssignment, { + where: { firstMileId: record.id }, + }), + this.dataSource.manager.find(FirstMileContainerAllocation, { + where: { firstMileId: record.id }, + }), + ]); + const vehicleIds = [ + ...new Set( + [ + ...assignments.map((a) => a.vehicleId), + ...recordAllocations.map((a) => a.vehicleId), + record.vehicleId ?? null, + ].filter((id): id is string => Boolean(id)), + ), + ]; await this.vehiclesService.releaseIfUnused(vehicleIds); } + /** + * Replace the full set of vehicles serving a first-mile pickup (multi-truck). + * Diffs against the current junction rows, syncing availability + audit history + * for each added/removed vehicle. The first vehicle is mirrored onto the legacy + * `vehicleId` column for back-compat with single-vehicle readers. + */ + async setVehicles( + id: string, + inputs: Array<{ vehicleId: string; containerNumber?: string | null }>, + ): Promise { + const existing = await this.findById(id); + // Dedupe by vehicleId, keeping the container number; preserve order. + const desiredMap = new Map(); + for (const inp of inputs) { + if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null); + } + const desired = [...desiredMap.keys()]; + const desiredSet = new Set(desired); + + const manager = this.dataSource.manager; + const current = await manager.find(FirstMileVehicleAssignment, { + where: { firstMileId: id }, + }); + const junctionSet = new Set(current.map((a) => a.vehicleId)); + // Fold the legacy vehicleId into the release set — a vehicle assigned via the + // old single-vehicle path has no junction row but must still be freed. + const releaseIds = [...new Set( + current.map((a) => a.vehicleId).concat(existing.vehicleId ? [existing.vehicleId] : []), + )]; + const added = desired.filter((v) => !junctionSet.has(v)); + const removed = releaseIds.filter((v) => !desiredSet.has(v)); + // Vehicles that stay but whose container number changed. + const changed = current.filter( + (a) => + desiredMap.has(a.vehicleId) && + (a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null), + ); + + await this.dataSource.transaction(async (tx) => { + if (removed.length) { + await tx.delete(FirstMileVehicleAssignment, { + firstMileId: id, + vehicleId: In(removed), + }); + } + for (const vehicleId of added) { + await tx.insert(FirstMileVehicleAssignment, { + firstMileId: id, + vehicleId, + containerNumber: desiredMap.get(vehicleId) ?? null, + }); + } + for (const row of changed) { + await tx.update( + FirstMileVehicleAssignment, + { firstMileId: id, vehicleId: row.vehicleId }, + { containerNumber: desiredMap.get(row.vehicleId) ?? null }, + ); + } + }); + + // Legacy primary vehicle = first of the set (null when cleared). + await this.firstMileRepository.update(id, { vehicleId: desired[0] ?? null } as any); + + const bookingRef = await this.resolveBookingRef(existing); + for (const vehicleId of added) { + await this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY); + void this.notifyDriverAssignment(vehicleId, existing); + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId, + firstMileId: id, + driverId: info.driverId, + label: existing.status, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + for (const vehicleId of removed) { + await this.vehiclesService.releaseIfUnused([vehicleId]); + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + firstMileId: id, + driverId: info.driverId, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + + return this.findById(id); + } + + /** + * Record each truck's actual distance. The pickup total (exact_km) is their + * sum and drives billing; `remainingPayment` (total km × rate) is recomputed + * client-side. Does NOT generate an invoice — that's a separate explicit step. + */ + async setDistances( + id: string, + distances: Array<{ vehicleId: string; distanceKm: number }>, + remainingPayment?: number, + ): Promise { + await this.findById(id); + + // Distances are locked once the invoice exists. + const invoices = await this.billing.findBySourceIds('first_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Distances cannot be changed after the invoice is generated', + ); + } + + for (const d of distances) { + await this.dataSource.manager.update( + FirstMileVehicleAssignment, + { firstMileId: id, vehicleId: d.vehicleId }, + { distanceKm: d.distanceKm }, + ); + } + const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0); + await this.firstMileRepository.update(id, { + exactKm: total, + ...(remainingPayment != null ? { remainingPayment } : {}), + } as any); + return this.findById(id); + } + private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); @@ -570,8 +714,44 @@ export class FirstMileService { ); } + // Every vehicle this pickup holds — junction + legacy + container rows. + const [assignments, allocations] = await Promise.all([ + this.dataSource.manager.find(FirstMileVehicleAssignment, { + where: { firstMileId: id }, + }), + this.dataSource.manager.find(FirstMileContainerAllocation, { + where: { firstMileId: id }, + }), + ]); + const vehicleIds = [ + ...new Set( + [ + ...assignments.map((a) => a.vehicleId), + ...allocations.map((a) => a.vehicleId), + existing.vehicleId ?? null, + ].filter((v): v is string => Boolean(v)), + ), + ]; + await this.firstMileRepository.softDelete(id); - // Free the trucks it was holding (direct + container), unless still in use. - await this.releaseVehicles(existing); + if (assignments.length) { + await this.dataSource.manager.softDelete(FirstMileVehicleAssignment, { firstMileId: id }); + } + + // Free every vehicle no longer held by another active trip and audit release. + if (vehicleIds.length) { + await this.vehiclesService.releaseIfUnused(vehicleIds); + const bookingRef = await this.resolveBookingRef(existing); + for (const vehicleId of vehicleIds) { + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + firstMileId: id, + driverId: info.driverId, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + } } } diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index f7a4b82bc..7a5602751 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -5,12 +5,14 @@ import { Eye, MoreHorizontal, PackageCheck, + Plus, Printer, Receipt, RefreshCw, Ruler, Trash, Truck, + X, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; @@ -45,6 +47,7 @@ import { FIRST_MILE_STATUSES, type FirstMileApiStatus, type FirstMileRecord, + type FirstMileVehicle, firstMileService, } from "@/services/first-mile.service"; import { bookingsService } from "@/services/bookings.service"; @@ -109,7 +112,14 @@ const vehicleLabel = (record: FirstMileRecord) => { return parts.join(" · "); }; -const isAssigned = (record: FirstMileRecord) => Boolean(record.vehicleId); +const isAssigned = (record: FirstMileRecord) => + Boolean(record.vehicleId) || Boolean(record.vehicleAssignments?.length); + +/** Container numbers on a booking, in line order (skips lines without one). */ +const bookingContainerNumbers = (record: FirstMileRecord): string[] => + (record.booking?.bookingContainers ?? []) + .map((c) => c.containerNumber) + .filter((n): n is string => Boolean(n)); // Paid = record flag set OR its invoice reached PAID. const isPaidRecord = (r: FirstMileRecord) => @@ -360,7 +370,10 @@ const FirstMilePage = () => { const [tripSlipOpen, setTripSlipOpen] = useState(false); const [tripSlipRecord, setTripSlipRecord] = useState(null); const [activeId, setActiveId] = useState(null); - const [vehicleValue, setVehicleValue] = useState(null); + // Multi-vehicle assign: one row per truck — vehicle + the container it carries. + const [vehicleRows, setVehicleRows] = useState< + Array<{ vehicleId: string | null; containerNumber: string }> + >([{ vehicleId: null, containerNumber: "" }]); const [acceptOpen, setAcceptOpen] = useState(false); const [acceptStep, setAcceptStep] = useState<1 | 2>(1); @@ -369,7 +382,8 @@ const FirstMilePage = () => { const [bookingSearch, setBookingSearch] = useState(""); const [distanceOpen, setDistanceOpen] = useState(false); - const [distanceValue, setDistanceValue] = useState(""); + // Per-vehicle actual distance, keyed by vehicleId. + const [distanceRows, setDistanceRows] = useState>({}); const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false); const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState(null); @@ -435,14 +449,36 @@ const FirstMilePage = () => { }, }); - const updateDistanceMutation = useMutation({ - mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) => - firstMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }), + const setVehiclesMutation = useMutation({ + mutationFn: ({ + id, + vehicles, + }: { + id: string; + vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>; + }) => firstMileService.setVehicles(id, vehicles), onSuccess: () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); - if (activeRecord) { - toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` }); - } + void qc.invalidateQueries({ queryKey: ["vehicles"] }); + }, + onError: () => { + toast({ title: "Assign failed", variant: "destructive" }); + }, + }); + + const setDistancesMutation = useMutation({ + mutationFn: ({ + id, + distances, + remainingPayment, + }: { + id: string; + distances: Array<{ vehicleId: string; distanceKm: number }>; + remainingPayment?: number; + }) => firstMileService.setDistances(id, distances, remainingPayment), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); + toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined }); closeDistance(); }, onError: () => { @@ -501,6 +537,37 @@ const FirstMilePage = () => { [records, activeId], ); + // Picker options = free vehicles PLUS the ones already on this record (which are + // BUSY, so absent from the free list) so a reassign shows its current trucks + // selected instead of blank. + const assignVehicleOptions = useMemo(() => { + const opts = [...vehicleOptions]; + const seen = new Set(opts.map((o) => o.value)); + const pushVehicle = (v?: FirstMileVehicle | null) => { + if (v && !seen.has(v.id)) { + seen.add(v.id); + const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber]; + if (v.code) parts.unshift(v.code); + opts.push({ value: v.id, label: parts.join(" · ") }); + } + }; + for (const a of activeRecord?.vehicleAssignments ?? []) pushVehicle(a.vehicle); + pushVehicle(activeRecord?.vehicle); + for (const a of activeRecord?.vehicleAssignments ?? []) { + if (!seen.has(a.vehicleId)) { + seen.add(a.vehicleId); + opts.push({ + value: a.vehicleId, + label: a.containerNumber ? `Assigned · ${a.containerNumber}` : "Assigned vehicle", + }); + } + } + if (activeRecord?.vehicleId && !seen.has(activeRecord.vehicleId)) { + opts.push({ value: activeRecord.vehicleId, label: "Assigned vehicle" }); + } + return opts; + }, [vehicleOptions, activeRecord]); + const selectedIds = useMemo( () => Object.keys(rowSelection).filter((id) => rowSelection[id]), [rowSelection], @@ -554,15 +621,20 @@ const FirstMilePage = () => { }; const openDistance = (id: string) => { + const rec = records.find((r) => r.id === id); + const rows: Record = {}; + for (const a of rec?.vehicleAssignments ?? []) { + rows[a.vehicleId] = a.distanceKm != null ? String(a.distanceKm) : ""; + } setActiveId(id); - setDistanceValue(""); + setDistanceRows(rows); setDistanceOpen(true); }; const closeDistance = () => { setDistanceOpen(false); setActiveId(null); - setDistanceValue(""); + setDistanceRows({}); }; @@ -577,24 +649,27 @@ const FirstMilePage = () => { }; const handleSaveDistance = () => { - const distance = parseFloat(distanceValue); - if (!activeId || isNaN(distance) || distance < 0) { - toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" }); + const distances = Object.entries(distanceRows) + .map(([vehicleId, val]) => ({ vehicleId, distanceKm: parseFloat(val) })) + .filter((d) => !Number.isNaN(d.distanceKm) && d.distanceKm >= 0); + + if (!activeId || !distances.length) { + toast({ title: "Invalid distance", description: "Enter a distance for at least one vehicle.", variant: "destructive" }); return; } + const total = distances.reduce((s, d) => s + d.distanceKm, 0); let remainingPayment: number | undefined; if (ratesData?.data) { const firstMileRate = ratesData.data.find( (r) => r.rateType === "FIRST_MILE" && (r.status === "LIVE" || r.status === "DRAFT") ); if (firstMileRate) { - const rateValue = parseFloat(firstMileRate.rateValue); - remainingPayment = distance * rateValue; + remainingPayment = total * parseFloat(firstMileRate.rateValue); } } - updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment }); + setDistancesMutation.mutate({ id: activeId, distances, remainingPayment }); }; const matchesFilter = (r: FirstMileRecord) => { @@ -651,16 +726,29 @@ const FirstMilePage = () => { const openAssign = (id: string | null) => { const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null; + const rec = records.find((r) => r.id === resolved); + // Prefill each row's container number from the booking's container numbers + // (by order) when the assignment doesn't already carry one. + const nums = rec ? bookingContainerNumbers(rec) : []; + const rows = + rec?.vehicleAssignments?.length + ? rec.vehicleAssignments.map((a, i) => ({ + vehicleId: a.vehicleId, + containerNumber: a.containerNumber ?? nums[i] ?? "", + })) + : rec?.vehicleId + ? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }] + : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]; setBulkMode(false); setActiveId(resolved); - setVehicleValue(null); + setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]); setAssignOpen(true); }; const openBulkAssign = () => { setBulkMode(true); setActiveId(null); - setVehicleValue(null); + setVehicleRows([{ vehicleId: null, containerNumber: "" }]); setAssignOpen(true); }; @@ -668,28 +756,31 @@ const FirstMilePage = () => { setAssignOpen(false); setBulkMode(false); setActiveId(null); - setVehicleValue(null); + setVehicleRows([{ vehicleId: null, containerNumber: "" }]); }; const handleAssign = () => { - if (!vehicleValue) { - toast({ title: "Select a vehicle", description: "Choose a vehicle to assign.", variant: "destructive" }); - return; - } - + const seen = new Set(); + const vehicles = vehicleRows + .filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId)) + .filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId))) + .map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null })); + const count = vehicles.length; const targetIds = bulkMode ? selectedIds : [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id)); if (!targetIds.length) return; - const selectedLabel = vehicleOptions.find((o) => o.value === vehicleValue)?.label ?? vehicleValue; - - Promise.all(targetIds.map((id) => updateMutation.mutateAsync({ id, data: { vehicleId: vehicleValue } }))) + // Empty set = unassign all (setVehicles releases the removed vehicles). + Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles }))) .then(() => { toast({ - title: "Vehicle assigned", - description: bulkMode ? `${targetIds.length} pickups → ${selectedLabel}` : selectedLabel, + title: count === 0 ? "Vehicles unassigned" : count > 1 ? "Vehicles assigned" : "Vehicle assigned", + description: + count === 0 + ? bulkMode ? `${targetIds.length} pickups` : undefined + : `${bulkMode ? `${targetIds.length} pickups · ` : ""}${count} vehicle${count > 1 ? "s" : ""}`, }); if (bulkMode) setRowSelection({}); closeAssign(); @@ -772,8 +863,39 @@ const FirstMilePage = () => { id: "vehicle", header: "Vehicle", meta: { headerClassName, cellClassName }, - cell: ({ row }) => - vehicleLabel(row.original) ?? Unassigned, + cell: ({ row }) => { + const assigns = row.original.vehicleAssignments ?? []; + if (assigns.length > 1) { + const labelFor = (a: (typeof assigns)[number]) => { + const v = a.vehicle; + const l = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return a.containerNumber ? `${l} · ${a.containerNumber}` : l; + }; + return ( + + {assigns.map(labelFor).join("\n")} + + } + > + + + {assigns[0].vehicle + ? [assigns[0].vehicle.code, assigns[0].vehicle.plateNumber].filter(Boolean).join(" · ") + : assigns[0].vehicleId} + + + +{assigns.length - 1} + + + + ); + } + return vehicleLabel(row.original) ?? Unassigned; + }, }, { id: "exactKm", @@ -1036,7 +1158,7 @@ const FirstMilePage = () => { {bulkMode ? ( - Assigning a vehicle to{" "} + Assigning vehicles to{" "} {selectedIds.length}{" "} selected {selectedIds.length === 1 ? "pickup" : "pickups"}. @@ -1046,28 +1168,80 @@ const FirstMilePage = () => { No unassigned pickups available. )} - o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value), + )} + value={row.vehicleId} + onChange={(v) => + setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x))) + } + searchable + clearable + disabled={assignVehicleOptions.length === 0} + /> + { + const value = e.currentTarget.value; + setVehicleRows((prev) => + prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)), + ); + }} + /> + {vehicleRows.length > 1 && ( + setVehicleRows((prev) => prev.filter((_, idx) => idx !== i))} + > + + + )} + + ))} + + @@ -1274,34 +1448,51 @@ const FirstMilePage = () => { {activeRecord && ( - + {bookingRef(activeRecord)} - - Customer - {customerName(activeRecord)} - - - Est. Distance (KM) - {activeRecord.estimatedKm ?? "—"} - - + Est. {activeRecord.estimatedKm ?? "—"} km + )} - setDistanceValue(String(v ?? ""))} - min={0} - step={0.1} - decimalScale={2} - /> + {(activeRecord?.vehicleAssignments?.length ?? 0) === 0 ? ( + Assign a vehicle before entering distance. + ) : ( + + {activeRecord!.vehicleAssignments!.map((a) => { + const v = a.vehicle; + const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return ( + + setDistanceRows((prev) => ({ ...prev, [a.vehicleId]: String(val ?? "") })) + } + min={0} + step={0.1} + decimalScale={2} + /> + ); + })} + + Total + + {Object.values(distanceRows) + .reduce((s, val) => s + (parseFloat(val) || 0), 0) + .toFixed(2)}{" "} + km + + + + )} diff --git a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts index a1c2ec8e2..ab5dfb7d9 100644 --- a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts @@ -23,6 +23,14 @@ export interface FirstMileBooking { originYard?: { id: string; label?: string } | null; destinationYard?: { id: string; label?: string } | null; cargoType?: { id: string; label?: string } | null; + /** Container lines — total container count drives how many trucks are needed. */ + bookingContainers?: Array<{ + id: string; + quantity: number; + containerNumber?: string | null; + containerSize?: string | null; + containerType?: { id: string; name?: string; label?: string; code?: string } | null; + }>; } export interface FirstMileVehicle { @@ -30,9 +38,12 @@ export interface FirstMileVehicle { plateNumber: string; manufacturer: string; model: string; + vehicleType?: string | null; code?: string | null; powerPlateNo?: string | null; trailerPlateNo?: string | null; + assignedDriverId?: string | null; + assignedDriverName?: string | null; } export interface FirstMileRecord { @@ -46,6 +57,14 @@ export interface FirstMileRecord { vehicleId?: string | null; booking?: FirstMileBooking | null; vehicle?: FirstMileVehicle | null; + /** Full set of vehicles serving this pickup (multi-truck). */ + vehicleAssignments?: Array<{ + id: string; + vehicleId: string; + containerNumber?: string | null; + distanceKm?: number | null; + vehicle?: FirstMileVehicle | null; + }>; /** Present only when an invoice has actually been generated (not on distance). */ invoice?: { id: string; number: string; status: string } | null; createdAt: string; @@ -69,6 +88,15 @@ export const firstMileService = { api.post(FM.ACCEPT(bookingReference)), remove: (id: string) => api.delete(FM.BY_ID(id)), + setVehicles: ( + id: string, + vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>, + ) => api.post(`${FM.BASE}/${id}/vehicles`, { vehicles }), + setDistances: ( + id: string, + distances: Array<{ vehicleId: string; distanceKm: number }>, + remainingPayment?: number, + ) => api.post(`${FM.BASE}/${id}/distances`, { distances, remainingPayment }), generateInvoice: (id: string) => api.post<{ id: string; invoiceNumber?: string } | null>(`${FM.BASE}/${id}/invoice`), }; From 00d4b729a2a43fafa5a676f47639e0f6e35cd134 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 01:59:16 +0000 Subject: [PATCH 56/90] fix --- .../src/pages/operations/FirstMilePage.tsx | 159 +++++++++++++++--- 1 file changed, 138 insertions(+), 21 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 7a5602751..efc3af7ca 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -202,21 +202,48 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => { ); }; -const tripSlipRows = (record: FirstMileRecord): [string, string][] => [ - ["Customer", customerName(record)], - ["Service", serviceTypeName(record)], - ["Pickup location", pickupLocation(record)], - ["Destination yard", destinationYardName(record)], - ["Cargo", cargoDesc(record)], - ["Advanced Payment", formatPrice(record.advancedPayment, currencyOf(record))], - ["Post Payment", formatPrice(record.remainingPayment, currencyOf(record))], - ["Vehicle", vehicleLabel(record) ?? "Unassigned"], - ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], - ["Requested date", requestedDate(record)], - ["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"], - ["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"], - ["Status", STATUS_META[record.status].label], -]; +type TripSlipVehicle = NonNullable[number]; + +const tripSlipRows = ( + record: FirstMileRecord, + vehicle?: TripSlipVehicle | null, +): [string, string][] => { + // Per-vehicle block when a specific truck is chosen (its own driver, container + // and distance); else fall back to the record-level vehicle summary. + const vehicleRows: [string, string][] = vehicle + ? [ + [ + "Vehicle", + vehicle.vehicle + ? [vehicle.vehicle.code, vehicle.vehicle.plateNumber].filter(Boolean).join(" · ") + : vehicle.vehicleId, + ], + ["Driver", vehicle.vehicle?.assignedDriverName || "—"], + [ + "Container(s)", + vehicle.containerNumber || bookingContainerNumbers(record).join(", ") || "—", + ], + ["Distance (KM)", vehicle.distanceKm != null ? String(vehicle.distanceKm) : "—"], + ] + : [ + ["Vehicle", vehicleLabel(record) ?? "Unassigned"], + ["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"], + ]; + return [ + ["Customer", customerName(record)], + ["Service", serviceTypeName(record)], + ["Pickup location", pickupLocation(record)], + ["Destination yard", destinationYardName(record)], + ["Cargo", cargoDesc(record)], + ["Advanced Payment", formatPrice(record.advancedPayment, currencyOf(record))], + ["Post Payment", formatPrice(record.remainingPayment, currencyOf(record))], + ...vehicleRows, + ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], + ["Requested date", requestedDate(record)], + ["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"], + ["Status", STATUS_META[record.status].label], + ]; +}; const SampleStamp = () => ( @@ -282,7 +309,13 @@ const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) ); -const TripSlipDocument = ({ record }: { record: FirstMileRecord }) => ( +const TripSlipDocument = ({ + record, + vehicle, +}: { + record: FirstMileRecord; + vehicle?: TripSlipVehicle | null; +}) => ( EDR Freight @@ -294,7 +327,7 @@ const TripSlipDocument = ({ record }: { record: FirstMileRecord }) => ( - {tripSlipRows(record).map(([label, value]) => ( + {tripSlipRows(record, vehicle).map(([label, value]) => ( ))} @@ -309,8 +342,8 @@ const TripSlipDocument = ({ record }: { record: FirstMileRecord }) => ( const escapeHtml = (v: string) => v.replace(/&/g, "&").replace(//g, ">"); -const buildTripSlipHtml = (record: FirstMileRecord) => { - const rows = tripSlipRows(record) +const buildTripSlipHtml = (record: FirstMileRecord, vehicle?: TripSlipVehicle | null) => { + const rows = tripSlipRows(record, vehicle) .map(([l, v]) => `${escapeHtml(l)}${escapeHtml(v)}`) .join(""); const sig = (title: string, withStamp: boolean) => ` @@ -369,6 +402,9 @@ const FirstMilePage = () => { const [detailOpen, setDetailOpen] = useState(false); const [tripSlipOpen, setTripSlipOpen] = useState(false); const [tripSlipRecord, setTripSlipRecord] = useState(null); + // Which vehicle the trip slip is for (per-truck), + the pre-print picker. + const [tripSlipVehicleId, setTripSlipVehicleId] = useState(null); + const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false); const [activeId, setActiveId] = useState(null); // Multi-vehicle assign: one row per truck — vehicle + the container it carries. const [vehicleRows, setVehicleRows] = useState< @@ -801,10 +837,27 @@ const FirstMilePage = () => { }; const handlePrintTripSlip = (record: FirstMileRecord) => { + // Always open the picker so the operator chooses which truck to print. setTripSlipRecord(record); + setTripSlipVehicleId(null); + setTripSlipSelectOpen(true); + }; + + const printBookingSlip = () => { + setTripSlipVehicleId(null); + setTripSlipSelectOpen(false); setTripSlipOpen(true); }; + const chooseTripSlipVehicle = (vehicleId: string) => { + setTripSlipVehicleId(vehicleId); + setTripSlipSelectOpen(false); + setTripSlipOpen(true); + }; + + const tripSlipVehicle = + tripSlipRecord?.vehicleAssignments?.find((a) => a.vehicleId === tripSlipVehicleId) ?? null; + const printTripSlip = () => { if (!tripSlipRecord) return; const win = window.open("", "_blank", "width=820,height=920"); @@ -812,8 +865,17 @@ const FirstMilePage = () => { toast({ title: "Pop-up blocked", description: "Allow pop-ups to print the trip slip.", variant: "destructive" }); return; } - win.document.write(buildTripSlipHtml(tripSlipRecord)); + win.document.write(buildTripSlipHtml(tripSlipRecord, tripSlipVehicle)); win.document.close(); + win.focus(); + // Explicit print after the doc paints (onload can miss with document.write). + setTimeout(() => { + try { + win.print(); + } catch { + /* window may have been closed */ + } + }, 250); }; const columns = useMemo((): ColumnDef[] => { @@ -1264,6 +1326,61 @@ const FirstMilePage = () => { + {/* Trip slip — pick a vehicle (multi-truck) */} + setTripSlipSelectOpen(false)} + title={Print trip slip — select vehicle} + radius="lg" + centered + > + + + {tripSlipRecord ? bookingRef(tripSlipRecord) : ""} — pick a truck to print its slip. + + {(tripSlipRecord?.vehicleAssignments ?? []).map((a) => { + const v = a.vehicle; + const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return ( + chooseTripSlipVehicle(a.vehicleId)} + style={{ cursor: "pointer" }} + className="hover:bg-gray-50" + > + + + + + {label} + + + Driver: {v?.assignedDriverName || "—"} + + + Container: {a.containerNumber || "—"} + + + + + + + + ); + })} + {(tripSlipRecord?.vehicleAssignments?.length ?? 0) === 0 && ( + No vehicles assigned yet. + )} + + + + + {/* Trip slip modal */} { centered > - {tripSlipRecord && } + {tripSlipRecord && } From cac6e4eaa1da19ddd4a5c52ec8838b3a475be1fc Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 02:07:31 +0000 Subject: [PATCH 57/90] fix --- .../src/pages/operations/FirstMilePage.tsx | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index efc3af7ca..c3dac97d8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -40,6 +40,7 @@ import { } from "@mantine/core"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; +import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; import { ReceiveInventoryModal } from "@/components/warehouses/ReceiveInventoryModal"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { useToast } from "@/hooks/use-toast"; @@ -121,6 +122,50 @@ const bookingContainerNumbers = (record: FirstMileRecord): string[] => .map((c) => c.containerNumber) .filter((n): n is string => Boolean(n)); +/** Progress stages of the first-mile pickup workflow, for the detail stepper. */ +const computeFirstMileSteps = (record: FirstMileRecord): LastMileStepState[] => { + const exactKm = record.exactKm; + const inTransitOrPast = + record.status === "IN_TRANSIT" || record.status === "RECEIVED_TO_PORT"; + const flags = [ + record.status !== "PAYMENT_PENDING", // Ready to Transit + isAssigned(record), // Assign vehicle + inTransitOrPast, // In transit + exactKm != null, // Add distance + Boolean(record.invoice), // Generate Invoice + record.status === "RECEIVED_TO_PORT",// Received to port + ]; + // Current step = earliest incomplete one. + const activeIdx = flags.findIndex((f) => !f); + const labels = [ + "Ready to Transit", + "Assign vehicle", + "In transit", + "Add distance", + "Generate Invoice", + "Received to port", + ]; + const vehCount = record.vehicleAssignments?.length ?? (record.vehicleId ? 1 : 0); + const primaryPlate = + record.vehicle?.plateNumber ?? + record.vehicleAssignments?.[0]?.vehicle?.plateNumber ?? + null; + const details: (string | null)[] = [ + null, + vehCount > 1 ? `${vehCount} vehicles` : primaryPlate, + null, + exactKm != null ? `${exactKm} KM` : null, + record.invoice?.number ?? null, + null, + ]; + return labels.map((label, i) => ({ + label, + done: flags[i], + active: i === activeIdx, + detail: details[i], + })); +}; + // Paid = record flag set OR its invoice reached PAID. const isPaidRecord = (r: FirstMileRecord) => Boolean((r as { paid?: boolean }).paid) || @@ -1320,6 +1365,37 @@ const FirstMilePage = () => { > {activeRecord && } + {activeRecord && (activeRecord.vehicleAssignments?.length ?? 0) > 0 && ( + + Assigned vehicles + + {activeRecord.vehicleAssignments!.map((a) => { + const v = a.vehicle; + const label = v + ? [v.code, v.plateNumber].filter(Boolean).join(" · ") + : a.vehicleId; + return ( + + {label} + {a.containerNumber ? ( + + {a.containerNumber} + + ) : ( + No container no. + )} + + ); + })} + + + )} + {activeRecord && ( + + Pickup steps + + + )} From 2e31fd12e1b25f0adb00c79830ae7409340dbc31 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 02:26:19 +0000 Subject: [PATCH 58/90] fix --- .../modules/first-mile/first-mile.service.ts | 2 ++ .../modules/last-mile/last-mile.service.ts | 4 ++-- .../src/pages/operations/FirstMilePage.tsx | 24 +++++++++++++++---- .../src/pages/operations/LastMilePage.tsx | 24 +++++++++++++++---- .../src/services/first-mile.service.ts | 3 +++ .../src/services/last-mile.service.ts | 3 +++ 6 files changed, 48 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 7cdf6398b..5c12d94ee 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -208,6 +208,7 @@ export class FirstMileService { originYard: true, destinationYard: true, cargoType: true, + bookingContainers: { containerType: true, units: true }, }, vehicle: true, vehicleAssignments: { vehicle: true }, @@ -255,6 +256,7 @@ export class FirstMileService { originYard: true, destinationYard: true, cargoType: true, + bookingContainers: { containerType: true, units: true }, }, vehicle: true, vehicleAssignments: { vehicle: true }, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index a42ce289a..22f25a6aa 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -170,7 +170,7 @@ export class LastMileService { const [data, total] = await this.lastMileRepository.findAndCount({ where, relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true } }, + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } }, vehicle: true, vehicleAssignments: { vehicle: true }, }, @@ -195,7 +195,7 @@ export class LastMileService { async findById(id: string): Promise { const record = await this.lastMileRepository.findById(id, { relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true } }, + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } }, vehicle: true, vehicleAssignments: { vehicle: true }, }, diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index c3dac97d8..56d84470b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -116,11 +116,25 @@ const vehicleLabel = (record: FirstMileRecord) => { const isAssigned = (record: FirstMileRecord) => Boolean(record.vehicleId) || Boolean(record.vehicleAssignments?.length); -/** Container numbers on a booking, in line order (skips lines without one). */ -const bookingContainerNumbers = (record: FirstMileRecord): string[] => - (record.booking?.bookingContainers ?? []) - .map((c) => c.containerNumber) - .filter((n): n is string => Boolean(n)); +/** Real per-physical-container numbers on a booking, in order. Prefers each + * line's `units` (the actual numbers) over the line-level number (often a + * "TBD-…" placeholder). One entry per physical container, for per-truck prefill. */ +const bookingContainerNumbers = (record: FirstMileRecord): string[] => { + const out: string[] = []; + const real = (n?: string | null): n is string => + Boolean(n) && !/^TBD/i.test(n!.trim()); + for (const c of record.booking?.bookingContainers ?? []) { + const units = [...(c.units ?? [])].sort( + (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0), + ); + if (units.length) { + for (const u of units) if (real(u.containerNumber)) out.push(u.containerNumber!); + } else if (real(c.containerNumber)) { + out.push(c.containerNumber!); + } + } + return out; +}; /** Progress stages of the first-mile pickup workflow, for the detail stepper. */ const computeFirstMileSteps = (record: FirstMileRecord): LastMileStepState[] => { diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index d7545fd7b..a43790346 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -128,11 +128,25 @@ const requiredVehicles = (record: LastMileRecord) => { return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0; }; -/** Container numbers on a booking, in line order (skips lines without one). */ -const bookingContainerNumbers = (record: LastMileRecord): string[] => - (record.booking?.bookingContainers ?? []) - .map((c) => c.containerNumber) - .filter((n): n is string => Boolean(n)); +/** Real per-physical-container numbers on a booking, in order. Prefers each + * line's `units` (the actual numbers) over the line-level number (often a + * "TBD-…" placeholder). One entry per physical container, for per-truck prefill. */ +const bookingContainerNumbers = (record: LastMileRecord): string[] => { + const out: string[] = []; + const real = (n?: string | null): n is string => + Boolean(n) && !/^TBD/i.test(n!.trim()); + for (const c of record.booking?.bookingContainers ?? []) { + const units = [...(c.units ?? [])].sort( + (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0), + ); + if (units.length) { + for (const u of units) if (real(u.containerNumber)) out.push(u.containerNumber!); + } else if (real(c.containerNumber)) { + out.push(c.containerNumber!); + } + } + return out; +}; /** Container badges for a booking: the container number when known, else the * type × quantity. */ diff --git a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts index ab5dfb7d9..6fc80a282 100644 --- a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts @@ -30,6 +30,9 @@ export interface FirstMileBooking { containerNumber?: string | null; containerSize?: string | null; containerType?: { id: string; name?: string; label?: string; code?: string } | null; + /** Physical containers under this line — their real numbers (line-level + * containerNumber is often a TBD placeholder). */ + units?: Array<{ id: string; containerNumber?: string | null; sortOrder?: number }>; }>; } diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index 2f8a1cec5..88e4bb9c0 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -30,6 +30,9 @@ export interface LastMileBooking { containerNumber?: string | null; containerSize?: string | null; containerType?: { id: string; name?: string; label?: string; code?: string } | null; + /** Physical containers under this line — their real numbers (line-level + * containerNumber is often a TBD placeholder). */ + units?: Array<{ id: string; containerNumber?: string | null; sortOrder?: number }>; }>; } From 8b20924ba8e71c59577270add33686c689fa46c7 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 02:45:10 +0000 Subject: [PATCH 59/90] fix --- .../src/pages/operations/FirstMilePage.tsx | 15 ++++++++++----- .../src/pages/operations/LastMilePage.tsx | 15 ++++++++++----- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 56d84470b..9f5efd993 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -20,6 +20,7 @@ import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { ActionIcon, + Autocomplete, Badge, Box, Button, @@ -1307,17 +1308,21 @@ const FirstMilePage = () => { clearable disabled={assignVehicleOptions.length === 0} /> - + n === row.containerNumber || + !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), + )} value={row.containerNumber} - onChange={(e) => { - const value = e.currentTarget.value; + onChange={(value) => setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)), - ); - }} + ) + } /> {vehicleRows.length > 1 && ( { clearable disabled={assignVehicleOptions.length === 0} /> - + n === row.containerNumber || + !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), + )} value={row.containerNumber} - onChange={(e) => { - const value = e.currentTarget.value; + onChange={(value) => setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)), - ); - }} + ) + } /> {vehicleRows.length > 1 && ( Date: Sat, 4 Jul 2026 03:02:17 +0000 Subject: [PATCH 60/90] fix --- .../src/pages/operations/FirstMilePage.tsx | 31 +++++++++++++++++- .../src/pages/operations/LastMilePage.tsx | 32 ++++++++++++++++++- .../backoffice/src/types/booking.ts | 2 ++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 9f5efd993..2b130848c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -664,6 +664,35 @@ const FirstMilePage = () => { return opts; }, [vehicleOptions, activeRecord]); + // Full booking (with container units) for the assign modal's container dropdown. + // Fetched on open so container numbers show regardless of what the list embeds. + const { data: assignBooking } = useQuery({ + queryKey: activeRecord?.bookingId + ? QUERY_KEYS.BOOKINGS.byId(activeRecord.bookingId) + : ["bookings", "detail", "none"], + queryFn: () => bookingsService.getById(activeRecord!.bookingId), + enabled: assignOpen && !bulkMode && Boolean(activeRecord?.bookingId), + }); + + // Container-number options for the dropdown = the booking's real per-container + // numbers (units), falling back to whatever the list record carried. + const containerOptions = useMemo(() => { + const real = (n?: string | null): n is string => + Boolean(n) && !/^TBD/i.test(n!.trim()); + const out: string[] = []; + for (const c of assignBooking?.bookingContainers ?? []) { + const units = [...(c.units ?? [])].sort( + (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0), + ); + if (units.length) { + for (const u of units) if (real(u.containerNumber)) out.push(u.containerNumber); + } else if (real(c.containerNumber)) { + out.push(c.containerNumber); + } + } + return out.length ? out : activeRecord ? bookingContainerNumbers(activeRecord) : []; + }, [assignBooking, activeRecord]); + const selectedIds = useMemo( () => Object.keys(rowSelection).filter((id) => rowSelection[id]), [rowSelection], @@ -1312,7 +1341,7 @@ const FirstMilePage = () => { style={{ flex: 1 }} label={i === 0 ? "Container no." : undefined} placeholder="Container number" - data={(activeRecord ? bookingContainerNumbers(activeRecord) : []).filter( + data={containerOptions.filter( (n) => n === row.containerNumber || !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 5ee5f05b4..4b5cec080 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -42,6 +42,7 @@ import { } from "@mantine/core"; import type { ArrivalQueueItem, ImportUnloadedItem, WarehouseInventoryItem } from "@/types/warehouse"; import { warehouseService } from "@/services/warehouse.service"; +import { bookingsService } from "@/services/bookings.service"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; @@ -853,6 +854,35 @@ const LastMilePage = () => { return opts; }, [vehicleOptions, activeRecord]); + // Full booking (with container units) for the assign modal's container dropdown. + // Fetched on open so container numbers show regardless of what the list embeds. + const { data: assignBooking } = useQuery({ + queryKey: activeRecord?.bookingId + ? QUERY_KEYS.BOOKINGS.byId(activeRecord.bookingId) + : ["bookings", "detail", "none"], + queryFn: () => bookingsService.getById(activeRecord!.bookingId), + enabled: assignOpen && !bulkMode && Boolean(activeRecord?.bookingId), + }); + + // Container-number options for the dropdown = the booking's real per-container + // numbers (units), falling back to whatever the list record carried. + const containerOptions = useMemo(() => { + const real = (n?: string | null): n is string => + Boolean(n) && !/^TBD/i.test(n!.trim()); + const out: string[] = []; + for (const c of assignBooking?.bookingContainers ?? []) { + const units = [...(c.units ?? [])].sort( + (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0), + ); + if (units.length) { + for (const u of units) if (real(u.containerNumber)) out.push(u.containerNumber); + } else if (real(c.containerNumber)) { + out.push(c.containerNumber); + } + } + return out.length ? out : activeRecord ? bookingContainerNumbers(activeRecord) : []; + }, [assignBooking, activeRecord]); + const pickupReadyByBooking = useMemo(() => { const map = new Map(); for (const row of pickupReadyRows) { @@ -1671,7 +1701,7 @@ const LastMilePage = () => { style={{ flex: 1 }} label={i === 0 ? "Container no." : undefined} placeholder="Container number" - data={(activeRecord ? bookingContainerNumbers(activeRecord) : []).filter( + data={containerOptions.filter( (n) => n === row.containerNumber || !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 71d9fd793..2c0be4726 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -83,6 +83,8 @@ export interface BookingContainerUnit { export interface BookingContainerLine { id: string; containerTypeId: string; + /** Line-level number — often a "TBD-…" placeholder; real numbers live in units. */ + containerNumber?: string | null; quantity: number; vgmPerUnitTons: number; containerType?: { From 9d3efe7f15c30ff90e0236447aa69d58a5de7d38 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 03:47:36 +0000 Subject: [PATCH 61/90] fix --- .../components/ContainerAllocationTable.tsx | 11 +- .../gl-actions/TransportDocumentCard.tsx | 2 +- .../backoffice/src/constants/URLS.ts | 1 - .../src/pages/bookings/BookingDetailPage.tsx | 10 +- .../pages/contracts/GlClearanceDetailPage.tsx | 2 +- .../src/pages/fleet/DriverDetailPage.tsx | 2 +- .../src/pages/fleet/FinancialReportsPage.tsx | 62 +++-- .../src/pages/fleet/FleetDashboard.tsx | 45 ++-- .../src/pages/fleet/FleetResourcePage.tsx | 2 +- .../src/pages/fleet/FuelPurchasePage.tsx | 54 ++-- .../src/pages/fleet/FuelStatsPage.tsx | 33 ++- .../src/pages/fleet/MaintenancePage.tsx | 235 +++++++++++------- .../backoffice/src/pages/fleet/RoutesPage.tsx | 1 - .../src/pages/fleet/TrackingPage.tsx | 51 ++-- .../src/pages/fleet/VehicleDetailPage.tsx | 2 +- .../src/pages/fleet/config/resources.ts | 2 +- .../src/pages/ruleEngine/CargoTypesPage.tsx | 1 - .../src/services/bookings.service.ts | 5 - .../src/services/vehicles.service.ts | 3 + 19 files changed, 304 insertions(+), 220 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx index 8971d4ccb..67407486f 100644 --- a/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx @@ -33,7 +33,6 @@ export interface ContainerAllocationTableProps { * Displays containers with type/qty, vehicle dropdown per row, and save action. */ export function ContainerAllocationTable({ - bookingId, containers, onSave, }: ContainerAllocationTableProps) { @@ -43,7 +42,10 @@ export function ContainerAllocationTable({ const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ queryKey: ["vehicles", "free"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), + queryFn: async () => { + const res = await vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }); + return res.data ?? []; + }, }); const vehicleOptions = useMemo( @@ -85,13 +87,12 @@ export function ContainerAllocationTable({ }); const allocatedCount = Object.values(allocations).filter(Boolean).length; - const allAllocated = allocatedCount === containers.length; if (vehiclesLoading) { return ( - + - + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/TransportDocumentCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/TransportDocumentCard.tsx index 52bcfef98..1ef5d1139 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/TransportDocumentCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/TransportDocumentCard.tsx @@ -32,7 +32,7 @@ export function TransportDocumentCard({ bookingId }: { bookingId: string }) { if (!file) return; setLoading(true); try { - await contractsService.uploadTransportDocument(bookingId, file); + await contractsService.uploadTransportDocument(bookingId, { transportDocument: file }); toast.success("Transport document uploaded"); } catch (e) { toast.error(e instanceof Error ? e.message : "Upload failed"); diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index d0f09f508..e1e379bf0 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -282,7 +282,6 @@ export const URL_CONSTANTS = { `/train-scheduling/schedules/${id}/run-allocation`, DOC_REVIEW_COMPLETE: (id: string) => `/train-scheduling/schedules/${id}/doc-review-complete`, - RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`, ASSIGN_UNASSIGNED_BOOKING: (id: string) => `/train-scheduling/schedules/${id}/assign-unassigned-booking`, BOOKING_WINDOW: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx index d49e24f95..7408b9aca 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx @@ -18,8 +18,8 @@ import { type BookingDetailView, } from "@/components/bookings/detail"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; -import ContainerAllocationTable from "@/components/ContainerAllocationTable"; -import { api } from "@/services/api"; +import { ContainerAllocationTable } from "@/components/ContainerAllocationTable"; +import { api } from "@/auth/http"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; const BookingDetailPage = () => { @@ -160,9 +160,9 @@ const BookingDetailPage = () => { type: c.containerType?.label ?? "Unknown", qty: c.quantity, }))} - onSave={(allocations) => - allocateMutation.mutateAsync({ allocations }) - } + onSave={async (allocations) => { + await allocateMutation.mutateAsync({ allocations }); + }} /> - + {data.kind === "booking" ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx index dfdf822e4..d5e8655be 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx @@ -70,7 +70,7 @@ const DriverDetailPage = () => { driver?.licenseExpiryDate && new Date(driver.licenseExpiryDate) < new Date(); return ( - + navigate("/dashboard/drivers")} aria-label="Back"> diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx index 73a7456af..a62c4cbc5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx @@ -1,26 +1,10 @@ import { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Card, Button, Stack, Group, Grid, Select, Text, ThemeIcon, RingProgress, Container } from '@mantine/core'; +import { Card, Stack, Group, Grid, Select, Text, RingProgress, Container, Title } from '@mantine/core'; +import Breadcrumbs from '@/components/ui/Breadcrumbs'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; -import { api } from '@/services/api'; +import { api } from '@/auth/http'; import { vehiclesService } from '@/services/vehicles.service'; -import { freightBrand } from '@/theme/freight-brand'; - -interface FuelStats { - vehicleId: string; - totalPurchases: number; - totalFuel: number; - totalCost: number; - averageCostPerLiter: number; -} - -interface MaintenanceStats { - vehicleId: string; - totalCost: number; - numberOfMaintenanceItems: number; - averageCostPerMaintenance: number; - costByType: Record; -} interface CombinedReport { vehicleId: string; @@ -31,6 +15,9 @@ interface CombinedReport { maintenancePercentage: number; } +const etb = (n: number) => + 'ETB ' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + export function FinancialReportsPage() { const [selectedVehicle, setSelectedVehicle] = useState(null); const [months, setMonths] = useState('12'); @@ -45,13 +32,21 @@ export function FinancialReportsPage() { const { data: fuelStats } = useQuery({ queryKey: QUERY_KEYS.FUEL.stats(selectedVehicle || ''), - queryFn: () => selectedVehicle ? api.get(`/fuel/stats/${selectedVehicle}?months=${months}`) : Promise.resolve(null), + queryFn: async () => { + if (!selectedVehicle) return Promise.resolve(null); + const res = await api.get(`/fuel/stats/${selectedVehicle}?months=${months}`); + return res.data; + }, enabled: !!selectedVehicle, }); const { data: maintenanceStats } = useQuery({ queryKey: QUERY_KEYS.MAINTENANCE.stats(selectedVehicle || ''), - queryFn: () => selectedVehicle ? api.get(`/maintenance/stats/${selectedVehicle}`) : Promise.resolve(null), + queryFn: async () => { + if (!selectedVehicle) return Promise.resolve(null); + const res = await api.get(`/maintenance/stats/${selectedVehicle}`); + return res.data; + }, enabled: !!selectedVehicle, }); @@ -60,11 +55,11 @@ export function FinancialReportsPage() { [vehicles] ); - const report = useMemo(() => { - if (!fuelStats || !maintenanceStats) return null; + const report = useMemo(() => { + if (!fuelStats && !maintenanceStats) return null; - const fuelCost = Number(fuelStats.totalCost) || 0; - const maintenanceCost = Number(maintenanceStats.totalCost) || 0; + const fuelCost = Number(fuelStats?.totalCost ?? 0) || 0; + const maintenanceCost = Number(maintenanceStats?.totalCost ?? 0) || 0; const total = fuelCost + maintenanceCost; return { @@ -93,6 +88,9 @@ export function FinancialReportsPage() { return ( + + Financial Reports + Fleet Financial Analysis @@ -126,13 +124,13 @@ export function FinancialReportsPage() { <> - + - + - + @@ -141,7 +139,7 @@ export function FinancialReportsPage() { Monthly Avg - ${(report.totalOperatingCost / parseInt(months)).toFixed(2)} + {etb(report.totalOperatingCost / parseInt(months))} @@ -166,7 +164,7 @@ export function FinancialReportsPage() { + {report.fuelPercentage}% } @@ -184,7 +182,7 @@ export function FinancialReportsPage() { + {report.maintenancePercentage}% } @@ -228,7 +226,7 @@ export function FinancialReportsPage() { Avg Maintenance Cost - ${maintenanceStats?.averageCostPerMaintenance?.toFixed(2) || '0.00'} + {etb(maintenanceStats?.averageCostPerMaintenance ?? 0)} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx index a311a16eb..32341505a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx @@ -1,7 +1,8 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container, Title, Box, Tabs, Button } from '@mantine/core'; -import { Truck, Fuel, Wrench, TrendingUp, AlertCircle, Users, User, MapPin, Calendar, BarChart3 } from 'lucide-react'; +import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container, Title, Box, Tabs } from '@mantine/core'; +import { Truck, Fuel, Wrench, AlertCircle, Users, User, MapPin } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; import Breadcrumbs from '@/components/ui/Breadcrumbs'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { api } from '@/auth/http'; @@ -39,7 +40,20 @@ interface FleetMetrics { assignedDrivers: number; } -const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change }: any) => ( +/** ETB money, whole-birr for dashboard headlines. */ +const etb = (n: number) => `ETB ${(Number(n) || 0).toLocaleString('en-US', { maximumFractionDigits: 0 })}`; +/** Safe percentage — 0 when the denominator is 0 (empty fleet). */ +const pct = (n: number, d: number) => (d > 0 ? (n / d) * 100 : 0); + +interface StatCardProps { + icon: LucideIcon; + label: string; + value: string | number; + color?: string; + change?: number; +} + +const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change }: StatCardProps) => ( @@ -111,8 +125,8 @@ export function FleetDashboard() { const totalDrivers = (drivers as Driver[]).length; const assignedDrivers = (drivers as Driver[]).filter(d => d.assignedVehicle).length; - const fuelTotal = fuelStats?.totalCost || 0; - const maintenanceTotal = maintenanceStats?.totalCost || 0; + const fuelTotal = Number(fuelStats?.totalCost) || 0; + const maintenanceTotal = Number(maintenanceStats?.totalCost) || 0; return { totalVehicles, @@ -152,10 +166,10 @@ export function FleetDashboard() { - + - + @@ -173,7 +187,7 @@ export function FleetDashboard() { Active Vehicles {metrics.activeVehicles} / {metrics.totalVehicles} - +
@@ -189,7 +203,7 @@ export function FleetDashboard() { Idle / Under Maintenance {metrics.totalVehicles - metrics.activeVehicles} - +
@@ -212,7 +226,7 @@ export function FleetDashboard() { label={
- ${operatingCost.toFixed(0)} + {etb(operatingCost)} Total Cost @@ -325,13 +339,12 @@ export function FleetDashboard() { {d.licenseNumber || 'N/A'} - + {d.phone && ( - - - {d.phone} - - + + + {d.phone} + )} {d.email && {d.email}} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index ff7caa9d2..f7bee5b41 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -357,7 +357,7 @@ const FleetResourcePage = () => { const itemLabel = config.label.toLowerCase(); return ( - + diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx index b3c168df3..6ca9f8870 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx @@ -1,11 +1,11 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { - Box, Button, Card, Container, Group, + Loader, Modal, NumberInput, Select, @@ -17,13 +17,12 @@ import { Badge, Grid, } from "@mantine/core"; -import { Plus, Trash2 } from "lucide-react"; +import { Plus } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { useToast } from "@/hooks/use-toast"; import { api } from "@/auth/http"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service"; -import { freightBrand } from "@/theme/freight-brand"; interface FuelPurchase { id: string; @@ -67,7 +66,7 @@ export default function FuelPurchasePage() { }); // Fetch fuel purchases - const { data: purchasesData = [] } = useQuery({ + const { data: purchasesData = [], isLoading: isLoadingPurchases } = useQuery({ queryKey: ["fuel-purchases"], queryFn: async () => { const res = await api.get("/fuel/purchases"); @@ -104,8 +103,8 @@ export default function FuelPurchasePage() { onError: (error: any) => { toast({ title: "Error recording purchase", - message: error?.response?.data?.message || "Failed to record fuel purchase", - color: "red", + description: error?.response?.data?.message || "Failed to record fuel purchase", + variant: "destructive", }); }, }); @@ -118,6 +117,17 @@ export default function FuelPurchasePage() { const totalCost = formData.liters * formData.costPerLiter; + // Aggregate stats (guarded against divide-by-zero when there are no purchases) + const totalLiters = (purchasesData as FuelPurchase[]).reduce( + (sum, p) => sum + Number(p.liters), + 0 + ); + const totalPurchaseCost = (purchasesData as FuelPurchase[]).reduce( + (sum, p) => sum + Number(p.totalCost), + 0 + ); + const avgPricePerLiter = totalLiters > 0 ? totalPurchaseCost / totalLiters : 0; + return ( @@ -147,10 +157,7 @@ export default function FuelPurchasePage() { Total Liters - {purchasesData - .reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0) - .toFixed(2)}{" "} - L + {totalLiters.toFixed(2)} L @@ -160,9 +167,7 @@ export default function FuelPurchasePage() { Total Cost - ETB {purchasesData - .reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0) - .toLocaleString("en-US", { maximumFractionDigits: 2 })} + ETB {totalPurchaseCost.toLocaleString("en-US", { maximumFractionDigits: 2 })} @@ -172,11 +177,7 @@ export default function FuelPurchasePage() { Avg Price/L - ETB{" "} - {( - purchasesData.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0) / - purchasesData.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0) || 0 - ).toFixed(2)} + ETB {avgPricePerLiter.toFixed(2)} @@ -197,6 +198,23 @@ export default function FuelPurchasePage() { + {isLoadingPurchases ? ( + + + + + + + + ) : purchasesData.length === 0 ? ( + + + + No fuel purchases recorded yet. + + + + ) : null} {(purchasesData as FuelPurchase[])?.map((purchase) => ( {(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx index 04c872966..904347cf0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx @@ -1,20 +1,11 @@ import { useQuery } from "@tanstack/react-query"; -import { Box, Card, Container, Grid, Group, Select, Stack, Table, Text, Title, Badge } from "@mantine/core"; +import { Card, Container, Grid, Group, Loader, Select, Stack, Text, Title } from "@mantine/core"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { api } from "@/auth/http"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service"; import { useState } from "react"; -interface FuelStats { - vehicleId: string; - totalPurchases: number; - totalLiters: number; - totalCost: number; - averagePricePerLiter: number; - dateRange: { startDate: string; endDate: string }; -} - export default function FuelStatsPage() { const [selectedVehicleId, setSelectedVehicleId] = useState(""); const [monthsBack, setMonthsBack] = useState("12"); @@ -29,7 +20,7 @@ export default function FuelStatsPage() { }); // Fetch fuel stats - const { data: statsData } = useQuery({ + const { data: statsData, isFetching: isStatsFetching } = useQuery({ queryKey: ["fuel-stats", selectedVehicleId, monthsBack], queryFn: async () => { if (!selectedVehicleId) return null; @@ -102,7 +93,7 @@ export default function FuelStatsPage() { Total Purchases - {statsData.totalPurchases} + {Number(statsData.totalPurchases) || 0} @@ -112,7 +103,7 @@ export default function FuelStatsPage() { Total Fuel - {statsData.totalLiters.toFixed(2)} L + {(Number(statsData.totalLiters) || 0).toFixed(2)} L @@ -122,7 +113,7 @@ export default function FuelStatsPage() { Total Cost - ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })} + ETB {(Number(statsData.totalCost) || 0).toLocaleString("en-US", { maximumFractionDigits: 2 })} @@ -132,7 +123,7 @@ export default function FuelStatsPage() { Avg Price/L - ETB {statsData.averagePricePerLiter.toFixed(2)} + ETB {(Number(statsData.averagePricePerLiter) || 0).toFixed(2)} @@ -172,15 +163,15 @@ export default function FuelStatsPage() { {selectedVehicle?.plateNumber} consumed{" "} - {statsData.totalLiters.toFixed(2)} liters + {(Number(statsData.totalLiters) || 0).toFixed(2)} liters {" "} over the last {monthsBack} months, costing{" "} - ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })} + ETB {(Number(statsData.totalCost) || 0).toLocaleString("en-US", { maximumFractionDigits: 2 })} . Average fuel price was{" "} - ETB {statsData.averagePricePerLiter.toFixed(2)} per liter + ETB {(Number(statsData.averagePricePerLiter) || 0).toFixed(2)} per liter . @@ -188,6 +179,12 @@ export default function FuelStatsPage() { + ) : selectedVehicleId && isStatsFetching ? ( + + + + + ) : ( diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx index b2bd155d3..3aa2506b5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -1,12 +1,26 @@ -import { useState, useMemo } from 'react'; +import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text, Container } from '@mantine/core'; -import { DateInput } from '@mantine/dates'; +import { + Card, + Button, + Modal, + Stack, + Group, + Select, + TextInput, + NumberInput, + Table, + Badge, + Text, + Title, + Container, +} from '@mantine/core'; import { Plus } from 'lucide-react'; +import Breadcrumbs from '@/components/ui/Breadcrumbs'; +import { useToast } from '@/hooks/use-toast'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; -import { api } from '@/services/api'; -import { vehiclesService } from '@/services/vehicles.service'; -import { freightBrand } from '@/theme/freight-brand'; +import { api } from '@/auth/http'; +import { vehiclesService, type Vehicle as VehicleType } from '@/services/vehicles.service'; interface MaintenanceSchedule { id: string; @@ -21,21 +35,23 @@ interface MaintenanceSchedule { serviceProvider?: string; } +const emptyForm = { + maintenanceType: 'PREVENTIVE', + description: '', + scheduledDate: new Date().toISOString().split('T')[0], + estimatedCost: 0, + serviceProvider: '', + notes: '', +}; + export function MaintenancePage() { + const { toast } = useToast(); + const queryClient = useQueryClient(); const [selectedVehicle, setSelectedVehicle] = useState(null); const [openScheduleModal, setOpenScheduleModal] = useState(false); - const [formData, setFormData] = useState({ - maintenanceType: 'PREVENTIVE', - description: '', - scheduledDate: new Date(), - estimatedCost: 0, - serviceProvider: '', - notes: '', - }); + const [formData, setFormData] = useState(emptyForm); - const queryClient = useQueryClient(); - - const { data: vehicles } = useQuery({ + const { data: vehiclesData } = useQuery({ queryKey: QUERY_KEYS.VEHICLES.list(), queryFn: async () => { const res = await vehiclesService.getAll({ limit: 1000 }); @@ -45,36 +61,49 @@ export function MaintenancePage() { const { data: upcoming, isLoading } = useQuery({ queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''), - queryFn: () => selectedVehicle ? api.get(`/maintenance/upcoming/${selectedVehicle}`) : Promise.resolve([]), + queryFn: async () => { + if (!selectedVehicle) return []; + const res = await api.get(`/maintenance/upcoming/${selectedVehicle}`); + return res.data || []; + }, enabled: !!selectedVehicle, }); + const upcomingList: MaintenanceSchedule[] = Array.isArray(upcoming) ? upcoming : []; + const scheduleMutation = useMutation({ mutationFn: async () => { if (!selectedVehicle) return; - return api.post('/maintenance/schedules', { + const res = await api.post('/maintenance/schedules', { vehicleId: selectedVehicle, ...formData, }); + return res.data; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || '') }); + toast({ title: 'Maintenance scheduled' }); + queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''), + }); setOpenScheduleModal(false); - setFormData({ - maintenanceType: 'PREVENTIVE', - description: '', - scheduledDate: new Date(), - estimatedCost: 0, - serviceProvider: '', - notes: '', + setFormData(emptyForm); + }, + onError: (err: any) => { + toast({ + title: 'Error', + description: err?.response?.data?.message ?? 'Failed', + variant: 'destructive', }); }, }); - const vehicleOptions = useMemo( - () => vehicles?.map(v => ({ label: v.registrationNumber || v.id, value: v.id })) || [], - [vehicles] - ); + const vehicleOptions = + vehiclesData?.map((v: VehicleType) => ({ + value: v.id, + label: v.plateNumber + ? `${v.plateNumber} - ${v.manufacturer} ${v.model}` + : v.registrationNumber || v.id, + })) || []; const statusColor = (status: string) => { const colors: Record = { @@ -88,66 +117,85 @@ export function MaintenancePage() { return ( + + + + Maintenance + + + - - - - Schedule Maintenance - - - - + setIdentifier(event.target.value)} - placeholder="name@company.com or 09XXXXXXXX" - autoComplete="username" - className={fieldClass} - /> -
+ + setIdentifier(event.target.value)} + /> -
- -
- setPassword(event.target.value)} - placeholder="Enter your password" - className={`${fieldClass} pr-11`} - /> - -
-
+ setPassword(event.target.value)} + /> {error ? ( -
+ }> {error} -
+ ) : null} - - -

- Need an account?{" "} - - Contact your admin - -

- - + +
+ ); const mfaForm = ( -
-
- EDR Freight -
+ +
+ EDR Freight +
-
-

+ + Multi-factor verification - </h1> - <p className="text-sm leading-relaxed text-gray-500"> + + We sent a verification code to{" "} - + {normalizedIdentifier} - + . Enter it below to complete sign in. -

-

+ + -
-
- - + + + Verification code + + setOtp(event.target.value)} - placeholder="Enter the code" - className={fieldClass} + placeholder="0" + disabled={submitting} + styles={{ input: { textAlign: "center" } }} + onChange={setOtp} /> -
+ {error ? ( -
+ }> {error} -
+ ) : null} -
- - -
-
- + Verify + + + +
); - return ( - <> - - - - -
-
- - -
- - -
- -
- -
-
-
- {!needsMfa ? loginForm : mfaForm} -
-
-
- - -
-
-
- - ); + return {!needsMfa ? loginForm : mfaForm}; }; export default LoginPage; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index bdd10871b..323b5d5a8 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -1,9 +1,11 @@ import { type FormEvent, useState } from "react"; -import { Eye, EyeOff } from "lucide-react"; +import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core"; +import { AlertCircle } from "lucide-react"; import { useLocation, useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; -import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; +import AuthShell from "@/components/auth/AuthShell"; +import { extractApiError } from "@/utils/result"; const EDR_LOGO = "/assets/edr-logo.png"; @@ -24,7 +26,6 @@ export default function LoginPage() { const { login } = useAuth(); const [identifier, setIdentifier] = useState(""); const [password, setPassword] = useState(""); - const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -41,8 +42,8 @@ export default function LoginPage() { } else { setError(result.error.message); } - } catch { - setError("An unexpected error occurred"); + } catch (err) { + setError(extractApiError(err).message); } finally { setLoading(false); } @@ -64,60 +65,42 @@ export default function LoginPage() {

-
-
- - setIdentifier(event.target.value)} - placeholder="name@company.com or 09XXXXXXXX" - disabled={loading} - autoComplete="username" - className={fieldClass} - /> -
+ + setIdentifier(event.target.value)} + /> -
-
- +
+ -
- setPassword(event.target.value)} - placeholder="Enter your password" - disabled={loading} - className={`${fieldClass} pr-11`} - /> - -
+ setPassword(event.target.value)} + />
{error ? ( -
+ }> {error} -
+ ) : null} - +

Don't have an account?{" "} @@ -129,7 +112,7 @@ export default function LoginPage() { Create an account

-
+ ); From e8e3e01f312398088f9d473395867957577be05c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 4 Jul 2026 07:36:53 +0000 Subject: [PATCH 72/90] release order document fix --- apps/edr-freight-api/Dockerfile | 11 ++++++++++- .../train-scheduling/train-scheduling.service.ts | 7 +++++-- .../warehouses/warehouse-release-document.service.ts | 12 ++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index f9107ed23..b781fb4c0 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -7,6 +7,9 @@ RUN apk add --no-cache libc6-compat # `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds. ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" +# Puppeteer uses the system Chromium installed in the runner stage — skip the +# ~150MB bundled-Chromium download during pnpm install. +ENV PUPPETEER_SKIP_DOWNLOAD=true RUN corepack enable WORKDIR /app @@ -32,8 +35,14 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy FROM node:24.15.0-alpine AS runner -RUN apk add --no-cache libc6-compat +# Chromium + fonts for headless PDF rendering (puppeteer). Alpine ships the +# binary at /usr/bin/chromium-browser, which the PDF renderer auto-detects +# (also pinned via PUPPETEER_EXECUTABLE_PATH). Without this, PDF generation +# falls back to a degraded hand-built layout. +RUN apk add --no-cache libc6-compat \ + chromium nss freetype harfbuzz ca-certificates ttf-freefont ENV NODE_ENV=production +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser WORKDIR /app RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 --ingroup nodejs nestjs diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 32a046356..86e37451e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1265,7 +1265,9 @@ export class TrainSchedulingService { performedBy: 'DOCUMENT_GENERATION', }); const html = this.buildImportLoadListHtml(loadList); - const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + // Generic render — NOT the release-order fallback (would mislabel this as a + // gate-clearance / release order when Chromium is unavailable). + const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list'); const reference = loadList.trainNumber ?? loadList.trainScheduleId; return { filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`, @@ -1283,7 +1285,8 @@ export class TrainSchedulingService { } const html = this.buildExportLoadListHtml(schedule); - const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + // Generic render — NOT the release-order fallback (see importLoadListDocument). + const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list'); const reference = schedule.trainNumber ?? schedule.id; return { filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index f8c0dd355..68e630e0b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -20,6 +20,18 @@ export class WarehouseReleaseDocumentService { }); } + /** + * Render arbitrary document HTML to PDF via the shared renderer WITHOUT the + * release-order fallback. Non-release documents (e.g. the import/export + * marshalling load list) must use this so a Chromium-less fallback degrades to + * a plain-text dump of *their own* content — instead of masquerading as a + * "Warehouse Gate Clearance / Release Order", which the release-specific + * fallback would otherwise draw regardless of the input HTML. + */ + renderDocumentHtml(html: string, label = 'Document'): Promise { + return this.pdf.htmlToPdfBuffer(html, { label }); + } + private htmlToBasicPdfBuffer(html: string): Buffer { const doc = this.extractReleaseDocument(html); const body: string[] = [ From 145240d3bded71b8da3c36ded965f89c27e6d93d Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 07:43:03 +0000 Subject: [PATCH 73/90] refactor: remove gate pass granting logic from clearance services and UI - Removed the gate pass granting functionality from the BookingClearanceService and ContractClearanceService, replacing it with a new method to retrieve gate pass status from train schedules. - Updated the ContractsController to eliminate endpoints related to gate pass granting. - Refactored the UI components (ExportClearanceStepper and PhasedClearanceActionPanel) to reflect the new gate pass securing process, linking to the train scheduling interface instead. - Cleaned up related constants and query hooks, removing unused code and references to the gate pass functionality. - Adjusted types in the contracts to accommodate changes in the gate pass handling logic. --- .../contracts/booking-clearance.service.ts | 12 +- .../contracts/contract-clearance.service.ts | 14 +- .../modules/contracts/contracts.controller.ts | 40 -- .../contracts/dto/phased-clearance.dto.ts | 8 - .../contracts/gl-operations.service.ts | 222 +++-------- .../contracts/ExportClearanceStepper.tsx | 102 ++--- .../contracts/PhasedClearanceActionPanel.tsx | 103 +----- .../backoffice/src/constants/QUERY_KEYS.ts | 1 - .../backoffice/src/constants/URLS.ts | 5 - .../src/hooks/contracts/useContracts.ts | 9 - .../contracts/GlDjiboutiClearanceListPage.tsx | 349 +++--------------- .../src/services/contracts.service.ts | 29 -- packages/types/src/freight/contracts.ts | 26 +- 13 files changed, 136 insertions(+), 784 deletions(-) diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 3f169c49c..61ac93925 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -205,7 +205,7 @@ export class BookingClearanceService { const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId); const bookingMilestone = (code: string) => milestones.find((m) => m.milestoneCode === code); - const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const gatepass = await this.glOperationsService.gatepassForBooking(bookingId); const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); const riskMilestone = bookingMilestone('RISK_ASSIGNED'); const secondDuty = this.glOperationsService.secondDutyState(milestones, files); @@ -242,14 +242,8 @@ export class BookingClearanceService { workflowFiles, t1, train, - gatepassGranted: gatepassMilestone?.status === 'COMPLETED', - gatepassAt: - gatepassMilestone?.status === 'COMPLETED' - ? (gatepassMilestone.metadata?.gatepassAt ?? - (gatepassMilestone.triggeredAt - ? gatepassMilestone.triggeredAt.toISOString() - : null)) - : null, + gatepassGranted: gatepass.granted, + gatepassAt: gatepass.grantedAt, t1Closed: t1ClosedMilestone?.status === 'COMPLETED', t1ClosedAt: t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 532d26359..2c79ba42f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -278,7 +278,9 @@ export class ContractClearanceService { } const bookingMilestone = (code: string) => bookingMilestones.find((m) => m.milestoneCode === code); - const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const gatepass = cycle?.bookingId + ? await this.glOperationsService.gatepassForBooking(cycle.bookingId) + : { granted: false, grantedAt: null }; const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); const riskMilestone = bookingMilestone('RISK_ASSIGNED'); const secondDuty = this.glOperationsService.secondDutyState( @@ -344,14 +346,8 @@ export class ContractClearanceService { workflowFiles, t1, train, - gatepassGranted: gatepassMilestone?.status === 'COMPLETED', - gatepassAt: - gatepassMilestone?.status === 'COMPLETED' - ? (gatepassMilestone.metadata?.gatepassAt ?? - (gatepassMilestone.triggeredAt - ? gatepassMilestone.triggeredAt.toISOString() - : null)) - : null, + gatepassGranted: gatepass.granted, + gatepassAt: gatepass.grantedAt, t1Closed: t1ClosedMilestone?.status === 'COMPLETED', t1ClosedAt: t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 06ac31d68..a22c7cad4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -77,7 +77,6 @@ import { } from './dto/gl-operations.dto'; import { AdviseContractDutyDto, - GatepassDto, RoAmendmentDto, } from './dto/phased-clearance.dto'; @@ -688,30 +687,6 @@ export class ContractsController { return this.clearanceService.djQueue(filter); } - @Get('clearance/dj-schedules') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'Train schedules carrying customs bookings — GL DJ gate-pass table' }) - djClearanceSchedules() { - return this.glOperationsService.djSchedules(); - } - - @Post('clearance/schedules/:scheduleId/gatepass') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ - summary: 'GL DJ grants the gate pass for every customs booking on a train schedule', - }) - grantScheduleGatepass( - @Param('scheduleId', ParseUUIDPipe) scheduleId: string, - @Body() dto: GatepassDto, - @CurrentUser() user: AuthUserPayload, - ) { - return this.glOperationsService.grantScheduleGatepass( - scheduleId, - dto?.gatepassAt, - resolveAuthUserId(user), - ); - } - // ── Path A self-clearance — Operations reviews the customer's own docs ─────── @Get('clearance/ops-queue') @@ -947,21 +922,6 @@ export class ContractsController { return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user)); } - @Post('bookings/:bookingId/gatepass') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' }) - grantGatepass( - @Param('bookingId', ParseUUIDPipe) bookingId: string, - @Body() dto: GatepassDto, - @CurrentUser() user: AuthUserPayload, - ) { - return this.glOperationsService.grantGatepass( - bookingId, - dto?.gatepassAt, - resolveAuthUserId(user), - ); - } - @Post('bookings/:bookingId/final-invoice') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(FileInterceptor('file')) diff --git a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts index 6b784073b..34a903427 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts @@ -36,11 +36,3 @@ export class RoAmendmentDto { note?: string; } -export class GatepassDto { - @ApiPropertyOptional({ - description: 'When the gate pass was granted (ISO datetime; defaults to now)', - }) - @IsOptional() - @IsString() - gatepassAt?: string; -} diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 8fed3a8ff..e639f0867 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -4,7 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { DataSource, In, IsNull } from 'typeorm'; +import { DataSource, IsNull } from 'typeorm'; import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types'; import { BillingService } from '../billing/billing.service'; @@ -17,7 +17,6 @@ import { ClearanceIncident, IncidentType, } from './entities/clearance-incident.entity'; -import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { @@ -198,6 +197,7 @@ export class GlOperationsService { } return { + scheduleId: schedule?.id ?? null, wagonAllocated, departedAt: schedule?.actualDepartureAt ? new Date(schedule.actualDepartureAt).toISOString() @@ -208,6 +208,41 @@ export class GlOperationsService { }; } + /** + * Gate pass status for a booking, sourced from the train schedule's Djibouti + * gate-pass operation (secured via the train-scheduling "Save as Secured" + * action) rather than a clearance milestone. For EXPORT bookings this also + * backfills the arrival-chain milestones once secured, same as the retired + * clearance-side grant action used to. + */ + async gatepassForBooking( + bookingId: string, + ): Promise<{ granted: boolean; grantedAt: string | null }> { + const train = await this.trainState(bookingId); + if (!train.scheduleId) return { granted: false, grantedAt: null }; + const operation = await this.dataSource + .getRepository(ImportDjiboutiOperation) + .findOne({ where: { trainScheduleId: train.scheduleId } }); + const grantedAt = operation?.gatepassGrantedAt + ? new Date(operation.gatepassGrantedAt).toISOString() + : null; + + if (grantedAt) { + const booking = await this.getBooking(bookingId); + if ((booking.tradeDirection ?? 'IMPORT') === 'EXPORT') { + const milestones = await this.milestoneService.listForBooking(bookingId); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) { + if (byCode.get(code)?.status === 'PENDING') { + await this.milestoneService.completeForBooking(bookingId, code); + } + } + } + } + + return { granted: Boolean(grantedAt), grantedAt }; + } + /** * T1 transit-document lifecycle state for an import shipment booking. Wagon * allocation opens the upload window; train departure locks it; train arrival @@ -302,8 +337,11 @@ export class GlOperationsService { 'The transport document must be uploaded before T1 can be closed.', ); } - if (!done('GATEPASS_GRANTED')) { - throw new BadRequestException('Grant the gate pass before closing T1.'); + const gatepass = await this.gatepassForBooking(bookingId); + if (!gatepass.granted) { + throw new BadRequestException( + 'Secure the Djibouti gate pass on the train schedule before closing T1.', + ); } // Export bookings seeded before T1_CLOSED joined the catalog lack the row. await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection); @@ -322,182 +360,6 @@ export class GlOperationsService { 'ARRIVED_AT_DJIBOUTI', ]; - /** - * GL Djibouti grants the gate pass for a customs booking, capturing the time. - * Export: requires the train to have arrived at Djibouti; back-fills the - * arrival-chain milestones. Import: requires wagon allocation (pre-loading). - */ - async grantGatepass( - bookingId: string, - gatepassAt?: string, - userId?: string, - ): Promise<{ bookingId: string; gatepassAt: string }> { - const booking = await this.getBooking(bookingId); - if (!booking.customsClearingEnabled) { - throw new BadRequestException('Gate pass applies to customs bookings only.'); - } - const tradeDirection = booking.tradeDirection ?? 'IMPORT'; - const milestones = await this.milestoneService.listForBooking(bookingId); - const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); - - const existing = byCode.get('GATEPASS_GRANTED'); - if (existing?.status === 'COMPLETED') { - return { - bookingId, - gatepassAt: - existing.metadata?.gatepassAt ?? - (existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''), - }; - } - - const train = await this.trainState(bookingId); - if (tradeDirection === 'EXPORT') { - if (!train.arrivedAt) { - throw new BadRequestException( - 'The train has not arrived at Djibouti yet — gate pass can be granted after arrival.', - ); - } - for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) { - if (byCode.get(code)?.status === 'PENDING') { - await this.milestoneService.completeForBooking(bookingId, code, userId); - } - } - } else if (!train.wagonAllocated) { - throw new BadRequestException( - 'Wagons must be allocated before the gate pass can be granted.', - ); - } - - const at = gatepassAt?.trim() || new Date().toISOString(); - await this.milestoneService.completeWithMetadataForBooking( - bookingId, - 'GATEPASS_GRANTED', - { gatepassAt: at }, - userId, - ); - return { bookingId, gatepassAt: at }; - } - - /** Train schedules carrying ≥1 customs booking — the GL Djibouti gate-pass table. */ - async djSchedules(): Promise { - const schedules = await this.dataSource.getRepository(TrainSchedule).find({ - relations: { - scheduleBookings: { booking: true }, - originStation: true, - destinationStation: true, - }, - order: { scheduledDepartureDate: 'DESC' }, - }); - - const withCustoms = schedules - .filter((s) => s.status !== 'CANCELLED') - .map((s) => ({ - schedule: s, - customs: (s.scheduleBookings ?? []) - .map((sb) => sb.booking) - .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)), - })) - .filter((s) => s.customs.length > 0); - - const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id)); - const gatepassRows = bookingIds.length - ? await this.dataSource.getRepository(ClearanceMilestone).find({ - where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' }, - }) - : []; - const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m])); - - return withCustoms.map(({ schedule, customs }) => { - const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))]; - return { - id: schedule.id, - trainNumber: schedule.trainNumber ?? null, - routeName: null, - origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, - destination: - schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, - status: schedule.status, - scheduledDepartureDate: schedule.scheduledDepartureDate - ? new Date(schedule.scheduledDepartureDate).toISOString() - : null, - actualDepartureAt: schedule.actualDepartureAt - ? new Date(schedule.actualDepartureAt).toISOString() - : null, - actualArrivalAt: schedule.actualArrivalAt - ? new Date(schedule.actualArrivalAt).toISOString() - : null, - freightType: - freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null, - customsBookings: customs.map((b) => { - const m = gatepassByBooking.get(b.id); - const granted = m?.status === 'COMPLETED'; - return { - bookingId: b.id, - reference: b.reference ?? b.id, - tradeDirection: b.tradeDirection ?? 'IMPORT', - contractId: b.contractId ?? null, - gatepassGranted: granted, - gatepassAt: granted - ? (m?.metadata?.gatepassAt ?? - (m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null)) - : null, - }; - }), - }; - }); - } - - /** - * One-click gate pass for every customs booking on a train schedule. Per-booking - * guard failures are collected, not fatal. Import schedules also get the - * schedule-level ImportDjiboutiOperation gate pass so loading unblocks. - */ - async grantScheduleGatepass( - scheduleId: string, - gatepassAt?: string, - userId?: string, - ): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> { - const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ - where: { id: scheduleId }, - relations: { scheduleBookings: { booking: true } }, - }); - if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); - - const customs = (schedule.scheduleBookings ?? []) - .map((sb) => sb.booking) - .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)); - if (customs.length === 0) { - throw new BadRequestException('No customs bookings ride this schedule.'); - } - - let granted = 0; - const skipped: Array<{ bookingId: string; error: string }> = []; - for (const booking of customs) { - try { - await this.grantGatepass(booking.id, gatepassAt, userId); - granted += 1; - } catch (e) { - skipped.push({ - bookingId: booking.id, - error: e instanceof Error ? e.message : 'Failed', - }); - } - } - - if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) { - const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation); - let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } }); - if (!operation) { - operation = opRepo.create({ trainScheduleId: scheduleId }); - } - if (!operation.gatepassGrantedAt) { - operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date(); - await opRepo.save(operation); - } - } - - return { granted, skipped }; - } /** * GL Djibouti raises the post-offload final invoice (export): manual amount + diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx index d7cd9207b..b13e38961 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -14,7 +14,7 @@ import { Text, Textarea, } from "@mantine/core"; -import { DateInput, DateTimePicker } from "@mantine/dates"; +import { DateInput } from "@mantine/dates"; import { AlertTriangle, CheckCircle2, @@ -397,15 +397,10 @@ export function ExportClearanceStepper({ : } > - + void; -}) { - const [opened, setOpened] = useState(false); - const [at, setAt] = useState(new Date()); - const [loading, setLoading] = useState(false); +/** + * Gate pass status, read-only. Secured on the train schedule's "Save as + * Secured" action (train-scheduling-v2) — clearance no longer grants it directly. + */ +function GatepassStep({ clearance }: { clearance: ClearanceViewLike }) { + const scheduleId = clearance.train?.scheduleId ?? null; if (clearance.gatepassGranted) { return ( @@ -526,68 +513,21 @@ function GatepassStep({ done={false} pendingLabel={ arrived - ? "Train arrived — GL Djibouti can grant the gate pass." + ? "Train arrived — secure the gate pass on the train schedule." : "Available once the train arrives at Djibouti." } doneLabel="" /> - {canAct && bookingId ? ( - <> - - setOpened(false)} - title={Grant gate pass} - radius="md" - size="sm" - > - - setAt(v ? new Date(v) : null)} - required - /> - - - - - - - + {scheduleId ? ( + ) : null} ); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 82ac61382..d0b7f2c87 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -4,7 +4,6 @@ import { Badge, Button, Group, - Modal, NumberInput, Paper, SegmentedControl, @@ -15,7 +14,6 @@ import { Text, TextInput, } from "@mantine/core"; -import { DateTimePicker } from "@mantine/dates"; import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone"; import { TransitPermitMultiUpload, @@ -536,17 +534,12 @@ export function PhasedClearanceActionPanel({ : } > - + void; -}) { - const [opened, setOpened] = useState(false); - const [at, setAt] = useState(new Date()); - const [loading, setLoading] = useState(false); +/** + * Gate pass status, read-only. Secured on the train schedule's "Save as + * Secured" action (train-scheduling-v2) — clearance no longer grants it directly. + */ +function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) { + const scheduleId = clearance.train?.scheduleId ?? null; if (clearance.gatepassGranted) { return ( @@ -852,68 +836,21 @@ function ImportGatepassStep({ done={false} pendingLabel={ wagonAllocated - ? "Wagons allocated — GL Djibouti can grant the gate pass." + ? "Wagons allocated — secure the gate pass on the train schedule." : "Available once wagons are allocated." } doneLabel="" /> - {canAct && bookingId ? ( - <> - - setOpened(false)} - title={Grant gate pass} - radius="md" - size="sm" - > - - setAt(v ? new Date(v) : null)} - required - /> - - - - - - - + {scheduleId ? ( + ) : null} ); diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 3275ef662..bd1c51e47 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -70,7 +70,6 @@ export const QUERY_KEYS = { ["contracts", "clearance-queue", region ?? "ET"] as const, clearanceHistory: (region?: string) => ["contracts", "clearance-history", region ?? "ET"] as const, - djSchedules: ["contracts", "clearance-dj-schedules"] as const, milestones: (id: string) => ["contracts", "milestones", id] as const, capacity: (id: string) => ["contracts", "capacity", id] as const, bookingMilestones: (bookingId: string) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index d0f09f508..c22427881 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -232,11 +232,6 @@ export const URL_CONSTANTS = { `/contracts/bookings/${bookingId}/t1-documents`, BOOKING_T1_CLOSE: (bookingId: string) => `/contracts/bookings/${bookingId}/t1-close`, - CLEARANCE_DJ_SCHEDULES: "/contracts/clearance/dj-schedules", - CLEARANCE_SCHEDULE_GATEPASS: (scheduleId: string) => - `/contracts/clearance/schedules/${scheduleId}/gatepass`, - BOOKING_GATEPASS: (bookingId: string) => - `/contracts/bookings/${bookingId}/gatepass`, BOOKING_FINAL_INVOICE: (bookingId: string) => `/contracts/bookings/${bookingId}/final-invoice`, BOOKING_FINAL_INVOICE_CONFIRM: (bookingId: string) => diff --git a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts index 56229415f..04a4720aa 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts @@ -68,15 +68,6 @@ export function useDjClearanceQueue(enabled = true) { }); } -/** Train schedules carrying customs bookings — GL DJ gate-pass table. */ -export function useDjClearanceSchedules(enabled = true) { - return useQuery({ - queryKey: QUERY_KEYS.CONTRACTS.djSchedules, - queryFn: () => contractsService.getDjClearanceSchedules(), - enabled, - }); -} - /** Path A self-clearance queue (Operations reviews non-customs contracts). */ export function useOpsClearanceQueue(enabled = true) { return useQuery({ diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx index 3957fb7a5..0b7456851 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx @@ -1,326 +1,65 @@ -import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { - Badge, - Button, - Card, - Group, - Loader, - Modal, - Stack, - Tabs, - Text, -} from "@mantine/core"; -import { DateTimePicker } from "@mantine/dates"; -import { ChevronRight, Ship, Train, Truck } from "lucide-react"; -import { DataTable, type ColumnDef } from "@edr/ui-common"; -import type { Freight } from "@edr/types"; -import toast from "react-hot-toast"; +import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core"; +import { ChevronRight, Ship } from "lucide-react"; import { PageContainer } from "@/components/page/PageContainer"; import { PageHeader } from "@/components/page/PageHeader"; -import { - useDjClearanceQueue, - useDjClearanceSchedules, -} from "@/hooks/contracts/useContracts"; -import { contractsService } from "@/services/contracts.service"; +import { useDjClearanceQueue } from "@/hooks/contracts/useContracts"; export default function GlDjiboutiClearanceListPage() { const navigate = useNavigate(); const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue(); - const schedulesQuery = useDjClearanceSchedules(); const contractItems = contractQueue?.items ?? []; - const scheduleItems = schedulesQuery.data ?? []; - - const [gatepassTarget, setGatepassTarget] = - useState(null); - const [gatepassAt, setGatepassAt] = useState(new Date()); - const [granting, setGranting] = useState(false); - - const columns = useMemo[]>( - () => [ - { - header: "Train", - accessorKey: "trainNumber", - cell: ({ row }) => ( - - {row.original.trainNumber ?? "—"} - - ), - }, - { - header: "Route", - id: "route", - cell: ({ row }) => ( - - {row.original.origin ?? "—"} → {row.original.destination ?? "—"} - - ), - }, - { - header: "Scheduled departure", - id: "scheduled", - cell: ({ row }) => ( - - {row.original.scheduledDepartureDate - ? new Date(row.original.scheduledDepartureDate).toLocaleDateString() - : "—"} - - ), - }, - { - header: "Departed", - id: "departed", - cell: ({ row }) => ( - - {row.original.actualDepartureAt - ? new Date(row.original.actualDepartureAt).toLocaleString() - : "—"} - - ), - }, - { - header: "Arrived", - id: "arrived", - cell: ({ row }) => ( - - {row.original.actualArrivalAt - ? new Date(row.original.actualArrivalAt).toLocaleString() - : "—"} - - ), - }, - { - header: "Status", - accessorKey: "status", - cell: ({ row }) => ( - - {row.original.status} - - ), - }, - { - header: "Customs bookings", - id: "customs", - cell: ({ row }) => { - const bookings = row.original.customsBookings; - const directions = [...new Set(bookings.map((b) => b.tradeDirection))]; - return ( - - - {bookings.length} - - {directions.map((d) => ( - - {d} - - ))} - - ); - }, - }, - { - header: "Gate pass", - id: "gatepass", - cell: ({ row }) => { - const bookings = row.original.customsBookings; - const allGranted = - bookings.length > 0 && bookings.every((b) => b.gatepassGranted); - const grantedAt = bookings.find((b) => b.gatepassAt)?.gatepassAt ?? null; - if (allGranted) { - return ( - - Granted{grantedAt ? ` · ${new Date(grantedAt).toLocaleString()}` : ""} - - ); - } - return ( - - ); - }, - }, - ], - [], - ); return ( - - - Contracts ({contractItems.length}) - }> - Schedules ({scheduleItems.length}) - - - - - {contractsLoading ? ( - - - - ) : ( - - {contractItems.length === 0 ? ( - - No Djibouti customs contracts yet. - - ) : ( - contractItems.map((c) => ( - navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)} - > - - - -
- {c.reference} - - {c.tradeDirection} · {c.status} - -
-
- - - Contract - - - -
-
- )) - )} -
- )} -
- - - void schedulesQuery.refetch(), - } - : undefined - } - emptyMessage="No train schedules carry customs bookings yet." - /> - -
- - setGatepassTarget(null)} - title={ - - - - Gate pass — train {gatepassTarget?.trainNumber ?? ""} + {contractsLoading ? ( + + + + ) : ( + + {contractItems.length === 0 ? ( + + No Djibouti customs contracts yet. - - } - radius="md" - size="sm" - > - - - Grants the gate pass for all{" "} - {gatepassTarget?.customsBookings.length ?? 0} customs booking - {(gatepassTarget?.customsBookings.length ?? 0) === 1 ? "" : "s"} on this - train. - - setGatepassAt(v ? new Date(v) : null)} - required - /> - - - - + ) : ( + contractItems.map((c) => ( + navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)} + > + + + +
+ {c.reference} + + {c.tradeDirection} · {c.status} + +
+
+ + + Contract + + + +
+
+ )) + )}
-
+ )}
); } - -function statusColor(status: string): string { - switch (status) { - case "SCHEDULED": - return "blue"; - case "DISPATCHED": - return "yellow"; - case "ARRIVED": - return "edr-green"; - default: - return "gray"; - } -} diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index 8860df62a..7ad051ce7 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -391,35 +391,6 @@ export const contractsService = { return unwrap(response.data) as Freight.ClearanceT1State; }, - /** Train schedules carrying customs bookings — GL DJ gate-pass table. */ - getDjClearanceSchedules: async (): Promise => { - const response = await client.get(C.CLEARANCE_DJ_SCHEDULES); - return unwrap(response.data) as Freight.DjClearanceSchedule[]; - }, - - /** Gate pass for every customs booking on a train schedule (captures time). */ - grantScheduleGatepass: async ( - scheduleId: string, - gatepassAt?: string, - ): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> => { - const response = await client.post(C.CLEARANCE_SCHEDULE_GATEPASS(scheduleId), { - gatepassAt, - }); - return unwrap(response.data) as { - granted: number; - skipped: Array<{ bookingId: string; error: string }>; - }; - }, - - /** Gate pass for a single customs booking (captures time). */ - grantGatepass: async ( - bookingId: string, - gatepassAt?: string, - ): Promise<{ bookingId: string; gatepassAt: string }> => { - const response = await client.post(C.BOOKING_GATEPASS(bookingId), { gatepassAt }); - return unwrap(response.data) as { bookingId: string; gatepassAt: string }; - }, - /** GL DJ raises the post-offload final invoice (amount + invoice document). */ sendFinalInvoice: async ( bookingId: string, diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index fe653573d..829ae3217 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -265,6 +265,7 @@ export interface ClearanceT1State { /** Train link state for the booking tied to a customs clearance flow. */ export interface ClearanceTrainState { + scheduleId: string | null; wagonAllocated: boolean; departedAt: string | null; arrivedAt: string | null; @@ -305,31 +306,6 @@ export interface ClearanceSecondDuty { paid: boolean; } -/** A customs booking riding a train schedule, as shown on the GL DJ schedules tab. */ -export interface DjClearanceScheduleBooking { - bookingId: string; - reference: string; - tradeDirection: string; - contractId: string | null; - gatepassGranted: boolean; - gatepassAt: string | null; -} - -/** Train schedule row for the GL Djibouti gate-pass table. */ -export interface DjClearanceSchedule { - id: string; - trainNumber: string | null; - routeName: string | null; - origin: string | null; - destination: string | null; - status: string; - scheduledDepartureDate: string | null; - actualDepartureAt: string | null; - actualArrivalAt: string | null; - freightType: string | null; - customsBookings: DjClearanceScheduleBooking[]; -} - export interface ContractClearanceView { contractId: string; /** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */ From 8916182a6248018d7058e76c6223f15db3517946 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 4 Jul 2026 07:56:23 +0000 Subject: [PATCH 74/90] Truck assign containers --- .../bookings/booking-transition.service.ts | 11 +++++++ .../CustomerTruckAssignmentCard.tsx | 33 +++++++++++++++---- packages/types/src/freight/index.ts | 4 +++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 35fc7fd54..06edbf04e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -1086,6 +1086,9 @@ export class BookingTransitionService { offeredAmount: number; paymentDeadline: Date; } | null; + /** Flat list of physical container numbers on this booking (for the + * customer truck-assignment container picker). */ + containerNumbers: string[]; } > { // This enrichment runs AFTER the transition has committed. A failure here @@ -1141,12 +1144,20 @@ export class BookingTransitionService { `enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`, ); } + // Physical container numbers entered at booking time (booking_container + // units), flattened for the customer truck-assignment container picker. + const containerNumbers = (booking.bookingContainers ?? []) + .flatMap((bc) => bc.units ?? []) + .map((unit) => unit.containerNumber) + .filter((n): n is string => Boolean(n)); + return { ...booking, latestChangeRequestNote: note?.note ?? null, contractSummary: summary, nextStep, activeBatchOffer, + containerNumbers, }; } } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index 37e65ab2b..c6ec6bf5d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -38,6 +38,11 @@ export function CustomerTruckAssignmentCard({ ); const [error, setError] = useState(null); + // Physical container numbers on this booking — the customer picks which one to + // load onto the truck instead of typing it. Falls back to free entry when the + // booking has no container numbers recorded. + const containerOptions = booking.containerNumbers ?? []; + const assignMutation = useMutation(api.bookings.assignCustomerTruck.mutationOptions()); const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions()); @@ -120,13 +125,27 @@ export function CustomerTruckAssignmentCard({ onChange={(value) => setTruckType(value ?? "")} disabled={assigned} /> - setContainerNumberToLoad(e.currentTarget.value.toUpperCase())} - readOnly={assigned} - /> + {containerOptions.length > 0 ? ( + setTruckType(value ?? "")} + /> + + + + + + + ) : ( + trucks.length > 0 && ( + + All containers on this booking have been assigned to a truck. + + ) )} - - setTruckPlateNumber(e.currentTarget.value)} - readOnly={assigned} - /> - setDriverName(e.currentTarget.value)} - readOnly={assigned} - /> - setContainerNumberToLoad(value ?? "")} - searchable - disabled={assigned} - nothingFoundMessage="No matching container" - /> - ) : ( - setContainerNumberToLoad(e.currentTarget.value.toUpperCase())} - readOnly={assigned} - /> - )} - - - - {assigned ? ( + {trucks.length > 0 && ( + - ) : ( - - )} - + + )} ); diff --git a/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts b/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts new file mode 100644 index 000000000..ee317e204 --- /dev/null +++ b/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts @@ -0,0 +1,33 @@ +import type { Freight } from "@edr/types"; + +import { URL_CONSTANTS } from "@/constants/URLS"; +import { client } from "../utils/api"; + +const B = URL_CONSTANTS.BOOKINGS; + +/** + * Multi-truck self-haul assignment for a booking (no EDR first/last mile). + * Each truck carries 1–2 of the booking's containers and tracks its own arrival. + */ +export const customerTrucksService = { + list: async (bookingId: string): Promise => { + const { data } = await client.get(B.CUSTOMER_TRUCKS(bookingId)); + return data.data ?? data; + }, + + add: async ( + bookingId: string, + payload: Freight.AddCustomerTruckPayload, + ): Promise => { + const { data } = await client.post(B.CUSTOMER_TRUCKS(bookingId), payload); + return data.data ?? data; + }, + + remove: async ( + bookingId: string, + assignmentId: string, + ): Promise => { + const { data } = await client.delete(B.CUSTOMER_TRUCK(bookingId, assignmentId)); + return data.data ?? data; + }, +}; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 705439904..698266f67 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -383,6 +383,32 @@ export interface IYard extends BaseEntity { displayOrder: number; } +/** One container number loaded onto a customer self-haul truck. */ +export interface ICustomerTruckContainer { + id: string; + containerNumber: string; +} + +/** A customer self-haul truck on a booking, carrying 1–2 containers. */ +export interface ICustomerTruck { + id: string; + bookingId: string; + plateNumber: string; + driverName: string; + truckType: string; + assignedAt: string; + arrivedAt?: string | null; + containers?: ICustomerTruckContainer[]; +} + +/** Payload to add a customer self-haul truck (1–2 container numbers). */ +export interface AddCustomerTruckPayload { + truckPlateNumber: string; + driverName: string; + truckType: string; + containerNumbers: string[]; +} + export interface IBooking extends BaseEntity { reference: string; customerId: string; @@ -434,6 +460,8 @@ export interface IBooking extends BaseEntity { customerTruckArrivedAt?: string | null; customsClearingEnabled?: boolean; + // (multi-truck self-haul lives in ICustomerTruck[], fetched via the + // /customer-trucks endpoint; the fields above are the booking-level flag.) customsClearingAgent?: string | null; equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN"; From b866b59478086cb62d7c1f17854e3679e1bb90ee Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 4 Jul 2026 09:02:49 +0000 Subject: [PATCH 80/90] feat: add etrade precheck --- .../modules/companies/companies.service.ts | 6 +- .../companies/dto/create-company.dto.ts | 7 +- .../companies/dto/etrade-response.dto.ts | 2 + .../companies/dto/update-profile.dto.ts | 7 +- .../src/components/onboarding/ETradeInfo.tsx | 95 ++++++++++++++----- .../src/pages/accounts/CompanyProfileForm.tsx | 4 +- packages/types/src/freight/etrade.ts | 2 + 7 files changed, 86 insertions(+), 37 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 02f77b2e0..62be578bb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -1183,9 +1183,11 @@ export class CompaniesService { const { businessInfo } = await this.etradeService.resolveCompanyData(tin); if (!businessInfo) { throw new BadRequestException( - "No business license found for this TIN. Please check the number and try again.", + "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", ); } - return this.etradeService.extractRegistrationData(businessInfo); + const registrationData = this.etradeService.extractRegistrationData(businessInfo); + const tinTaken = await this.companiesRepo.existsByTin(tin); + return { ...registrationData, tinTaken }; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index e5b686d11..a56ea5ad8 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator'; import { CompanyType, CompanyStatus } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; @@ -17,10 +17,7 @@ export class CreateCompanyDto { @IsString() @IsNotEmpty() - @Length(10, 10) - @Matches(/^00\d{8}$/, { - message: 'TIN must be 10 digits starting with 00', - }) + @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) tin!: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts index 200b69fee..ef7eb2a21 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts @@ -17,6 +17,7 @@ export class ETradeResponseDto implements CompanyRegistrationData { managerName!: string; managerEmail?: string; managerPhone!: string; + tinTaken?: boolean; constructor(data: CompanyRegistrationData) { this.licenceNumber = data.licenceNumber; @@ -35,5 +36,6 @@ export class ETradeResponseDto implements CompanyRegistrationData { this.managerName = data.managerName; this.managerEmail = data.managerEmail; this.managerPhone = data.managerPhone; + this.tinTaken = data.tinTaken; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 316038dc9..9fd8f28ae 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator'; import { CompanyNationality } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; @@ -34,10 +34,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() - @Length(10, 10) - @Matches(/^00\d{8}$/, { - message: 'TIN must be 10 digits starting with 00', - }) + @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) tin?: string; @IsOptional() diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index 98efc2613..6c38bba46 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -7,9 +7,11 @@ import { Text, TextInput, } from "@mantine/core"; +import { useEffect, useRef } from "react"; import type { UseFormRegisterReturn } from "react-hook-form"; -import { AlertCircle, CheckCircle2, Download } from "lucide-react"; +import { AlertCircle, CheckCircle2, Download, Info } from "lucide-react"; import { useETradeData } from "@/hooks/useETradeData"; +import { extractApiError } from "@/utils/result"; import type { CompanyRegistrationData } from "@edr/types"; interface ETradeInfoProps { @@ -22,6 +24,8 @@ interface ETradeInfoProps { onDataLoaded: (data: CompanyRegistrationData) => void; } +const isValidTin = (tin: string) => tin.length === 10; + export default function ETradeInfo({ tin, register, @@ -30,53 +34,100 @@ export default function ETradeInfo({ }: ETradeInfoProps) { const mutation = useETradeData(); const isLoading = mutation.isPending; - const hasData = mutation.data; + const tinTaken = mutation.data?.tinTaken; + const hasData = + mutation.data && !mutation.data.tinTaken ? mutation.data : null; const handleFetch = async () => { - if (!tin || tin.length !== 10 || !tin.startsWith("00")) return; + if (!isValidTin(tin)) return; const result = await mutation.mutateAsync(tin); - if (result) { + if (result && !result.tinTaken) { onDataLoaded(result); } }; - const errorMessage = + // Auto-fetch as soon as the TIN reaches its full 10-digit length — only + // once per distinct value, so retyping the same TIN doesn't refetch. + const lastFetchedTin = useRef(null); + useEffect(() => { + if (isValidTin(tin) && lastFetchedTin.current !== tin) { + lastFetchedTin.current = tin; + handleFetch(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tin]); + + const apiError = mutation.isError && mutation.error - ? (mutation.error as any).message || - "Failed to fetch company information. Please try again." + ? extractApiError(mutation.error) + : null; + // A 400 here means eTrade simply has no record for this TIN — not a + // failure. Soft-pedal it as an FYI, not a red error, so filling in + // manually doesn't feel like something went wrong. + const notFound = apiError?.statusCode === 400; + const errorMessage = + apiError && !notFound + ? apiError.message || + "We couldn't reach eTrade to fetch your company information. Please try again, or fill in the details manually below." : null; return ( TIN Number (10 digits) *} + label={ + <> + TIN Number (10 digits){" "} + * + + } placeholder="0012345678" maxLength={10} error={error} {...register} /> - + {errorMessage && ( + + )} + {notFound && ( + } color="gray"> + We couldn't find a matching business record for this TIN — no + problem, just fill in the details below. + + )} + {errorMessage && ( } color="red" - title="Failed to fetch data" + title="Couldn't fetch eTrade data" > - {errorMessage} You can still fill in the details manually below. + {errorMessage} + + )} + + {tinTaken && ( + } + color="red" + title="TIN already registered" + > + This TIN is already registered to another company account. Please + double-check the number, or contact support if you believe this is a + mistake. )} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 85af9bfdd..3cce6d4f8 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -450,8 +450,6 @@ export default function CompanyProfileForm({ onDataLoaded={handleETradeDataLoaded} /> - - - + Date: Sat, 4 Jul 2026 09:03:24 +0000 Subject: [PATCH 81/90] changes --- .../train-scheduling.service.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 3b36fdc9a..08c2ea229 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -20,6 +20,7 @@ import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; import { Container } from '../container-management/entities/container.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; @@ -1168,12 +1169,47 @@ export class TrainSchedulingService { notes: dto.notes ?? operation.notes ?? null, }); + await this.completeGatepassMilestoneForSchedule(scheduleId, securedAt); + console.log( `[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`, ); return this.getImportDjiboutiOperation(schedule.id); } + /** + * Bridge write: also flips the legacy clearance-side GATEPASS_GRANTED + * milestone for every customs booking on this schedule, so contract/booking + * clearance views still reading that milestone (older deployed builds) see + * the gate pass as done. Drop once every clearance-api deployment reads + * ImportDjiboutiOperation.gatepassGrantedAt directly. + */ + private async completeGatepassMilestoneForSchedule( + scheduleId: string, + securedAt: Date, + ): Promise { + const bookings = await this.dataSource.getRepository(Booking).find({ + where: { trainScheduleId: scheduleId, customsClearingEnabled: true }, + }); + if (bookings.length === 0) return; + + const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone); + const rows = await milestoneRepo.find({ + where: { + bookingId: In(bookings.map((b) => b.id)), + milestoneCode: 'GATEPASS_GRANTED', + }, + }); + + for (const row of rows) { + if (row.status === 'COMPLETED') continue; + row.status = 'COMPLETED'; + row.triggeredAt = securedAt; + row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() }; + await milestoneRepo.save(row); + } + } + async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { const schedule = await this.getImportDjiboutiSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); From a70804da1c742dcf231db4030fa68d74e83a8a26 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 4 Jul 2026 09:08:40 +0000 Subject: [PATCH 82/90] fix: gm step --- .../src/pages/accounts/CompanyProfileForm.tsx | 68 +++++++++++-------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 3cce6d4f8..d7c3b913b 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -11,7 +11,7 @@ import { } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; -import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react"; +import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -279,22 +279,39 @@ export default function CompanyProfileForm({ }); }; - /** Fill the General Manager from the eTrade business owner. */ - const useOwnerAsManager = () => { - if (!etradeOwner) return; - setValue("generalManagerName", etradeOwner.name); - setValue("generalManagerEmail", user.email); - setValue("generalManagerPhone", etradeOwner.phone ?? "", { - shouldValidate: true, - }); - }; - // "Same as …" links. A checked card prefills the target step's fields from the // source step and disables them (kept mirrored while linked); unchecking clears // them and re-enables editing. + const [gmSameAsOwner, setGmSameAsOwner] = useState(false); const [contactSameAsGm, setContactSameAsGm] = useState(false); const [poaSameAsContact, setPoaSameAsContact] = useState(false); + // General Manager source: the eTrade-registered business owner when a TIN + // lookup found one, otherwise the registering user's own account details. + const gmSourceName = etradeOwner?.name ?? user.name?.en ?? ""; + const gmSourcePhone = etradeOwner + ? etradeOwner.phone + : toEthiopianE164(user.phoneNumber); + + useEffect(() => { + if (!gmSameAsOwner) return; + setValue("generalManagerName", gmSourceName, { shouldValidate: true }); + setValue("generalManagerEmail", user.email ?? "", { shouldValidate: true }); + setValue("generalManagerPhone", gmSourcePhone ?? "", { + shouldValidate: true, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gmSameAsOwner, gmSourceName, gmSourcePhone, user.email]); + + const toggleGmSameAsOwner = (checked: boolean) => { + setGmSameAsOwner(checked); + if (!checked) { + setValue("generalManagerName", ""); + setValue("generalManagerEmail", ""); + setValue("generalManagerPhone", ""); + } + }; + const gmName = watch("generalManagerName"); const gmEmail = watch("generalManagerEmail"); const gmPhone = watch("generalManagerPhone"); @@ -584,22 +601,19 @@ export default function CompanyProfileForm({ {step === "personnel" && ( <> - - - General Manager - - {etradeOwner && ( - - )} - + + General Manager + + Date: Sat, 4 Jul 2026 09:21:16 +0000 Subject: [PATCH 83/90] changes --- apps/edr-freight-api/Dockerfile | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index b781fb4c0..f9107ed23 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -7,9 +7,6 @@ RUN apk add --no-cache libc6-compat # `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds. ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" -# Puppeteer uses the system Chromium installed in the runner stage — skip the -# ~150MB bundled-Chromium download during pnpm install. -ENV PUPPETEER_SKIP_DOWNLOAD=true RUN corepack enable WORKDIR /app @@ -35,14 +32,8 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy FROM node:24.15.0-alpine AS runner -# Chromium + fonts for headless PDF rendering (puppeteer). Alpine ships the -# binary at /usr/bin/chromium-browser, which the PDF renderer auto-detects -# (also pinned via PUPPETEER_EXECUTABLE_PATH). Without this, PDF generation -# falls back to a degraded hand-built layout. -RUN apk add --no-cache libc6-compat \ - chromium nss freetype harfbuzz ca-certificates ttf-freefont +RUN apk add --no-cache libc6-compat ENV NODE_ENV=production -ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser WORKDIR /app RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 --ingroup nodejs nestjs From 7a8e8dbc961bee789e6dda6fc76a71b5c3793304 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Sat, 4 Jul 2026 12:25:41 +0300 Subject: [PATCH 84/90] Update deploy.yml --- .github/workflows/deploy.yml | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 72ad6de66..62530611c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -182,24 +182,6 @@ jobs: set -euo pipefail docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate - - name: Verify deployment health - if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) - run: | - set -euo pipefail - PORT=$(grep '^PORT=' "${SERVICE_ENV_FILE}" | cut -d= -f2) - echo "Waiting for service to become healthy on port ${PORT}..." - for i in $(seq 1 12); do - if wget -qO- "http://localhost:${PORT}/health/ready" 2>/dev/null | grep -q '"status":"ok"'; then - echo "Service is healthy." - exit 0 - fi - echo "Attempt ${i}/12 — not ready yet, waiting 10s..." - sleep 10 - done - echo "Service failed health check after 120s — rolling back" - docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate || true - exit 1 - - name: Remove npm credentials from workspace if: always() run: rm -f .npmrc .npmrc_temp From 1c15a2117b10491c27cdf55aea23352f948ffbfe Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 4 Jul 2026 09:27:22 +0000 Subject: [PATCH 85/90] feat: add verifcation on the document step --- .../components/onboarding/RoleLicenseStep.tsx | 8 ++ .../src/pages/accounts/CompanyProfileForm.tsx | 100 +++++++++++++++--- 2 files changed, 91 insertions(+), 17 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx index 451222841..fa1061622 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx @@ -70,6 +70,8 @@ interface RoleLicenseStepProps { /** Newly-selected files per profile id (not yet uploaded). */ value: Record; onChange: (value: Record) => void; + /** "Business license is required" style error, keyed by profile id. */ + errors?: Record; } /** @@ -82,6 +84,7 @@ export default function RoleLicenseStep({ profiles, value, onChange, + errors, }: RoleLicenseStepProps) { const setFiles = (profileId: string, files: File[]) => { onChange({ ...value, [profileId]: files }); @@ -123,6 +126,11 @@ export default function RoleLicenseStep({ file={buildLicenseSetting(profile.id, label)} value={{ [LICENSE_FILE_KEY]: selected }} uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined} + errors={ + errors?.[profile.id] + ? { [LICENSE_FILE_KEY]: errors[profile.id] } + : undefined + } onChange={(v) => { const next = v[LICENSE_FILE_KEY]; const files = Array.isArray(next) ? next : next ? [next] : []; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index d7c3b913b..cc9f81a29 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -21,6 +21,7 @@ import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { CompanyRegistrationData } from "@edr/types"; import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField"; import { SmartFileInput } from "@edr/ui-common"; +import { getMinFiles } from "@/types/fileUploadSettings"; import { api } from "@/services/api"; import RoleLicenseStep, { type RoleLicenseProfile, @@ -358,6 +359,72 @@ export default function CompanyProfileForm({ const hasDocuments = Boolean(uploadSetting?.fields?.length); + // Hard verification for the documents step: required company-level + // documents and a business license per operational profile must both be + // present before the user can continue. + const [documentFieldErrors, setDocumentFieldErrors] = useState< + Record + >({}); + const [licenseFieldErrors, setLicenseFieldErrors] = useState< + Record + >({}); + + const validateRequiredDocuments = (): Record => { + const errs: Record = {}; + for (const field of uploadSetting?.fields ?? []) { + const min = getMinFiles(field); + if (min <= 0) continue; + if ((uploadedDocumentKeys ?? []).includes(field.fileKey)) continue; + const v = documentFiles[field.fileKey]; + const count = Array.isArray(v) ? v.length : v ? 1 : 0; + if (count < min) { + errs[field.fileKey] = `${field.fileLabel} is required`; + } + } + return errs; + }; + + // Every role needs at least one license file (existing or newly selected). + const validateLicenses = (): Record => { + const errs: Record = {}; + for (const p of roleProfiles ?? []) { + const hasNew = (licenseFiles?.[p.id]?.length ?? 0) > 0; + const hasExisting = p.existingFiles.length > 0; + if (!hasNew && !hasExisting) { + errs[p.id] = "Business license is required"; + } + } + return errs; + }; + + const handleDocumentFilesChange = ( + next: Record, + ) => { + setDocumentFiles(next); + setDocumentFieldErrors((prev) => { + if (Object.keys(prev).length === 0) return prev; + const updated = { ...prev }; + for (const key of Object.keys(updated)) { + const v = next[key]; + const hasValue = Array.isArray(v) ? v.length > 0 : v != null; + if (hasValue) delete updated[key]; + } + return updated; + }); + }; + + const handleLicenseFilesChange = (next: Record) => { + onLicenseChange?.(next); + setLicenseFieldErrors((prev) => { + if (Object.keys(prev).length === 0) return prev; + const updated = { ...prev }; + for (const id of Object.keys(updated)) { + if ((next[id]?.length ?? 0) > 0) delete updated[id]; + } + return updated; + }); + }; + // The registration/license details come straight from the eTrade lookup and // are not user-editable — shown as a read-only confirmation once a TIN lookup // (or rehydration) has filled them in. The address fields below are separate: @@ -402,18 +469,21 @@ export default function CompanyProfileForm({ } }; - // Every role needs at least one license file (existing or newly selected). - const licenseComplete = (roleProfiles ?? []).every( - (p) => - (licenseFiles?.[p.id]?.length ?? 0) > 0 || p.existingFiles.length > 0, - ); - const nextStep = async () => { userNavigatedRef.current = true; - // The documents step auto-uploads whatever the user selected as they - // continue (partial uploads are allowed — required-doc completeness is - // re-checked on resume). A failed upload holds them on the step. + // The documents step hard-blocks on required company documents and a + // business license per operational profile before it auto-uploads and + // submits — no partial-completion path forward. if (step === "documents") { + const docErrors = validateRequiredDocuments(); + const licenseErrors = validateLicenses(); + if (Object.keys(docErrors).length > 0 || Object.keys(licenseErrors).length > 0) { + setDocumentFieldErrors(docErrors); + setLicenseFieldErrors(licenseErrors); + setSaveError("Please upload all required documents before continuing."); + return; + } + if (onUploadDocuments) { setSaving(true); try { @@ -427,12 +497,6 @@ export default function CompanyProfileForm({ } } - if (!licenseComplete) { - setSaveError( - "Please upload a business license for each of your operational profiles.", - ); - return; - } setSaveError(null); handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; @@ -749,15 +813,17 @@ export default function CompanyProfileForm({ file={uploadSetting} value={documentFiles} uploadedKeys={uploadedDocumentKeys} + errors={documentFieldErrors} containerClassName="lg:grid grid-cols-2 items-stretch" - onChange={setDocumentFiles} + onChange={handleDocumentFilesChange} /> )} { })} + onChange={handleLicenseFilesChange} + errors={licenseFieldErrors} /> )} From 66ffd51d5b355a82c62d58c60ea735dbfe97742f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 4 Jul 2026 09:27:38 +0000 Subject: [PATCH 86/90] style: update the multi file document ui --- .../src/components/SmartFileInput/index.tsx | 600 ++++++++++++------ 1 file changed, 397 insertions(+), 203 deletions(-) diff --git a/packages/ui-common/src/components/SmartFileInput/index.tsx b/packages/ui-common/src/components/SmartFileInput/index.tsx index 0da3e2f37..1e1eaf223 100644 --- a/packages/ui-common/src/components/SmartFileInput/index.tsx +++ b/packages/ui-common/src/components/SmartFileInput/index.tsx @@ -153,6 +153,76 @@ function ExistingFileLink({ ); } +/** + * A file the user just picked (in memory, not yet persisted). Rendered with a + * subtle "just added" entrance + an emerald accent so a fresh upload reads as + * distinct from the neutral surrounding surface. + */ +function NewFileCard({ + file: fileObj, + onRemove, + disabled, + hasError, + inputName, +}: { + file: File; + onRemove: () => void; + disabled?: boolean; + hasError?: boolean; + inputName: string; +}) { + return ( +
+
+
+ +
+ +
+

+ {fileObj.name} +

+
+ + {formatBytes(fileObj.size)} + + + Ready to upload + +
+
+
+ + + + {/* Hidden input to represent file details in traditional form submissions */} + +
+ ); +} + export function SmartFileInput({ file, value, @@ -407,228 +477,352 @@ export function SmartFileInput({

)} - {/* Selected Files List */} - {currentFiles.length > 0 && ( -
- {currentFiles.map((fileObj, idx) => ( -
-
-
- -
- -
-

- {fileObj.name} -

-
- - {formatBytes(fileObj.size)} - - - Ready - -
-
-
- - - - {/* Hidden inputs to represent file details in traditional form submissions */} - -
- ))} -
- )} - - {/* Dropzone area */} - {!reachedLimit && - (variant === "minimal" ? ( -
- + {/* + Multiple-file fields (default variant) render as ONE integrated + drag-and-drop surface. Uploaded files live INSIDE the dropzone as + lightweight rows — part of the surface, not separate cards — with + the "add more" prompt on the same surface below them. A full-cover + transparent input makes clicking anywhere (outside a file row) + open the picker; the prompt is pointer-transparent so clicks fall + through to it, while file rows and their controls sit above it. + */} + {variant === "default" && field.isMultiple ? ( +
handleDrag(e, field.fileKey, true)} + onDragLeave={(e) => handleDrag(e, field.fileKey, false)} + onDrop={(e) => handleDrop(e, field)} + className={cn( + "relative flex flex-col gap-2.5 rounded-xl border-2 border-dashed p-4 transition-all", + isDragOver + ? "border-primary bg-primary/5 dark:bg-primary/10" + : fieldError + ? "border-destructive/70" + : "border-border bg-card/40 hover:border-primary/40", + disabled && "pointer-events-none opacity-50", + )} + > + {/* Click anywhere on the surface (except a file row) to browse */} + {!reachedLimit && ( { - if (fileInputRefs.current) { - fileInputRefs.current[field.fileKey] = el; - } - }} - multiple={field.isMultiple} + multiple accept={acceptString} disabled={disabled} onChange={(e) => handleFileSelect(e, field)} - className="hidden" - /> - - Accepts:{" "} - {field.allowedExtensions.join(", ").toUpperCase() || - "All"} - - {existingForField.length > 0 && ( -
- {existingForField.map((f, idx) => ( - - ))} -
- )} -
- ) : isUploaded ? ( - // Uploaded state: a solid success panel that still doubles as a - // replace target (click anywhere or drag a new file onto it). -
handleDrag(e, field.fileKey, true)} - onDragLeave={(e) => handleDrag(e, field.fileKey, false)} - onDrop={(e) => handleDrop(e, field)} - className={cn( - "group relative flex items-center gap-4 rounded-lg border p-4 transition-all", - isDragOver - ? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10" - : "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10", - disabled && - "opacity-50 pointer-events-none cursor-not-allowed", - )} - > - handleFileSelect(e, field)} - id={`file-input-${field.fileKey}`} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" - aria-label={`Replace ${field.fileLabel}`} + className="absolute inset-0 z-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed" + aria-label={`Add files to ${field.fileLabel}`} /> + )} -
- {isDragOver ? ( - - ) : ( - - )} -
- -
-

- {isDragOver ? "Drop to replace" : "Document uploaded"} -

- {existingForField.length > 0 ? ( -
- {existingForField.map((f, idx) => ( + {(existingForField.length > 0 || currentFiles.length > 0) && ( +
+ {/* Already-saved (server) files — view/download only */} + {existingForField.map((f, idx) => ( +
+ +
- ))} +
+ + Saved +
- ) : ( -

- {isDragOver - ? "Release to replace the document on file." - : "Saved to your application. Drag a new file here or click to replace it."} -

+ ))} + + {/* Just-added (in-memory) files */} + {currentFiles.map((fileObj, idx) => ( +
+ +
+

+ {fileObj.name} +

+

+ {formatBytes(fileObj.size)} +

+
+ + Ready + + + +
+ ))} +
+ )} + + {reachedLimit ? ( +
+ + Maximum of {maxFiles} files reached +
+ ) : ( +
0 || currentFiles.length > 0 + ? "py-1" + : "py-6", )} -
- - - - Replace - -
- ) : ( -
handleDrag(e, field.fileKey, true)} - onDragLeave={(e) => handleDrag(e, field.fileKey, false)} - onDrop={(e) => handleDrop(e, field)} - className={cn( - "relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50", - isDragOver - ? "border-primary bg-primary/5 dark:bg-primary/10" - : "border-border hover:border-primary/50 hover:bg-muted/10", - fieldError && - "border-destructive hover:border-destructive/80", - disabled && - "opacity-50 pointer-events-none cursor-not-allowed", - )} - > - handleFileSelect(e, field)} - id={`file-input-${field.fileKey}`} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" - /> - -
- +
0 || currentFiles.length > 0 + ? "p-1.5" + : "p-3", )} - /> + > + 0 || + currentFiles.length > 0 + ? "h-4 w-4" + : "h-6 w-6", + isDragOver && "animate-bounce text-primary", + )} + /> +
+

+ {isDragOver + ? "Drop your files here" + : existingForField.length > 0 || + currentFiles.length > 0 + ? "Add more files, or " + : "Drag & drop your files here, or "} + {!isDragOver && ( + browse + )} +

+

+ {field.allowedExtensions.join(", ").toUpperCase() || + "All formats"} + {" • "} + {currentFiles.length}/{maxFiles} added +

+ )} +
+ ) : ( + <> + {/* Selected Files List */} + {currentFiles.length > 0 && ( +
+ {currentFiles.map((fileObj, idx) => ( + removeFile(field.fileKey, idx)} + /> + ))} +
+ )} -

- Drag & drop your file here, or{" "} - - browse - -

+ {/* Dropzone area */} + {!reachedLimit && + (variant === "minimal" ? ( +
+ + { + if (fileInputRefs.current) { + fileInputRefs.current[field.fileKey] = el; + } + }} + multiple={field.isMultiple} + accept={acceptString} + disabled={disabled} + onChange={(e) => handleFileSelect(e, field)} + className="hidden" + /> + + Accepts:{" "} + {field.allowedExtensions.join(", ").toUpperCase() || + "All"} + + {existingForField.length > 0 && ( +
+ {existingForField.map((f, idx) => ( + + ))} +
+ )} +
+ ) : isUploaded ? ( + // Uploaded state: a solid success panel that still doubles as a + // replace target (click anywhere or drag a new file onto it). +
handleDrag(e, field.fileKey, true)} + onDragLeave={(e) => handleDrag(e, field.fileKey, false)} + onDrop={(e) => handleDrop(e, field)} + className={cn( + "group relative flex items-center gap-4 rounded-lg border p-4 transition-all", + isDragOver + ? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10" + : "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10", + disabled && + "opacity-50 pointer-events-none cursor-not-allowed", + )} + > + handleFileSelect(e, field)} + id={`file-input-${field.fileKey}`} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" + aria-label={`Replace ${field.fileLabel}`} + /> -

- Supported formats:{" "} - {field.allowedExtensions.join(", ").toUpperCase() || - "All"} -

-
- ))} +
+ {isDragOver ? ( + + ) : ( + + )} +
+ +
+

+ {isDragOver + ? "Drop to replace" + : "Document uploaded"} +

+ {existingForField.length > 0 ? ( +
+ {existingForField.map((f, idx) => ( + + ))} +
+ ) : ( +

+ {isDragOver + ? "Release to replace the document on file." + : "Saved to your application. Drag a new file here or click to replace it."} +

+ )} +
+ + + + Replace + +
+ ) : ( +
handleDrag(e, field.fileKey, true)} + onDragLeave={(e) => handleDrag(e, field.fileKey, false)} + onDrop={(e) => handleDrop(e, field)} + className={cn( + "relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50", + isDragOver + ? "border-primary bg-primary/5 dark:bg-primary/10" + : "border-border hover:border-primary/50 hover:bg-muted/10", + fieldError && + "border-destructive hover:border-destructive/80", + disabled && + "opacity-50 pointer-events-none cursor-not-allowed", + )} + > + handleFileSelect(e, field)} + id={`file-input-${field.fileKey}`} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" + /> + +
+ +
+ +

+ Drag & drop your file here, or{" "} + + browse + +

+ +

+ Supported formats:{" "} + {field.allowedExtensions.join(", ").toUpperCase() || + "All"} +

+
+ ))} + + )} {/* Validation Error Message */} {fieldError && ( From 00cd1fccf6c6c32e7f3bbdfbbe72ba2a015538cf Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 4 Jul 2026 09:33:07 +0000 Subject: [PATCH 87/90] train loading for import --- ...ContainerReceiptToBookingContainerUnits.ts | 36 +++++ ...1970000000000-AddCustomerTruckDeparture.ts | 26 ++++ .../modules/bookings/bookings.controller.ts | 52 +++++++ .../src/modules/bookings/bookings.module.ts | 3 + .../bookings/container-receipt.service.ts | 145 ++++++++++++++++++ .../bookings/customer-truck.service.ts | 111 ++++++++++++-- .../bookings/dto/add-customer-truck.dto.ts | 14 +- .../bookings/dto/depart-customer-truck.dto.ts | 37 +++++ .../modules/bookings/dto/generate-grn.dto.ts | 17 ++ .../entities/booking-container-unit.entity.ts | 13 ++ .../customer-truck-assignment.entity.ts | 8 + .../warehouses/warehouse-inventory.service.ts | 69 ++++++++- .../CustomerTruckAssignmentCard.tsx | 37 +++-- 13 files changed, 534 insertions(+), 34 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts create mode 100644 apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts diff --git a/apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts b/apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts new file mode 100644 index 000000000..59a0441c5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-container receive tracking. A booking's containers arrive individually + * (on separate self-haul trucks), so each container unit tracks whether it has + * been received into the port and, once staff confirm it, the GRN it belongs to. + * A single GRN covers the containers received together — so if the whole booking + * arrives at once, all its units share one GRN (per-booking GRN). + */ +export class AddContainerReceiptToBookingContainerUnits1960000000000 + implements MigrationInterface +{ + name = 'AddContainerReceiptToBookingContainerUnits1960000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_container_units + ADD COLUMN IF NOT EXISTS received_to_port boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS received_at timestamptz, + ADD COLUMN IF NOT EXISTS grn_number varchar(100) + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_booking_container_units_grn" ON freight.booking_container_units (grn_number);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_booking_container_units_grn";`); + await queryRunner.query(` + ALTER TABLE freight.booking_container_units + DROP COLUMN IF EXISTS received_to_port, + DROP COLUMN IF EXISTS received_at, + DROP COLUMN IF EXISTS grn_number + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts b/apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts new file mode 100644 index 000000000..e36420751 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Import self-haul trucks are weighed on leaving. The customer does not + * pre-specify what an import truck takes — staff register the containers loaded + * and the weighed gross when the truck departs. These columns capture that. + */ +export class AddCustomerTruckDeparture1970000000000 implements MigrationInterface { + name = 'AddCustomerTruckDeparture1970000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_assignments + ADD COLUMN IF NOT EXISTS gross_weight_kg numeric(14, 2), + ADD COLUMN IF NOT EXISTS departed_at timestamptz + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_assignments + DROP COLUMN IF EXISTS gross_weight_kg, + DROP COLUMN IF EXISTS departed_at + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 795040eeb..106b7ed5b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, + ForbiddenException, Get, HttpCode, Param, @@ -62,7 +63,10 @@ import { import { ContractViewDto } from './dto/contract-view.dto'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; +import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { CustomerTruckService } from './customer-truck.service'; +import { GenerateGrnDto } from './dto/generate-grn.dto'; +import { ContainerReceiptService } from './container-receipt.service'; import { SignContractDto } from './dto/sign-contract.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; import { @@ -86,6 +90,7 @@ export class BookingsController { private readonly contractService: BookingContractService, private readonly bookingClearanceService: BookingClearanceService, private readonly customerTruckService: CustomerTruckService, + private readonly containerReceiptService: ContainerReceiptService, ) {} @Post() @@ -353,6 +358,53 @@ export class BookingsController { return this.customerTruckService.removeTruck(id, assignmentId); } + @Post(':id/customer-trucks/:assignmentId/depart') + @ApiOperation({ + summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)', + }) + async departCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Body() dto: DepartCustomerTruckDto, + @CurrentUser() user: TCurrentUser, + ) { + // Weighing + registering the load on exit is a warehouse/gate staff action. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can register a truck departure'); + } + return this.customerTruckService.departTruck(id, assignmentId, dto); + } + + @Get(':id/received-pending-grn') + @ApiOperation({ summary: 'Containers received into port but not yet on a GRN' }) + async receivedPendingGrn( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + // GRN is a warehouse-staff action — no customer access. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); + } + return this.containerReceiptService.listReceivedPendingGrn(id); + } + + @Post(':id/generate-grn') + @ApiOperation({ + summary: + 'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch', + }) + async generateGrn( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: GenerateGrnDto, + @CurrentUser() user: TCurrentUser, + ) { + // GRN is a warehouse-staff action — no customer access. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); + } + return this.containerReceiptService.generateGrn(id, dto.containerNumbers); + } + @Get(':id/tracking') @ApiOperation({ summary: "Shipment tracking timeline for a booking", diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 8f750af34..2cb10ce8e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -37,6 +37,7 @@ import { CustomerTruckAssignment } from './entities/customer-truck-assignment.en import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; import { CustomerTruckService } from './customer-truck.service'; +import { ContainerReceiptService } from './container-receipt.service'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractsModule } from '../contracts/contracts.module'; import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; @@ -99,6 +100,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ContractPdfService, CustomerTruckAssignmentsRepository, CustomerTruckService, + ContainerReceiptService, ], exports: [ BookingsService, @@ -106,6 +108,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingPricingService, BookingInvoiceService, CustomerTruckService, + ContainerReceiptService, ], }) export class BookingsModule { } diff --git a/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts new file mode 100644 index 000000000..fde3ab797 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts @@ -0,0 +1,145 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; + +export interface ReceivedUnitRow { + id: string; + containerNumber: string; + receivedToPort: boolean; + receivedAt: string | null; + grnNumber: string | null; +} + +/** + * Per-container receive + GRN tracking on booking_container_units. + * + * Containers arrive individually (on separate self-haul trucks), so each unit is + * flipped `received_to_port` when its truck arrives (auto). Staff then confirm a + * Goods Received Note over the received-but-un-GRN'd containers: one GRN covers a + * batch, so if the whole booking arrives together every unit shares a single GRN + * (per-booking GRN); if trucks arrive separately each batch gets its own GRN. + */ +@Injectable() +export class ContainerReceiptService { + constructor(private readonly dataSource: DataSource) {} + + /** + * Auto-mark the containers loaded on an arrived truck as received into the + * port. Idempotent — only flips units not already received. Runs inside the + * caller's transaction when a manager is supplied. + */ + async markReceivedForAssignment( + bookingId: string, + assignmentId: string, + manager?: EntityManager, + ): Promise { + const m = manager ?? this.dataSource.manager; + await m.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + updated_at = NOW() + FROM freight.booking_containers bc, + freight.customer_truck_containers ctc + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND ctc.assignment_id = $2 + AND ctc.deleted_at IS NULL + AND ctc.container_number = bcu.container_number + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = false`, + [bookingId, assignmentId], + ); + } + + /** Received-into-port containers that have not yet been assigned a GRN. */ + async listReceivedPendingGrn(bookingId: string): Promise { + return this.dataSource.query( + `SELECT bcu.id, + bcu.container_number AS "containerNumber", + bcu.received_to_port AS "receivedToPort", + bcu.received_at AS "receivedAt", + bcu.grn_number AS "grnNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = true + AND bcu.grn_number IS NULL + ORDER BY bcu.received_at`, + [bookingId], + ); + } + + /** + * Confirm a GRN over the currently received-but-un-GRN'd containers (optionally + * a subset by container number). Assigns one GRN number to the whole batch and + * returns it with the covered containers. If the batch covers every container + * on the booking it is effectively a per-booking GRN. + */ + async generateGrn( + bookingId: string, + containerNumbers?: string[], + ): Promise<{ grnNumber: string; containerNumbers: string[]; perBooking: boolean }> { + const [booking] = await this.dataSource.query( + `SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + + return this.dataSource.transaction(async (manager) => { + const wanted = containerNumbers?.map((n) => n.trim().toUpperCase()); + const pending: ReceivedUnitRow[] = await manager.query( + `SELECT bcu.id, bcu.container_number AS "containerNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = true + AND bcu.grn_number IS NULL + ${wanted ? 'AND bcu.container_number = ANY($2::varchar[])' : ''}`, + wanted ? [bookingId, wanted] : [bookingId], + ); + if (!pending.length) { + throw new BadRequestException('No received containers are awaiting a GRN'); + } + + // Batch sequence = number of GRNs already issued for this booking + 1. + const [{ batches }]: Array<{ batches: string }> = await manager.query( + `SELECT COUNT(DISTINCT bcu.grn_number) AS batches + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`, + [bookingId], + ); + const seq = Number(batches) + 1; + const grnNumber = `GRN-${String(booking.reference).replace(/^BK-?/i, '')}-${String(seq).padStart(2, '0')}`; + + const ids = pending.map((p) => p.id); + await manager.query( + `UPDATE freight.booking_container_units + SET grn_number = $1, updated_at = NOW() + WHERE id = ANY($2::uuid[])`, + [grnNumber, ids], + ); + + // Per-booking when no container on the booking is left un-GRN'd. + const [{ remaining }]: Array<{ remaining: string }> = await manager.query( + `SELECT COUNT(*) AS remaining + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`, + [bookingId], + ); + + return { + grnNumber, + containerNumbers: pending.map((p) => p.containerNumber), + perBooking: Number(remaining) === 0 && seq === 1, + }; + }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 5ea1a898c..5d0650219 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -7,6 +7,7 @@ import { import { DataSource, EntityManager, IsNull } from 'typeorm'; import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; +import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; @@ -41,17 +42,31 @@ export class CustomerTruckService { const booking = await this.loadBookingGuard(bookingId); this.assertSelfHaulPaid(booking); - const requested = dto.containerNumbers.map((n) => n.trim().toUpperCase()); - const bookingNumbers = await this.bookingContainerNumbers(bookingId); - for (const n of requested) { - if (!bookingNumbers.includes(n)) { - throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + const isExport = booking.tradeDirection === 'EXPORT'; + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + + // EXPORT: the truck delivers 1–2 known containers. IMPORT: containers are + // not pre-specified — they are registered + weighed when the truck leaves. + if (isExport) { + if (requested.length < 1 || requested.length > 2) { + throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers'); } + } else if (requested.length > 2) { + throw new BadRequestException('A truck carries at most 2 containers'); } - const alreadyAssigned = await this.assignedContainerNumbers(bookingId); - for (const n of requested) { - if (alreadyAssigned.includes(n)) { - throw new ConflictException(`Container ${n} is already loaded onto another truck`); + + if (requested.length) { + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + for (const n of requested) { + if (!bookingNumbers.includes(n)) { + throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + } + } + const alreadyAssigned = await this.assignedContainerNumbers(bookingId); + for (const n of requested) { + if (alreadyAssigned.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another truck`); + } } } @@ -118,6 +133,71 @@ export class CustomerTruckService { return this.listTrucks(bookingId); } + /** + * Register an IMPORT self-haul truck leaving the port: the containers it + * actually loaded (replacing any provisional list) and its weighed gross. + * Export bookings have no truck departure — trucks only deliver (receive). + */ + async departTruck( + bookingId: string, + assignmentId: string, + dto: DepartCustomerTruckDto, + ): Promise { + const booking = await this.loadBookingGuard(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException( + 'Truck departure/weighing applies to import self-haul only (export trucks only deliver)', + ); + } + const assignment = await this.assignments.findByIdWithContainers(assignmentId); + if (!assignment || assignment.bookingId !== bookingId) { + throw new NotFoundException('Truck assignment not found for this booking'); + } + // Once filled, the departure record is uneditable. + if (assignment.departedAt) { + throw new ConflictException('This truck has already departed — its exit record is locked'); + } + + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (requested.length) { + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + for (const n of requested) { + if (!bookingNumbers.includes(n)) { + throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + } + } + const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); + for (const n of requested) { + if (elsewhere.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another truck`); + } + } + } + + await this.dataSource.transaction(async (manager) => { + if (requested.length) { + // Replace the truck's containers with what was actually loaded. + await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + await manager.getRepository(CustomerTruckContainer).save( + requested.map((containerNumber) => + manager.getRepository(CustomerTruckContainer).create({ + assignmentId, + bookingId, + containerNumber, + }), + ), + ); + } + await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { + grossWeightKg: dto.grossWeightKg, + departedAt: dto.gateOutTime ? new Date(dto.gateOutTime) : new Date(), + arrivedAt: assignment.arrivedAt ?? new Date(), + }); + }); + + return this.listTrucks(bookingId); + } + /** * Mark the truck carrying `containerNumber` as arrived. Called by the warehouse * receive flow. When every truck on the booking has arrived, the booking-level @@ -225,4 +305,17 @@ export class CustomerTruckService { ); return rows.map((r) => r.containerNumber.trim().toUpperCase()); } + + private async assignedContainerNumbersExcept( + bookingId: string, + exceptAssignmentId: string, + ): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT container_number AS "containerNumber" + FROM freight.customer_truck_containers + WHERE booking_id = $1 AND assignment_id <> $2 AND deleted_at IS NULL`, + [bookingId, exceptAssignmentId], + ); + return rows.map((r) => r.containerNumber.trim().toUpperCase()); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts index 17458dafa..4356d66ec 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts @@ -1,10 +1,10 @@ import { ArrayMaxSize, - ArrayMinSize, ArrayUnique, IsArray, IsIn, IsNotEmpty, + IsOptional, IsString, Matches, MaxLength, @@ -13,9 +13,11 @@ import { import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; /** - * Add one external customer truck to a booking, carrying 1–2 container numbers. - * Each container must be one of the booking's containers and not already loaded - * onto another truck (enforced in the service + a partial unique index). + * Add one external customer truck to a booking. + * - EXPORT: the truck delivers 1–2 known containers (required, validated in the + * service against the booking's containers). + * - IMPORT: the customer does not pre-specify — containers are registered and + * weighed when the truck leaves, so `containerNumbers` may be omitted/empty. */ export class AddCustomerTruckDto { @IsString() @@ -33,13 +35,13 @@ export class AddCustomerTruckDto { @IsIn(CUSTOMER_TRUCK_TYPES) truckType!: string; + @IsOptional() @IsArray() - @ArrayMinSize(1) @ArrayMaxSize(2) @ArrayUnique() @Matches(/^[A-Z]{4}\d{7}$/, { each: true, message: 'each container number must match ISO container format, e.g. ABCD1234567', }) - containerNumbers!: string[]; + containerNumbers?: string[]; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts new file mode 100644 index 000000000..31ab1b5bd --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts @@ -0,0 +1,37 @@ +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsDateString, + IsNumber, + IsOptional, + Matches, + Min, +} from 'class-validator'; + +/** + * Register an import self-haul truck leaving the port: the containers it actually + * loaded (staff read them off the truck) and the weighed gross. Container numbers + * are optional here only because they may already have been recorded; the weighed + * gross is required. + */ +export class DepartCustomerTruckDto { + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; + + @IsNumber() + @Min(0) + grossWeightKg!: number; + + /** Gate-out time. Defaults to now when omitted. */ + @IsOptional() + @IsDateString() + gateOutTime?: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts new file mode 100644 index 000000000..2f5ea86af --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts @@ -0,0 +1,17 @@ +import { ArrayUnique, IsArray, IsOptional, Matches } from 'class-validator'; + +/** + * Confirm a Goods Received Note. Omit `containerNumbers` to GRN every + * received-but-un-GRN'd container on the booking (per-booking when that's all of + * them); pass a subset to GRN just those. + */ +export class GenerateGrnDto { + @IsOptional() + @IsArray() + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts index e8ef1b138..619013280 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts @@ -34,4 +34,17 @@ export class BookingContainerUnit extends BaseEntity { @Column({ name: 'sort_order', type: 'smallint', default: 0 }) sortOrder!: number; + + /** Whether this container has been received into the port (auto-set when its + * self-haul truck arrives). */ + @Column({ name: 'received_to_port', type: 'boolean', default: false }) + receivedToPort!: boolean; + + @Column({ name: 'received_at', type: 'timestamptz', nullable: true }) + receivedAt?: Date | null; + + /** The GRN this container was received under (assigned when staff confirm the + * Goods Received Note for a batch of received containers). */ + @Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true }) + grnNumber?: string | null; } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts index 83b70a135..6eeaba963 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts @@ -34,6 +34,14 @@ export class CustomerTruckAssignment extends BaseEntity { @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) arrivedAt?: Date | null; + /** Weighed gross of what the truck actually loaded (import), captured on + * leaving. Null until the truck departs. */ + @Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true }) + grossWeightKg?: number | null; + + @Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) + departedAt?: Date | null; + @OneToMany(() => CustomerTruckContainer, (c) => c.assignment, { cascade: true }) containers?: CustomerTruckContainer[]; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 9a9f8caa4..c9844051b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -920,6 +920,23 @@ export class WarehouseInventoryService { }), ); + // Receiving the booking flags every container unit as received into the + // port (self-haul export: the delivering truck's goods are now in) so + // staff can raise the per-container GRN over what's received. + await manager.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + updated_at = NOW() + FROM freight.booking_containers bc + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND bc.deleted_at IS NULL + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = false`, + [bookingId], + ); + await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', @@ -1774,6 +1791,26 @@ export class WarehouseInventoryService { await this.applyCapacityDelta(manager, dto, weight, volume, containerCount); + // Per-container receive: flag this container's unit as received into the + // port so staff can raise the GRN over what's received. + if (dto.bookingId && dto.containerId) { + await manager.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + updated_at = NOW() + FROM freight.booking_containers bc, freight.containers cont + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND bc.deleted_at IS NULL + AND cont.id = $2 + AND cont.container_number = bcu.container_number + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = false`, + [dto.bookingId, dto.containerId], + ); + } + await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', @@ -2084,6 +2121,9 @@ export class WarehouseInventoryService { AND a.deleted_at IS NULL`, [item.bookingId, item.containerId], ); + // NB: import arrival changes nothing on the goods — received_to_port is + // an EXPORT concept (set when a truck delivers into the port). Import + // load + weight are captured on truck departure, not arrival. } // Booking-level flag stamped on the FIRST truck arrival. The import // handover is signed ONCE (before the first truck leaves), even though @@ -2175,12 +2215,16 @@ export class WarehouseInventoryService { truckType: string; containerNumbers: string; truckWeightTons: string | number | null; + grossWeightKg: string | number | null; + departedAt: string | null; } | null = null; if (row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) { const [truckRow] = await this.dataSource.query( `SELECT a.plate_number AS "plateNumber", a.driver_name AS "driverName", a.truck_type AS "truckType", + a.gross_weight_kg AS "grossWeightKg", + a.departed_at AS "departedAt", string_agg(DISTINCT c2.container_number, ', ' ORDER BY c2.container_number) AS "containerNumbers", COALESCE(( SELECT SUM(bcu.vgm_tons) @@ -2231,8 +2275,14 @@ export class WarehouseInventoryService { truckPlateNumber: truck?.plateNumber ?? null, truckDriverName: truck?.driverName ?? null, truckType: truck?.truckType ?? null, - truckContainers: truck?.containerNumbers ?? null, - truckWeightKg: truck ? Number(truck.truckWeightTons ?? 0) * 1000 : null, + truckGateOut: truck?.departedAt ?? null, + // Prefer the weighed gross captured on departure; fall back to the summed + // container VGM when the truck hasn't been weighed yet. + truckWeightKg: truck + ? Number(truck.grossWeightKg ?? 0) > 0 + ? Number(truck.grossWeightKg) + : Number(truck.truckWeightTons ?? 0) * 1000 + : null, }); return { @@ -3165,7 +3215,7 @@ export class WarehouseInventoryService { truckPlateNumber?: string | null; truckDriverName?: string | null; truckType?: string | null; - truckContainers?: string | null; + truckGateOut?: string | null; truckWeightKg?: number | null; }): string { const esc = (value: unknown) => @@ -3208,7 +3258,18 @@ export class WarehouseInventoryService { ['Pickup Truck Plate', data.truckPlateNumber], ['Truck Driver', data.truckDriverName], ['Truck Type', data.truckType], - ['Containers Loaded on Truck', data.truckContainers], + [ + 'Gate-Out Time', + data.truckGateOut + ? new Date(data.truckGateOut).toLocaleString('en-GB', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + : null, + ], ] as [string, string | null][]) : []), ...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []), diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index ad7f6b72b..65af584d3 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -73,6 +73,10 @@ export function CustomerTruckAssignmentCard({ (n) => !assignedNumbers.has(n), ); + // EXPORT trucks deliver known containers (pre-selected). IMPORT trucks don't — + // staff register + weigh what was loaded when the truck leaves. + const isExport = booking.tradeDirection === "EXPORT"; + const resetForm = () => { setPlateNumber(""); setDriverName(""); @@ -87,7 +91,8 @@ export function CustomerTruckAssignmentCard({ truckPlateNumber: plateNumber.trim().toUpperCase(), driverName: driverName.trim(), truckType: truckType.trim(), - containerNumbers: containers, + // Import: containers are registered + weighed on departure, not here. + containerNumbers: isExport ? containers : [], }), onSuccess: (list) => { queryClient.setQueryData(trucksKey, list); @@ -118,7 +123,7 @@ export function CustomerTruckAssignmentCard({ setError("Plate number, driver name and truck type are required."); return; } - if (containers.length < 1 || containers.length > 2) { + if (isExport && (containers.length < 1 || containers.length > 2)) { setError("Select 1 or 2 container numbers for this truck."); return; } @@ -202,8 +207,8 @@ export function CustomerTruckAssignmentCard({ )} - {/* Add-truck form */} - {availableContainers.length > 0 ? ( + {/* Add-truck form. Export needs unassigned containers; import always allows another truck. */} + {(isExport ? availableContainers.length > 0 : true) ? ( <> @@ -226,17 +231,19 @@ export function CustomerTruckAssignmentCard({ value={truckType || null} onChange={(value) => setTruckType(value ?? "")} /> - + {isExport && ( + + )} + Continue + -

- Already have an account?{" "} - -

- - + < p className = "text-center text-sm text-gray-500" > + Already have an account ? { " "} + < button + type = "button" +onClick = {() => navigate("/login")} +className = "font-semibold text-primary hover:underline" + > + Sign In + +

+ + ) : ( - -
- - - -
-
-

- Verify your {otpChannel === "email" ? "email" : "phone"} -

-

- We sent a 6-digit code to{" "} - - {otpChannel === "email" - ? maskEmail(pendingData?.email ?? "") - : maskPhone(pendingData?.phone ?? "")} - - . Enter it to finish creating your account. + +

+ + + +
+ < div className = "space-y-1.5 text-center" > +

+ Verify your { otpChannel === "email" ? "email" : "phone" } +

+ < p className = "text-sm leading-relaxed text-gray-500" > + We sent a 6 - digit code to{ " " } + + { otpChannel === "email" + ? maskEmail(pendingData?.email ?? "") + : maskPhone(pendingData?.phone ?? "")} + + .Enter it to finish creating your account.

-
+
- {otpError ? ( - } +{ + otpError ? ( + } > - {otpError} - + { otpError } + ) : null} - - - Verification code - - - + + + Verification code + + < PinInput +length = { 6} +type = "number" +oneTimeCode +value = { otpCode } +placeholder = "0" +disabled = { verifying } +styles = {{ input: { textAlign: "center" } }} +onChange = { setOtpCode } + /> + - + < Button +color = "edr-green" +fullWidth +loading = { verifying } +disabled = { verifying || otpCode.trim().length !== 6} +onClick = { confirmOtp } + > + Verify & amp; create account + -
- - -
- + Back + + < Button +variant = "subtle" +color = "edr-green" +leftSection = {< RotateCw size = { 14} />} +disabled = { resendIn > 0 || sending || verifying} +onClick = { resendOtp } + > + { resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"} + +
+
)} -
- +
+ ); } diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index 58b81c5ba..3f9ef4e53 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -54,7 +54,7 @@ export const authService = { }, checkAvailability: async (params: CheckAvailabilityPayload) => { - const res = await client.get>( + const res = await client.get( URL_CONSTANTS.USERS.CHECK_AVAILABILITY, { params }, );