diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index a8f03027a..d8413bf71 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -69,8 +69,8 @@ jobs:
fi
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api")
- echo "$CHANGED" | grep -q "^apps/edr-freight-web-portal/" && SERVICES+=("freight-portal")
- echo "$CHANGED" | grep -q "^apps/edr-freight-web-backoffice/" && SERVICES+=("freight-backoffice")
+ echo "$CHANGED" | grep -q "^apps/edr-freight-web/portal/" && SERVICES+=("freight-portal")
+ echo "$CHANGED" | grep -q "^apps/edr-freight-web/backoffice/" && SERVICES+=("freight-backoffice")
echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
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/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index 4d855e055..50bec92e9 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -44,7 +44,6 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
-import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
@@ -56,6 +55,7 @@ import { ContainersModule } from './modules/container-management/containers.modu
import { CargoesModule } from './modules/cargoes/cargoes.module';
import { RoutesModule } from './modules/routes/routes.module';
import { OverviewModule } from './modules/overview/overview.module';
+import { VehiclesModule } from './modules/vehicles/vehicles.module';
@Module({
imports: [
@@ -113,12 +113,12 @@ import { OverviewModule } from './modules/overview/overview.module';
CargoesModule,
RoutesModule,
OverviewModule,
+ VehiclesModule,
],
providers: [
EdrOrgSeeder,
DemoUsersSeeder,
FreightStaffUsersSeeder,
- DemoBookingsSeeder,
PricingDataSeeder,
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
@@ -131,9 +131,6 @@ export class AppModule implements OnApplicationBootstrap {
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
- private readonly demoBookingsSeeder: DemoBookingsSeeder,
- private readonly pricingDataSeeder: PricingDataSeeder,
- private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
) { }
@@ -144,11 +141,11 @@ export class AppModule implements OnApplicationBootstrap {
await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run();
await this.freightStaffUsersSeeder.run();
- await this.demoBookingsSeeder.run();
- await this.pricingDataSeeder.run();
- await this.fileUploadSettingsSeeder.run();
- // Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
- // Each block self-guards on an empty-table check, so this is safe every boot.
+ // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
+ // FileUploadSettingsSeeder) are intentionally disabled — they stay
+ // registered as providers but are not run. Re-inject + call .run() to enable.
+ // demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
+ // rules are disabled inside the seeder). Kept running for the staff users.
await this.demoFreightDataSeeder.run();
}
}
diff --git a/apps/edr-freight-api/src/migrations/1770000000000-CreateVehiclesTable.ts b/apps/edr-freight-api/src/migrations/1770000000000-CreateVehiclesTable.ts
new file mode 100644
index 000000000..2cf09c9f5
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1770000000000-CreateVehiclesTable.ts
@@ -0,0 +1,39 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class CreateVehiclesTable1770000000000 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ DO $$
+ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'vehicles' AND table_schema = 'freight') THEN
+ CREATE TABLE freight.vehicles (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ plate_number VARCHAR NOT NULL UNIQUE,
+ registration_number VARCHAR NOT NULL UNIQUE,
+ vehicle_type VARCHAR NOT NULL,
+ manufacturer VARCHAR NOT NULL,
+ model VARCHAR NOT NULL,
+ year INTEGER NOT NULL,
+ fuel_type VARCHAR NOT NULL,
+ capacity NUMERIC NOT NULL,
+ status VARCHAR DEFAULT 'ACTIVE' NOT NULL,
+ description TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ deleted_at TIMESTAMP NULL
+ );
+
+ CREATE INDEX idx_vehicles_plate_number ON freight.vehicles(plate_number);
+ CREATE INDEX idx_vehicles_registration_number ON freight.vehicles(registration_number);
+ CREATE INDEX idx_vehicles_status ON freight.vehicles(status);
+ CREATE INDEX idx_vehicles_vehicle_type ON freight.vehicles(vehicle_type);
+ CREATE INDEX idx_vehicles_manufacturer ON freight.vehicles(manufacturer);
+ END IF;
+ END $$;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.vehicles CASCADE;`);
+ }
+}
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-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts
index 9c92a036d..bcfa643b6 100644
--- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts
+++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts
@@ -18,7 +18,8 @@ import {
export class PaymentClientService {
private readonly logger = new Logger(PaymentClientService.name);
private readonly baseUrl = (
- process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
+ // process.env.PAYMENT_API_URL ??
+ "https://paymentcallback.triaplc.com"
).replace(/\/$/, "");
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
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-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts
new file mode 100644
index 000000000..fb5c4e92b
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts
@@ -0,0 +1,32 @@
+import { IsString, IsEnum, IsNumber, IsOptional } from 'class-validator';
+import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity';
+
+export class CreateVehicleDto {
+ @IsString()
+ plateNumber!: string;
+
+ @IsEnum(VehicleType)
+ vehicleType!: VehicleType;
+
+ @IsString()
+ manufacturer!: string;
+
+ @IsString()
+ model!: string;
+
+ @IsNumber()
+ year!: number;
+
+ @IsEnum(FuelType)
+ fuelType!: FuelType;
+
+ @IsNumber()
+ capacity!: number;
+
+ @IsEnum(VehicleStatus)
+ status!: VehicleStatus;
+
+ @IsOptional()
+ @IsString()
+ description?: string;
+}
diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/update-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/update-vehicle.dto.ts
new file mode 100644
index 000000000..953917b2f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/vehicles/dto/update-vehicle.dto.ts
@@ -0,0 +1,4 @@
+import { PartialType } from '@nestjs/mapped-types';
+import { CreateVehicleDto } from './create-vehicle.dto';
+
+export class UpdateVehicleDto extends PartialType(CreateVehicleDto) {}
diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts
new file mode 100644
index 000000000..773e8051a
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts
@@ -0,0 +1,64 @@
+import { Entity, Column, Index } from 'typeorm';
+import { BaseEntity } from '@edr/api-common';
+
+export enum VehicleType {
+ TRUCK = 'TRUCK',
+ VAN = 'VAN',
+ CAR = 'CAR',
+ BUS = 'BUS',
+ TRAILER = 'TRAILER',
+ TANKER = 'TANKER',
+ FLATBED = 'FLATBED',
+}
+
+export enum FuelType {
+ PETROL = 'PETROL',
+ DIESEL = 'DIESEL',
+ ELECTRIC = 'ELECTRIC',
+ HYBRID = 'HYBRID',
+}
+
+export enum VehicleStatus {
+ ACTIVE = 'ACTIVE',
+ MAINTENANCE = 'MAINTENANCE',
+ RETIRED = 'RETIRED',
+ OUT_OF_SERVICE = 'OUT_OF_SERVICE',
+}
+
+@Entity({ name: 'vehicles', schema: 'freight' })
+@Index(['plateNumber'])
+@Index(['registrationNumber'])
+@Index(['status'])
+@Index(['vehicleType'])
+@Index(['manufacturer'])
+export class Vehicle extends BaseEntity {
+ @Column({ name: 'plate_number', unique: true })
+ plateNumber!: string;
+
+ @Column({ name: 'registration_number', unique: true })
+ registrationNumber!: string;
+
+ @Column({ name: 'vehicle_type', type: 'varchar' })
+ vehicleType!: VehicleType;
+
+ @Column()
+ manufacturer!: string;
+
+ @Column()
+ model!: string;
+
+ @Column()
+ year!: number;
+
+ @Column({ name: 'fuel_type', type: 'varchar' })
+ fuelType!: FuelType;
+
+ @Column()
+ capacity!: number;
+
+ @Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE })
+ status!: VehicleStatus;
+
+ @Column({ type: 'text', nullable: true })
+ description!: string | null;
+}
diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts
new file mode 100644
index 000000000..24ff2d022
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts
@@ -0,0 +1,74 @@
+import {
+ Controller,
+ Get,
+ Post,
+ Patch,
+ Delete,
+ Param,
+ Body,
+ Query,
+ ParseUUIDPipe,
+} from '@nestjs/common';
+import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { FleetManage, FleetView } from '../../common/booking-guards';
+import { VehiclesService } from './vehicles.service';
+import { CreateVehicleDto } from './dto/create-vehicle.dto';
+import { UpdateVehicleDto } from './dto/update-vehicle.dto';
+
+@ApiTags('vehicles')
+@ApiBearerAuth()
+@Controller('vehicles')
+@FleetView()
+export class VehiclesController {
+ constructor(private readonly vehiclesService: VehiclesService) {}
+
+ @Post()
+ @FleetManage()
+ @ApiOperation({ summary: 'Create a new vehicle' })
+ create(@Body() createVehicleDto: CreateVehicleDto) {
+ return this.vehiclesService.create(createVehicleDto);
+ }
+
+ @Get()
+ @ApiOperation({ summary: 'Get all vehicles with filters' })
+ findAll(
+ @Query('search') search?: string,
+ @Query('status') status?: string,
+ @Query('page') page?: string,
+ @Query('limit') limit?: string,
+ @Query('sortBy') sortBy?: string,
+ @Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
+ ) {
+ return this.vehiclesService.findAll({
+ search,
+ status: status as any,
+ page: page ? parseInt(page) : undefined,
+ limit: limit ? parseInt(limit) : undefined,
+ sortBy,
+ sortOrder,
+ });
+ }
+
+ @Get(':id')
+ @ApiOperation({ summary: 'Get vehicle by id' })
+ findOne(@Param('id', ParseUUIDPipe) id: string) {
+ return this.vehiclesService.findById(id);
+ }
+
+ @Patch(':id')
+ @FleetManage()
+ @ApiOperation({ summary: 'Update a vehicle' })
+ update(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() updateVehicleDto: UpdateVehicleDto,
+ ) {
+ return this.vehiclesService.update(id, updateVehicleDto);
+ }
+
+ @Delete(':id')
+ @FleetManage()
+ @ApiOperation({ summary: 'Delete a vehicle' })
+ remove(@Param('id', ParseUUIDPipe) id: string) {
+ return this.vehiclesService.remove(id);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts
new file mode 100644
index 000000000..07aa4bd2f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts
@@ -0,0 +1,13 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { Vehicle } from './entities/vehicle.entity';
+import { VehiclesService } from './vehicles.service';
+import { VehiclesController } from './vehicles.controller';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([Vehicle])],
+ providers: [VehiclesService],
+ controllers: [VehiclesController],
+ exports: [VehiclesService],
+})
+export class VehiclesModule {}
diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts
new file mode 100644
index 000000000..9c5bad1e9
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts
@@ -0,0 +1,15 @@
+import { BaseRepository } from '@edr/api-common';
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { Vehicle } from './entities/vehicle.entity';
+
+@Injectable()
+export class VehiclesRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(Vehicle)
+ repository: Repository,
+ ) {
+ super(repository);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts
new file mode 100644
index 000000000..12970a8a8
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts
@@ -0,0 +1,109 @@
+import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { CreateVehicleDto } from './dto/create-vehicle.dto';
+import { UpdateVehicleDto } from './dto/update-vehicle.dto';
+import { Vehicle, VehicleStatus } from './entities/vehicle.entity';
+
+@Injectable()
+export class VehiclesService {
+ constructor(
+ @InjectRepository(Vehicle)
+ private readonly vehicleRepo: Repository,
+ ) {}
+
+ async create(dto: CreateVehicleDto): Promise {
+ const existing = await this.vehicleRepo.findOne({
+ where: { plateNumber: dto.plateNumber },
+ });
+
+ if (existing) {
+ throw new ConflictException(
+ `Vehicle with plate number ${dto.plateNumber} already exists`,
+ );
+ }
+
+ const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
+ const vehicle = this.vehicleRepo.create({
+ ...dto,
+ registrationNumber,
+ });
+
+ return this.vehicleRepo.save(vehicle);
+ }
+
+ async findAll(query: {
+ search?: string;
+ status?: VehicleStatus | string;
+ page?: number;
+ limit?: number;
+ sortBy?: string;
+ sortOrder?: 'ASC' | 'DESC';
+ } = {}): Promise<{ data: Vehicle[]; total: number; page: number; limit: number }> {
+ const page = query.page || 1;
+ const limit = query.limit || 10;
+ const skip = (page - 1) * limit;
+
+ const where: any = {};
+ if (query.status) where.status = query.status;
+
+ let qb = this.vehicleRepo.createQueryBuilder('v');
+
+ if (query.search) {
+ qb = qb.where(
+ 'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search',
+ { search: `%${query.search}%` },
+ );
+ }
+
+ if (query.status) {
+ qb = qb.andWhere('v.status = :status', { status: query.status });
+ }
+
+ const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes(
+ query.sortBy ?? '',
+ )
+ ? query.sortBy
+ : 'createdAt';
+ const sortOrder = (query.sortOrder ?? 'DESC').toUpperCase();
+
+ const [data, total] = await qb
+ .orderBy(`v.${sortBy}`, sortOrder as 'ASC' | 'DESC')
+ .skip(skip)
+ .take(limit)
+ .getManyAndCount();
+
+ return { data, total, page, limit };
+ }
+
+ async findById(id: string): Promise {
+ const vehicle = await this.vehicleRepo.findOne({ where: { id } });
+ if (!vehicle) {
+ throw new NotFoundException(`Vehicle ${id} not found`);
+ }
+ return vehicle;
+ }
+
+ async update(id: string, dto: UpdateVehicleDto): Promise {
+ const vehicle = await this.findById(id);
+
+ if (dto.plateNumber && dto.plateNumber !== vehicle.plateNumber) {
+ const existing = await this.vehicleRepo.findOne({
+ where: { plateNumber: dto.plateNumber },
+ });
+ if (existing) {
+ throw new ConflictException(
+ `Vehicle with plate number ${dto.plateNumber} already exists`,
+ );
+ }
+ }
+
+ Object.assign(vehicle, dto);
+ return this.vehicleRepo.save(vehicle);
+ }
+
+ async remove(id: string): Promise {
+ await this.findById(id);
+ await this.vehicleRepo.softDelete(id);
+ }
+}
diff --git a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts
index f4931f77b..b391d3f9e 100644
--- a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts
+++ b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts
@@ -42,8 +42,13 @@ export class DemoFreightDataSeeder {
async run() {
await this.dataSource.transaction(async (manager) => {
- await this.seedWagons(manager);
- await this.seedApprovalRules(manager);
+ // Demo freight data (wagons + approval rules) disabled — keep only the
+ // 4 staff users. The seeders are retained for easy re-enabling; flip
+ // SEED_DEMO_FREIGHT_DATA=true to run them.
+ if (process.env.SEED_DEMO_FREIGHT_DATA === 'true') {
+ await this.seedWagons(manager);
+ await this.seedApprovalRules(manager);
+ }
await this.seedStaffUsers(manager);
});
}
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/DraftBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx
index f20679ab9..351ff6f5f 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx
@@ -23,6 +23,7 @@ import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
+import type { SubmitBookingResponse } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { REQUIRED_DOC_FIELDS } from "./constants";
@@ -60,6 +61,8 @@ export function DraftBookingView({
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
const [docError, setDocError] = useState("");
+ const [priceChangeModal, setPriceChangeModal] =
+ useState(null);
const anyFileSelected = Object.values(selectedFiles).some(Boolean);
const uploadedCodes = useMemo(
@@ -72,9 +75,11 @@ export function DraftBookingView({
const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length;
const { data: generatedPricing } = useQuery(
- api.bookings.generatePrice.queryOptions({ input: { id: booking.id },
-
- enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
+ api.bookings.generatePrice.queryOptions({
+ input: { id: booking.id },
+ enabled:
+ (booking.status === "DRAFT" || booking.status === "CHANGES_REQUESTED") &&
+ !booking.pricingBreakdown,
}),
);
const pricing = (booking.pricingBreakdown ??
@@ -82,8 +87,17 @@ export function DraftBookingView({
null) as Freight.PricingBreakdown | null;
const uploadMutation = useMutation({
- mutationFn: (files: Record) =>
- api.bookings.uploadDocuments.call({ id: booking.id, files }),
+ mutationFn: async (files: Record) => {
+ if (booking.status === "CHANGES_REQUESTED") {
+ const result = await api.bookings.update.call({
+ id: booking.id,
+ dto: {},
+ documents: files,
+ });
+ return result.booking;
+ }
+ return api.bookings.uploadDocuments.call({ id: booking.id, files });
+ },
onSuccess: () => {
setSelectedFiles({});
setDocError("");
@@ -93,7 +107,20 @@ export function DraftBookingView({
const submitMutation = useMutation({
mutationFn: () => api.bookings.submit.call({ id: booking.id }),
+ onSuccess: (result) => {
+ if (result.priceChanged) {
+ setPriceChangeModal(result);
+ return;
+ }
+ onBookingUpdated();
+ queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
+ },
+ });
+
+ const confirmSubmitMutation = useMutation({
+ mutationFn: () => api.bookings.confirmSubmit.call({ id: booking.id }),
onSuccess: () => {
+ setPriceChangeModal(null);
onBookingUpdated();
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
@@ -155,7 +182,12 @@ export function DraftBookingView({
/>
@@ -163,7 +195,9 @@ export function DraftBookingView({
booking.latestChangeRequestNote ? (
navigate(`/bookings/${booking.id}/edit`)}
+ onAction={() =>
+ navigate(`/bookings/${booking.id}/edit?section=documents`)
+ }
>
{booking.latestChangeRequestNote}
@@ -260,6 +294,8 @@ export function DraftBookingView({
const isUploaded = uploadedCodes.has(doc.key);
const selected = selectedFiles[doc.key];
const file = booking.files?.find((f) => f.code === doc.key);
+ const allowReplace =
+ !isUploaded || booking.status === "CHANGES_REQUESTED";
return (
}
/>
) : (
<>
+ {isUploaded && (
+ }
+ />
+ )}
{
fileInputRefs.current[doc.key] = el;
@@ -318,7 +360,7 @@ export function DraftBookingView({
},
}}
>
- {selected ? "Change" : "Add"}
+ {selected ? "Change" : isUploaded ? "Replace" : "Add"}
{selected && (
+ setPriceChangeModal(null)}
+ title={Price has changed }
+ radius="lg"
+ centered
+ >
+ {priceChangeModal && (
+
+
+ {priceChangeModal.message ??
+ "The booking price has been updated. Confirm to submit with the new total."}
+
+ {priceChangeModal.previousTotalAmount !== undefined && (
+
+
+ Previous total
+
+
+ {priceChangeModal.previousTotalAmount.toLocaleString()}{" "}
+ {priceChangeModal.currency}
+
+
+ )}
+
+ New total
+
+ {priceChangeModal.totalAmount.toLocaleString()}{" "}
+ {priceChangeModal.currency}
+
+
+ {priceChangeModal.lineItems && priceChangeModal.lineItems.length > 0 && (
+
+ {priceChangeModal.lineItems.map((item) => (
+
+
+ {item.description}
+
+
+ {item.amount.toLocaleString()} {item.currency}
+
+
+ ))}
+
+ )}
+
+ setPriceChangeModal(null)}
+ >
+ Review later
+
+ confirmSubmitMutation.mutate()}
+ >
+ Confirm & submit
+
+
+
+ )}
+
+
setCancelDialogOpen(false)}
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/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx
index b81cc455a..a778bf8e5 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx
@@ -15,6 +15,7 @@ import {
SimpleGrid,
Stack,
Switch,
+ Tabs,
Text,
Textarea,
TextInput,
@@ -36,7 +37,7 @@ import {
} from "lucide-react";
import { useMemo, useRef, type ReactNode } from "react";
import { Controller, useForm } from "react-hook-form";
-import { useNavigate, useParams } from "react-router-dom";
+import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
CountChip,
DocRow,
@@ -52,12 +53,33 @@ import {
type BookingFormValues,
} from "./new-booking-form/schema";
import { SelectField } from "./new-booking-form/shared";
-import { Step5CargoDetails } from "./new-booking-form/steps";
+import { PaymentCurrencyField } from "./new-booking-form/payment-currency-field";
+import { Step5CargoDetails, StepScheduling } from "./new-booking-form/steps";
-function yardNameFromBooking(
- yard: { label?: string; code?: string; name?: string } | undefined | null,
+const EDIT_SECTIONS = [
+ "service",
+ "route",
+ "cargo",
+ "schedule",
+ "documents",
+ "notes",
+] as const;
+
+type EditSection = (typeof EDIT_SECTIONS)[number];
+
+function isEditSection(value: string | null): value is EditSection {
+ return EDIT_SECTIONS.includes(value as EditSection);
+}
+
+function yardIdFromBooking(
+ yard: Freight.IYard | null | undefined,
+ referenceData: Freight.BookingReferenceData,
): string {
- return yard?.label ?? yard?.name ?? yard?.code ?? "";
+ if (yard?.id) return yard.id;
+ const label = yard?.label ?? "";
+ return (
+ referenceData.yard.find((y) => y.name === label || y.id === label)?.id ?? ""
+ );
}
/** Fallback container type for a size, used only when a booking row has no
@@ -108,14 +130,20 @@ function mapBookingToFormValues(
booking.equipmentReturn === "WITH_RETURN"
? "with_return"
: "without_return",
- originYard: yardNameFromBooking(booking.originYard),
- destinationYard: yardNameFromBooking(booking.destinationYard),
+ originYard: yardIdFromBooking(booking.originYard, referenceData),
+ destinationYard: yardIdFromBooking(booking.destinationYard, referenceData),
cargoType: booking.freightType === "BULK" ? "bulk" : "container",
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
isHazardous: booking.isHazardous ?? false,
isRefrigerated: booking.isRefrigerated ?? false,
shippingLine: (booking as any).shippingLine?.name ?? "",
consolidationEnabled: booking.allowConsolidation ?? false,
+ paymentCurrency:
+ booking.paymentCurrency === "ETB" ? "ETB" : "USD",
+ scheduledDate: booking.scheduledDate
+ ? new Date(booking.scheduledDate).toISOString().slice(0, 10)
+ : "",
+ trainScheduleId: (booking as { trainScheduleId?: string }).trainScheduleId ?? "",
notes: "",
containers: [],
} as BookingFormInputValues;
@@ -237,9 +265,20 @@ const DIRECTION_LABEL: Record = {
export default function EditBookingPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const docInputRefs = useRef>({});
+ const sectionParam = searchParams.get("section");
+ const activeSection: EditSection = isEditSection(sectionParam)
+ ? sectionParam
+ : "service";
+
+ function setSection(section: EditSection) {
+ setSearchParams({ section });
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ }
+
const bookingQuery = useQuery(
api.bookings.get.queryOptions({
input: { id: id! },
@@ -269,18 +308,17 @@ export default function EditBookingPage() {
const updateMutation = useMutation({
mutationFn: async (payload: Partial) => {
- const result = await api.bookings.update.call({ id: id!, dto: payload });
-
- // Upload any newly attached documents against the existing booking.
const documents = (form.getValues("documents") ?? {}) as BookingDocuments;
- const hasDocuments = Object.values(documents).some((value) =>
- Array.isArray(value) ? value.length > 0 : Boolean(value),
- );
- if (hasDocuments) {
- await api.bookings.uploadDocuments.call({ id: id!, files: documents });
+ const newDocuments: BookingDocuments = {};
+ for (const [key, value] of Object.entries(documents)) {
+ if (value) newDocuments[key] = value;
}
-
- return result;
+ return api.bookings.update.call({
+ id: id!,
+ dto: payload,
+ documents:
+ Object.keys(newDocuments).length > 0 ? newDocuments : undefined,
+ });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
@@ -304,9 +342,9 @@ export default function EditBookingPage() {
);
const direction = useMemo(() => {
- const origin = referenceData?.yard.find((y) => y.name === originYard);
+ const origin = referenceData?.yard.find((y) => y.id === originYard);
const destination = referenceData?.yard.find(
- (y) => y.name === destinationYard,
+ (y) => y.id === destinationYard,
);
return getRouteDirection(origin, destination);
}, [originYard, destinationYard, referenceData]);
@@ -314,7 +352,7 @@ export default function EditBookingPage() {
const yardOptions = useMemo(() => {
if (!referenceData?.yard) return [];
return referenceData.yard.map((y) => ({
- value: y.name,
+ value: y.id,
label: y.name,
country: y.country,
}));
@@ -338,14 +376,9 @@ export default function EditBookingPage() {
};
const handleSubmit = form.handleSubmit((data) => {
- const yards = referenceData?.yard ?? [];
- const services = referenceData?.service ?? [];
const shippingLines = referenceData?.shipping_line ?? [];
const containerGroups = referenceData?.containers ?? [];
- const findYardId = (name: string): string =>
- yards.find((y) => y.name === name)?.id ?? "";
-
const findShippingLineId = (name: string): string | undefined =>
shippingLines.find((l) => l.name === name)?.id;
@@ -369,10 +402,15 @@ export default function EditBookingPage() {
)
: Number(data.cargoWeight || 0);
- const selectedSvc = services.find((s) => s.id === data.serviceTypeId);
+ const selectedSvc = referenceData?.service.find(
+ (s) => s.id === data.serviceTypeId,
+ );
const apiPayload: Partial = {
- scheduledDate: new Date().toISOString().slice(0, 10),
+ scheduledDate: data.scheduledDate
+ ? new Date(data.scheduledDate).toISOString()
+ : undefined,
+ trainScheduleId: data.trainScheduleId || undefined,
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: data.serviceTypeId,
@@ -380,8 +418,8 @@ export default function EditBookingPage() {
data.equipmentReturn === "with_return"
? "WITH_RETURN"
: "WITHOUT_RETURN",
- originYardId: findYardId(data.originYard),
- destinationYardId: findYardId(data.destinationYard),
+ originYardId: data.originYard,
+ destinationYardId: data.destinationYard,
tradeDirection:
direction === "EXPORT"
? "EXPORT"
@@ -391,9 +429,8 @@ export default function EditBookingPage() {
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
- paymentCurrency: "USD",
+ paymentCurrency: data.paymentCurrency,
allowConsolidation: data.consolidationEnabled,
- // @ts-ignore
freightType:
data.cargoType === "container"
? ("CONTAINER" as const)
@@ -505,9 +542,34 @@ export default function EditBookingPage() {
)}
-
- {/* ── Section 1: Service ── */}
-
+ {booking.status === "CHANGES_REQUESTED" && (
+ } radius="md" mt="lg">
+
+ Staff requested changes
+
+
+ Update the sections below and save. Then return to the booking page to
+ resubmit for review.
+
+
+ )}
+
+ value && setSection(value as EditSection)}
+ mt="xl"
+ >
+
+ Service
+ Route
+ Cargo
+ Schedule
+ Documents
+ Notes
+
+
+
+
+
+
{(selectedService?.includesFirstMile ||
selectedService?.includesLastMile ||
selectedService?.includesCustoms) && (
@@ -648,10 +712,9 @@ export default function EditBookingPage() {
)}
+
-
-
- {/* ── Section 3: Route ── */}
+
+
-
-
- {/* ── Section 4: Cargo ── */}
+
+
-
+
+
+
- {/* ── Section 5: Documents ── */}
+
+
-
-
- {/* ── Section 6: Notes ── */}
+
-
+
+
{/* ── Submit ── */}
= {
- 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") {
+ if (status === "CHANGES_REQUESTED") {
return (
onNavigate(`/bookings/${id}`)}
+ color="orange"
+ rightSection={ }
+ onClick={() => onNavigate(`/bookings/${id}/edit?section=documents`)}
>
- Pay
+ Review changes
);
}
- 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?.meta?.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 +395,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 +461,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 +486,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 +530,6 @@ export default function MyBookings() {
},
];
- const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
-
return (
@@ -305,81 +540,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 +657,8 @@ export default function MyBookings() {
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
+ manualPagination: true,
+ pageCount,
}}
containerClassName="border-0 shadow-none rounded-none"
footer={DataTableFooter}
@@ -398,6 +666,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/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
index a7b3cab11..fd068e8ef 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
@@ -1,7 +1,9 @@
import { api } from "@/services/api";
+import { hasAllRequiredDocuments } from "@/services/booking-form-data";
import type {
CreateBookingPayload,
GeneratePriceResponse,
+ SubmitBookingResponse,
} from "@/services/bookings.service";
import { zodResolver } from "@hookform/resolvers/zod";
import {
@@ -35,6 +37,7 @@ import {
getRouteDirection,
initialBookingFormValues,
stepFields,
+ type BookingDocuments,
type BookingFormValues,
} from "./new-booking-form/schema";
import { StepIndicator } from "./new-booking-form/StepIndicator";
@@ -48,6 +51,8 @@ import {
StepScheduling,
} from "./new-booking-form/steps";
+type PriceModalMode = "submit" | "draft";
+
export default function NewBookingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -91,68 +96,61 @@ export default function NewBookingPage() {
);
}
- const createMutation = useMutation({
- mutationFn: async (payload: CreateBookingPayload) => {
- const booking = await api.bookings.create.call(payload);
+ const persistAndPriceMutation = useMutation({
+ mutationFn: async ({
+ payload,
+ mode,
+ existingBookingId,
+ }: {
+ payload: CreateBookingPayload;
+ mode: PriceModalMode;
+ existingBookingId: string | null;
+ }) => {
+ const documents = (form.getValues("documents") ?? {}) as BookingDocuments;
+ let bookingId = existingBookingId;
- // Documents can't ride along with creation — upload them against the
- // new booking id once it exists. Optional here; the booking detail page
- // remains the catch-all for any docs the user skips.
- const documents = form.getValues("documents") ?? {};
- const hasDocuments = Object.values(documents).some((value) =>
- Array.isArray(value) ? value.length > 0 : Boolean(value),
- );
- if (hasDocuments) {
- await api.bookings.uploadDocuments.call({
- id: booking.id,
- files: documents,
- });
+ if (bookingId) {
+ await api.bookings.update.call({ id: bookingId, dto: payload, documents });
+ } else {
+ const booking = await api.bookings.create.call({ payload, documents });
+ bookingId = booking.id;
}
- return booking;
+ const pricing = await api.bookings.generatePrice.call({ id: bookingId });
+ return { bookingId, pricing, mode };
},
- onSuccess: (booking) => {
- queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
- navigate(`/bookings/${booking.id}`);
- },
- });
-
- const createAndPriceMutation = useMutation({
- mutationFn: async (payload: CreateBookingPayload) => {
- const booking = await api.bookings.create.call(payload);
-
- const documents = form.getValues("documents") ?? {};
- const hasDocs = Object.values(documents).some((value) =>
- Array.isArray(value) ? value.length > 0 : Boolean(value),
- );
- if (hasDocs) {
- await api.bookings.uploadDocuments.call({
- id: booking.id,
- files: documents,
- });
- }
-
- const pricing = await api.bookings.generatePrice.call({ id: booking.id });
-
- return { bookingId: booking.id, pricing };
- },
- onSuccess: ({ bookingId, pricing }) => {
+ onSuccess: ({ bookingId, pricing, mode }) => {
setPriceBookingId(bookingId);
setPricingData(pricing);
- setPricingPhase("ready");
+ setPriceModalMode(mode);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
- onError: () => {
- setPricingPhase("idle");
- },
});
const confirmMutation = useMutation({
mutationFn: async () => {
if (!priceBookingId) throw new Error("No booking to confirm");
- await api.bookings.submit.call({ id: priceBookingId });
+ return api.bookings.submit.call({ id: priceBookingId });
+ },
+ onSuccess: (result) => {
+ if (result.priceChanged) {
+ setPriceChangeResult(result);
+ return;
+ }
+ setPriceModalMode(null);
+ queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
+ navigate(`/bookings/${priceBookingId}`);
+ },
+ });
+
+ const confirmSubmitMutation = useMutation({
+ mutationFn: async () => {
+ if (!priceBookingId) throw new Error("No booking to confirm");
+ return api.bookings.confirmSubmit.call({ id: priceBookingId });
},
onSuccess: () => {
+ setPriceChangeResult(null);
+ setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${priceBookingId}`);
},
@@ -188,22 +186,15 @@ export default function NewBookingPage() {
return route;
}, [originYard, destinationYard]);
- const docValues = form.watch("documents") ?? {};
- const hasDocuments = useMemo(
- () =>
- Object.values(docValues).some((value) =>
- Array.isArray(value) ? value.length > 0 : Boolean(value),
- ),
- [docValues],
- );
-
- const [pricingPhase, setPricingPhase] = useState<
- "idle" | "generating" | "ready"
- >("idle");
const [pricingData, setPricingData] = useState(
null,
);
const [priceBookingId, setPriceBookingId] = useState(null);
+ const [priceModalMode, setPriceModalMode] = useState(
+ null,
+ );
+ const [priceChangeResult, setPriceChangeResult] =
+ useState(null);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
@@ -211,6 +202,14 @@ export default function NewBookingPage() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
if (!valid) return;
+ if (step === 6 && !hasAllRequiredDocuments(form.getValues("documents"))) {
+ form.setError("documents", {
+ type: "manual",
+ message: "Upload all four required documents.",
+ });
+ return;
+ }
+
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
}
@@ -265,7 +264,9 @@ export default function NewBookingPage() {
)!;
return {
- scheduledDate: new Date().toISOString(),
+ scheduledDate: data.scheduledDate
+ ? new Date(data.scheduledDate).toISOString()
+ : new Date().toISOString(),
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: data.serviceTypeId,
@@ -273,7 +274,7 @@ export default function NewBookingPage() {
data.equipmentReturn === "with_return"
? "WITH_RETURN"
: "WITHOUT_RETURN",
- paymentCurrency: "USD",
+ paymentCurrency: data.paymentCurrency,
originYardId: data.originYard,
destinationYardId: data.destinationYard,
tradeDirection: direction!,
@@ -313,25 +314,55 @@ export default function NewBookingPage() {
};
}
- const handleDraftSubmit = form.handleSubmit((data) => {
+ const handleSaveDraft = form.handleSubmit((data) => {
try {
const apiPayload = buildApiPayload(data);
- createMutation.mutate(apiPayload);
+ persistAndPriceMutation.mutate({
+ payload: apiPayload,
+ mode: "draft",
+ existingBookingId: priceBookingId,
+ });
} catch {
// validation error already handled
}
});
- const handleGeneratePrice = form.handleSubmit((data) => {
+ const handleSubmitBooking = form.handleSubmit((data) => {
+ if (!hasAllRequiredDocuments(data.documents)) {
+ form.setError("documents", {
+ type: "manual",
+ message: "Upload all four required documents.",
+ });
+ setStep(6);
+ return;
+ }
try {
const apiPayload = buildApiPayload(data);
- setPricingPhase("generating");
- createAndPriceMutation.mutate(apiPayload);
+ persistAndPriceMutation.mutate({
+ payload: apiPayload,
+ mode: "submit",
+ existingBookingId: priceBookingId,
+ });
} catch {
// validation error already handled
}
});
+ const isPricing =
+ persistAndPriceMutation.isPending || confirmMutation.isPending;
+
+ function closePriceModal() {
+ setPriceModalMode(null);
+ if (priceModalMode === "draft" && priceBookingId) {
+ navigate(`/bookings/${priceBookingId}`);
+ }
+ }
+
+ function handleDraftModalOk() {
+ setPriceModalMode(null);
+ if (priceBookingId) navigate(`/bookings/${priceBookingId}`);
+ }
+
return (
e.preventDefault()}
>
- {createMutation.isError && (
+ {persistAndPriceMutation.isError && (
}
@@ -391,29 +422,11 @@ export default function NewBookingPage() {
mb="lg"
>
- Failed to save draft
+ Failed to save booking or generate price
- {createMutation.error instanceof Error
- ? createMutation.error.message
- : "An unexpected error occurred. Please try again."}
-
-
- )}
-
- {createAndPriceMutation.isError && (
- }
- radius="md"
- mb="lg"
- >
-
- Failed to generate price estimate
-
-
- {createAndPriceMutation.error instanceof Error
- ? createAndPriceMutation.error.message
+ {persistAndPriceMutation.error instanceof Error
+ ? persistAndPriceMutation.error.message
: "An unexpected error occurred. Please try again."}
@@ -450,17 +463,16 @@ export default function NewBookingPage() {
setStep={setStep}
direction={direction!}
referenceData={referenceData}
- pricingPhase={pricingPhase}
- pricingData={pricingData}
- onConfirm={() => confirmMutation.mutate()}
- onContinueLater={
- priceBookingId
- ? () => navigate(`/bookings/${priceBookingId}`)
- : undefined
+ onSaveDraft={handleSaveDraft}
+ onSubmit={handleSubmitBooking}
+ saveDraftPending={
+ persistAndPriceMutation.isPending &&
+ persistAndPriceMutation.variables?.mode === "draft"
+ }
+ submitPending={
+ persistAndPriceMutation.isPending &&
+ persistAndPriceMutation.variables?.mode === "submit"
}
- onAbort={() => setCancelDialogOpen(true)}
- confirmPending={confirmMutation.isPending}
- abortPending={abortMutation.isPending}
/>
)}
@@ -501,51 +513,151 @@ export default function NewBookingPage() {
>
Continue
- ) : pricingPhase === "idle" ? (
-
-
- }
- >
- {createMutation.isPending
- ? "Saving Draft..."
- : "Save as Draft"}
-
- {hasDocuments && (
-
- )
- }
- onClick={() => handleGeneratePrice()}
- >
- {createAndPriceMutation.isPending
- ? "Generating price…"
- : "Submit"}
-
- )}
-
- ) : pricingPhase === "generating" ? (
-
- Generating price estimate…
+ ) : (
+ }
+ onClick={handleSubmitBooking}
+ loading={isPricing}
+ >
+ Submit
- ) : null}
+ )}
+
+ {priceModalMode === "submit"
+ ? "Confirm booking submission"
+ : "Draft saved — price estimate"}
+
+ }
+ radius="lg"
+ centered
+ size="md"
+ >
+ {pricingData && (
+
+
+ {priceModalMode === "submit"
+ ? "Review the price estimate below. Confirm to submit your booking for EDR staff review."
+ : "Your booking has been saved as a draft. Here is the estimated price."}
+
+
+ {pricingData.lineItems.map((item) => (
+
+
+ {item.description}
+
+
+ {item.amount.toLocaleString()} {item.currency}
+
+
+ ))}
+
+
+
+ Total
+
+
+ {pricingData.totalAmount.toLocaleString()} {pricingData.currency}
+
+
+ {pricingData.warnings.length > 0 && (
+
+ {pricingData.warnings.join(", ")}
+
+ )}
+
+ {priceModalMode === "submit" ? (
+ <>
+
+ Cancel
+
+ }
+ onClick={() => confirmMutation.mutate()}
+ loading={confirmMutation.isPending}
+ >
+ Confirm & submit
+
+ >
+ ) : (
+
+ OK
+
+ )}
+
+
+ )}
+
+
+ setPriceChangeResult(null)}
+ title={Price has changed }
+ radius="lg"
+ centered
+ >
+ {priceChangeResult && (
+
+
+ {priceChangeResult.message ??
+ "The booking price has been updated. Confirm to submit with the new total."}
+
+ {priceChangeResult.previousTotalAmount !== undefined && (
+
+
+ Previous total
+
+
+ {priceChangeResult.previousTotalAmount.toLocaleString()}{" "}
+ {priceChangeResult.currency}
+
+
+ )}
+
+ New total
+
+ {priceChangeResult.totalAmount.toLocaleString()}{" "}
+ {priceChangeResult.currency}
+
+
+
+ setPriceChangeResult(null)}
+ >
+ Cancel
+
+ confirmSubmitMutation.mutate()}
+ >
+ Confirm & submit
+
+
+
+ )}
+
+
setCancelDialogOpen(false)}
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/payment-currency-field.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx
new file mode 100644
index 000000000..c11620d4f
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx
@@ -0,0 +1,59 @@
+import { Box, Text } from "@mantine/core";
+import { Banknote, DollarSign } from "lucide-react";
+import { Controller, type Control } from "react-hook-form";
+import {
+ PAYMENT_CURRENCY_OPTIONS,
+ type BookingFormInputValues,
+ type BookingFormValues,
+ type PaymentCurrency,
+} from "./schema";
+import { OptionCard, OptionFieldError, StepLabel } from "./shared";
+
+const CURRENCY_ICONS: Record<
+ PaymentCurrency,
+ { icon: typeof DollarSign; bg: string; color: string }
+> = {
+ USD: { icon: DollarSign, bg: "#EEF0FB", color: "#4F46E5" },
+ ETB: { icon: Banknote, bg: "#ECF6F1", color: "#0A6F4D" },
+};
+
+export function PaymentCurrencyField({
+ control,
+}: {
+ control: Control
;
+}) {
+ return (
+
+ Payment currency
+
+ Choose the currency for your freight quote and invoices.
+
+ (
+
+
+ {PAYMENT_CURRENCY_OPTIONS.map((option) => {
+ const Icon = CURRENCY_ICONS[option.value].icon;
+ return (
+ field.onChange(option.value)}
+ icon={ }
+ iconBg={CURRENCY_ICONS[option.value].bg}
+ iconColor={CURRENCY_ICONS[option.value].color}
+ title={option.label}
+ description={option.description}
+ />
+ );
+ })}
+
+
+
+ )}
+ />
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts
index dbf943068..ea4b1214a 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts
@@ -14,9 +14,7 @@ export const STEPS = [
/**
* Shipment documents collected during booking creation. The fileKeys mirror
- * `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts so anything attached
- * here shows up as "Uploaded" on the booking detail page. All optional in this
- * flow — the detail page remains the catch-all for uploading them later.
+ * `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts.
*/
const DOC_SETTING_TS = "2024-01-01T00:00:00.000Z";
@@ -34,7 +32,7 @@ function docField(
fileKey,
fileLabel,
helpText: null,
- isRequired: false,
+ isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
@@ -51,7 +49,7 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
code: "booking_documents",
label: "Booking Documents",
description:
- "Attach your shipment documents now, or skip and upload them later from the booking page.",
+ "Attach all four required shipment documents before submitting your booking.",
entity: "booking",
fields: [
docField("commercial_invoice", "Commercial Invoice", 1),
@@ -63,11 +61,32 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
export type BookingDocuments = Record;
+export const PAYMENT_CURRENCIES = ["USD", "ETB"] as const;
+export type PaymentCurrency = (typeof PAYMENT_CURRENCIES)[number];
+
+export const PAYMENT_CURRENCY_OPTIONS: Array<{
+ value: PaymentCurrency;
+ label: string;
+ description: string;
+}> = [
+ {
+ value: "USD",
+ label: "USD",
+ description: "US Dollar — international pricing and invoicing.",
+ },
+ {
+ value: "ETB",
+ label: "ETB",
+ description: "Ethiopian Birr — local pricing and invoicing.",
+ },
+];
+
export const bookingFormSchema = z
.object({
contractType: z.enum(["new", "renewal"], "Select a contract type."),
previousContractRef: z.string(),
serviceTypeId: z.string("Select a service type."),
+ paymentCurrency: z.enum(PAYMENT_CURRENCIES, "Select a payment currency."),
firstMile: z
.object({
@@ -202,6 +221,7 @@ export const initialBookingFormValues: DeepPartial = {
previousContractRef: "",
serviceTypeId: "",
+ paymentCurrency: "USD",
firstMile: {
enabled: false,
pickUpAddress: "",
@@ -232,6 +252,7 @@ export const stepFields: Record>> = {
1: ["contractType", "previousContractRef"],
2: [
"serviceTypeId",
+ "paymentCurrency",
"firstMile",
"lastMile",
"equipmentReturn",
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..b53571c03 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,18 @@
-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 { PaymentCurrencyField } from "./payment-currency-field";
import type { Freight } from "@edr/types";
@@ -61,10 +70,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}
+ />
+ ))}
)}
/>
+
+
{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 ?? false}
+ 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 ?? false}
+ 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 ?? false}
+ 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..ae7cddf4f 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
@@ -1,26 +1,46 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import {
+ Badge,
Box,
Button,
- Card,
- Divider,
Group,
- Loader,
- SimpleGrid,
+ Paper,
Stack,
+ Table,
Text,
Textarea,
} from "@mantine/core";
-import { Check, Send, XCircle, FileText, Route, Package, Truck } from "lucide-react";
+import { format } from "date-fns";
+import {
+ Calendar,
+ CheckCircle2,
+ Circle,
+ ClipboardCheck,
+ FileText,
+ Package,
+ Pencil,
+ Route,
+ Send,
+ Truck,
+} from "lucide-react";
+import type { Freight } from "@/types";
+import { hasAllRequiredDocuments } from "@/services/booking-form-data";
import {
- BookingFormInputValues,
BOOKING_DOCS_SETTING,
type BookingDocuments,
+ type BookingFormInputValues,
type BookingFormValues,
} from "./schema";
import { StepHeader } from "./shared";
-import type { Freight } from "@/types";
-import type { GeneratePriceResponse } from "@/services/bookings.service";
+
+export const REVIEW_STEP_TARGETS = {
+ contract: 1,
+ service: 2,
+ route: 3,
+ cargo: 4,
+ schedule: 5,
+ documents: 6,
+} as const;
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -28,113 +48,135 @@ type BookingForm = UseFormReturn<
BookingFormValues
>;
+function OverviewSection({
+ icon,
+ title,
+ onEdit,
+ children,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ onEdit: () => void;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {icon}
+
+
+ {title}
+
+
+ }
+ onClick={onEdit}
+ >
+ Edit
+
+
+ {children}
+
+ );
+}
+
+function DetailRow({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+
+ {value || "—"}
+
+
+ );
+}
+
+function ReadinessItem({
+ done,
+ label,
+}: {
+ done: boolean;
+ label: string;
+}) {
+ return (
+
+ {done ? (
+
+ ) : (
+
+ )}
+
+ {label}
+
+
+ );
+}
+
export function Step8Review({
form,
setStep,
direction,
referenceData,
- pricingPhase = "idle",
- pricingData,
- onConfirm,
- onContinueLater,
- onAbort,
- confirmPending = false,
- abortPending = false,
+ onSaveDraft,
+ onSubmit,
+ saveDraftPending = false,
+ submitPending = false,
}: {
form: BookingForm;
setStep: (step: number) => void;
direction: Freight.ScheduleTradeDirection;
referenceData?: Freight.BookingReferenceData;
- pricingPhase?: "idle" | "generating" | "ready";
- pricingData?: GeneratePriceResponse | null;
- onConfirm?: () => void;
- onContinueLater?: () => void;
- onAbort?: () => void;
- confirmPending?: boolean;
- abortPending?: boolean;
+ onSaveDraft?: () => void;
+ onSubmit?: () => void;
+ saveDraftPending?: boolean;
+ submitPending?: boolean;
}) {
const values = form.watch();
const serviceType = referenceData?.service.find(
(s) => s.id === values.serviceTypeId,
);
- function CompactRow({
- label,
- value,
- target,
- }: {
- label: string;
- value: string;
- target: number;
- }) {
- return (
-
-
-
- {label}
-
-
- {value || "—"}
-
-
-
setStep(target)}
- className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
- >
- Edit
-
-
- );
- }
-
- function CompactCard({
- icon: Icon,
- title,
- children,
- }: {
- icon: React.ReactNode;
- title: string;
- children: React.ReactNode;
- }) {
- return (
-
-
- {Icon}
-
- {title}
-
-
- {children}
-
- );
- }
-
const containerSummary =
values.cargoType === "container" && values.containers.length > 0
? values.containers
- .filter((c) => +c.qty > 0)
- .map((c) => `${c.qty} × ${c.type}`)
- .join(", ")
+ .filter((c) => +c.qty > 0)
+ .map((c) => `${c.qty} × ${c.containerType || c.type}`)
+ .join(", ")
: "";
const totalVgm =
values.cargoType === "container"
? values.containers.reduce(
- (sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
- 0,
- )
- : 0;
+ (sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
+ 0,
+ )
+ : Number(values.cargoWeight || 0);
const documents = (values.documents ?? {}) as BookingDocuments;
const docsAttached = BOOKING_DOCS_SETTING.fields.filter((f) => {
const value = documents[f.fileKey];
return Array.isArray(value) ? value.length > 0 : Boolean(value);
}).length;
- const docsTotal = BOOKING_DOCS_SETTING.fields.length;
+ const allDocsReady = hasAllRequiredDocuments(documents);
const cargoValue = (() => {
- if (values.cargoType === "container") return containerSummary;
+ if (values.cargoType === "container") return "Container freight";
if (!referenceData) return "";
const path = values.cargoTypePath ?? [];
const group = referenceData.cargo_type.find((g) => g.id === path[0]);
@@ -143,226 +185,320 @@ export function Step8Review({
return child ? `${group.name} — ${child.name}` : group.name;
})();
- const originYardName = referenceData?.yard.find(
- (y) => y.id === values.originYard,
- )?.name ?? values.originYard;
+ const originYardName =
+ referenceData?.yard.find((y) => y.id === values.originYard)?.name ??
+ values.originYard;
- const destinationYardName = referenceData?.yard.find(
- (y) => y.id === values.destinationYard,
- )?.name ?? values.destinationYard;
+ const destinationYardName =
+ referenceData?.yard.find((y) => y.id === values.destinationYard)?.name ??
+ values.destinationYard;
+
+ const scheduleLabel = values.scheduledDate
+ ? format(new Date(values.scheduledDate), "EEEE, MMM d, yyyy")
+ : "—";
+
+ const directionLabel = direction
+ ? direction.charAt(0) + direction.slice(1).toLowerCase()
+ : "—";
return (
-
+
}
title="Review & Submit"
- description="Confirm your contract request before sending it for EDR staff review."
+ description="Review your booking overview before sending it for EDR staff review."
/>
- {/* Pricing Card - Prominent at top */}
- {pricingPhase === "generating" && (
-
-
-
-
- Generating price estimate…
-
-
-
- )}
-
- {pricingPhase === "ready" && pricingData && (
-
-
-
- 💳 Price Breakdown
-
-
- {pricingData.lineItems.map((item) => (
-
-
- {item.description}
-
-
- {item.amount.toLocaleString()} {item.currency}
-
-
- ))}
-
-
-
-
- Total
-
-
- {pricingData.totalAmount.toLocaleString()} {pricingData.currency}
-
+
+ {/* Left — booking summary */}
+
+
+
+
+
+ Booking overview
+
+
+ {values.contractType === "new" ? "New Contract" : "Contract Renewal"}
+
+
+ {serviceType?.name ?? "—"} · {originYardName} → {destinationYardName}
+
+
+
+ {directionLabel}
+
- {pricingData.warnings.length > 0 && (
-
- ⚠️ {pricingData.warnings.join(", ")}
-
+
+
+ }
+ title="Contract & Service"
+ onEdit={() => setStep(REVIEW_STEP_TARGETS.contract)}
+ >
+
+ {values.contractType === "renewal" && values.previousContractRef && (
+
)}
-
- }
- onClick={onConfirm}
- loading={confirmPending}
- className="flex-1"
- >
- {confirmPending ? "Confirming…" : "Confirm"}
-
- }
- onClick={onContinueLater}
- className="flex-1"
- >
- Continue later
-
- : undefined}
- onClick={onAbort}
- loading={abortPending}
- >
- Abort
-
-
-
-
- )}
-
- {/* Review Details - Compact Cards Grid */}
-
- } title="Contract & Service">
-
-
-
-
- } title="Route">
-
-
-
-
- } title="Logistics">
-
-
-
-
-
-
- } title="Cargo Details">
-
-
-
-
-
- } title="Containers">
-
- 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
- target={4}
- />
-
-
- } title="Documents">
-
-
-
- Attached
-
-
- {docsAttached > 0
- ? `${docsAttached} of ${docsTotal}`
- : "None"}
-
-
-
+
+
setStep(5)}
- className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
+ variant="subtle"
+ size="compact-xs"
+ color="gray"
+ mt={4}
+ onClick={() => setStep(REVIEW_STEP_TARGETS.service)}
>
- Edit
-
-
-
-
+ Edit service options
+
+
- {/* Notes */}
-
(
- }
+ title="Route"
+ onEdit={() => setStep(REVIEW_STEP_TARGETS.route)}
+ >
+
+
+
+
+
+
+ }
+ title="Logistics"
+ onEdit={() => setStep(REVIEW_STEP_TARGETS.service)}
+ >
+
+
+
+
+
+
+ }
+ title="Schedule"
+ onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
+ >
+
+
+
+
+ }
+ title="Cargo"
+ onEdit={() => setStep(REVIEW_STEP_TARGETS.cargo)}
+ >
+
+ 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
+ />
+
+ {values.cargoType === "container" && values.containers.length > 0 && (
+
+
+
+ Type
+ Qty
+ VGM (t)
+
+
+
+ {values.containers
+ .filter((c) => +c.qty > 0)
+ .map((c, i) => (
+
+ {c.containerType || c.type}
+ {c.qty}
+ {c.vgm}
+
+ ))}
+
+
+ )}
+ {containerSummary && (
+
+ )}
+
+
+ }
+ title="Documents"
+ onEdit={() => setStep(REVIEW_STEP_TARGETS.documents)}
+ >
+
+ {BOOKING_DOCS_SETTING.fields.map((field) => {
+ const file = documents[field.fileKey];
+ const attached = Array.isArray(file)
+ ? file.length > 0
+ : Boolean(file);
+ const fileName = attached
+ ? Array.isArray(file)
+ ? file[0]?.name
+ : (file as File)?.name
+ : null;
+ return (
+
+
+ {attached ? (
+
+ ) : (
+
+ )}
+ {field.fileLabel}
+
+
+ {fileName ?? "Missing"}
+
+
+ );
+ })}
+
+
+ {docsAttached} of {BOOKING_DOCS_SETTING.fields.length} attached
+
+
+
+ (
+
+ )}
/>
- )}
- />
+
+
+ {/* Right — sticky actions */}
+
+
+
+
+ Submission readiness
+
+
+
+
+
+
+ +c.qty > 0)
+ : Boolean(values.cargoWeight)
+ }
+ label="Cargo details complete"
+ />
+
+
+
+
+
+
+ {allDocsReady
+ ? "Ready to submit. You'll review the price estimate before final submission."
+ : "Upload all four documents to enable submission."}
+
+
+ }
+ onClick={onSubmit}
+ loading={submitPending}
+ disabled={!allDocsReady || submitPending}
+ >
+ Submit
+
+
+ Save as draft
+
+
+
+
+
+
);
}
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..475125b6d 100644
--- a/apps/edr-freight-web/portal/src/services/api.ts
+++ b/apps/edr-freight-web/portal/src/services/api.ts
@@ -13,7 +13,9 @@ import {
BookingListFilter,
CreateBookingPayload,
GeneratePriceResponse,
+ SubmitBookingResponse,
} from "./bookings.service";
+import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
import {
paymentsService,
InitiatePaymentPayload,
@@ -141,16 +143,29 @@ export const api = {
({ id }) => bookingsService.get(id),
),
- create: endpoint(
+ tracking: endpoint<{ id: string }, Freight.IBookingTracking>(
"bookings",
- "create",
- bookingsService.create,
+ "tracking",
+ ({ id }) => bookingsService.tracking(id),
+ ),
+
+ create: endpoint<
+ { payload: CreateBookingPayload; documents?: BookingDocuments },
+ Freight.IBooking
+ >("bookings", "create", ({ payload, documents }) =>
+ bookingsService.create(payload, documents),
),
update: endpoint<
- { id: string; dto: Partial },
+ {
+ id: string;
+ dto: Partial;
+ documents?: BookingDocuments;
+ },
{ booking: Freight.IBooking; warnings: string[] }
- >("bookings", "update", ({ id, dto }) => bookingsService.update(id, dto)),
+ >("bookings", "update", ({ id, dto, documents }) =>
+ bookingsService.update(id, dto, documents),
+ ),
referenceData: endpoint(
"bookings",
@@ -174,12 +189,18 @@ export const api = {
({ id }) => bookingsService.generatePrice(id),
),
- submit: endpoint<{ id: string }, Freight.IBooking>(
+ submit: endpoint<{ id: string }, SubmitBookingResponse>(
"bookings",
"submit",
({ id }) => bookingsService.submit(id),
),
+ confirmSubmit: endpoint<{ id: string }, SubmitBookingResponse>(
+ "bookings",
+ "confirmSubmit",
+ ({ id }) => bookingsService.confirmSubmit(id),
+ ),
+
uploadDocuments: endpoint<
{ id: string; files: Record },
Freight.IBooking
diff --git a/apps/edr-freight-web/portal/src/services/booking-form-data.ts b/apps/edr-freight-web/portal/src/services/booking-form-data.ts
new file mode 100644
index 000000000..52616b09e
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/services/booking-form-data.ts
@@ -0,0 +1,88 @@
+import type { CreateBookingPayload } from "./bookings.service";
+import { BOOKING_DOCS_SETTING, type BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
+
+function appendValue(formData: FormData, key: string, value: unknown) {
+ if (value === undefined || value === null) return;
+ if (typeof value === "boolean") {
+ formData.append(key, value ? "true" : "false");
+ return;
+ }
+ if (typeof value === "number") {
+ formData.append(key, String(value));
+ return;
+ }
+ if (typeof value === "string") {
+ formData.append(key, value);
+ return;
+ }
+}
+
+function appendContainers(
+ formData: FormData,
+ containers: NonNullable,
+) {
+ containers.forEach((container, index) => {
+ formData.append(
+ `containers[${index}][containerTypeId]`,
+ container.containerTypeId,
+ );
+ formData.append(
+ `containers[${index}][quantity]`,
+ String(container.quantity),
+ );
+ formData.append(
+ `containers[${index}][vgmPerUnitTons]`,
+ String(container.vgmPerUnitTons),
+ );
+ });
+}
+
+function appendDocuments(
+ formData: FormData,
+ documents?: Record,
+) {
+ if (!documents) return;
+ for (const [key, fileOrFiles] of Object.entries(documents)) {
+ if (!fileOrFiles) continue;
+ if (Array.isArray(fileOrFiles)) {
+ for (const file of fileOrFiles) {
+ formData.append(key, file);
+ }
+ } else {
+ formData.append(key, fileOrFiles);
+ }
+ }
+}
+
+/** Flatten a booking payload (and optional document files) into multipart FormData. */
+export function buildBookingFormData(
+ payload: Partial,
+ documents?: BookingDocuments,
+): FormData {
+ const formData = new FormData();
+ const skipKeys = new Set(["containers", "freightShapeValidation"]);
+
+ for (const [key, value] of Object.entries(payload)) {
+ if (skipKeys.has(key)) continue;
+ appendValue(formData, key, value);
+ }
+
+ if (payload.containers?.length) {
+ appendContainers(formData, payload.containers);
+ }
+
+ appendDocuments(formData, documents);
+ return formData;
+}
+
+/** Returns true when every required booking document field has a file attached. */
+export function hasAllRequiredDocuments(
+ documents: BookingDocuments | undefined | null,
+): boolean {
+ const docs = documents ?? {};
+ return BOOKING_DOCS_SETTING.fields.every((field) => {
+ const value = docs[field.fileKey];
+ if (Array.isArray(value)) return value.length > 0;
+ return Boolean(value);
+ });
+}
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..115edef6d 100644
--- a/apps/edr-freight-web/portal/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts
@@ -1,6 +1,8 @@
import type { Freight, PaginatedResponse } from "@edr/types";
import { URL_CONSTANTS } from "@/constants/URLS";
+import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
+import { buildBookingFormData } from "./booking-form-data";
import { client } from "../utils/api";
const B = URL_CONSTANTS.BOOKINGS;
@@ -45,6 +47,17 @@ export interface GeneratePriceResponse {
warnings: string[];
}
+export interface SubmitBookingResponse {
+ bookingId: string;
+ status: string;
+ priceChanged: boolean;
+ previousTotalAmount?: number;
+ totalAmount: number;
+ currency: string;
+ lineItems?: PriceLineItem[];
+ message?: string;
+}
+
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
@@ -54,6 +67,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,8 +86,18 @@ export const bookingsService = {
const { data } = await client.get(`/api/bookings/${id}`);
return data.data;
},
- create: async (payload: CreateBookingPayload): Promise => {
- const { data } = await client.post("/api/bookings", payload);
+ tracking: async (id: string): Promise => {
+ const { data } = await client.get(`/api/bookings/${id}/tracking`);
+ return data.data;
+ },
+ create: async (
+ payload: CreateBookingPayload,
+ documents?: BookingDocuments,
+ ): Promise => {
+ const formData = buildBookingFormData(payload, documents);
+ const { data } = await client.post("/api/bookings", formData, {
+ headers: { "Content-Type": "multipart/form-data" },
+ });
return data.data.booking;
},
getReferenceData: async (): Promise => {
@@ -82,8 +107,12 @@ export const bookingsService = {
update: async (
id: string,
payload: Partial,
+ documents?: BookingDocuments,
): Promise<{ booking: Freight.IBooking; warnings: string[] }> => {
- const { data } = await client.patch(`/api/bookings/${id}`, payload);
+ const formData = buildBookingFormData(payload, documents);
+ const { data } = await client.patch(`/api/bookings/${id}`, formData, {
+ headers: { "Content-Type": "multipart/form-data" },
+ });
return data.data;
},
@@ -101,11 +130,16 @@ export const bookingsService = {
return data.data;
},
- submit: async (id: string): Promise => {
+ submit: async (id: string): Promise => {
const { data } = await client.post(`/api/bookings/${id}/submit`);
return data.data;
},
+ confirmSubmit: async (id: string): Promise => {
+ const { data } = await client.post(`/api/bookings/${id}/confirm-submit`);
+ return data.data;
+ },
+
uploadDocuments: async (
id: string,
files: Record,
diff --git a/apps/edr-payment-api/src/config/app.config.ts b/apps/edr-payment-api/src/config/app.config.ts
index c1128a872..041c47ac6 100644
--- a/apps/edr-payment-api/src/config/app.config.ts
+++ b/apps/edr-payment-api/src/config/app.config.ts
@@ -10,10 +10,10 @@ export default registerAs("app", () => ({
serviceAuthToken: process.env.SERVICE_AUTH_TOKEN ?? "",
reconciliation: {
/** How often the stale-intent sweep runs. */
- sweepIntervalMs: parseInt(
- process.env.RECONCILE_SWEEP_INTERVAL_MS ?? "60000",
- 10,
- ),
+ // TODO(demo): revert to the env-driven line below after the demo. Temporarily FORCED to 30s
+ // here so the .env (RECONCILE_SWEEP_INTERVAL_MS) cannot override it.
+ // sweepIntervalMs: parseInt(process.env.RECONCILE_SWEEP_INTERVAL_MS ?? "60000", 10),
+ sweepIntervalMs: 30_000,
/** An intent is "stale" when non-terminal and untouched for this long. */
staleAfterMs: parseInt(process.env.RECONCILE_STALE_AFTER_MS ?? "60000", 10),
batchSize: parseInt(process.env.RECONCILE_BATCH_SIZE ?? "20", 10),
diff --git a/apps/edr-payment-api/src/config/waafi.config.ts b/apps/edr-payment-api/src/config/waafi.config.ts
index 05624922f..cfe2102f7 100644
--- a/apps/edr-payment-api/src/config/waafi.config.ts
+++ b/apps/edr-payment-api/src/config/waafi.config.ts
@@ -13,7 +13,10 @@ export default registerAs("waafi", () => ({
// Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front.
paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? "MWALLET_ACCOUNT",
// Waafi has no ETB; when set this overrides the asserted currency (USD/DJF/SLSH).
- currency: process.env.WAAFI_CURRENCY ?? "DJF",
+ // TODO(demo): revert to the env-driven line below after the demo. Temporarily FORCED to USD
+ // here so the .env (WAAFI_CURRENCY) cannot override it.
+ // currency: process.env.WAAFI_CURRENCY ?? "DJF",
+ currency: "USD",
// Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth).
successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? "",
failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? "",
diff --git a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts
index b74c9b899..98595f8f2 100644
--- a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts
+++ b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts
@@ -42,7 +42,7 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy {
private readonly cacBankProvider: CacBankProvider,
) {
this.intervalMs =
- config.get("app.reconciliation.sweepIntervalMs") ?? 60_000;
+ config.get("app.reconciliation.sweepIntervalMs") ?? 30_000;
this.staleAfterMs =
config.get("app.reconciliation.staleAfterMs") ?? 60_000;
this.batchSize = config.get("app.reconciliation.batchSize") ?? 20;
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;