From 1c18fdbd529b7b080fd2abdb8dcd8a7a6937dd66 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 7 Jul 2026 09:49:12 +0000 Subject: [PATCH 1/6] Enhance contract management by adding booking windows section and updating invoice logic for offloaded cargo --- .../contracts/gl-operations.service.ts | 12 +- .../contracts/ExportClearanceStepper.tsx | 6 +- .../contracts/GlUpcomingWindowsSection.tsx | 75 +++- .../contracts/ContractClearanceDetailPage.tsx | 5 + .../backoffice/src/types/trainScheduling.ts | 2 + .../ContractBookingWindowsSection.tsx | 325 ++++++++++++++++++ .../pages/contracts/ContractDetailPage.tsx | 13 +- .../src/pages/contracts/NewContractPage.tsx | 6 +- 8 files changed, 422 insertions(+), 22 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx 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 181d8b688..28a31c331 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 @@ -395,14 +395,20 @@ export class GlOperationsService { } if (!file) throw new BadRequestException('Attach the invoice document.'); + // Invoiceable once cargo is offloaded, or — for export, where OFFLOADED is a + // DJ doc milestone that may never be recorded — once the Djibouti gate pass + // is secured. The invoice itself stays optional; nothing forces GL DJ to send one. const milestones = await this.milestoneService.listForBooking(bookingId); const offloaded = milestones.find( (m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED', ); if (!offloaded) { - throw new BadRequestException( - 'Cargo must be offloaded before the final invoice can be raised.', - ); + const gatepass = await this.gatepassForBooking(bookingId); + if (!gatepass.granted) { + throw new BadRequestException( + 'Cargo must be offloaded (or the gate pass secured) before the final invoice can be raised.', + ); + } } const existing = await this.billingService.findInvoice( 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 db6e79430..dc59c4dfc 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -646,7 +646,9 @@ function FinalInvoiceStep({ const invoice = clearance.finalInvoice ?? null; const paid = invoice?.status === "PAID"; - if (!clearance.offloaded && !invoice) { + // Export: OFFLOADED is a DJ doc milestone that may never be recorded, so the + // secured gate pass is enough to open invoicing. Sending an invoice is optional. + if (!clearance.offloaded && !clearance.gatepassGranted && !invoice) { return ( - Cargo offloaded — send the final invoice to the customer. + Send the final invoice to the customer if post-arrival charges apply (optional). )} + {hasFile && ( + + + + )} 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 75e901d10..6256bd9cc 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -727,9 +727,9 @@ function ImportT1UploadStep({ ); } - const departed = Boolean(t1.trainDepartedAt); - const canUpload = - canDjAct && t1.wagonAllocated && gatepassGranted && !departed && !t1.closed; + // Departure no longer locks T1 docs — GL DJ may replace them until GL Ethiopia + // closes/accepts the T1. + const canUpload = canDjAct && t1.wagonAllocated && gatepassGranted && !t1.closed; return ( @@ -769,10 +769,6 @@ function ImportT1UploadStep({ pendingLabel="Waiting for the gate pass to be secured on the train schedule." doneLabel="" /> - ) : departed ? ( - }> - The train has departed — T1 documents are locked and can no longer be changed. - ) : uploaded.length === 0 && !canUpload ? ( = { APPROVED: "cyan", PAID: "edr-green", IN_TRANSIT: "blue", + ARRIVED: "teal", COMPLETED: "indigo", REJECTED: "red", CANCELLED: "red", diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx index de010e380..887b235c4 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx @@ -33,7 +33,9 @@ const FleetRecordActions = ({ const isVehicle = config.slug === "vehicles"; const showHistory = Boolean(onHistory) && - (config.slug === "drivers" || config.slug === "vehicles"); + (config.slug === "drivers" || + config.slug === "vehicles" || + config.slug === "wagons"); const handleDetail = () => { if (!config.detailPath || !("id" in record)) return; diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/WagonMovementHistoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/WagonMovementHistoryModal.tsx new file mode 100644 index 000000000..e2713fdcb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/fleet/WagonMovementHistoryModal.tsx @@ -0,0 +1,133 @@ +import type { ReactNode } from "react"; +import { Badge, Center, Group, Loader, Modal, Text, Timeline } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowRight, PackageCheck, TrainFront, Wrench } from "lucide-react"; + +import { api } from "@/services/api"; +import type { FleetRecord } from "@/services/fleet/fleet.service"; +import type { WagonMovementRecord } from "@/services/wagon.service"; + +export interface WagonMovementHistoryModalProps { + opened: boolean; + onClose: () => void; + record: FleetRecord | null; +} + +const asObj = (r: FleetRecord | null) => (r ?? {}) as Record; + +/** Chip style per wagon_movements ledger kind. */ +const KIND_META: Record = { + LOADED: { + label: "Loaded leg", + color: "edr-green", + icon: , + }, + EMPTY_REPOSITION: { + label: "Empty reposition", + color: "blue", + icon: , + }, + MANUAL: { + label: "Manual move", + color: "orange", + icon: , + }, +}; + +const yardLabel = ( + yard: { label?: string; code?: string } | null | undefined, + yardId: string | null, +) => yard?.label ?? yard?.code ?? yardId ?? "Unknown"; + +const fmt = (iso: string) => { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); +}; + +/** + * Movement ledger for one wagon: every relocation between yards — booking legs, + * empty reposition rides, and manual staff corrections — newest first. + */ +const WagonMovementHistoryModal = ({ + opened, + onClose, + record, +}: WagonMovementHistoryModalProps) => { + const r = asObj(record); + const id = r.id ? String(r.id) : ""; + const wagonNumber = r.wagonNumber ? String(r.wagonNumber) : ""; + + const { data, isLoading } = useQuery( + api.wagons.movements.queryOptions({ + input: { id }, + enabled: opened && Boolean(id), + }), + ); + + const movements: WagonMovementRecord[] = data ?? []; + + return ( + {`Wagon history — ${wagonNumber}`.trim()}} + radius="lg" + size="lg" + centered + > + {isLoading ? ( +
+ +
+ ) : movements.length === 0 ? ( + + No movements recorded yet. Every yard-to-yard move appears here — a + booking's loaded leg, an empty reposition ride, or a manual correction. + + ) : ( + + {movements.map((movement) => { + const meta = KIND_META[movement.kind] ?? { + label: movement.kind, + color: "gray", + icon: , + }; + const from = yardLabel(movement.fromYard, movement.fromYardId); + const to = yardLabel(movement.toYard, movement.toYardId); + return ( + + + {from} + + + + {to} + + + {meta.label} + + + } + > + {movement.note && ( + + {movement.note} + + )} + + {fmt(movement.occurredAt)} + + + ); + })} + + )} +
+ ); +}; + +export default WagonMovementHistoryModal; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/YardWorkPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/YardWorkPanel.tsx new file mode 100644 index 000000000..76ccdfde2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/YardWorkPanel.tsx @@ -0,0 +1,333 @@ +import { + Alert, + Badge, + Button, + Divider, + Group, + Loader, + Paper, + Stack, + Table, + Text, + Tooltip, +} from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertCircle, MapPin, PackageCheck, PackageOpen, TrainFront } from "lucide-react"; +import { Freight } from "@edr/types"; + +import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; +import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; +import type { YardWorkBookingRow, YardWorkYard } from "@/types/trainScheduling"; + +const parseError = (error: unknown, fallback: string) => { + const message = (error as { response?: { data?: { message?: string | string[] } } }) + ?.response?.data?.message; + if (Array.isArray(message)) return message.join("; "); + return message || (error as Error)?.message || fallback; +}; + +const fmtDate = (iso: string) => { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); +}; + +const DIRECTION_COLORS: Record = { + IMPORT: "blue", + EXPORT: "teal", + DOMESTIC: "violet", +}; + +/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */ +const DIRECTION_LABELS: Record = Freight.TRADE_DIRECTION_LABELS; + +function DirectionChip({ direction }: { direction: string }) { + return ( + + {DIRECTION_LABELS[direction] ?? direction} + + ); +} + +function BookingCell({ row }: { row: YardWorkBookingRow }) { + return ( + + + {row.reference ?? row.id.slice(0, 8)} + + {row.isGovernment && ( + + GOV + + )} + + ); +} + +function WorkTable({ + rows, + side, + trainHere, + onLoad, + onUnload, + pendingBookingId, +}: { + rows: YardWorkBookingRow[]; + side: "load" | "unload"; + trainHere: boolean; + onLoad: (bookingId: string) => void; + onUnload: (bookingId: string) => void; + pendingBookingId: string | null; +}) { + if (rows.length === 0) { + return ( + + {side === "load" ? "No bookings board here." : "No bookings alight here."} + + ); + } + return ( + + + + + Booking + Customer + Direction + Status + {side === "load" ? "Loaded" : "Arrived"} + + + + + {rows.map((row) => { + const timestamp = side === "load" ? row.loadedAt : row.arrivedAt; + const canAct = side === "load" ? row.canLoad : row.canUnload; + return ( + + + + + + {row.customer} + + + + + + + + + {timestamp ? ( + + {fmtDate(timestamp)} + + ) : ( + + — + + )} + + + + {side === "load" ? ( + + + + ) : ( + + + + )} + + + + ); + })} + +
+
+ ); +} + +/** + * Per-yard load/unload worklist for one schedule — every trade direction. Each + * booking boards at its origin yard and alights at its destination yard; the + * operator confirms both while the train's last recorded checkpoint is at that + * yard (the server validates the position). Unloading stamps the booking's own + * arrival — ARRIVED for import/export, COMPLETED for intercity. + */ +export function YardWorkPanel({ scheduleId }: { scheduleId: string }) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const yardWorkQuery = useQuery( + api.trainScheduling.yardWork.queryOptions({ + input: { scheduleId }, + refetchInterval: 60_000, + }), + ); + + const invalidate = () => + queryClient.invalidateQueries({ + queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }), + }); + + const load = useMutation( + api.trainScheduling.loadScheduleBooking.mutationOptions({ + onSuccess: () => { + void invalidate(); + toast({ title: "Cargo loaded" }); + }, + onError: (err) => + toast({ + title: "Load failed", + description: parseError(err, "Could not confirm loading"), + variant: "destructive", + }), + }), + ); + + const unload = useMutation( + api.trainScheduling.unloadScheduleBooking.mutationOptions({ + onSuccess: (result) => { + void invalidate(); + toast({ + title: + result.status === "COMPLETED" + ? "Cargo unloaded — booking completed" + : "Cargo unloaded — booking arrived", + }); + }, + onError: (err) => + toast({ + title: "Unload failed", + description: parseError(err, "Could not confirm unloading"), + variant: "destructive", + }), + }), + ); + + const data = yardWorkQuery.data; + const yards: YardWorkYard[] = data?.yards ?? []; + const trainAtYardId = data?.trainAtYardId ?? null; + const pendingLoadId = load.isPending ? (load.variables?.bookingId ?? null) : null; + const pendingUnloadId = unload.isPending ? (unload.variables?.bookingId ?? null) : null; + + return ( + + + + + Yard load / unload + + + {yardWorkQuery.isLoading ? ( + + + + Loading yard worklists… + + + ) : yardWorkQuery.isError ? ( + }> + {parseError(yardWorkQuery.error, "Could not load the yard worklist")} + + ) : yards.length === 0 ? ( + + No bookings are assigned to this schedule yet. + + ) : ( + <> + + What boards and alights at each stop. Confirm loading at a booking's + origin and unloading at its destination while the train is at that + yard — unloading stamps the booking's own arrival, even before the + train's final stop. + + {yards.map((yard, index) => { + const trainHere = trainAtYardId === yard.yardId; + return ( + + {index > 0 && } + + {yard.yard} + {trainHere && ( + } + > + Train here + + )} + + + + Board here + + load.mutate({ scheduleId, bookingId })} + onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })} + pendingBookingId={pendingLoadId} + /> + + + + Alight here + + load.mutate({ scheduleId, bookingId })} + onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })} + pendingBookingId={pendingUnloadId} + /> + + + ); + })} + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index a42c7c01b..05bb06372 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -326,6 +326,11 @@ export const URL_CONSTANTS = { PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`, FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`, DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`, + YARD_WORK: (id: string) => `/train-scheduling/schedules/${id}/yard-work`, + BOOKING_LOAD: (id: string, bookingId: string) => + `/train-scheduling/schedules/${id}/bookings/${bookingId}/load`, + BOOKING_UNLOAD: (id: string, bookingId: string) => + `/train-scheduling/schedules/${id}/bookings/${bookingId}/unload`, INTERCITY_CANDIDATES: (id: string) => `/train-scheduling/schedules/${id}/intercity-candidates`, INTERCITY_ACCEPT: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts index 127c8e708..25f4b51bc 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts @@ -66,6 +66,10 @@ export const BOOKING_STATUS_STYLES: Record = { label: "In Transit", color: "bg-sky-50 text-sky-700 border-sky-200", }, + ARRIVED: { + label: "Arrived", + color: "bg-emerald-50 text-emerald-700 border-emerald-200", + }, COMPLETED: { label: "Completed", color: "bg-indigo-50 text-indigo-700 border-indigo-200", @@ -208,6 +212,12 @@ export const BOOKING_STATUS_META: Record = { color: "text-sky-600", stage: 4, }, + ARRIVED: { + title: "Arrived", + description: "Cargo unloaded at its destination yard.", + color: "text-emerald-600", + stage: 4, + }, COMPLETED: { title: "Completed", description: "Booking fulfilled.", @@ -290,7 +300,7 @@ export const BOOKING_LIST_TABS = [ { key: "operations", label: "Operations", - statuses: ["PAID", "IN_TRANSIT", "ROAD_DISPATCH_PENDING"], + statuses: ["PAID", "IN_TRANSIT", "ARRIVED", "ROAD_DISPATCH_PENDING"], }, { key: "completed", label: "Completed", statuses: ["COMPLETED"] }, { key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] }, @@ -328,7 +338,7 @@ export const WORKFLOW_STAGES = [ }, { label: "Operations", - statuses: ["PAID", "IN_TRANSIT"], + statuses: ["PAID", "IN_TRANSIT", "ARRIVED"], }, { label: "Done", statuses: ["COMPLETED"] }, ] as const; 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 f7bee5b41..820424283 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -13,6 +13,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog"; import FleetHistoryModal from "@/components/fleet/FleetHistoryModal"; import FleetRecordActions from "@/components/fleet/FleetRecordActions"; import FleetToolbar from "@/components/fleet/FleetToolbar"; +import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal"; import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat"; import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; @@ -585,12 +586,20 @@ const FleetResourcePage = () => {
- setHistoryTarget(null)} - entity={slug === "vehicles" ? "vehicle" : "driver"} - record={historyTarget} - /> + {slug === "wagons" ? ( + setHistoryTarget(null)} + record={historyTarget} + /> + ) : ( + setHistoryTarget(null)} + entity={slug === "vehicles" ? "vehicle" : "driver"} + record={historyTarget} + /> + )} ); }; 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 2014f9c65..20b632710 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 @@ -92,6 +92,12 @@ const TRADE_DIRECTIONS = [ { label: "Both", value: "BOTH" }, ]; +// Mirrors the YardCountry enum in @edr/types — the only two countries on the line. +const YARD_COUNTRIES = [ + { label: "Ethiopia", value: "Ethiopia" }, + { label: "Djibouti", value: "Djibouti" }, +]; + const APPROVAL_ROLES = [ { label: "Line staff", value: "LINE_STAFF" }, { label: "Director", value: "DIRECTOR" }, @@ -431,7 +437,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ ], formFields: [ { name: "label", label: "Label", type: "text", required: true }, - { name: "country", label: "Country", type: "text", required: true }, + { + name: "country", + label: "Country", + type: "select", + required: true, + options: YARD_COUNTRIES, + }, { name: "isActive", label: "Active", type: "boolean" }, ], }, 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 7ee0f7896..ca2b7c39e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -49,6 +49,7 @@ import { import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel"; +import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel"; // import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal"; @@ -1131,6 +1132,7 @@ export default function TrainScheduleV2DetailPage() { void detailQuery.refetch(); }} /> + {scheduleId ? : null} {scheduleId ? ( TRAIN_SCHEDULING_INVALIDATIONS, ), + yardWork: endpoint< + { scheduleId: string }, + import("@/types/trainScheduling").YardWorkResult + >( + "train-scheduling", + "yard-work", + ({ scheduleId }) => trainSchedulingService.getYardWork(scheduleId), + ({ scheduleId }) => ["train-scheduling", "yard-work", scheduleId], + ), + + loadScheduleBooking: endpoint< + { scheduleId: string; bookingId: string }, + import("@/types/trainScheduling").BookingLoadResult + >( + "train-scheduling", + "booking-load", + ({ scheduleId, bookingId }) => + trainSchedulingService.loadScheduleBooking(scheduleId, bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + unloadScheduleBooking: endpoint< + { scheduleId: string; bookingId: string }, + import("@/types/trainScheduling").BookingUnloadResult + >( + "train-scheduling", + "booking-unload", + ({ scheduleId, bookingId }) => + trainSchedulingService.unloadScheduleBooking(scheduleId, bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + intercityCandidates: endpoint< { scheduleId: string }, import("@/types/trainScheduling").IntercityCandidatesResult @@ -1493,6 +1528,13 @@ export const api = { wagonService.getById(id).then((r) => r.data), ), + movements: endpoint<{ id: string }, WagonMovementRecord[]>( + "wagons", + "movements", + ({ id }) => wagonService.getMovements(id).then((r) => r.data), + ({ id }) => ["wagons", "movements", id], + ), + assignToTrain: endpoint< { wagonId: string; trainId: string; sequenceNumber?: number }, Wagon 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 3f4c6822f..be05c6d8c 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -8,6 +8,8 @@ import type { BookableSchedule, BookingWindow, AssignBookingsPayload, + BookingLoadResult, + BookingUnloadResult, CompositionRemovalEntry, UnassignedBookingsResponse, CreateTrainSchedulePayload, @@ -35,6 +37,7 @@ import type { UploadImportDjiboutiDocumentPayload, WagonAllocationAttemptResult, YardOption, + YardWorkResult, } from "@/types/trainScheduling"; interface BookingReferenceDataResponse { @@ -330,6 +333,35 @@ export const trainSchedulingService = { return unwrap(response.data); }, + getYardWork: async (scheduleId: string): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.YARD_WORK(scheduleId), + ); + return unwrap(response.data); + }, + + loadScheduleBooking: async ( + scheduleId: string, + bookingId: string, + ): Promise => { + const response = await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_LOAD(scheduleId, bookingId), + {}, + ); + return unwrap(response.data); + }, + + unloadScheduleBooking: async ( + scheduleId: string, + bookingId: string, + ): Promise => { + const response = await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_UNLOAD(scheduleId, bookingId), + {}, + ); + return unwrap(response.data); + }, + getIntercityCandidates: async ( scheduleId: string, ): Promise => { diff --git a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts index da7b2b509..a200195e0 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts @@ -37,6 +37,27 @@ export interface WagonListFilters { trainId?: string; } +/** + * One row of the wagon_movements ledger: every physical relocation between + * yards — a booking's loaded leg, an empty reposition ride, or a manual staff + * correction. Returned newest first by the API. + */ +export interface WagonMovementRecord { + id: string; + wagonId: string; + fromYardId: string | null; + toYardId: string; + fromYard?: { id?: string; label?: string; code?: string } | null; + toYard?: { id?: string; label?: string; code?: string } | null; + trainScheduleId: string | null; + bookingId: string | null; + kind: Freight.WagonMovementKind; + movedByUserId: string | null; + occurredAt: string; + note: string | null; + createdAt: string; +} + export const wagonService = { getAll: (filters: WagonListFilters = {}) => { const params = new URLSearchParams(); @@ -49,6 +70,8 @@ export const wagonService = { return apiClient.get(`/wagons${qs ? `?${qs}` : ''}`); }, getById: (id: string) => apiClient.get(`/wagons/${id}`), + getMovements: (id: string) => + apiClient.get(`/wagons/${id}/movements`), getByTrain: (trainId: string) => apiClient.get(`/wagons?trainId=${trainId}`), assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) => apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }), diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 2c0be4726..41c4a155a 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -19,6 +19,7 @@ export const BOOKING_STATUSES = [ "PAYMENT_VERIFICATION_IN_PROGRESS", "PAID", "IN_TRANSIT", + "ARRIVED", "COMPLETED", "REJECTED", "CANCELLED", diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index a67f1732f..e92d75c89 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -118,6 +118,7 @@ export type CustomerBookingStatus = | "APPROVED" | "PAID" | "IN_TRANSIT" + | "ARRIVED" | "COMPLETED" | "REJECTED" | "CANCELLED"; diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 395a8c5de..5e3ab1b0e 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -796,3 +796,52 @@ export interface IntercityAcceptResult { rejected: Array<{ bookingId: string; reason: string }>; remaining: IntercityCapacity; } + +// ── Yard load / unload worklist ────────────────────────────────────────────── +// Per-booking journey along the train's corridor: every booking boards at its +// origin yard and alights at its destination yard, confirmed by the yard +// operator while the train's latest checkpoint is at that yard. + +export interface YardWorkBookingRow { + id: string; + reference: string | null; + status: string; + tradeDirection: string; + isGovernment: boolean; + customer: string; + originYardId: string; + destinationYardId: string; + origin: string; + destination: string; + loadedAt: string | null; + arrivedAt: string | null; + canLoad: boolean; + canUnload: boolean; +} + +export interface YardWorkYard { + yardId: string; + yard: string; + toLoad: YardWorkBookingRow[]; + toUnload: YardWorkBookingRow[]; +} + +export interface YardWorkResult { + scheduleId: string; + scheduleStatus: string; + trainAtYardId: string | null; + yards: YardWorkYard[]; +} + +export interface BookingLoadResult { + bookingId: string; + status: string; + loadedAt: string; +} + +export interface BookingUnloadResult { + bookingId: string; + /** 'ARRIVED' for import/export, 'COMPLETED' for intercity. */ + status: string; + arrivedAt: string; +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx index a65aa009e..ea06ae3e5 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx @@ -17,13 +17,15 @@ export const ActivityRow = memo(function ActivityRow({ const verb = booking.status === "IN_TRANSIT" ? "departed" - : booking.status === "COMPLETED" - ? "delivered" - : booking.status === "PENDING_APPROVAL" - ? "quote ready" - : booking.status === "SUBMITTED" - ? "submitted for review" - : "created"; + : booking.status === "ARRIVED" + ? "arrived" + : booking.status === "COMPLETED" + ? "delivered" + : booking.status === "PENDING_APPROVAL" + ? "quote ready" + : booking.status === "SUBMITTED" + ? "submitted for review" + : "created"; return ( = { badgeDot: "edr-green.5", action: { label: "Track", kind: "outline", icon: MapPin }, }, + ARRIVED: { + stage: 3, + icon: MapPin, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Arrived at destination yard · awaiting release", + step: "edr-green.5", + badgeLabel: "Arrived", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "Track", kind: "outline", icon: MapPin }, + }, COMPLETED: { stage: 4, icon: CheckCircle2, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 6d274f22f..40994d589 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -118,7 +118,9 @@ export function ReadonlyBookingView({ const canAssignCustomerTruck = booking.paymentStatus === "PAID" && usesCustomerTruck && - ["PAID", "IN_TRANSIT", "COMPLETED", "TRUCK_ASSIGNED"].includes(status); + ["PAID", "IN_TRANSIT", "ARRIVED", "COMPLETED", "TRUCK_ASSIGNED"].includes( + status, + ); const showCountdown = canPay && !!booking.paymentDeadline; const isExpired = status === "EXPIRED"; const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx index 127901186..13404f8f6 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx @@ -66,10 +66,12 @@ export function StatusHero({ }) { const status = booking.status; const stage = resolveStage(booking); - // The Arrival stage has no booking status of its own — it lights up from the - // train's ARRIVED state, so the headline is overridden here. + // Legacy bookings never reach the ARRIVED status — they light up the Arrival + // stage from the train's ARRIVED state while staying IN_TRANSIT, so the + // headline is overridden here. Bookings with a per-booking journey carry the + // ARRIVED status themselves and use its own STATUS_MAP copy. const cfg = - stage === ARRIVAL_STAGE + stage === ARRIVAL_STAGE && STATUS_MAP[status]?.stage !== ARRIVAL_STAGE ? { title: "Train arrived at destination", description: diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts index 2a41a6fa3..4b32a059c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -54,12 +54,13 @@ export const PROGRESS_STAGES = [ statuses: ["EXPIRED", "IN_TRANSIT"], }, { - // No booking status maps here: the booking stays IN_TRANSIT until - // delivery, so this stage lights up from the assigned train's own status + // ARRIVED: cargo unloaded at the booking's own destination yard (segment + // corridor journeys). Legacy bookings stay IN_TRANSIT until delivery, so + // this stage also lights up from the assigned train's own status // (trainScheduleStatus === "ARRIVED") — see resolveStage. label: "Arrival", icon: MapPin, - statuses: [], + statuses: ["ARRIVED"], }, { label: "Complete", @@ -75,8 +76,10 @@ export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex( /** * Stage for a booking, factoring in the assigned train's operational status: - * a booking is stuck at IN_TRANSIT between dispatch and delivery, so once its - * train has ARRIVED the tracker advances to the Arrival stage. + * a booking with per-booking journey data reaches ARRIVED when it is unloaded + * at its own destination yard; a legacy booking is stuck at IN_TRANSIT between + * dispatch and delivery, so once its train has ARRIVED the tracker advances to + * the Arrival stage. */ export function resolveStage(booking: { status: string; @@ -177,6 +180,12 @@ export const STATUS_MAP: Record< description: "Your shipment is currently moving through the rail network.", stage: 6, }, + ARRIVED: { + title: "Arrived at destination", + description: + "Your cargo has been unloaded at its destination yard and is being prepared for release.", + stage: 7, + }, OPERATION_REQUEST_PENDING: { title: "Operation request under review", description: diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx index 4a7643377..bd68632f7 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx @@ -58,6 +58,7 @@ import { const TRACKABLE_STATUSES = new Set([ "PAID", "IN_TRANSIT", + "ARRIVED", "COMPLETED", "DELIVERED", ]); @@ -83,7 +84,7 @@ const STATUS_FILTERS = [ statuses: "SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED", }, - { key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" }, + { key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT,ARRIVED" }, { key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" }, { key: "closed", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx index 64ee63d5e..4282c7e81 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx @@ -6,6 +6,7 @@ import { Clock, Flag, MapPin, + PackageCheck, PackageX, RefreshCw, Train, @@ -15,11 +16,14 @@ import { api } from "@/services/api"; import { Freight } from "@edr/types"; import { + bookingJourneyState, + bookingLegRange, + bookingShipmentStatusLabel, checkpointKindLabel, corridorProgress, isArrived, isDispatched, - shipmentStatusLabel, + type BookingJourneyState, } from "./trackingStages"; const GREEN = "#0EA371"; @@ -70,6 +74,7 @@ export function ShipmentTrackingModal({ trainNumber={data?.trainNumber ?? null} status={data?.scheduleStatus ?? null} currentSequenceNo={data?.currentSequenceNo ?? -1} + journey={data ? bookingJourneyState(data) : null} onClose={onClose} onRefresh={() => refetch()} refreshing={isFetching} @@ -95,6 +100,7 @@ export function ShipmentTrackingModal({ ) : data ? ( + @@ -111,6 +117,7 @@ function Header({ trainNumber, status, currentSequenceNo, + journey, onClose, onRefresh, refreshing, @@ -119,6 +126,7 @@ function Header({ trainNumber: string | null; status: Freight.TrainScheduleStatus | null; currentSequenceNo: number; + journey: BookingJourneyState; onClose: () => void; onRefresh: () => void; refreshing: boolean; @@ -171,7 +179,11 @@ function Header({ - + @@ -223,12 +235,17 @@ function IconButton({ function HeaderStatusPill({ status, currentSequenceNo, + journey, }: { status: Freight.TrainScheduleStatus | null; currentSequenceNo: number; + journey: BookingJourneyState; }) { - const arrived = isArrived(status); - const moving = isDispatched(status); + // The booking's own journey wins: a sub-corridor booking can be unloaded + // (arrived) at its own yard while the train is still moving. + const arrived = journey === "arrived" || (!journey && isArrived(status)); + const moving = !arrived && (journey === "in-transit" || isDispatched(status)); + const label = bookingShipmentStatusLabel(journey, status, currentSequenceNo); const bg = arrived ? "rgba(14,163,113,0.22)" : moving @@ -254,7 +271,7 @@ function HeaderStatusPill({ }} /> - {shipmentStatusLabel(status, currentSequenceNo)} + {label} ); @@ -263,7 +280,10 @@ function HeaderStatusPill({ // ── Summary bar (ETA / departure / arrival) ──────────────────────────────────── function SummaryBar({ data }: { data: Freight.IBookingTracking }) { - const arrived = isArrived(data.scheduleStatus); + const journey = bookingJourneyState(data); + // Booking-level arrival (unloaded at its own destination yard) counts as + // arrived even while the train itself is still moving down the corridor. + const arrived = journey === "arrived" || isArrived(data.scheduleStatus); const items: Array<{ label: string; value: string; accent?: boolean }> = [ { label: "Departed", @@ -271,7 +291,11 @@ function SummaryBar({ data }: { data: Freight.IBookingTracking }) { }, { label: arrived ? "Arrived" : "Est. arrival", - value: fmtTime(data.actualArrivalAt ?? data.scheduledArrivalAt), + value: fmtTime( + (journey === "arrived" ? data.arrivedAt : null) ?? + data.actualArrivalAt ?? + data.scheduledArrivalAt, + ), accent: !arrived, }, { @@ -317,6 +341,66 @@ function SummaryBar({ data }: { data: Freight.IBookingTracking }) { ); } +// ── Per-booking journey line (loaded / unloaded at the booking's own yards) ──── + +function BookingJourneyLine({ data }: { data: Freight.IBookingTracking }) { + if (!data.loadedAt && !data.arrivedAt) return null; + + const stationLabel = (yardId?: string | null) => + data.stations.find((s) => s.yardId === yardId)?.label ?? null; + const origin = stationLabel(data.bookingOriginYardId) ?? "origin yard"; + const destination = + stationLabel(data.bookingDestinationYardId) ?? "destination yard"; + + return ( + + {data.loadedAt && ( + } + text={`Loaded at ${origin}`} + time={fmtTime(data.loadedAt)} + /> + )} + {data.arrivedAt && ( + } + text={`Arrived at ${destination}`} + time={fmtTime(data.arrivedAt)} + /> + )} + + ); +} + +function JourneyChip({ + icon, + text, + time, +}: { + icon: React.ReactNode; + text: string; + time: string; +}) { + return ( + + {icon} + + {text} + + + · {time} + + + ); +} + // ── Corridor: stations + train marker ────────────────────────────────────────── function Corridor({ data }: { data: Freight.IBookingTracking }) { @@ -326,6 +410,14 @@ function Corridor({ data }: { data: Freight.IBookingTracking }) { const current = data.currentSequenceNo; const progress = corridorProgress(stations.length, current, arrived); + // The booking's own leg on the corridor (sub-corridor bookings ride only a + // slice of the train's route). Stations outside the leg render dimmed. + const leg = bookingLegRange( + stations, + data.bookingOriginYardId, + data.bookingDestinationYardId, + ); + // Map sequenceNo → latest checkpoint at that station for captions. const checkpointBySeq = new Map(); for (const c of data.checkpoints) checkpointBySeq.set(c.sequenceNo, c); @@ -415,6 +507,7 @@ function Corridor({ data }: { data: Freight.IBookingTracking }) { const reached = arrived || (current >= 0 && i <= current); const isCurrent = !arrived && i === current; const isLast = i === stations.length - 1; + const onLeg = !leg || (i >= leg.start && i <= leg.end); const cp = checkpointBySeq.get(s.sequenceNo); return ( @@ -441,6 +535,7 @@ function StationNode({ isCurrent, isEndpoint, arrivedHere, + dimmed, time, align, }: { @@ -449,6 +544,8 @@ function StationNode({ isCurrent: boolean; isEndpoint: boolean; arrivedHere: boolean; + /** Station lies outside the booking's own leg — render muted. */ + dimmed: boolean; time: string | null; align: "left" | "center" | "right"; }) { @@ -462,6 +559,7 @@ function StationNode({ flex: isEndpoint ? "0 0 auto" : 1, minWidth: 0, maxWidth: 120, + opacity: dimmed ? 0.4 : 1, }} > s.yardId === originYardId); + const end = stations.findIndex((s) => s.yardId === destinationYardId); + if (start < 0 || end < 0) return null; + return start <= end ? { start, end } : { start: end, end: start }; +} + /** Caption for a checkpoint kind. */ export function checkpointKindLabel(kind: Freight.TrainCheckpointKind): string { switch (kind) { diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx index 0c63f1b95..f9b3c3420 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx @@ -13,12 +13,15 @@ import { import { ArrowRight, CalendarClock, + CheckCircle2, ChevronLeft, ChevronRight, + Clock, } from "lucide-react"; import { CountdownTimer } from "@edr/ui-common"; import type { MyBookingWindow } from "@/services/bookings.service"; +import { formatWindowOpensAt, soonestUpcomingWindow } from "./booking-window"; const INK = "#10202F"; const MUTED = "#6B7C8E"; @@ -209,6 +212,65 @@ function WindowCard({ w }: { w: MyBookingWindow }) { ); } +/** + * One-line status strip above the cards: green when a window is open right now + * (the customer can act), neutral with the next opening time otherwise. + */ +function WindowStatusBanner({ windows }: { windows: MyBookingWindow[] }) { + const open = windows.find((w) => w.isOpenNow); + if (open) { + const lane = + open.origin && open.destination + ? ` on ${open.origin} → ${open.destination}` + : ""; + return ( + + + + A booking window is open right now{lane} — you can create a shipment + booking before it closes. + + + ); + } + + const next = soonestUpcomingWindow(windows); + return ( + + + + {next?.windowOpensAt + ? `Booking is not open yet — the next window opens ${formatWindowOpensAt( + next.windowOpensAt, + )} EAT.` + : "Booking is not open right now. You'll see the opening time here once a window is announced."} + + + ); +} + interface ContractBookingWindowsSectionProps { /** Windows already scoped to this contract's routes/direction by the API. */ windows: MyBookingWindow[]; @@ -248,8 +310,6 @@ export function ContractBookingWindowsSection({ safePage * PER_PAGE + PER_PAGE, ); - if (!isLoading && sorted.length === 0) return null; - return ( @@ -313,12 +373,39 @@ export function ContractBookingWindowsSection({ ))} + ) : sorted.length === 0 ? ( + + + + No booking windows announced yet + + + When a train is scheduled on this contract's routes, its + booking window will appear here with the opening time. + + ) : ( - - {visible.map((w) => ( - - ))} - + <> + + + {visible.map((w) => ( + + ))} + + )} ); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index c3c7710e6..24991d94a 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -507,23 +507,17 @@ export default function NewContractPage({ const isContainer = data.cargoType === "container"; // Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled - // size; bulk: a single commodity row. - // GENERAL contracts carry a quantity cap (draw-down); ONE_TIME does not. - const isGeneral = data.contractKind === "general_contract"; + // size; bulk: a single commodity row. Both GENERAL and ONE_TIME are uncapped + // (quantityCap omitted → NULL): the customer books repeatedly against a + // GENERAL contract until its validity expires. const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer ? data.enabledContainerSizes.map((size) => ({ containerSize: size, - quantityCap: - isGeneral && data.containerSizeCaps[size] - ? data.containerSizeCaps[size] - : undefined, })) : [ { cargoTypeId: data.cargoTypePath?.[1] || undefined, cargoFreeText: data.cargoFreeText || undefined, - quantityCap: - isGeneral && data.bulkQuantityCap ? data.bulkQuantityCap : undefined, }, ]; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx index 4d0dd3564..565ca2079 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx @@ -156,6 +156,7 @@ export const CONTRACT_STATUS_CONFIG: Record< PNR_GENERATED: { label: "Payment Reference Ready", ...TONE.warning }, PAID: { label: "Paid", ...TONE.success }, IN_TRANSIT: { label: "In Transit", ...TONE.info }, + ARRIVED: { label: "Arrived", ...TONE.success }, COMPLETED: { label: "Completed", ...TONE.success }, }; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/payment-currency-field.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/payment-currency-field.tsx index 06f67f7fc..d53b64552 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/payment-currency-field.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/payment-currency-field.tsx @@ -9,23 +9,27 @@ import { fieldStyles } from "./shared"; export function PaymentCurrencyField({ control, + etbOnly = false, }: { control: Control; + /** Intercity (domestic) contracts are priced in ETB only. */ + etbOnly?: boolean; }) { + const options = etbOnly + ? PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB") + : PAYMENT_CURRENCY_OPTIONS; return ( { - const selected = PAYMENT_CURRENCY_OPTIONS.find( - (o) => o.value === field.value, - ); + const selected = options.find((o) => o.value === field.value); return (