mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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' })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user