feat: add shipment tracking feature with live updates

- Implemented tracking functionality for bookings, allowing users to track their shipments in real-time.
- Added new API endpoints for fetching tracking data.
- Created a ShipmentTrackingModal component to display tracking information, including current status, checkpoints, and estimated arrival times.
- Enhanced the booking detail view to include tracking options for eligible bookings.
- Updated the train scheduling service to include scheduled departure and arrival times in the response.
- Introduced utility functions for managing tracking stages and statuses.
This commit is contained in:
Marshal
2026-06-17 13:23:40 +00:00
parent cd31f3612c
commit 7eb6228d31
11 changed files with 1206 additions and 78 deletions

View File

@@ -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' })

View File

@@ -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<Freight.IBookingTracking> {
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<TrainSchedulingService['getScheduleCheckpoints']>
>;
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<BookingListSummaryDto> {
const page = filter.page ?? 1;

View File

@@ -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,

View File

@@ -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

View File

@@ -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 (
<Group
onClick={onSelect}
gap={12}
role="button"
tabIndex={0}
onKeyDown={(e) => {
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",
}}
>
<Box
style={{
width: 40,
height: 40,
width: 52,
height: 52,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: selected ? "#0A6F4D" : "#F1F4F7",
color: selected ? "#fff" : "#475569",
borderRadius: 12,
overflow: "hidden",
border: "1px solid #EEF2F6",
backgroundColor: "#fff",
}}
>
<Icon size={19} />
<Image
src={option.logo}
alt={`${option.label} logo`}
w={52}
h={52}
fit="cover"
/>
</Box>
<Box style={{ flex: 1 }}>
<Text fz="14px" fw={700} c="#10202F">
<Box style={{ flex: 1, minWidth: 0 }}>
<Text fz="15px" fw={800} c="#10202F" tt="capitalize">
{option.label}
</Text>
<Text fz="12.5px" c="#9AA8B5">
<Text fz="12.5px" c="#7A8794" truncate>
{option.description}
</Text>
</Box>
<Box
style={{
width: 18,
height: 18,
width: 22,
height: 22,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
border: `2px solid ${selected ? "#0A6F4D" : "#CBD5E1"}`,
backgroundColor: selected ? "#0A6F4D" : "transparent",
boxShadow: selected ? "inset 0 0 0 3px #fff" : undefined,
border: `2px solid ${selected ? option.accent : "#CBD5E1"}`,
backgroundColor: selected ? option.accent : "transparent",
transition: "all .14s",
}}
/>
>
{selected && <Check size={13} color="#fff" strokeWidth={3} />}
</Box>
</Group>
);
}
@@ -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<PaymentMethod>(PROVIDERS[0].method);
const providers = useMemo(() => providersForCurrency(currency), [currency]);
const [method, setMethod] = useState<PaymentMethod>(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 (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size={460}
title={
<Stack gap={2}>
<Text fw={800} fz="17px" c="#10202F">
Choose a payment method
</Text>
{amountLabel && (
<Text fz="12.5px" c="#9AA8B5">
Amount due: {amountLabel}
</Text>
)}
</Stack>
}
radius={18}
size={480}
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
>
<Stack gap={10}>
{PROVIDERS.map((option) => (
<ProviderRow
key={option.method}
option={option}
selected={method === option.method}
onSelect={() => setMethod(option.method)}
/>
))}
{/* Header */}
<Box px={24} pt={24} pb={18}>
<Text fw={800} fz="19px" c="#10202F" lh={1.2}>
Complete your payment
</Text>
<Text mt={4} fz="13px" c="#7A8794">
Choose how you'd like to pay for this booking.
</Text>
{amountLabel && (
<Group
mt={16}
justify="space-between"
align="center"
px={16}
py={13}
style={{
borderRadius: 12,
background:
"linear-gradient(135deg, #FEF8EC 0%, #F4FAF7 100%)",
border: "1px solid #F2E4C4",
}}
>
<Text fz="12.5px" fw={700} c="#B07D14" tt="uppercase" style={{ letterSpacing: 0.5 }}>
Amount due
</Text>
<Text fz="20px" fw={800} c="#10202F">
{amountLabel}
</Text>
</Group>
)}
</Box>
{/* Provider options */}
<Box px={24} pb={4}>
<Text fz="11.5px" fw={700} c="#9AA8B5" tt="uppercase" mb={10} style={{ letterSpacing: 0.6 }}>
Payment method
</Text>
<Stack gap={10}>
{providers.map((option) => (
<ProviderRow
key={option.method}
option={option}
selected={method === option.method}
onSelect={() => setMethod(option.method)}
/>
))}
</Stack>
</Box>
{/* Footer */}
<Box px={24} pt={16} pb={22}>
{error && (
<Text fz="12.5px" c="#C0392B" fw={600}>
<Text fz="12.5px" c="#C0392B" fw={600} mb={10}>
{error}
</Text>
)}
<Button
fullWidth
mt={6}
radius={10}
color="edr-green"
disabled={processing}
loading={processing}
onClick={() => onConfirm(method)}
styles={{
root: { height: 46 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
{processing ? "Redirecting…" : "Continue to payment"}
</Button>
<Text fz="11.5px" c="#9AA8B5" ta="center">
You'll be redirected to your provider to complete payment securely.
</Text>
</Stack>
<Group gap={6} align="center" justify="center" mb={12}>
<ShieldCheck size={14} color="#0A8A5F" />
<Text fz="11.5px" c="#7A8794">
Secured · you'll be redirected to your provider to pay
</Text>
</Group>
<Group gap={10} wrap="nowrap">
<Button
variant="default"
radius={12}
onClick={onClose}
disabled={processing}
styles={{
root: { height: 48, flex: "0 0 38%" },
label: { fontSize: 14, fontWeight: 700, color: "#475569" },
}}
>
Cancel
</Button>
<Button
radius={12}
color="edr-green"
disabled={processing}
loading={processing}
onClick={() => onConfirm(method)}
styles={{
root: { height: 48, flex: 1 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
{processing ? "Redirecting…" : "Continue to payment"}
</Button>
</Group>
</Box>
</Modal>
);
}

View File

@@ -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<StatusFilterKey>("all");
const [query, setQuery] = useState("");
const [trackingBooking, setTrackingBooking] = useState<Freight.IBooking | null>(
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 (
<Group justify="flex-end" gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
{trackable && (
<Button
size="xs"
radius="md"
variant="light"
color="edr-green"
fw={700}
fz={13}
leftSection={<Train size={14} />}
onClick={() => setTrackingBooking(booking)}
>
Track
</Button>
)}
<PrimaryAction status={booking.status} id={booking.id} onNavigate={navigate} />
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
<Menu.Target>
@@ -483,6 +512,14 @@ export default function MyBookings() {
<Menu.Item onClick={() => navigate(`/bookings/${booking.id}`)}>
View details
</Menu.Item>
{trackable && (
<Menu.Item
leftSection={<Train size={15} />}
onClick={() => setTrackingBooking(booking)}
>
Track shipment
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Group>
@@ -627,6 +664,20 @@ export default function MyBookings() {
)}
</Card>
</Stack>
<ShipmentTrackingModal
opened={trackingBooking !== null}
onClose={() => setTrackingBooking(null)}
bookingId={trackingBooking?.id ?? ""}
bookingReference={trackingBooking?.reference ?? ""}
originLabel={
trackingBooking?.originYard?.label ?? trackingBooking?.originYard?.code
}
destinationLabel={
trackingBooking?.destinationYard?.label ??
trackingBooking?.destinationYard?.code
}
/>
</Box>
);
}

View File

@@ -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 (
<Modal
opened={opened}
onClose={onClose}
centered
size={900}
radius={20}
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.5, blur: 4 }}
styles={{ content: { overflow: "hidden" } }}
>
<Header
bookingReference={data?.bookingReference ?? bookingReference}
trainNumber={data?.trainNumber ?? null}
status={data?.scheduleStatus ?? null}
currentSequenceNo={data?.currentSequenceNo ?? -1}
onClose={onClose}
onRefresh={() => refetch()}
refreshing={isFetching}
/>
<Box px={28} py={24}>
{isLoading ? (
<Center mih={280}>
<Stack align="center" gap="sm">
<Loader color="edr-green" />
<Text fz="sm" c={MUTED}>
Locating your train
</Text>
</Stack>
</Center>
) : isError ? (
<ErrorState onRetry={() => refetch()} />
) : !hasSchedule ? (
<NotDispatchedState
origin={originLabel ?? "Origin"}
destination={destinationLabel ?? "Destination"}
/>
) : data ? (
<Stack gap={26}>
<SummaryBar data={data} />
<Corridor data={data} />
<CheckpointFeed data={data} />
</Stack>
) : null}
</Box>
</Modal>
);
}
// ── 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 (
<Box
px={28}
py={22}
style={{
background:
"linear-gradient(120deg, #0C1A2B 0%, #123047 60%, #0A6F4D 140%)",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={14} align="center" wrap="nowrap">
<Box
style={{
width: 48,
height: 48,
borderRadius: 13,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(255,255,255,0.12)",
color: "#fff",
}}
>
<Train size={24} />
</Box>
<Box>
<Text
fz="11px"
fw={700}
tt="uppercase"
c="#9FE9CC"
style={{ letterSpacing: 0.7 }}
>
Live shipment tracking
</Text>
<Text fz="20px" fw={800} c="#fff" lh={1.2}>
{bookingReference}
</Text>
{trainNumber && (
<Text fz="12px" c="#A9BBCB">
Train {trainNumber}
</Text>
)}
</Box>
</Group>
<Group gap={10} align="center" wrap="nowrap">
<HeaderStatusPill status={status} currentSequenceNo={currentSequenceNo} />
<IconButton title="Refresh" onClick={onRefresh} spinning={refreshing}>
<RefreshCw size={16} />
</IconButton>
<IconButton title="Close" onClick={onClose}>
<span style={{ fontSize: 18, lineHeight: 1, fontWeight: 600 }}>×</span>
</IconButton>
</Group>
</Group>
</Box>
);
}
function IconButton({
children,
onClick,
title,
spinning,
}: {
children: React.ReactNode;
onClick: () => void;
title: string;
spinning?: boolean;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onClick={onClick}
style={{
width: 34,
height: 34,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 9,
border: "1px solid rgba(255,255,255,0.18)",
backgroundColor: "rgba(255,255,255,0.08)",
color: "#fff",
cursor: "pointer",
animation: spinning ? "edr-spin 0.9s linear infinite" : undefined,
}}
>
{children}
</button>
);
}
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 (
<Group
gap={7}
align="center"
wrap="nowrap"
px={12}
py={7}
style={{ borderRadius: 999, backgroundColor: bg }}
>
<Box
style={{
width: 7,
height: 7,
borderRadius: "50%",
backgroundColor: dot,
animation: moving ? "edr-pulse 1.4s ease-in-out infinite" : undefined,
}}
/>
<Text fz="12px" fw={700} c="#fff">
{shipmentStatusLabel(status, currentSequenceNo)}
</Text>
</Group>
);
}
// ── 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 (
<Group
gap={0}
wrap="nowrap"
style={{
borderRadius: 14,
border: "1px solid #E6ECF2",
overflow: "hidden",
}}
>
{items.map((it, i) => (
<Box
key={it.label}
style={{
flex: 1,
padding: "14px 16px",
borderLeft: i > 0 ? "1px solid #EEF2F6" : undefined,
background: it.accent ? "#FEFBF3" : "#FBFCFD",
}}
>
<Text
fz="10.5px"
fw={700}
tt="uppercase"
c={it.accent ? "#B07D14" : MUTED}
style={{ letterSpacing: 0.5 }}
>
{it.label}
</Text>
<Text fz="15px" fw={800} c={INK} mt={2}>
{it.value}
</Text>
</Box>
))}
</Group>
);
}
// ── 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<number, Freight.ITrackingCheckpoint>();
for (const c of data.checkpoints) checkpointBySeq.set(c.sequenceNo, c);
return (
<Box>
<Group gap={8} align="center" mb={16}>
<MapPin size={15} color={GREEN_DARK} />
<Text fz="14px" fw={800} c={INK}>
Where is your train
</Text>
<Text fz="12.5px" c={MUTED}>
· {progress}% of the route
</Text>
</Group>
{/* Horizontal rail */}
<Box style={{ position: "relative", paddingTop: 44, paddingBottom: 4 }}>
{/* base rail */}
<Box
style={{
position: "absolute",
top: 54,
left: 16,
right: 16,
height: 5,
borderRadius: 999,
background: "#EAF0F5",
}}
/>
{/* filled rail */}
<Box
style={{
position: "absolute",
top: 54,
left: 16,
width: `calc((100% - 32px) * ${progress / 100})`,
height: 5,
borderRadius: 999,
background: `linear-gradient(90deg, ${GREEN_DARK}, ${GREEN})`,
transition: "width 600ms ease",
}}
/>
{/* train marker riding the filled rail */}
<Box
style={{
position: "absolute",
top: 18,
left: `calc(16px + (100% - 32px) * ${progress / 100})`,
transform: "translateX(-50%)",
transition: "left 600ms ease",
zIndex: 3,
}}
>
<Box
style={{
width: 38,
height: 38,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: arrived
? `linear-gradient(135deg, ${GREEN}, ${GREEN_DARK})`
: `linear-gradient(135deg, ${ACCENT}, #D98A06)`,
color: "#fff",
boxShadow: "0 6px 16px rgba(16,24,40,0.20)",
border: "3px solid #fff",
animation: moving ? "edr-bob 1.8s ease-in-out infinite" : undefined,
}}
>
{arrived ? <CheckCircle2 size={18} /> : <Train size={18} />}
</Box>
</Box>
{/* station nodes */}
<Box
style={{
position: "relative",
display: "flex",
justifyContent: "space-between",
zIndex: 2,
}}
>
{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 (
<StationNode
key={`${s.yardId}-${i}`}
label={s.label}
reached={reached}
isCurrent={isCurrent}
isEndpoint={i === 0 || isLast}
arrivedHere={isLast && arrived}
time={cp ? fmtTime(cp.occurredAt) : null}
align={i === 0 ? "left" : isLast ? "right" : "center"}
/>
);
})}
</Box>
</Box>
</Box>
);
}
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 (
<Box
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
flex: isEndpoint ? "0 0 auto" : 1,
minWidth: 0,
maxWidth: 120,
}}
>
<Box
style={{
width: isCurrent ? 18 : 14,
height: isCurrent ? 18 : 14,
borderRadius: "50%",
background: "#fff",
border: `3px solid ${color}`,
boxShadow: isCurrent ? `0 0 0 4px ${ACCENT}22` : undefined,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Box
style={{
width: isCurrent ? 7 : 5,
height: isCurrent ? 7 : 5,
borderRadius: "50%",
background: color,
}}
/>
</Box>
<Text
fz="11.5px"
fw={reached ? 700 : 600}
c={reached ? INK : "#9AA8B5"}
mt={8}
ta={align}
truncate
style={{ maxWidth: 110 }}
title={label}
>
{label}
</Text>
{time && (
<Text fz="10px" c={MUTED} mt={1}>
{time}
</Text>
)}
</Box>
);
}
// ── 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 (
<Box
p={20}
style={{
borderRadius: 16,
border: "1px solid #E6ECF2",
background: "#FBFCFD",
}}
>
<Group gap={8} align="center" mb={ordered.length ? 16 : 0}>
<Clock size={15} color={GREEN_DARK} />
<Text fz="14px" fw={800} c={INK}>
Journey log
</Text>
</Group>
{ordered.length === 0 ? (
<Text fz="13px" c={MUTED}>
No checkpoints logged yet. Updates appear here as the train passes each
station along the corridor.
</Text>
) : (
<Box>
{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 (
<Group key={cp.id} gap={14} wrap="nowrap" align="flex-start">
<Box
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
alignSelf: "stretch",
}}
>
<Box
style={{
width: 30,
height: 30,
borderRadius: 9,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: isLatest ? "#ECF6F1" : "#F1F4F7",
color: isLatest ? GREEN_DARK : "#64748B",
flexShrink: 0,
}}
>
<Icon size={15} />
</Box>
{!last && (
<Box
style={{
flex: 1,
width: 2,
marginTop: 4,
marginBottom: 4,
background: "#E1E7EE",
}}
/>
)}
</Box>
<Box pb={last ? 0 : 16} style={{ flex: 1, minWidth: 0 }}>
<Group gap={8} align="center" wrap="wrap">
<Text fz="13.5px" fw={700} c={INK}>
{cp.label ?? "Checkpoint"}
</Text>
<Box
component="span"
style={{
borderRadius: 999,
padding: "2px 9px",
fontSize: 10.5,
fontWeight: 700,
background: isLatest ? "#ECF6F1" : "#F1F4F7",
color: isLatest ? GREEN_DARK : "#475569",
}}
>
{checkpointKindLabel(cp.kind)}
</Box>
{isLatest && (
<Box
component="span"
style={{
borderRadius: 999,
padding: "2px 9px",
fontSize: 10.5,
fontWeight: 700,
background: "#FEF6E6",
color: "#B07D14",
}}
>
Latest
</Box>
)}
</Group>
{cp.note && (
<Text fz="12.5px" c={MUTED} mt={2}>
{cp.note}
</Text>
)}
<Text fz="11.5px" c="#9AA8B5" mt={3}>
{fmtTime(cp.occurredAt)}
</Text>
</Box>
</Group>
);
})}
</Box>
)}
</Box>
);
}
// ── Empty / error states ───────────────────────────────────────────────────────
function NotDispatchedState({
origin,
destination,
}: {
origin: string;
destination: string;
}) {
return (
<Stack align="center" gap={6} py={40} ta="center">
<Box
style={{
width: 64,
height: 64,
borderRadius: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#FEF6E6",
color: ACCENT,
}}
>
<PackageX size={30} />
</Box>
<Text fz="18px" fw={800} c={INK} mt={4}>
Not on the rails yet
</Text>
<Text fz="13.5px" c={MUTED} maw={440}>
Your shipment from <b>{origin}</b> to <b>{destination}</b> hasn't been
assigned to a train. Live tracking begins the moment it's dispatched and
starts moving along the corridor.
</Text>
</Stack>
);
}
function ErrorState({ onRetry }: { onRetry: () => void }) {
return (
<Stack align="center" gap={8} py={40} ta="center">
<Box
style={{
width: 60,
height: 60,
borderRadius: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#FBEAE7",
color: "#C0392B",
}}
>
<AlertTriangle size={28} />
</Box>
<Text fz="16px" fw={800} c={INK}>
Couldn't load tracking
</Text>
<Text fz="13px" c={MUTED}>
Something went wrong fetching your shipment status.
</Text>
<button
type="button"
onClick={onRetry}
style={{
marginTop: 6,
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "9px 16px",
borderRadius: 10,
border: "1px solid #E6ECF2",
background: "#fff",
color: INK,
fontWeight: 700,
fontSize: 13,
cursor: "pointer",
}}
>
<RefreshCw size={15} /> Try again
</button>
</Stack>
);
}
// ── 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);
}

View File

@@ -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";
}
}
/**
* 0100 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";
}
}

View File

@@ -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<CreateBookingPayload, Freight.IBooking>(
"bookings",
"create",

View File

@@ -73,6 +73,10 @@ export const bookingsService = {
const { data } = await client.get(`/api/bookings/${id}`);
return data.data;
},
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
const { data } = await client.get(`/api/bookings/${id}/tracking`);
return data.data;
},
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
const { data } = await client.post("/api/bookings", payload);
return data.data.booking;

View File

@@ -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;