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 b2ac73f57..ae0f1765a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -213,6 +213,28 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Get(':id/tracking') + @ApiOperation({ + summary: 'Shipment tracking timeline for a booking', + description: + "Returns the booking's consignment (once dispatched) and its ordered " + + 'tracking events. Scoped to the customer\'s own company.', + }) + async findTracking( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + // Staff see any booking; customers only their own company's. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } + return this.bookingsService.getBookingTracking(id); + } + @Delete(':id') @HttpCode(204) @ApiOperation({ summary: 'Soft-delete DRAFT booking' }) 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 70cc9affc..65c773f3c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -2,12 +2,15 @@ import { BadRequestException, ConflictException, ForbiddenException, + forwardRef, + Inject, Injectable, NotFoundException, } from '@nestjs/common'; -import { SchedulingStatus } from '@edr/types'; +import { Freight, SchedulingStatus } from '@edr/types'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; +import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; @@ -53,6 +56,8 @@ export class BookingsService { private readonly minioService: MinioService, // private readonly customersService: CustomersService, private readonly companiesService: CompaniesService, + @Inject(forwardRef(() => TrainSchedulingService)) + private readonly trainSchedulingService: TrainSchedulingService, private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, private readonly consolidationService: ConsolidationService, @@ -669,6 +674,75 @@ export class BookingsService { } } + /** + * Build the customer-facing shipment tracking payload for a booking from the + * train schedule it is assigned to and the live checkpoint log. The caller is + * responsible for authorizing access to the booking first. + * + * When the booking has not been assigned to a train yet, returns a valid + * "no schedule" payload so the UI can show a pre-dispatch state. + */ + async getBookingTracking( + bookingId: string, + ): Promise { + const booking = await this.findById(bookingId); + + const empty: Freight.IBookingTracking = { + bookingId: booking.id, + bookingReference: booking.reference, + hasSchedule: false, + scheduleId: null, + trainNumber: null, + scheduleStatus: null, + direction: null, + origin: null, + destination: null, + stations: [], + checkpoints: [], + currentSequenceNo: -1, + actualDepartureAt: null, + actualArrivalAt: null, + scheduledDepartureAt: null, + scheduledArrivalAt: null, + }; + + if (!booking.trainScheduleId) { + return empty; + } + + // Pull the live corridor + checkpoints for the assigned schedule. If the + // schedule was removed, fall back to the pre-dispatch state rather than 500. + let track: Awaited< + ReturnType + >; + try { + track = await this.trainSchedulingService.getScheduleCheckpoints( + booking.trainScheduleId, + ); + } catch { + return empty; + } + + return { + bookingId: booking.id, + bookingReference: booking.reference, + hasSchedule: true, + scheduleId: track.scheduleId, + trainNumber: track.trainNumber, + scheduleStatus: track.status as Freight.TrainScheduleStatus, + direction: track.direction, + origin: track.origin, + destination: track.destination, + stations: track.stations, + checkpoints: track.checkpoints as Freight.ITrackingCheckpoint[], + currentSequenceNo: track.currentSequenceNo, + actualDepartureAt: track.actualDepartureAt, + actualArrivalAt: track.actualArrivalAt, + scheduledDepartureAt: track.scheduledDepartureAt, + scheduledArrivalAt: track.scheduledArrivalAt, + }; + } + /** Aggregate metrics and tab counts for the backoffice booking list. */ async getListSummary(filter: FilterBookingDto): Promise { const page = filter.page ?? 1; 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 17184bf97..8f44665ad 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 @@ -790,6 +790,12 @@ export class TrainSchedulingService { actualArrivalAt: schedule.actualArrivalAt ? schedule.actualArrivalAt.toISOString() : null, + scheduledDepartureAt: schedule.scheduledDepartureDate + ? schedule.scheduledDepartureDate.toISOString() + : null, + scheduledArrivalAt: schedule.scheduledArrivalDate + ? schedule.scheduledArrivalDate.toISOString() + : null, origin: stations[0]?.label ?? null, destination: stations[stations.length - 1]?.label ?? null, stations, 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 5998ea2fa..c340dc845 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 @@ -163,6 +163,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) } }} amountLabel={pricing ? priceTotal(pricing) : undefined} + currency={pricing?.currency ?? booking.paymentCurrency} processing={payMutation.isPending} error={ payMutation.isError diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx index 8e734229b..6e62888c8 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx @@ -1,6 +1,6 @@ -import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core"; -import { Smartphone, type LucideIcon } from "lucide-react"; -import { useState } from "react"; +import { Box, Button, Group, Image, Modal, Stack, Text } from "@mantine/core"; +import { Check, ShieldCheck } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import type { PaymentMethod } from "@/services/payments.service"; @@ -8,7 +8,10 @@ interface ProviderOption { method: PaymentMethod; label: string; description: string; - icon: LucideIcon; + logo: string; + /** Currencies this provider settles in. */ + currencies: string[]; + accent: string; } // Only Telebirr and Waafi are enabled for now. @@ -16,17 +19,32 @@ const PROVIDERS: ProviderOption[] = [ { method: "TELEBIRR", label: "telebirr", - description: "Ethiopian mobile money", - icon: Smartphone, + description: "Ethiopian mobile money · ETB", + logo: "/assets/telebirr.jpeg", + currencies: ["ETB"], + accent: "#0A6F4D", }, { method: "WAAFI", label: "Waafi", - description: "Djibouti mobile money", - icon: Smartphone, + description: "Djibouti mobile money · USD", + logo: "/assets/waafi.jpeg", + currencies: ["USD"], + accent: "#2E5B96", }, ]; +/** + * Pick the provider that settles in the booking's currency. USD → Waafi, + * ETB → Telebirr. Falls back to the first provider when unknown. + */ +function providersForCurrency(currency?: string | null): ProviderOption[] { + const cur = currency?.trim().toUpperCase(); + if (!cur) return PROVIDERS; + const matched = PROVIDERS.filter((p) => p.currencies.includes(cur)); + return matched.length > 0 ? matched : PROVIDERS; +} + function ProviderRow({ option, selected, @@ -36,55 +54,75 @@ function ProviderRow({ selected: boolean; onSelect: () => void; }) { - const Icon = option.icon; return ( { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(); + } + }} + gap={14} wrap="nowrap" + align="center" style={{ cursor: "pointer", - borderRadius: 12, - padding: "13px 14px", - border: `1.5px solid ${selected ? "#0A6F4D" : "#E6ECF1"}`, - backgroundColor: selected ? "#ECF6F1" : "#fff", - transition: "border-color .12s, background-color .12s", + borderRadius: 14, + padding: "14px 16px", + border: `1.5px solid ${selected ? option.accent : "#E6ECF1"}`, + backgroundColor: selected ? "#F6FBF8" : "#fff", + boxShadow: selected + ? `0 0 0 1px ${option.accent}, 0 6px 18px rgba(16,24,40,0.06)` + : "none", + transition: "border-color .14s, box-shadow .14s, background-color .14s", }} > - + {`${option.label} - - + + {option.label} - + {option.description} + > + {selected && } + ); } @@ -93,6 +131,7 @@ export function PaymentMethodModal({ opened, onClose, amountLabel, + currency, onConfirm, processing, error, @@ -101,67 +140,126 @@ export function PaymentMethodModal({ onClose: () => void; /** Human-readable total, e.g. "ETB 12,500". */ amountLabel?: string; + /** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */ + currency?: string | null; onConfirm: (method: PaymentMethod) => void; processing?: boolean; error?: string | null; }) { - const [method, setMethod] = useState(PROVIDERS[0].method); + const providers = useMemo(() => providersForCurrency(currency), [currency]); + const [method, setMethod] = useState(providers[0].method); + + // Keep the selection valid when the currency (and therefore provider list) changes. + useEffect(() => { + if (!providers.some((p) => p.method === method)) { + setMethod(providers[0].method); + } + }, [providers, method]); return ( - - Choose a payment method - - {amountLabel && ( - - Amount due: {amountLabel} - - )} - - } + radius={18} + size={480} + padding={0} + withCloseButton={false} + overlayProps={{ backgroundOpacity: 0.45, blur: 3 }} > - - {PROVIDERS.map((option) => ( - setMethod(option.method)} - /> - ))} + {/* Header */} + + + Complete your payment + + + Choose how you'd like to pay for this booking. + + {amountLabel && ( + + + Amount due + + + {amountLabel} + + + )} + + + {/* Provider options */} + + + Payment method + + + {providers.map((option) => ( + setMethod(option.method)} + /> + ))} + + + + {/* Footer */} + {error && ( - + {error} )} - - - You'll be redirected to your provider to complete payment securely. - - + + + + Secured · you'll be redirected to your provider to pay + + + + + + + + ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx index 0be9ef439..efe2f6b60 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx @@ -28,10 +28,21 @@ import { Package, Plus, Search, + Train, Wallet, X, } from "lucide-react"; +import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal"; + +// Bookings that have left (or are leaving) the yard can be tracked live. +const TRACKABLE_STATUSES = new Set([ + "PAID", + "IN_TRANSIT", + "COMPLETED", + "DELIVERED", +]); + import { api } from "@/services/api"; import type { BookingListFilter } from "@/services/bookings.service"; import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants"; @@ -307,6 +318,9 @@ export default function MyBookings() { const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [statusFilter, setStatusFilter] = useState("all"); const [query, setQuery] = useState(""); + const [trackingBooking, setTrackingBooking] = useState( + null, + ); const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses; @@ -470,8 +484,23 @@ export default function MyBookings() { header: () => null, cell: ({ row }) => { const booking = row.original; + const trackable = TRACKABLE_STATUSES.has(booking.status); return ( e.stopPropagation()}> + {trackable && ( + + )} @@ -483,6 +512,14 @@ export default function MyBookings() { navigate(`/bookings/${booking.id}`)}> View details + {trackable && ( + } + onClick={() => setTrackingBooking(booking)} + > + Track shipment + + )} @@ -627,6 +664,20 @@ export default function MyBookings() { )} + + setTrackingBooking(null)} + bookingId={trackingBooking?.id ?? ""} + bookingReference={trackingBooking?.reference ?? ""} + originLabel={ + trackingBooking?.originYard?.label ?? trackingBooking?.originYard?.code + } + destinationLabel={ + trackingBooking?.destinationYard?.label ?? + trackingBooking?.destinationYard?.code + } + /> ); } 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 new file mode 100644 index 000000000..64ee63d5e --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx @@ -0,0 +1,752 @@ +import { Box, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { + AlertTriangle, + CheckCircle2, + Clock, + Flag, + MapPin, + PackageX, + RefreshCw, + Train, +} from "lucide-react"; + +import { api } from "@/services/api"; +import { Freight } from "@edr/types"; + +import { + checkpointKindLabel, + corridorProgress, + isArrived, + isDispatched, + shipmentStatusLabel, +} from "./trackingStages"; + +const GREEN = "#0EA371"; +const GREEN_DARK = "#0A6F4D"; +const ACCENT = "#F2A516"; +const INK = "#10202F"; +const MUTED = "#6B7C8E"; + +interface ShipmentTrackingModalProps { + opened: boolean; + onClose: () => void; + bookingId: string; + bookingReference: string; + originLabel?: string; + destinationLabel?: string; +} + +export function ShipmentTrackingModal({ + opened, + onClose, + bookingId, + bookingReference, + originLabel, + destinationLabel, +}: ShipmentTrackingModalProps) { + const { data, isLoading, isError, refetch, isFetching } = useQuery({ + ...api.bookings.tracking.queryOptions({ input: { id: bookingId } }), + enabled: opened && Boolean(bookingId), + refetchInterval: opened ? 30_000 : false, + }); + + const hasSchedule = data?.hasSchedule ?? false; + + return ( + +
refetch()} + refreshing={isFetching} + /> + + + {isLoading ? ( +
+ + + + Locating your train… + + +
+ ) : isError ? ( + refetch()} /> + ) : !hasSchedule ? ( + + ) : data ? ( + + + + + + ) : null} +
+ + ); +} + +// ── Header ──────────────────────────────────────────────────────────────────── + +function Header({ + bookingReference, + trainNumber, + status, + currentSequenceNo, + onClose, + onRefresh, + refreshing, +}: { + bookingReference: string; + trainNumber: string | null; + status: Freight.TrainScheduleStatus | null; + currentSequenceNo: number; + onClose: () => void; + onRefresh: () => void; + refreshing: boolean; +}) { + return ( + + + + + + + + + Live shipment tracking + + + {bookingReference} + + {trainNumber && ( + + Train {trainNumber} + + )} + + + + + + + + + + × + + + + + ); +} + +function IconButton({ + children, + onClick, + title, + spinning, +}: { + children: React.ReactNode; + onClick: () => void; + title: string; + spinning?: boolean; +}) { + return ( + + ); +} + +function HeaderStatusPill({ + status, + currentSequenceNo, +}: { + status: Freight.TrainScheduleStatus | null; + currentSequenceNo: number; +}) { + const arrived = isArrived(status); + const moving = isDispatched(status); + const bg = arrived + ? "rgba(14,163,113,0.22)" + : moving + ? "rgba(242,165,22,0.20)" + : "rgba(255,255,255,0.12)"; + const dot = arrived ? "#5BE3B0" : moving ? ACCENT : "#CBD5E1"; + return ( + + + + {shipmentStatusLabel(status, currentSequenceNo)} + + + ); +} + +// ── Summary bar (ETA / departure / arrival) ──────────────────────────────────── + +function SummaryBar({ data }: { data: Freight.IBookingTracking }) { + const arrived = isArrived(data.scheduleStatus); + const items: Array<{ label: string; value: string; accent?: boolean }> = [ + { + label: "Departed", + value: fmtTime(data.actualDepartureAt ?? data.scheduledDepartureAt), + }, + { + label: arrived ? "Arrived" : "Est. arrival", + value: fmtTime(data.actualArrivalAt ?? data.scheduledArrivalAt), + accent: !arrived, + }, + { + label: "Stations", + value: `${Math.max(0, data.currentSequenceNo + (data.currentSequenceNo >= 0 ? 1 : 0))} / ${data.stations.length}`, + }, + ]; + return ( + + {items.map((it, i) => ( + 0 ? "1px solid #EEF2F6" : undefined, + background: it.accent ? "#FEFBF3" : "#FBFCFD", + }} + > + + {it.label} + + + {it.value} + + + ))} + + ); +} + +// ── Corridor: stations + train marker ────────────────────────────────────────── + +function Corridor({ data }: { data: Freight.IBookingTracking }) { + const arrived = isArrived(data.scheduleStatus); + const moving = isDispatched(data.scheduleStatus); + const stations = data.stations; + const current = data.currentSequenceNo; + const progress = corridorProgress(stations.length, current, arrived); + + // Map sequenceNo → latest checkpoint at that station for captions. + const checkpointBySeq = new Map(); + for (const c of data.checkpoints) checkpointBySeq.set(c.sequenceNo, c); + + return ( + + + + + Where is your train + + + · {progress}% of the route + + + + {/* Horizontal rail */} + + {/* base rail */} + + {/* filled rail */} + + + {/* train marker riding the filled rail */} + + + {arrived ? : } + + + + {/* station nodes */} + + {stations.map((s, i) => { + const reached = arrived || (current >= 0 && i <= current); + const isCurrent = !arrived && i === current; + const isLast = i === stations.length - 1; + const cp = checkpointBySeq.get(s.sequenceNo); + return ( + + ); + })} + + + + ); +} + +function StationNode({ + label, + reached, + isCurrent, + isEndpoint, + arrivedHere, + time, + align, +}: { + label: string; + reached: boolean; + isCurrent: boolean; + isEndpoint: boolean; + arrivedHere: boolean; + time: string | null; + align: "left" | "center" | "right"; +}) { + const color = arrivedHere ? GREEN : isCurrent ? ACCENT : reached ? GREEN : "#CBD5E1"; + return ( + + + + + + {label} + + {time && ( + + {time} + + )} + + ); +} + +// ── Checkpoint feed ──────────────────────────────────────────────────────────── + +function CheckpointFeed({ data }: { data: Freight.IBookingTracking }) { + // Newest first. + const ordered = [...data.checkpoints].sort( + (a, b) => + new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(), + ); + + return ( + + + + + Journey log + + + + {ordered.length === 0 ? ( + + No checkpoints logged yet. Updates appear here as the train passes each + station along the corridor. + + ) : ( + + {ordered.map((cp, i) => { + const isLatest = i === 0; + const last = i === ordered.length - 1; + const Icon = + cp.kind === Freight.TrainCheckpointKind.Arrived + ? CheckCircle2 + : cp.kind === Freight.TrainCheckpointKind.Departed + ? Flag + : Train; + return ( + + + + + + {!last && ( + + )} + + + + + + {cp.label ?? "Checkpoint"} + + + {checkpointKindLabel(cp.kind)} + + {isLatest && ( + + Latest + + )} + + {cp.note && ( + + {cp.note} + + )} + + {fmtTime(cp.occurredAt)} + + + + ); + })} + + )} + + ); +} + +// ── Empty / error states ─────────────────────────────────────────────────────── + +function NotDispatchedState({ + origin, + destination, +}: { + origin: string; + destination: string; +}) { + return ( + + + + + + Not on the rails yet + + + Your shipment from {origin} to {destination} hasn't been + assigned to a train. Live tracking begins the moment it's dispatched and + starts moving along the corridor. + + + ); +} + +function ErrorState({ onRetry }: { onRetry: () => void }) { + return ( + + + + + + Couldn't load tracking + + + Something went wrong fetching your shipment status. + + + + ); +} + +// ── helpers ──────────────────────────────────────────────────────────────────── + +function fmtTime(iso?: string | null): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + return d.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +// keyframes (injected once) +if ( + typeof document !== "undefined" && + !document.getElementById("edr-tracking-kf") +) { + const style = document.createElement("style"); + style.id = "edr-tracking-kf"; + style.textContent = ` +@keyframes edr-spin { to { transform: rotate(360deg); } } +@keyframes edr-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } } +@keyframes edr-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } } +`; + document.head.appendChild(style); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/tracking/trackingStages.ts b/apps/edr-freight-web/portal/src/pages/bookings/tracking/trackingStages.ts new file mode 100644 index 000000000..4acbcec8a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/tracking/trackingStages.ts @@ -0,0 +1,64 @@ +import { Freight } from "@edr/types"; + +const { TrainScheduleStatus } = Freight; + +export function isArrived( + status?: Freight.TrainScheduleStatus | null, +): boolean { + return status === TrainScheduleStatus.Arrived; +} + +export function isDispatched( + status?: Freight.TrainScheduleStatus | null, +): boolean { + return status === TrainScheduleStatus.Dispatched; +} + +/** Human label for the schedule status, from the rider's point of view. */ +export function shipmentStatusLabel( + status?: Freight.TrainScheduleStatus | null, + currentSequenceNo = -1, +): string { + switch (status) { + case TrainScheduleStatus.Arrived: + return "Arrived"; + case TrainScheduleStatus.Dispatched: + return currentSequenceNo <= 0 ? "Departed" : "In transit"; + case TrainScheduleStatus.Scheduled: + return "Scheduled"; + case TrainScheduleStatus.Cancelled: + return "Cancelled"; + case TrainScheduleStatus.Draft: + return "Preparing"; + default: + return "Not dispatched"; + } +} + +/** + * 0–100 progress across the corridor, derived from how many stations the train + * has reached. Arrived → 100. Not departed → 0. + */ +export function corridorProgress( + stationCount: number, + currentSequenceNo: number, + arrived: boolean, +): number { + if (arrived) return 100; + if (stationCount <= 1 || currentSequenceNo < 0) return 0; + const lastSeq = stationCount - 1; + return Math.round((Math.min(currentSequenceNo, lastSeq) / lastSeq) * 100); +} + +/** Caption for a checkpoint kind. */ +export function checkpointKindLabel(kind: Freight.TrainCheckpointKind): string { + switch (kind) { + case Freight.TrainCheckpointKind.Departed: + return "Departed"; + case Freight.TrainCheckpointKind.Arrived: + return "Arrived"; + case Freight.TrainCheckpointKind.Passed: + default: + return "Passed"; + } +} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 32ef5bbc0..6d93d11e2 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -141,6 +141,12 @@ export const api = { ({ id }) => bookingsService.get(id), ), + tracking: endpoint<{ id: string }, Freight.IBookingTracking>( + "bookings", + "tracking", + ({ id }) => bookingsService.tracking(id), + ), + create: endpoint( "bookings", "create", 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 b27d4e410..4f4154faf 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -73,6 +73,10 @@ export const bookingsService = { const { data } = await client.get(`/api/bookings/${id}`); return data.data; }, + tracking: async (id: string): Promise => { + const { data } = await client.get(`/api/bookings/${id}/tracking`); + return data.data; + }, create: async (payload: CreateBookingPayload): Promise => { const { data } = await client.post("/api/bookings", payload); return data.data.booking; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 6672f95e9..fd300b184 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -265,6 +265,56 @@ export interface IConsignment extends BaseEntity { destinationStation: string; } +/** One station along the train's corridor (origin → milestones → destination). */ +export interface ITrackingStation { + sequenceNo: number; + yardId: string; + label: string; + code: string; +} + +/** A logged checkpoint as the train passes a station. */ +export interface ITrackingCheckpoint { + id: string; + sequenceNo: number; + yardId: string; + label: string | null; + kind: TrainCheckpointKind; + occurredAt: string; + note: string | null; +} + +/** + * Customer-facing shipment tracking payload for a single booking, derived from + * the train schedule the booking is assigned to and the live checkpoint log. + * + * `hasSchedule` is false when the booking has not been assigned to a train yet + * (still pre-dispatch) — the UI shows a "not on the rails yet" state. + */ +export interface IBookingTracking { + bookingId: string; + bookingReference: string; + hasSchedule: boolean; + scheduleId: string | null; + trainNumber: string | null; + /** Operational status of the assigned schedule (DRAFT/SCHEDULED/DISPATCHED/ARRIVED). */ + scheduleStatus: TrainScheduleStatus | null; + direction: string | null; + origin: string | null; + destination: string | null; + /** Ordered stations forming the corridor. */ + stations: ITrackingStation[]; + /** Logged checkpoints, ordered by sequence then time. */ + checkpoints: ITrackingCheckpoint[]; + /** Highest reached station sequence (−1 = not departed). */ + currentSequenceNo: number; + actualDepartureAt: string | null; + actualArrivalAt: string | null; + /** Planned departure/arrival from the schedule, used as ETA hints. */ + scheduledDepartureAt: string | null; + scheduledArrivalAt: string | null; +} + export interface IYard extends BaseEntity { code: string; label: string;