diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example
index 73e122a1d..73312aae3 100644
--- a/apps/edr-freight-api/.env.example
+++ b/apps/edr-freight-api/.env.example
@@ -19,6 +19,11 @@ TELEBIRR_TIMEOUT_EXPRESS=15m
TELEBIRR_PRIVATE_KEY=
TELEBIRR_PUBLIC_KEY=
TELEBIRR_INSECURE_TLS=false
+
+# Portal pages the payment provider redirects the browser to after payment.
+# Point these at the freight portal's public payment result routes.
+PAYMENT_RETURN_URL=http://localhost:5173/payment/success
+PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure
# JWT (used by @tria-plc/api-common SharedAuthModule)
JWT_SECRET=
JWT_ACCESS_TOKEN_SECRET=
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
index b2c3ffbfb..c41013ddb 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
@@ -1,6 +1,5 @@
import {
BadRequestException,
- ConflictException,
forwardRef,
Inject,
Injectable,
@@ -69,7 +68,12 @@ export class BookingTransitionService {
priorityScore,
} as never);
- const finalBooking = await this.bookingsService.findById(updated!.id);
+ // Auto-consolidate now: a partial-wagon booking either pairs with a waiting
+ // partner (both → SUBMITTED) or is parked as PENDING_CONSOLIDATION until one
+ // arrives. The returned status reflects that outcome.
+ const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
+ updated!.id,
+ );
return {
bookingId: finalBooking.id,
status: finalBooking.status,
@@ -143,7 +147,10 @@ export class BookingTransitionService {
},
} as never);
- const finalBooking = await this.bookingsService.findById(updated!.id);
+ // Same consolidation treatment as the direct submit path.
+ const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
+ updated!.id,
+ );
return {
bookingId: finalBooking.id,
status: finalBooking.status,
@@ -188,18 +195,11 @@ export class BookingTransitionService {
async acceptIntake(bookingId: string, actorId: string): Promise {
const booking = await this.bookingsService.findById(bookingId);
+ // Only SUBMITTED bookings are acceptable. A booking that still needs
+ // consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
+ // is therefore never offered for accept until a partner moves it to SUBMITTED.
assertBookingStatus(booking, ['SUBMITTED']);
- // Consolidation gate: a booking whose containers don't fill whole wagons
- // cannot be accepted until it is paired with a complementary booking.
- const gate = await this.bookingsService.resolveConsolidationGate(bookingId);
- if (gate.blocked) {
- throw new ConflictException(
- gate.message ??
- 'Booking requires consolidation and cannot be accepted until a partner is found.',
- );
- }
-
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId,
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 ba9fffb66..ae0f1765a 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
@@ -11,6 +11,7 @@ import {
Query,
Request,
Res,
+ UnauthorizedException,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
@@ -117,8 +118,22 @@ export class BookingsController {
@Get()
@ApiOperation({ summary: 'List freight bookings (paginated)' })
- findAll(@Query() filter: FilterBookingDto) {
- return this.bookingsService.findAll(filter);
+ async findAll(
+ @Query() filter: FilterBookingDto,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ // Staff (backoffice) see every booking. Customers (portal) are always
+ // force-scoped to their own company, regardless of any companyId they pass.
+ if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
+ return this.bookingsService.findAll(filter);
+ }
+ const userId = user?.id;
+ if (!userId) throw new UnauthorizedException('Authentication required');
+ const companyId =
+ await this.bookingsService.resolveCustomerCompanyId(userId);
+ // No linked company yet → no bookings to show (avoids leaking all bookings).
+ if (!companyId) return { items: [], total: 0 };
+ return this.bookingsService.findAll(filter, companyId);
}
@Get('list-summary')
@@ -166,18 +181,60 @@ export class BookingsController {
@Get('by-reference/:reference')
@ApiOperation({ summary: 'Get booking by reference' })
- async findByReference(@Param('reference') reference: string) {
+ async findByReference(
+ @Param('reference') reference: string,
+ @CurrentUser() user: TCurrentUser,
+ ) {
const booking = await this.bookingsService.findByReference(reference);
+ // 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.transitionService.enrichBookingResponse(booking);
}
@Get(':id')
@ApiOperation({ summary: 'Get booking by ID' })
- async findOne(@Param('id', ParseUUIDPipe) id: string) {
+ async findOne(
+ @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.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.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
index d304e2946..edda990de 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
@@ -177,8 +177,11 @@ export class BookingsRepository extends BaseRepository {
.where('b.id != :bookingId', { bookingId: booking.id })
.andWhere('b.allowConsolidation = true')
.andWhere('b.consolidationPartnerId IS NULL')
+ // Only pair bookings the customer has committed (SUBMITTED) or that are
+ // already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so
+ // pairing never prematurely submits an unfinished/unpriced draft.
.andWhere('b.status IN (:...statuses)', {
- statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'],
+ statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
})
.andWhere('b.originYardId = :originYardId', {
originYardId: booking.originYardId,
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 ebf8273de..b32b16b32 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -1,12 +1,16 @@
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';
@@ -52,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,
@@ -146,13 +152,17 @@ export class BookingsService {
/**
* Enable consolidation when any container line leaves a wagon partially filled
- * (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out.
+ * (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon).
+ *
+ * Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a
+ * half-empty wagon, so `explicit === false` is ignored when consolidation is
+ * actually needed. The opt-in flag only matters for cargo that already fills
+ * whole wagons (where consolidation is moot anyway).
*/
private async resolveConsolidation(
containers: CreateBookingContainerDto[],
explicit?: boolean,
): Promise {
- if (explicit === false) return false;
const needs = await this.consolidationService.needsConsolidation(
containers.map((c) => ({
containerTypeId: c.containerTypeId,
@@ -193,10 +203,11 @@ export class BookingsService {
return { booking: paired, messages };
}
- if (booking.status === 'DRAFT') {
- await this.bookingsRepository.update(booking.id, {
- status: 'PENDING_CONSOLIDATION',
- } as never);
+ // No partner yet — park the booking so it waits. Applies both pre-submit
+ // (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never
+ // reach this method.
+ if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') {
+ await this.bookingsRepository.parkForConsolidation(booking.id);
}
const pending = await this.findById(booking.id);
@@ -205,45 +216,24 @@ export class BookingsService {
}
/**
- * Consolidation gate used at staff-accept time. Returns the (possibly newly
- * paired) booking plus whether it still needs a consolidation partner.
- * When a booking needs consolidation and none is found, it is parked in
- * PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept.
+ * Run consolidation right after a booking reaches SUBMITTED. If a complementary
+ * partner already exists, both are paired and moved (back) to SUBMITTED so staff
+ * can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and
+ * waits for a later complementary booking to complete the wagon.
+ *
+ * Returns the re-fetched booking, so callers can reflect the resulting status
+ * (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting).
*/
- async resolveConsolidationGate(bookingId: string): Promise<{
- booking: Booking;
- blocked: boolean;
- message?: string;
- }> {
- let booking = await this.findById(bookingId);
+ async runConsolidationOnSubmit(bookingId: string): Promise {
+ const booking = await this.findById(bookingId);
- // Already paired — passes the gate.
+ // Already paired (e.g. a partner submitted first) — nothing to do.
if (booking.consolidationPartnerId) {
- return { booking, blocked: false };
+ return booking;
}
- const needs =
- await this.consolidationService.needsConsolidationFromBooking(booking);
- if (!needs) {
- return { booking, blocked: false };
- }
-
- // A partner may have appeared since submission — try to pair now.
const result = await this.tryAutoConsolidate(booking);
- booking = result.booking;
- if (booking.consolidationPartnerId) {
- return { booking, blocked: false, message: result.messages.join(' ') };
- }
-
- // Still no partner — park it and block the accept.
- await this.bookingsRepository.parkForConsolidation(booking.id);
- booking = await this.findById(booking.id);
- const slots = await this.consolidationService.slotsFromBooking(booking);
- return {
- booking,
- blocked: true,
- message: this.consolidationService.describePending(booking, slots),
- };
+ return result.booking;
}
/** Create a new freight booking. */
@@ -575,6 +565,7 @@ export class BookingsService {
/** Return a paginated list of bookings matching the filter. */
async findAll(
filter: FilterBookingDto,
+ forceCompanyId?: string,
): Promise<{ items: Booking[]; total: number }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
@@ -587,7 +578,9 @@ export class BookingsService {
...statusFilter,
...schedulingStatusFilter,
assignedToSchedule: filter.assignedToSchedule,
- companyId: filter.companyId,
+ // A forced company scope (portal/customer) overrides any caller-provided
+ // companyId so a customer can only ever see their own company's bookings.
+ companyId: forceCompanyId ?? filter.companyId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
@@ -631,6 +624,109 @@ export class BookingsService {
});
}
+ /**
+ * Resolve the company a customer user belongs to, for scoping their own
+ * bookings. Returns null when no profile/company is linked yet.
+ */
+ async resolveCustomerCompanyId(userId: string): Promise {
+ try {
+ const { company } =
+ await this.companiesService.getCompanyInfoByUserId(userId);
+ return company?.id ?? null;
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * Authorize a customer's access to a single booking. Staff are scoped at the
+ * controller (they pass `isStaff`); for a customer, the booking must belong
+ * to the company the authenticated user is linked to — otherwise it is hidden
+ * behind a NotFound so booking IDs can't be probed.
+ */
+ async assertCustomerCanAccessBooking(
+ userId: string | undefined,
+ booking: Booking,
+ ): Promise {
+ if (!userId) {
+ throw new ForbiddenException('Authentication required');
+ }
+ const companyId = await this.resolveCustomerCompanyId(userId);
+ if (!companyId || booking.companyId !== companyId) {
+ // Don't reveal that the booking exists for another company.
+ throw new NotFoundException(`Booking ${booking.id} not found`);
+ }
+ }
+
+ /**
+ * 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/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts
index b24febf91..177b7475b 100644
--- a/apps/edr-freight-api/src/modules/payment/payment.service.ts
+++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts
@@ -145,9 +145,7 @@ export class PaymentService {
.findOneBy({ id: dto.bookingId });
if (!booking) throw new NotFoundException("Booking not found");
- console.log("bookingbooking",booking)
- const amountMinor = Math.round(Number(booking.totalAmount) * 100);
- console.log("amountminor",amountMinor)
+ const amountMinor = Math.round(Number(booking.totalAmount));
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
@@ -159,8 +157,8 @@ export class PaymentService {
provider: dto.method as unknown as ProviderMethod,
platform: dto.platform,
payerAccount: dto.payerAccount,
- returnUrl: dto.returnUrl ?? process.env.PAYMENT_RETURN_URL,
- failureUrl: dto.failureUrl ?? process.env.PAYMENT_FAILURE_URL,
+ returnUrl:'https://edrfreight.triaplc.com/payment/success',
+ failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
});
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts
index 7191a203e..dd20b100d 100644
--- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts
+++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts
@@ -49,8 +49,17 @@ export class SchedulingRescheduleService {
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
+ // A train can be rescheduled (with or without bookings) at any time UNLESS it
+ // is already on the move (DISPATCHED), has completed its run (ARRIVED), or was
+ // cancelled. Only DRAFT / SCHEDULED trains are reschedulable.
if (schedule.status === TrainScheduleStatus.Dispatched) {
- throw new BadRequestException('Cannot reschedule a dispatched train');
+ throw new BadRequestException('Cannot reschedule a train that is already dispatched');
+ }
+ if (schedule.status === TrainScheduleStatus.Arrived) {
+ throw new BadRequestException('Cannot reschedule a train that has already arrived');
+ }
+ if (schedule.status === TrainScheduleStatus.Cancelled) {
+ throw new BadRequestException('Cannot reschedule a cancelled train');
}
const currentOnSchedule = (schedule.scheduleBookings ?? [])
@@ -156,10 +165,24 @@ export class SchedulingRescheduleService {
}
}
- const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
- bookingIds: dto.finalBookingIds,
- forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
- });
+ // A train can be rescheduled even with no bookings (e.g. moved for
+ // maintenance). assignBookingsToSchedule requires at least one booking, so
+ // only call it when something is actually being (re)assigned — the new
+ // departure date above is the meaningful change for an empty train. The
+ // empty-train branch returns the same schedule-detail shape as the assign
+ // path so callers get a consistent response.
+ const assignResult = dto.finalBookingIds.length
+ ? await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
+ bookingIds: dto.finalBookingIds,
+ forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
+ })
+ : {
+ ...(await this.trainSchedulingService.getContainerTrainScheduleById(
+ scheduleId,
+ )),
+ warnings: [] as string[],
+ deferredBookings: [] as unknown[],
+ };
await this.schedulingRescheduleRepository.createEvent({
trainScheduleId: scheduleId,
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..98b6d70c2 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
@@ -721,27 +721,37 @@ export class TrainSchedulingService {
: null;
if (route) {
- const origin = route.originYard;
- const destination = route.destinationYard;
+ // `route.milestones` is the complete ordered corridor and already includes
+ // the origin (first) and destination (last) yards — `route.originYardId`
+ // and `route.destinationYardId` are derived from them. Use the milestones
+ // directly so the endpoints aren't double-counted (Addis…Addis, Dire…Dire).
const milestones = [...(route.milestones ?? [])].sort(
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo,
);
+
+ if (milestones.length > 0) {
+ milestones.forEach((m, i) =>
+ stations.push({
+ sequenceNo: i,
+ yardId: m.yardId,
+ label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
+ code: m.yard?.code ?? '',
+ }),
+ );
+ return stations;
+ }
+
+ // Route with no milestones recorded — fall back to its origin/destination.
+ const origin = route.originYard;
+ const destination = route.destinationYard;
stations.push({
sequenceNo: 0,
yardId: route.originYardId,
label: origin?.label ?? origin?.code ?? 'Origin',
code: origin?.code ?? '',
});
- milestones.forEach((m, i) =>
- stations.push({
- sequenceNo: i + 1,
- yardId: m.yardId,
- label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
- code: m.yard?.code ?? '',
- }),
- );
stations.push({
- sequenceNo: milestones.length + 1,
+ sequenceNo: 1,
yardId: route.destinationYardId,
label: destination?.label ?? destination?.code ?? 'Destination',
code: destination?.code ?? '',
@@ -775,8 +785,16 @@ export class TrainSchedulingService {
const stations = await this.buildScheduleStations(schedule);
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
+
+ // Resolve each checkpoint's position by its yard against the canonical
+ // corridor rather than the stored sequenceNo, so legacy checkpoints logged
+ // under an older station numbering still line up with the current stations.
+ const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
+ const resolvedSeq = (e: TrainCheckpointEvent) =>
+ seqByYard.get(e.yardId) ?? e.sequenceNo;
+
const currentSequenceNo = events.length
- ? Math.max(...events.map((e) => e.sequenceNo))
+ ? Math.max(...events.map(resolvedSeq))
: -1;
return {
@@ -790,13 +808,19 @@ 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,
currentSequenceNo,
checkpoints: events.map((e) => ({
id: e.id,
- sequenceNo: e.sequenceNo,
+ sequenceNo: resolvedSeq(e),
yardId: e.yardId,
label: e.yard?.label ?? e.yard?.code ?? null,
kind: e.kind,
diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx
index 07e212b76..af92d38fd 100644
--- a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx
@@ -12,9 +12,11 @@ import {
Text,
TextInput,
} from "@mantine/core";
+import { Badge as MantineBadge } from "@mantine/core";
import {
CheckCircle2,
CircleDollarSign,
+ LayoutGrid,
Loader2,
RotateCcw,
Search,
@@ -24,6 +26,7 @@ import {
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
+import "@/components/overview/overview.css";
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
import type {
PaymentMethod,
@@ -39,11 +42,16 @@ import {
} from "@edr/ui-common";
const STATUS_TABS = [
- { key: "all", label: "All", statuses: undefined as string | undefined },
- { key: "success", label: "Success", statuses: "success" },
- { key: "processing", label: "Processing", statuses: "processing,action-required" },
- { key: "failed", label: "Failed", statuses: "failed,canceled" },
- { key: "refunded", label: "Refunded", statuses: "refunded" },
+ { key: "all", label: "All", statuses: undefined as string | undefined, icon: LayoutGrid },
+ { key: "success", label: "Success", statuses: "success", icon: CheckCircle2 },
+ {
+ key: "processing",
+ label: "Processing",
+ statuses: "processing,action-required",
+ icon: Loader2,
+ },
+ { key: "failed", label: "Failed", statuses: "failed,canceled", icon: XCircle },
+ { key: "refunded", label: "Refunded", statuses: "refunded", icon: RotateCcw },
] as const;
type StatusTabKey = (typeof STATUS_TABS)[number]["key"];
@@ -166,6 +174,20 @@ export default function PaymentsPage() {
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
+ const tabCounts: Record = {
+ all:
+ summary === undefined
+ ? undefined
+ : (summary.success ?? 0) +
+ (summary.processing ?? 0) +
+ (summary.failed ?? 0) +
+ (summary.refunded ?? 0),
+ success: summary?.success,
+ processing: summary?.processing,
+ failed: summary?.failed,
+ refunded: summary?.refunded,
+ };
+
const columns: ColumnDef[] = [
{
id: "order",
@@ -274,13 +296,48 @@ export default function PaymentsPage() {
setStatusTab((value as StatusTabKey) ?? "all");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
+ variant="pills"
+ color="green"
+ keepMounted={false}
+ classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
- {STATUS_TABS.map((t) => (
-
- {t.label}
-
- ))}
+ {STATUS_TABS.map((t) => {
+ const isActive = statusTab === t.key;
+ const count = tabCounts[t.key];
+ const Icon = t.icon;
+ return (
+ }
+ rightSection={
+ count !== undefined ? (
+
+ {count}
+
+ ) : undefined
+ }
+ >
+ {t.label}
+
+ );
+ })}
diff --git a/apps/edr-freight-web/portal/public/assets/login.png b/apps/edr-freight-web/portal/public/assets/login.png
new file mode 100644
index 000000000..f4854a45c
Binary files /dev/null and b/apps/edr-freight-web/portal/public/assets/login.png differ
diff --git a/apps/edr-freight-web/portal/public/assets/telebirr.jpeg b/apps/edr-freight-web/portal/public/assets/telebirr.jpeg
new file mode 100644
index 000000000..04e034393
Binary files /dev/null and b/apps/edr-freight-web/portal/public/assets/telebirr.jpeg differ
diff --git a/apps/edr-freight-web/portal/public/assets/waafi.jpeg b/apps/edr-freight-web/portal/public/assets/waafi.jpeg
new file mode 100644
index 000000000..392de36cc
Binary files /dev/null and b/apps/edr-freight-web/portal/public/assets/waafi.jpeg differ
diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx
index 9938daff9..a4c2c8553 100644
--- a/apps/edr-freight-web/portal/src/App.tsx
+++ b/apps/edr-freight-web/portal/src/App.tsx
@@ -36,6 +36,8 @@ import EditBookingPage from "./pages/bookings/EditBookingPage";
import MyBookings from "./pages/bookings/MyBookings";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
+import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
+import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
import TrackingPage from "./pages/tracking/TrackingPage";
function FullScreenSpinner() {
@@ -158,6 +160,9 @@ const App = () => {
path="/booking/check-status/:orderId"
element={ }
/>
+ {/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */}
+ } />
+ } />
{/* Auth pages — inaccessible once logged in */}
}>
diff --git a/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx b/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx
new file mode 100644
index 000000000..96d55bdd0
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx
@@ -0,0 +1,151 @@
+import type { ReactNode } from "react";
+import { ArrowUpRight, ChevronDown, Globe } from "lucide-react";
+
+const LOGIN_IMAGE = "/assets/login.png";
+const EDR_LOGO = "/assets/logo.svg";
+
+export const fieldClass =
+ "h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10";
+
+export const primaryButtonClass =
+ "h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none";
+
+const LeftPanelDecor = () => (
+
+
+ {[0, 1, 2, 3, 4, 5].map((ring) => (
+
+ ))}
+
+
+
+);
+
+const RightPanelDecor = () => (
+
+);
+
+export interface AuthShellProps {
+ children: ReactNode;
+ /** Tagline shown in the highlighted card over the left image panel. */
+ tagline?: string;
+ taglineBody?: string;
+}
+
+const LeftPanel = ({ tagline, taglineBody }: Pick) => (
+
+
+
+
+
+
+
+
+
+
+
+
+ {tagline ?? "Empower Your Freight Operations"}
+
+
+
+ {taglineBody ??
+ "Sign in to manage shipments, track cargo, and run logistics operations on the Ethio Djibouti Railway freight platform."}
+
+
+
+
+);
+
+const LanguageSelector = () => (
+
+
+ Eng
+
+
+);
+
+const FormFooter = () => (
+
+);
+
+export default function AuthShell({ children, tagline, taglineBody }: AuthShellProps) {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx
index 27b923467..ffd59666e 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/BookingRow.tsx
@@ -2,6 +2,7 @@ import { Box, Group, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
import { Stepper } from "./Stepper";
+import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
interface BookingRowProps {
booking: any;
@@ -18,6 +19,10 @@ export const BookingRow = memo(function BookingRow({
const Icon = cfg.icon;
const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind];
+ // Payable bookings get an inline "Pay now" that opens the payment modal
+ // instead of navigating to the detail page.
+ const canPay =
+ booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
@@ -78,25 +83,29 @@ export const BookingRow = memo(function BookingRow({
{cfg.badgeLabel}
-
-
- {cfg.action.label}
-
- {AIcon && (
-
- )}
-
+ {canPay ? (
+
+ ) : (
+
+
+ {cfg.action.label}
+
+ {AIcon && (
+
+ )}
+
+ )}
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx
index 4e2c30343..3ebb9606f 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx
@@ -54,7 +54,7 @@ export const InvoicesSection = memo(function InvoicesSection({
Outstanding balance
- {formatCurrency(totalOutstanding || 377500, "ETB")}
+ {formatCurrency(totalOutstanding || 0, "ETB")}
= [
+ { value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" },
+ { value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" },
+];
+
export default function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { login } = useAuth();
const [method, setMethod] = useState("email");
const [identifier, setIdentifier] = useState("");
- const [countryCode, setCountryCode] = useState("+251");
- const [phoneNumber, setPhoneNumber] = useState("");
+ const [countryCode] = useState("+251");
const [password, setPassword] = useState("");
+ const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
+ const currentMethod = loginMethods.find((item) => item.value === method)!;
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault();
setError(null);
setLoading(true);
try {
const loginId =
method === "email"
? identifier
- : `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`;
+ : `${countryCode}${identifier.startsWith("0") ? identifier.slice(1) : identifier}`;
const result = await login({ email: loginId, password });
if (result.success) {
- const from = (location.state as { from?: { pathname: string } } | null)
- ?.from?.pathname;
+ const from = (location.state as { from?: { pathname: string } } | null)?.from
+ ?.pathname;
navigate(from ?? "/portal", { replace: true });
} else {
setError(result.error.message);
@@ -46,154 +58,105 @@ export default function LoginPage() {
};
return (
-
-
-
-
-
-
-
+
+
+
-
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx
index f85d11750..5f1e4b615 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx
@@ -1,7 +1,6 @@
-import { Alert, Box, Button, Group, PasswordInput, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { Check, ArrowRight, UserPlus, X } from "lucide-react";
import { useState } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
@@ -9,8 +8,9 @@ import { z } from "zod";
import { userType } from "@/enums/userType";
import useAuth from "@/hooks/useAuth";
import type { SignupPayload } from "@/types/auth";
-import AuthLayout from "@/components/auth/AuthLayout";
-import PhoneInput from "@/components/auth/PhoneInput";
+import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
+
+const EDR_LOGO = "/assets/logo.svg";
const passwordRequirements = [
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
@@ -20,11 +20,22 @@ const passwordRequirements = [
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
] as const;
+const ETHIOPIA_COUNTRY_CODE = "+251";
+
+const isValidEthiopianMobile = (value: string) => {
+ const digits = value.replace(/\D/g, "");
+ const normalized = digits.startsWith("0") ? digits.slice(1) : digits;
+ return /^9\d{8}$/.test(normalized);
+};
+
const userSchema = z
.object({
email: z.string().email("Invalid email address"),
- countryCode: z.string().min(1, "Country code is required"),
- phone: z.string().min(9, "Phone number is too short").max(9, "Phone number is too long"),
+ countryCode: z.literal(ETHIOPIA_COUNTRY_CODE),
+ phone: z
+ .string()
+ .min(1, "Phone number is required")
+ .refine(isValidEthiopianMobile, "Enter a valid mobile number (e.g. 0912345678)"),
userType: z.string(),
firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
@@ -44,11 +55,16 @@ const userSchema = z
type FormData = z.infer;
+const errorText = (msg?: string) =>
+ msg ? {msg}
: null;
+
export default function SignupPage() {
const navigate = useNavigate();
const { signup } = useAuth();
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
+ const [showPassword, setShowPassword] = useState(false);
+ const [showConfirm, setShowConfirm] = useState(false);
const {
register,
@@ -59,7 +75,7 @@ export default function SignupPage() {
resolver: zodResolver(userSchema),
defaultValues: {
email: "",
- countryCode: "+251",
+ countryCode: ETHIOPIA_COUNTRY_CODE,
phone: "",
userType: userType.individual,
firstName: { en: "", am: "" },
@@ -73,7 +89,8 @@ export default function SignupPage() {
setError(null);
setLoading(true);
try {
- const normalizedPhone = data.phone.startsWith("0") ? data.phone.slice(1) : data.phone;
+ const digits = data.phone.replace(/\D/g, "");
+ const normalizedPhone = digits.startsWith("0") ? digits.slice(1) : digits;
const payload: SignupPayload = {
email: data.email,
username: data.email,
@@ -102,145 +119,194 @@ export default function SignupPage() {
const passwordValue = watch("password") ?? "";
return (
-
-
-
-
-
-
-
- Create Account
-
-
+
-
+
);
}
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/PaymentDeadlineCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx
index 75ed3e652..54f900ab5 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx
@@ -69,11 +69,18 @@ export function PaymentDeadlineCard({
return () => clearInterval(interval);
}, [deadlineMs]);
- const accentBg = remaining.expired ? "#FBEAE7" : "#FDF3E0";
- const accentFg = remaining.expired ? "#C0392B" : "#9A5B00";
+ const accentBg = remaining.expired ? "#FBEAE7" : "#FEF6E6";
+ const accentFg = remaining.expired ? "#C0392B" : "#B07D14";
return (
-
+
Payment deadline
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.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}
)}
- onConfirm(method)}
- styles={{
- root: { height: 46 },
- label: { fontSize: 14, fontWeight: 800 },
- }}
- >
- {processing ? "Redirecting…" : "Continue to payment"}
-
-
- You'll be redirected to your provider to complete payment securely.
-
-
+
+
+
+ Secured · you'll be redirected to your provider to pay
+
+
+
+
+
+ Cancel
+
+ onConfirm(method)}
+ styles={{
+ root: { height: 48, flex: 1 },
+ label: { fontSize: 14, fontWeight: 800 },
+ }}
+ >
+ {processing ? "Redirecting…" : "Continue to payment"}
+
+
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx
index 31bbca748..5b5f41cb6 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx
@@ -1,12 +1,92 @@
import { Box, Group, Text } from "@mantine/core";
-import { AlertTriangle, Check, FileText, History } from "lucide-react";
+import {
+ AlertTriangle,
+ Check,
+ FileText,
+ History,
+ MapPin,
+ MoveRight,
+} from "lucide-react";
import type { Freight } from "@edr/types";
import { PROGRESS_STAGES, STATUS_MAP } from "../constants";
-import { fmtDate, isDraftLike, isNegative } from "../utils";
+import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
import { SectionCard } from "./layout";
+const ACCENT = "#F2A516";
+
+/** Origin → destination strip rendered above the progress tracker. */
+function RouteStrip({ booking }: { booking: Freight.IBooking }) {
+ const origin = yardLabel(booking.originYard);
+ const destination = yardLabel(booking.destinationYard);
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function RouteEndpoint({
+ label,
+ value,
+ alignRight,
+}: {
+ label: string;
+ value: string;
+ alignRight?: boolean;
+}) {
+ return (
+
+
+
+
+ {label}
+
+
+
+ {value}
+
+
+ );
+}
+
export function StatusHero({
booking,
children,
@@ -91,6 +171,8 @@ export function StatusHero({
+ {!negative && }
+
{children ?? (
est.
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 0ee19f95e..2ce56cf1b 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx
@@ -1,4 +1,4 @@
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
@@ -8,14 +8,44 @@ import {
Card,
Group,
Menu,
+ Paper,
+ Select,
+ SimpleGrid,
Stack,
Text,
+ TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
-import { ArrowUpDown, Download, Filter, MoreVertical, Package, Plus } from "lucide-react";
+import type { LucideIcon } from "lucide-react";
+import {
+ ArrowRight,
+ CheckCircle2,
+ FileEdit,
+ LayoutList,
+ MoreVertical,
+ Package,
+ Plus,
+ Search,
+ Train,
+ Wallet,
+ X,
+} from "lucide-react";
+
+import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
+import { PayNowButton } from "./payments/PayNowButton";
+
+// 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";
import type { Freight } from "@edr/types";
import {
DataTable,
@@ -24,25 +54,86 @@ import {
usePagination,
} from "@edr/ui-common";
-// ── Status badge ──────────────────────────────────────────────────────────────
+// ── Status filter options (grouped by lifecycle) ──────────────────────────────
-const STATUS_CONFIG: Record = {
- DRAFT: { bg: "#F1F4F7", dot: "#94A3B8", color: "#475569", label: "Draft" },
- REVIEWING: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Reviewing" },
- AWAITING_PAYMENT: { bg: "#FDF3E0", dot: "#F2A516", color: "#9A5B00", label: "Awaiting Payment" },
- CONFIRMED: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "Confirmed" },
- IN_TRANSIT: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "In Transit" },
- DELIVERED: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Delivered" },
- CANCELLED: { bg: "#FBEAE7", dot: "#C0392B", color: "#C0392B", label: "Cancelled" },
-};
+const STATUS_FILTERS = [
+ { key: "all", label: "All bookings", statuses: undefined as string | undefined },
+ {
+ key: "active",
+ label: "In progress",
+ statuses:
+ "SUBMITTED,CHANGES_REQUESTED,PENDING_APPROVAL,APPROVED_PENDING_SIGNATURE,APPROVED,CONTRACT_READY,SIGNED_CUSTOMER,FULLY_EXECUTED,PENDING_CONSOLIDATION,CONSOLIDATED",
+ },
+ { key: "draft", label: "Drafts", statuses: "DRAFT" },
+ {
+ key: "payment",
+ label: "Awaiting payment",
+ statuses:
+ "SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
+ },
+ { key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" },
+ { key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
+ { key: "closed", label: "Cancelled / rejected", statuses: "CANCELLED,REJECTED" },
+] as const;
+
+type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"];
+
+const SELECT_DATA = STATUS_FILTERS.map((f) => ({ value: f.key, label: f.label }));
+
+// ── Summary stat cards (clickable lifecycle filters) ──────────────────────────
+
+const STAT_CARDS: Array<{
+ key: StatusFilterKey;
+ label: string;
+ icon: LucideIcon;
+ iconBg: string;
+ iconColor: string;
+}> = [
+ {
+ key: "all",
+ label: "All bookings",
+ icon: LayoutList,
+ iconBg: "#ECF6F1",
+ iconColor: "#0A8A5F",
+ },
+ {
+ key: "active",
+ label: "In progress",
+ icon: Package,
+ iconBg: "#FDF3E0",
+ iconColor: "#C77F09",
+ },
+ {
+ key: "payment",
+ label: "Awaiting payment",
+ icon: Wallet,
+ iconBg: "#FEF6E6",
+ iconColor: "#F2A516",
+ },
+ {
+ key: "draft",
+ label: "Drafts",
+ icon: FileEdit,
+ iconBg: "#F1F4F7",
+ iconColor: "#475569",
+ },
+ {
+ key: "done",
+ label: "Completed",
+ icon: CheckCircle2,
+ iconBg: "#ECF6F1",
+ iconColor: "#0A8A5F",
+ },
+];
+
+// ── Status badge (reuses the shared portal status config) ─────────────────────
function StatusBadge({ status }: { status: string }) {
- const cfg = STATUS_CONFIG[status] ?? {
- bg: "#F1F4F7",
- dot: "#94A3B8",
- color: "#475569",
- label: status.replace(/_/g, " "),
- };
+ const cfg = STATUS_CONFIG[status];
+ const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
+ const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
+ const text = cfg ? `var(--mantine-color-${cfg.badgeText}-7, #475569)` : "#475569";
+ const dot = cfg ? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)` : "#94A3B8";
return (
@@ -60,12 +151,12 @@ function StatusBadge({ status }: { status: string }) {
width: 6,
height: 6,
borderRadius: "50%",
- backgroundColor: cfg.dot,
+ backgroundColor: dot,
flexShrink: 0,
}}
/>
-
- {cfg.label}
+
+ {label}
);
@@ -74,14 +165,14 @@ function StatusBadge({ status }: { status: string }) {
// ── Context-sensitive action button ───────────────────────────────────────────
function PrimaryAction({
- status,
- id,
+ booking,
onNavigate,
}: {
- status: string;
- id: string;
+ booking: Freight.IBooking;
onNavigate: (path: string) => void;
}) {
+ const { status, id } = booking;
+ const go = () => onNavigate(`/bookings/${id}`);
if (status === "DRAFT") {
return (
}
style={{ backgroundColor: "var(--mantine-color-edr-ink-0)", color: "#fff" }}
- onClick={() => onNavigate(`/bookings/${id}`)}
+ onClick={go}
>
Continue
);
}
- if (status === "AWAITING_PAYMENT") {
- return (
- onNavigate(`/bookings/${id}`)}
- >
- Pay
-
- );
- }
- if (status === "IN_TRANSIT") {
- return (
- onNavigate(`/bookings/${id}`)}
- >
- Track
-
- );
+ if (status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID") {
+ return ;
}
return (
- onNavigate(`/bookings/${id}`)}
- >
+
View
);
}
-// ── Column header label ───────────────────────────────────────────────────────
-
function ColHeader({ label }: { label: string }) {
return (
void;
+}) {
+ const Icon = card.icon;
+ return (
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onSelect();
+ }
+ }}
+ p="md"
+ radius="lg"
+ withBorder
+ style={{
+ cursor: "pointer",
+ transition: "box-shadow 140ms ease, border-color 140ms ease",
+ borderColor: active ? "#F2A516" : "var(--mantine-color-edr-border-0)",
+ boxShadow: active ? "0 0 0 1px #F2A516" : "none",
+ }}
+ >
+
+
+
+
+
+
+ {count ?? "—"}
+
+
+ {card.label}
+
+
+
+
+ );
+}
+
export default function MyBookings() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [query, setQuery] = useState("");
+ const [trackingBooking, setTrackingBooking] = useState(
+ null,
+ );
- const { data, isLoading, isError } = useQuery(api.bookings.list.queryOptions());
- const bookings = data?.items ?? [];
+ const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
- const total = bookings.length;
- const pageCount = Math.ceil(total / pagination.pageSize);
- const start = pagination.pageIndex * pagination.pageSize;
- const end = Math.min(start + pagination.pageSize, total);
- const paginatedData = useMemo(() => bookings.slice(start, end), [bookings, start, end]);
+ const selectFilter = (key: StatusFilterKey) => {
+ setStatusFilter(key);
+ setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
+ };
+
+ const filter: BookingListFilter = useMemo(
+ () => ({
+ statuses,
+ page: pagination.pageIndex + 1,
+ pageSize: pagination.pageSize,
+ }),
+ [statuses, pagination.pageIndex, pagination.pageSize],
+ );
+
+ const { data, isLoading, isError } = useQuery(
+ api.bookings.list.queryOptions({ input: filter }),
+ );
+
+ // Per-card lifecycle counts (one cheap query each, total-only).
+ const allCount = useStatusCount(undefined);
+ const activeCount = useStatusCount(
+ STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
+ );
+ const paymentCount = useStatusCount(
+ STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
+ );
+ const draftCount = useStatusCount(
+ STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
+ );
+ const doneCount = useStatusCount(
+ STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
+ );
+ const cardCounts: Record = {
+ all: allCount,
+ active: activeCount,
+ payment: paymentCount,
+ draft: draftCount,
+ done: doneCount,
+ transit: undefined,
+ closed: undefined,
+ };
+
+ const allItems = data?.items ?? [];
+ const total = data?.total ?? allItems.length;
+
+ // Server handles status + pagination; reference search is applied on the page.
+ const rows = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return allItems;
+ return allItems.filter((b) =>
+ [b.reference, b.originYard?.label, b.destinationYard?.label]
+ .filter(Boolean)
+ .some((v) => String(v).toLowerCase().includes(q)),
+ );
+ }, [allItems, query]);
+
+ const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
+ const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
+ const showEmpty =
+ !isLoading && !isError && rows.length === 0;
const columns: ColumnDef[] = [
{
@@ -178,8 +380,7 @@ export default function MyBookings() {
header: () => ,
cell: ({ row }) => {
const b = row.original;
- const cargoLabel =
- b.freightType === "BULK" ? "Bulk Cargo" : "Cargo";
+ const cargoLabel = b.freightType === "BULK" ? "Bulk cargo" : "Container";
return (
@@ -245,7 +446,10 @@ export default function MyBookings() {
meta: hMeta,
header: () => ,
cell: ({ row }) => {
- const b = row.original as Freight.IBooking & { totalAmount?: number; amount?: number };
+ const b = row.original as Freight.IBooking & {
+ totalAmount?: number;
+ amount?: number;
+ };
const amount = b.totalAmount ?? b.amount ?? null;
if (!amount) {
return (
@@ -267,24 +471,42 @@ export default function MyBookings() {
header: () => null,
cell: ({ row }) => {
const booking = row.original;
+ const trackable = TRACKABLE_STATUSES.has(booking.status);
return (
e.stopPropagation()}>
-
+ {trackable && (
+ }
+ onClick={() => setTrackingBooking(booking)}
+ >
+ Track
+
+ )}
+
-
+
navigate(`/bookings/${booking.id}`)}>
- View Details
+ View details
+ {trackable && (
+ }
+ onClick={() => setTrackingBooking(booking)}
+ >
+ Track shipment
+
+ )}
@@ -293,8 +515,6 @@ export default function MyBookings() {
},
];
- const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
-
return (
@@ -305,81 +525,112 @@ export default function MyBookings() {
Bookings
- Manage every cargo booking — from draft to delivery.
+ Track every cargo booking — from draft to delivery.
-
- }>
- Export
-
- }
- >
- New Booking
-
-
+ }
+ >
+ New booking
+
+ {/* ── Summary stat cards ──────────────────────────────────────── */}
+
+ {STAT_CARDS.map((card) => (
+ selectFilter(card.key)}
+ />
+ ))}
+
+
{/* ── Bookings table card ──────────────────────────────────────── */}
- {/* Toolbar */}
- }
- >
- Sort
-
- }
- >
- Filter
-
+
+ }
+ value={query}
+ onChange={(e) => setQuery(e.currentTarget.value)}
+ rightSection={
+ query ? (
+ setQuery("")}
+ >
+
+
+ ) : null
+ }
+ radius="md"
+ style={{ flex: 1, minWidth: 200, maxWidth: 340 }}
+ />
+ selectFilter((value as StatusFilterKey) ?? "all")}
+ allowDeselect={false}
+ radius="md"
+ checkIconPosition="right"
+ comboboxProps={{ withinPortal: true }}
+ style={{ width: 200 }}
+ aria-label="Filter by status"
+ />
+
+
+ {total} booking{total !== 1 ? "s" : ""}
+
- {/* Empty state */}
- {total === 0 && dataTableStatus === "success" ? (
+ {showEmpty ? (
- No bookings yet
+ {query ? "No bookings match your search" : "No bookings here yet"}
- You haven't made any booking requests yet. Create your first one to get started.
+ {query
+ ? "Try a different reference or clear the search."
+ : "Create your first booking to get started."}
- }
- >
- Create first booking
-
+ {!query && (
+ }
+ >
+ Create first booking
+
+ )}
) : (
navigate(`/bookings/${(row as Freight.IBooking).id}`)}
pagination={{
@@ -391,6 +642,8 @@ export default function MyBookings() {
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
+ manualPagination: true,
+ pageCount,
}}
containerClassName="border-0 shadow-none rounded-none"
footer={DataTableFooter}
@@ -398,6 +651,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/new-booking-form/StepIndicator.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx
index 7ac5411fb..c5d9ad433 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx
@@ -2,84 +2,88 @@ import { Check } from "lucide-react";
import { Fragment } from "react";
import { STEPS } from "./schema";
+const GREEN = "var(--mantine-color-edr-green-5)";
+const GREEN_DEEP = "var(--mantine-color-edr-green-7)";
+const BORDER = "var(--mantine-color-edr-border-0)";
+const MUTED = "var(--mantine-color-edr-muted-0)";
+const INK = "var(--mantine-color-edr-text-0)";
+
export function StepIndicator({ step }: { step: number }) {
return (
-
- {STEPS.map((item, index) => (
-
-
-
item.id
- ? {
- backgroundColor: "var(--mantine-color-edr-green-5)",
- color: "#fff",
- boxShadow: "0 2px 8px rgba(14,163,113,0.4)",
- }
- : step === item.id
+
+ {STEPS.map((item, index) => {
+ const done = step > item.id;
+ const active = step === item.id;
+ return (
+
+
+
- {step > item.id ? (
-
- ) : (
- item.id
- )}
+ : active
+ ? {
+ border: `2.5px solid ${GREEN}`,
+ color: GREEN_DEEP,
+ backgroundColor: "#fff",
+ boxShadow: "0 0 0 4px rgba(14,163,113,0.12)",
+ }
+ : {
+ backgroundColor: "#fff",
+ color: MUTED,
+ border: `2px solid ${BORDER}`,
+ }),
+ }}
+ >
+ {done ? : item.id}
+
+
= item.id ? INK : MUTED,
+ }}
+ className="md:!block"
+ >
+ {item.short}
+
- = item.id
- ? "var(--mantine-color-edr-text-0)"
- : "var(--mantine-color-edr-muted-0)",
- }}
- className="lg:!block"
- >
- {item.short}
-
-
- {index < STEPS.length - 1 && (
-
item.id
- ? "var(--mantine-color-edr-green-5)"
- : "var(--mantine-color-edr-border-0)",
- }}
- />
- )}
-
- ))}
+ {index < STEPS.length - 1 && (
+
+ )}
+
+ );
+ })}
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx
index 55b6eda44..b2ee1fd7e 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx
@@ -1,48 +1,167 @@
-import { Alert, Combobox, Input, InputBase, Select, Text, Title, useCombobox } from "@mantine/core";
-import { AlertTriangle, Check, CheckCircle2, Info, Loader, XCircle } from "lucide-react";
+import {
+ Alert,
+ Box,
+ Combobox,
+ Group,
+ Input,
+ InputBase,
+ Paper,
+ Select,
+ Text,
+ Title,
+ useCombobox,
+} from "@mantine/core";
+import {
+ AlertTriangle,
+ Check,
+ CheckCircle2,
+ Info,
+ Loader,
+ XCircle,
+} from "lucide-react";
import type { ReactNode } from "react";
import { useMemo } from "react";
-import type { ControllerRenderProps, FieldError as RhfFieldError } from "react-hook-form";
+import type {
+ ControllerRenderProps,
+ FieldError as RhfFieldError,
+} from "react-hook-form";
import type { BookingFormInputValues } from "./schema";
+// Brand tokens (kept local so the form reads consistently with the booking
+// detail page and the scheduling step).
+const INK = "#10202F";
+const MUTED = "#6B7C8E";
+const GREEN = "#0EA371";
+const GREEN_DARK = "#0A6F4D";
+const BORDER = "#E6ECF2";
+
export function OptionFieldError({ error }: { error?: { message?: string } }) {
if (!error?.message) return null;
return (
-
+
{error.message}
);
}
+/**
+ * Premium selectable option card with an icon tile, title, and description.
+ * Pass `icon`/`iconBg`/`iconColor` for the leading tile, or compose freely via
+ * `children` (legacy callers still work).
+ */
export function OptionCard({
selected,
onClick,
disabled,
+ icon,
+ iconBg = "#ECF6F1",
+ iconColor = GREEN_DARK,
+ title,
+ description,
children,
}: {
selected: boolean;
onClick?: () => void;
disabled?: boolean;
- children: ReactNode;
+ icon?: ReactNode;
+ iconBg?: string;
+ iconColor?: string;
+ title?: ReactNode;
+ description?: ReactNode;
+ children?: ReactNode;
}) {
return (
{
+ if (!disabled && !selected) {
+ e.currentTarget.style.borderColor = "#BFE3D2";
+ e.currentTarget.style.boxShadow = "0 6px 16px rgba(16,24,40,0.07)";
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!disabled && !selected) {
+ e.currentTarget.style.borderColor = BORDER;
+ e.currentTarget.style.boxShadow = "0 1px 2px rgba(16,24,40,0.04)";
+ }
+ }}
>
{selected && !disabled && (
-
-
+
+
)}
+
+ {/* Structured form (icon + title + description) */}
+ {(icon || title || description) && (
+
+ {icon && (
+
+ {icon}
+
+ )}
+ {title && (
+
+ {title}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+
+ )}
+
{children}
);
@@ -63,7 +182,7 @@ export function AlertBox({
};
const { color, icon } = map[tone];
return (
-
+
{children}
);
@@ -71,31 +190,89 @@ export function AlertBox({
export function StepLabel({ children }: { children: ReactNode }) {
return (
-
+
{children}
);
}
+/**
+ * Card shell that wraps a step's body. Gives every step the same premium
+ * surface, padding, and an optional eyebrow.
+ */
+export function StepCard({
+ children,
+ eyebrow,
+}: {
+ children: ReactNode;
+ eyebrow?: ReactNode;
+}) {
+ return (
+
+ {eyebrow}
+ {children}
+
+ );
+}
+
export function StepHeader({
title,
description,
+ icon,
}: {
title: string;
description: string;
+ icon?: ReactNode;
}) {
return (
-
-
- {title}
-
-
- {description}
-
-
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+
+ {title}
+
+
+ {description}
+
+
+
);
}
+/** Shared Mantine input styling so every field in the form matches. */
+export const fieldStyles = {
+ label: { fontWeight: 600, fontSize: 13, color: INK, marginBottom: 6 },
+ input: { borderRadius: 10, minHeight: 44, height: 44, borderColor: BORDER },
+} as const;
+
export function SelectField({
field,
error,
@@ -103,6 +280,7 @@ export function SelectField({
placeholder,
disabled,
data,
+ leftSection,
}: {
field: ControllerRenderProps;
error?: RhfFieldError;
@@ -110,6 +288,7 @@ export function SelectField({
placeholder: string;
disabled?: boolean;
data: string[] | { value: string; label: string }[];
+ leftSection?: ReactNode;
}) {
return (
);
}
@@ -166,12 +350,14 @@ export function AsyncComboboxField({
};
return (
-
-
+
+
{
onSearchChange(e.currentTarget.value);
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx
index f600d10d7..07a6d197c 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx
@@ -1,6 +1,6 @@
import { Box, Group, Text } from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common";
-import { CheckCircle2 } from "lucide-react";
+import { CheckCircle2, FileUp } from "lucide-react";
import { Controller, type UseFormReturn } from "react-hook-form";
import {
@@ -9,7 +9,7 @@ import {
type BookingDocuments,
type BookingFormValues,
} from "./schema";
-import { StepHeader } from "./shared";
+import { StepCard, StepHeader } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -30,10 +30,11 @@ export function StepDocuments({ form }: { form: BookingForm }) {
const total = BOOKING_DOCS_SETTING.fields.length;
return (
-
+
}
title="Shipment Documents"
- description="Attach your shipment documents now, or skip this step and upload them later from the booking page."
+ description="Attach your shipment documents now, or skip and upload them later from the booking page."
/>
)}
/>
-
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx
index 3a989e60c..a2a32bd5e 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-scheduling.tsx
@@ -385,64 +385,147 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
setSelectedDayForModal(null)}
- title={selectedDayForModal ? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEE, MMM d yyyy") : ""}
centered
- size="sm"
- styles={{
- header: { borderBottom: `1px solid ${theme.colors["edr-border"][0]}` },
- body: { padding: 24 },
- }}
+ size={520}
+ radius={18}
+ padding={0}
+ withCloseButton={false}
+ overlayProps={{ backgroundOpacity: 0.5, blur: 3 }}
>
-
-
- Choose a departure time
+ {/* Header */}
+
+
+
+
+ Available departures
+
+
+
+ {selectedDayForModal
+ ? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEEE, MMM d yyyy")
+ : ""}
- {selectedDayForModal?.schedules.map((schedule) => (
- handleSelectScheduleFromModal(schedule.id)}
- style={{ height: 64, justifyContent: "flex-start" }}
- styles={{
- inner: { justifyContent: "flex-start" },
- root: {
- borderColor: theme.colors["edr-border"][0],
+
+ {selectedDayForModal?.schedules.length ?? 0} train
+ {(selectedDayForModal?.schedules.length ?? 0) !== 1 ? "s" : ""} on{" "}
+ {originName} → {destinationName}
+
+
+
+ {/* Schedule list */}
+
+ {selectedDayForModal?.schedules.map((schedule) => {
+ const remaining = schedule.remainingWagons;
+ const max = schedule.maxWagons || 1;
+ const pct = Math.max(0, Math.min(100, Math.round((remaining / max) * 100)));
+ const isSelected = schedule.id === selectedScheduleId;
+ return (
+ handleSelectScheduleFromModal(schedule.id)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleSelectScheduleFromModal(schedule.id);
+ }
+ }}
+ style={{
+ cursor: "pointer",
+ borderRadius: 14,
+ padding: 16,
+ border: `1.5px solid ${isSelected ? theme.colors["edr-green"][5] : theme.colors["edr-border"][0]}`,
+ background: isSelected ? theme.colors["edr-soft"][0] : "#fff",
+ boxShadow: isSelected
+ ? `0 0 0 1px ${theme.colors["edr-green"][5]}`
+ : "0 1px 2px rgba(16,24,40,0.04)",
transition: "all 150ms ease",
- "&:hover": {
- borderColor: theme.colors["edr-green"][5],
- backgroundColor: theme.colors["edr-soft"][0],
- },
- },
- }}
- >
-
-
-
-
-
-
- {format(new Date(schedule.scheduleDate), "HH:mm")}
-
- {schedule.trainNumber && (
-
- Train {schedule.trainNumber}
-
- )}
-
-
-
- ))}
+ }}
+ >
+
+
+
+
+
+
+
+ {format(new Date(schedule.scheduleDate), "HH:mm")}
+
+
+ {schedule.trainNumber
+ ? `Train ${schedule.trainNumber}`
+ : `#${schedule.id.slice(0, 6)}`}
+
+
+ {/* capacity bar */}
+
+
+
+ {remaining} / {max} wagons free
+
+ 25 ? "edr-green.7" : "#C77F09"}>
+ {pct}%
+
+
+
+ 25
+ ? `linear-gradient(90deg, ${theme.colors["edr-green"][7]}, ${theme.colors["edr-green"][5]})`
+ : "#F2A516",
+ }}
+ />
+
+
+
+
+ {isSelected && }
+
+
+
+ );
+ })}
@@ -561,36 +644,42 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
)}
- {/* Schedule times */}
+ {/* Availability marker — a dot + count, never the schedule list itself. */}
{d.hasSchedule && (
-
- {d.schedules.slice(0, 2).map((s) => (
-
-
-
- {format(new Date(s.scheduleDate), "HH:mm")}
-
-
- ))}
- {d.schedules.length > 2 && (
-
- +{d.schedules.length - 2} more
+
+
+
+
+ {d.schedules.length} departure{d.schedules.length !== 1 ? "s" : ""}
- )}
-
+
+
)}
);
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx
index ae52a8645..f7545f042 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx
@@ -10,8 +10,11 @@ import {
AsyncComboboxField,
OptionCard,
OptionFieldError,
+ StepCard,
StepHeader,
} from "./shared";
+import { FileSignature } from "lucide-react";
+import { Stack } from "@mantine/core";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -52,7 +55,6 @@ export function Step1ContractType({
);
const contractOptions = useMemo(() => {
- console.log("Bookings data:", bookings);
if (!bookings) return [];
return bookings?.items
@@ -182,10 +184,11 @@ export function Step1ContractType({
};
return (
-
+
}
title="Contract Type"
- description="New contract or renewal of an existing one."
+ description="Start a new contract or renew an existing one to reuse its details."
/>
(
-
+
}
+ iconBg="#ECF6F1"
+ iconColor="#0A6F4D"
+ title="New Contract"
+ description="Create a fresh freight contract from scratch."
onClick={() => {
field.onChange("new");
form.clearErrors(["contractType", "previousContractRef"]);
form.setValue("previousContractRef", "");
}}
- >
-
-
-
-
New Contract
-
- Create a new contract.
-
-
+ />
}
+ iconBg="#EAF1FB"
+ iconColor="#2E5B96"
+ title="Contract Renewal"
+ description="Pick a previous reference to auto-fill historical parameters."
onClick={() => {
field.onChange("renewal");
form.clearErrors("contractType");
}}
- >
-
-
-
-
Contract Renewal
-
- Select a previous reference to auto-populate historical
- parameters.
-
-
+ />
@@ -234,7 +230,7 @@ export function Step1ContractType({
/>
{contractType === "renewal" && (
-
+
{error && (
Failed to load previous contracts. Please try again later.
@@ -263,8 +259,8 @@ export function Step1ContractType({
details will be pre-filled.
)}
-
+
)}
-
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx
index e01e2f879..20f63812f 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx
@@ -1,9 +1,17 @@
-import { Switch, TextInput } from "@mantine/core";
-import { FileText, Train, Truck } from "lucide-react";
+import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
+import type { ReactNode } from "react";
+import { FileText, Layers, Train, Truck } from "lucide-react";
import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { BookingFormInputValues, type BookingFormValues } from "./schema";
-import { OptionCard, OptionFieldError, StepHeader } from "./shared";
+import {
+ fieldStyles,
+ OptionCard,
+ OptionFieldError,
+ StepCard,
+ StepHeader,
+ StepLabel,
+} from "./shared";
import type { Freight } from "@edr/types";
@@ -61,10 +69,11 @@ export function Step2ServiceType({
const showServiceSections =
includesCustoms || includesFirstMile || includesLastMile;
return (
-
+
}
title="Service Type"
- description="Select the service combination and configure trucking options."
+ description="Choose the service combination, then configure your trucking options."
/>
(
-
+
{referenceData?.service
.filter((s) => s.canBeBookedAlone)
- .map((s) => {
- return (
-
field.onChange(s.id)}
- >
-
-
-
- {s.serviceName}
-
- {s.description}
-
-
- );
- })}
+ .map((s) => (
+
field.onChange(s.id)}
+ icon={ }
+ iconBg="#EEF0FB"
+ iconColor="#4F46E5"
+ title={s.serviceName}
+ description={s.description}
+ />
+ ))}
@@ -98,185 +103,201 @@ export function Step2ServiceType({
/>
{showServiceSections && (
-
+
+ Trucking & customs options
{/* First Mile */}
{includesFirstMile && (
-
-
(
-
-
-
-
-
- First Mile — Pick-up
-
-
- Truck pick-up from your premises (Door to Port) to the
- origin rail yard.
-
-
-
-
{
- const value = e.currentTarget.checked;
- field.onChange(value);
- if (!value) {
- form.setValue("firstMile.pickUpAddress", "", {
- shouldDirty: true,
- shouldValidate: true,
- });
- }
- }}
- color="edr-green"
- />
-
- )}
- />
- {firstMileEnabled && (
- (
- (
+ }
+ title="First Mile — Pick-up"
+ description="Truck pick-up from your premises (Door to Port) to the origin rail yard."
+ checked={field.value}
+ onChange={(value) => {
+ field.onChange(value);
+ if (!value) {
+ form.setValue("firstMile.pickUpAddress", "", {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+ }
+ }}
+ >
+ {firstMileEnabled && (
+ (
+
+ )}
/>
)}
- />
+
)}
-
+ />
)}
{/* Last Mile */}
{includesLastMile && (
-
-
(
-
-
-
-
-
- Last Mile — Delivery
-
-
- Truck delivery from the destination rail yard to the
- final address (Port to Door).
-
-
-
-
{
- const value = e.currentTarget.checked;
- field.onChange(value);
- if (!value) {
- form.setValue("lastMile.deliveryAddress", "", {
- shouldDirty: true,
- shouldValidate: true,
- });
- form.setValue("equipmentReturn", "with_return", {
- shouldDirty: true,
- });
- }
- }}
- color="edr-green"
- />
-
- )}
- />
- {lastMileEnabled && (
- (
- (
+ }
+ title="Last Mile — Delivery"
+ description="Truck delivery from the destination rail yard to the final address (Port to Door)."
+ checked={field.value}
+ onChange={(value) => {
+ field.onChange(value);
+ if (!value) {
+ form.setValue("lastMile.deliveryAddress", "", {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+ form.setValue("equipmentReturn", "with_return", {
+ shouldDirty: true,
+ });
+ }
+ }}
+ >
+ {lastMileEnabled && (
+ (
+
+ )}
/>
)}
- />
+
)}
-
+ />
)}
{/* Equipment Return */}
{includesLastMile && lastMileEnabled && (
-
-
(
-
-
-
Equipment Return
-
- {field.value === "with_return"
- ? "Container returned to EDR after unloading."
- : "Container retained by the customer after delivery."}
-
-
-
{
- field.onChange(
- e.currentTarget.checked
- ? "with_return"
- : "without_return",
- );
- }}
- color="edr-green"
- />
-
- )}
- />
-
+ (
+ }
+ title="Equipment Return"
+ description={
+ field.value === "with_return"
+ ? "Container returned to EDR after unloading."
+ : "Container retained by the customer after delivery."
+ }
+ checked={field.value === "with_return"}
+ onChange={(v) =>
+ field.onChange(v ? "with_return" : "without_return")
+ }
+ />
+ )}
+ />
)}
{/* Customs Clearing */}
{includesCustoms && (
-
-
(
-
-
-
-
-
- Customs Clearing Service
-
-
- EDR handles customs documentation and clearance on
- your behalf.
-
-
-
-
field.onChange(e.currentTarget.checked)}
- color="edr-green"
- />
-
- )}
- />
-
+ (
+ }
+ title="Customs Clearing Service"
+ description="EDR handles customs documentation and clearance on your behalf."
+ checked={field.value}
+ onChange={(v) => field.onChange(v)}
+ />
+ )}
+ />
)}
-
+
)}
-
+
+ );
+}
+
+function ServiceToggle({
+ icon,
+ title,
+ description,
+ checked,
+ onChange,
+ children,
+}: {
+ icon: ReactNode;
+ title: string;
+ description: string;
+ checked: boolean;
+ onChange: (v: boolean) => void;
+ children?: ReactNode;
+}) {
+ return (
+
+
+
+
+ {icon}
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+ onChange(e.currentTarget.checked)}
+ color="edr-green"
+ size="md"
+ style={{ flexShrink: 0 }}
+ />
+
+ {children}
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
index e9d4a387e..dd5df7d9c 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
@@ -1,6 +1,6 @@
import type { Freight } from "@edr/types";
-import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
-import { Flame, MapPin, Snowflake } from "lucide-react";
+import { Box, Divider, Group, Skeleton, Stack, Switch, Text } from "@mantine/core";
+import { Flame, MapPin, Route as RouteIcon, Snowflake } from "lucide-react";
import { useEffect, useMemo } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import {
@@ -8,7 +8,7 @@ import {
type BookingFormValues,
getRouteDirection,
} from "./schema";
-import { SelectField, StepHeader, StepLabel } from "./shared";
+import { SelectField, StepCard, StepHeader, StepLabel } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -63,7 +63,6 @@ export function Step4Route({
const origin = referenceData?.yard.find((y) => y.id === originYard);
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
const direction = getRouteDirection(origin, dest);
- console.log({ yardOptions, originYard, destinationYard, direction, origin, dest });
const directionStyle: Record
= {
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
@@ -85,10 +84,11 @@ export function Step4Route({
const stationSelectDisabled = yardOptions.length === 0;
return (
-
+
}
title="Route"
- description="Select the origin and destination yards."
+ description="Choose the origin and destination yards for your shipment."
/>
{isLoading ? (
@@ -96,7 +96,7 @@ export function Step4Route({
) : (
Route
-
+
)}
-
+
-
+
Cargo handling
+
(
-
-
-
-
-
Hazardous Material
-
- Applies a Hazard Surcharge to the final bill.
-
-
-
-
field.onChange(e.currentTarget.checked)}
- color="edr-green"
- />
-
+ }
+ iconBg="#FBEAE7"
+ iconColor="#C0392B"
+ title="Hazardous Material"
+ description="Applies a hazard surcharge to the final bill."
+ checked={field.value}
+ onChange={(v) => field.onChange(v)}
+ />
)}
/>
(
-
-
-
-
-
Refrigerated Cargo
-
- Temperature-controlled transport applies a Refrigerator
- Surcharge.
-
-
-
-
field.onChange(e.currentTarget.checked)}
- color="edr-green"
- />
-
+ }
+ iconBg="#E9F0F8"
+ iconColor="#2E5B96"
+ title="Refrigerated Cargo"
+ description="Temperature-controlled transport applies a refrigeration surcharge."
+ checked={field.value}
+ onChange={(v) => field.onChange(v)}
+ />
)}
/>
-
-
+
+
+ );
+}
+
+function ToggleRow({
+ icon,
+ iconBg,
+ iconColor,
+ title,
+ description,
+ checked,
+ onChange,
+}: {
+ icon: React.ReactNode;
+ iconBg: string;
+ iconColor: string;
+ title: string;
+ description: string;
+ checked: boolean;
+ onChange: (v: boolean) => void;
+}) {
+ return (
+
+
+
+ {icon}
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+ onChange(e.currentTarget.checked)}
+ color="edr-green"
+ size="md"
+ />
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
index 47e2fa2ff..ce40c6082 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
@@ -5,7 +5,6 @@ import {
ActionIcon,
Button,
Skeleton,
- InputLabel,
Text,
TextInput,
} from "@mantine/core";
@@ -17,9 +16,11 @@ import {
} from "./schema";
import {
AlertBox,
+ fieldStyles,
OptionCard,
OptionFieldError,
SelectField,
+ StepCard,
StepHeader,
StepLabel,
} from "./shared";
@@ -116,70 +117,66 @@ export function Step5CargoDetails({
if (isLoading) {
return (
-
+
}
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
/>
-
+
+
);
}
return (
-
+
}
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
/>
{/* Cargo Type */}
-
Cargo Type *
+
Cargo Type *
(
-
+
}
+ iconBg="#ECF6F1"
+ iconColor="#0A6F4D"
+ title="Containerized"
+ description="Pre-packed containerized cargo (20ft / 40ft)."
onClick={() => {
field.onChange("container");
form.setValue("cargoTypePath", [], { shouldDirty: true });
}}
- >
-
-
Containerized
-
- Pre-packed containerized cargo (20ft / 40ft).
-
-
+ />
}
+ iconBg="#FDF3E0"
+ iconColor="#C77F09"
+ title="General Cargo"
+ description="Bulk commodities or break-bulk cargo."
onClick={() => {
field.onChange("bulk");
form.setValue("containers", [], { shouldDirty: true });
}}
- >
-
-
-
-
General Cargo
-
- Bulk commodities or break-bulk cargo.
-
-
+ />
@@ -201,7 +198,8 @@ export function Step5CargoDetails({
placeholder="0.00"
leftSection={
}
error={fieldState.error?.message}
- radius="md"
+ radius={10}
+ styles={fieldStyles}
min={0}
step={0.01}
/>
@@ -484,6 +482,6 @@ export function Step5CargoDetails({
})()}
>
)}
-
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx
index 278698f83..387034ec5 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx
@@ -19,6 +19,7 @@ import {
type BookingFormValues,
} from "./schema";
import { StepHeader } from "./shared";
+import { ClipboardCheck } from "lucide-react";
import type { Freight } from "@/types";
import type { GeneratePriceResponse } from "@/services/bookings.service";
@@ -154,6 +155,7 @@ export function Step8Review({
return (
}
title="Review & Submit"
description="Confirm your contract request before sending it for EDR staff review."
/>
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx
new file mode 100644
index 000000000..ae1615b94
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx
@@ -0,0 +1,61 @@
+import { Button, type ButtonProps } from "@mantine/core";
+import { CreditCard } from "lucide-react";
+
+import { Freight } from "@edr/types";
+
+import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
+import { priceTotal } from "../BookingDetailPage/utils";
+import { useBookingPayment } from "./useBookingPayment";
+
+interface PayNowButtonProps {
+ booking: Freight.IBooking;
+ label?: string;
+ size?: ButtonProps["size"];
+ fullWidth?: boolean;
+}
+
+/**
+ * Self-contained "Pay now" action: shows the payment-method modal in place
+ * instead of navigating to the booking detail page. Drop it into list rows,
+ * cards, or anywhere a payable booking surfaces.
+ */
+export function PayNowButton({
+ booking,
+ label = "Pay now",
+ size = "xs",
+ fullWidth,
+}: PayNowButtonProps) {
+ const pay = useBookingPayment(booking.id);
+ const pricing = booking.pricingBreakdown;
+
+ return (
+ <>
+ }
+ onClick={(e) => {
+ // Don't let a surrounding row-click handler fire.
+ e.stopPropagation();
+ pay.open();
+ }}
+ >
+ {label}
+
+
+
+ >
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts b/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts
new file mode 100644
index 000000000..ddaa05906
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts
@@ -0,0 +1,54 @@
+import { useMutation } from "@tanstack/react-query";
+import { useState } from "react";
+
+import { api } from "@/services/api";
+import {
+ paymentsService,
+ type PaymentMethod,
+} from "@/services/payments.service";
+
+/**
+ * Shared payment flow for a single booking: opens the method modal, fires
+ * POST /payments/initiate, and redirects the browser to the provider (or the
+ * fallback checkout page). Reused by the booking detail page, the booking list,
+ * and the home page so "Pay now" behaves identically everywhere.
+ */
+export function useBookingPayment(bookingId: string) {
+ const [modalOpen, setModalOpen] = useState(false);
+
+ const mutation = useMutation({
+ mutationFn: (method: PaymentMethod) =>
+ api.payments.initiate.call({ bookingId, method }),
+ onSuccess: (data, method) => {
+ const redirectUrl =
+ data?.clientAction?.type === "REDIRECT" && data.clientAction.url
+ ? data.clientAction.url
+ : paymentsService.checkoutUrl({ bookingId, method });
+ window.location.href = redirectUrl;
+ },
+ });
+
+ const open = () => setModalOpen(true);
+
+ const close = () => {
+ if (!mutation.isPending) {
+ setModalOpen(false);
+ mutation.reset();
+ }
+ };
+
+ const error = mutation.isError
+ ? mutation.error instanceof Error
+ ? mutation.error.message
+ : "Could not start payment. Please try again."
+ : null;
+
+ return {
+ modalOpen,
+ open,
+ close,
+ processing: mutation.isPending,
+ error,
+ confirm: (method: PaymentMethod) => mutation.mutate(method),
+ };
+}
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 (
+
+ {children}
+
+ );
+}
+
+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.
+
+
+ Try again
+
+
+ );
+}
+
+// ── 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/pages/payments/PaymentFailurePage.tsx b/apps/edr-freight-web/portal/src/pages/payments/PaymentFailurePage.tsx
new file mode 100644
index 000000000..6b577ac06
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/payments/PaymentFailurePage.tsx
@@ -0,0 +1,43 @@
+import { XCircle } from "lucide-react";
+import { useNavigate } from "react-router-dom";
+import { Button } from "@edr/ui-common";
+
+/**
+ * Public page the payment provider redirects the browser to after a failed or
+ * cancelled payment (PAYMENT_FAILURE_URL). Generic — it explains nothing was
+ * charged and sends the customer back to their bookings to retry from "Pay now".
+ */
+export default function PaymentFailurePage() {
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+
+
+
+ Payment was not completed
+
+
+ Your payment didn't go through and you haven't been charged. You can
+ try again from your booking using "Pay now".
+
+
+ navigate("/bookings")}>
+ Back to My Bookings
+
+ navigate("/")}
+ >
+ Back to home
+
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx b/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx
new file mode 100644
index 000000000..4b2d766e7
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx
@@ -0,0 +1,43 @@
+import { CheckCircle2 } from "lucide-react";
+import { useNavigate } from "react-router-dom";
+import { Button } from "@edr/ui-common";
+
+/**
+ * Public page the payment provider redirects the browser to after a successful
+ * payment (PAYMENT_RETURN_URL). Generic — it confirms success and points the
+ * customer back to their bookings, where the booking reflects the paid state.
+ */
+export default function PaymentSuccessPage() {
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+
+
+
+ Payment successful
+
+
+ Thank you — your payment has been received. Your booking will be
+ updated shortly and is now confirmed for scheduling.
+
+
+ navigate("/bookings")}>
+ Go to My Bookings
+
+ navigate("/")}
+ >
+ Back to home
+
+
+
+
+
+ );
+}
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 9933fa227..4f4154faf 100644
--- a/apps/edr-freight-web/portal/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts
@@ -54,6 +54,8 @@ export interface SignContractPayload {
export interface BookingListFilter {
status?: string;
+ /** Comma-separated statuses (overrides `status` when set). */
+ statuses?: string;
page?: number;
pageSize?: number;
sortBy?: string;
@@ -71,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;