Merge branch 'freight/develop' into freight/feat/company-profile

This commit is contained in:
Nathnael Wondisha
2026-06-18 17:48:07 +03:00
committed by GitHub
116 changed files with 9882 additions and 2924 deletions

View File

@@ -69,8 +69,8 @@ jobs:
fi fi
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api") 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/portal/" && SERVICES+=("freight-portal")
echo "$CHANGED" | grep -q "^apps/edr-freight-web-backoffice/" && SERVICES+=("freight-backoffice") 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-api/" && SERVICES+=("passenger-api")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal") echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice") echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")

View File

@@ -19,6 +19,11 @@ TELEBIRR_TIMEOUT_EXPRESS=15m
TELEBIRR_PRIVATE_KEY= TELEBIRR_PRIVATE_KEY=
TELEBIRR_PUBLIC_KEY= TELEBIRR_PUBLIC_KEY=
TELEBIRR_INSECURE_TLS=false 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 (used by @tria-plc/api-common SharedAuthModule)
JWT_SECRET= JWT_SECRET=
JWT_ACCESS_TOKEN_SECRET= JWT_ACCESS_TOKEN_SECRET=

View File

@@ -43,7 +43,6 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder"; import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module"; import { PaymentModule } from "./modules/payment/payment.module";
import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
@@ -52,13 +51,14 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
//New Trains, Wagons, Container and Cargo management modules //New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module"; import { TrainsModule } from "./modules/trains/trains.module";
import { WagonsModule } from "./modules/wagons/wagons.module"; import { WagonsModule } from './modules/wagons/wagons.module';
import { ContainersModule } from "./modules/container-management/containers.module"; import { ContainersModule } from './modules/container-management/containers.module';
import { CargoesModule } from "./modules/cargoes/cargoes.module"; import { CargoesModule } from './modules/cargoes/cargoes.module';
import { RoutesModule } from "./modules/routes/routes.module"; import { RoutesModule } from './modules/routes/routes.module';
import { WarehousesModule } from "./modules/warehouses/warehouses.module"; import { WarehousesModule } from './modules/warehouses/warehouses.module';
import { FacilitiesModule } from "./modules/facilities/facilities.module"; import { FacilitiesModule } from './modules/facilities/facilities.module';
import { OverviewModule } from "./modules/overview/overview.module"; import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
@Module({ @Module({
imports: [ imports: [
@@ -117,12 +117,12 @@ import { OverviewModule } from "./modules/overview/overview.module";
FacilitiesModule, FacilitiesModule,
WarehousesModule, WarehousesModule,
OverviewModule, OverviewModule,
VehiclesModule,
], ],
providers: [ providers: [
EdrOrgSeeder, EdrOrgSeeder,
DemoUsersSeeder, DemoUsersSeeder,
FreightStaffUsersSeeder, FreightStaffUsersSeeder,
DemoBookingsSeeder,
PricingDataSeeder, PricingDataSeeder,
FileUploadSettingsSeeder, FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder, FreightPermissionKeyMigrationSeeder,
@@ -137,11 +137,6 @@ export class AppModule implements OnApplicationBootstrap {
private readonly edrOrgSeeder: EdrOrgSeeder, private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder, private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
private readonly demoBookingsSeeder: DemoBookingsSeeder,
private readonly pricingDataSeeder: PricingDataSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
) { } ) { }
@@ -152,13 +147,11 @@ export class AppModule implements OnApplicationBootstrap {
await this.edrOrgSeeder.run(); await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run(); await this.demoUsersSeeder.run();
await this.freightStaffUsersSeeder.run(); await this.freightStaffUsersSeeder.run();
await this.demoBookingsSeeder.run(); // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
await this.pricingDataSeeder.run(); // FileUploadSettingsSeeder) are intentionally disabled — they stay
await this.fileUploadSettingsSeeder.run(); // registered as providers but are not run. Re-inject + call .run() to enable.
await this.indodeFacilitySeeder.run(); // demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
await this.batch14TestDataSeeder.run(); // rules are disabled inside the seeder). Kept running for the staff users.
// 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.
await this.demoFreightDataSeeder.run(); await this.demoFreightDataSeeder.run();
} }
} }

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateVehiclesTable1770000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.vehicles CASCADE;`);
}
}

View File

@@ -1,6 +1,5 @@
import { import {
BadRequestException, BadRequestException,
ConflictException,
forwardRef, forwardRef,
Inject, Inject,
Injectable, Injectable,
@@ -69,7 +68,12 @@ export class BookingTransitionService {
priorityScore, priorityScore,
} as never); } 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 { return {
bookingId: finalBooking.id, bookingId: finalBooking.id,
status: finalBooking.status, status: finalBooking.status,
@@ -143,7 +147,10 @@ export class BookingTransitionService {
}, },
} as never); } 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 { return {
bookingId: finalBooking.id, bookingId: finalBooking.id,
status: finalBooking.status, status: finalBooking.status,
@@ -188,18 +195,11 @@ export class BookingTransitionService {
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> { async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); 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']); 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, { await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK', freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId, cargoTypeId: booking.cargoTypeId,

View File

@@ -11,6 +11,7 @@ import {
Query, Query,
Request, Request,
Res, Res,
UnauthorizedException,
UploadedFiles, UploadedFiles,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -117,8 +118,22 @@ export class BookingsController {
@Get() @Get()
@ApiOperation({ summary: 'List freight bookings (paginated)' }) @ApiOperation({ summary: 'List freight bookings (paginated)' })
findAll(@Query() filter: FilterBookingDto) { async findAll(
return this.bookingsService.findAll(filter); @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') @Get('list-summary')
@@ -166,18 +181,60 @@ export class BookingsController {
@Get('by-reference/:reference') @Get('by-reference/:reference')
@ApiOperation({ summary: 'Get booking by 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); 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); return this.transitionService.enrichBookingResponse(booking);
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get booking by 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); 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); 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') @Delete(':id')
@HttpCode(204) @HttpCode(204)
@ApiOperation({ summary: 'Soft-delete DRAFT booking' }) @ApiOperation({ summary: 'Soft-delete DRAFT booking' })

View File

@@ -177,8 +177,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
.where('b.id != :bookingId', { bookingId: booking.id }) .where('b.id != :bookingId', { bookingId: booking.id })
.andWhere('b.allowConsolidation = true') .andWhere('b.allowConsolidation = true')
.andWhere('b.consolidationPartnerId IS NULL') .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)', { .andWhere('b.status IN (:...statuses)', {
statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'], statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
}) })
.andWhere('b.originYardId = :originYardId', { .andWhere('b.originYardId = :originYardId', {
originYardId: booking.originYardId, originYardId: booking.originYardId,

View File

@@ -1,12 +1,16 @@
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
ForbiddenException,
forwardRef,
Inject,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { SchedulingStatus } from '@edr/types'; import { Freight, SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service'; // import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service'; import { CompaniesService } from '../companies/companies.service';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service'; import { MinioService } from '../minio/minio.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service';
@@ -52,6 +56,8 @@ export class BookingsService {
private readonly minioService: MinioService, private readonly minioService: MinioService,
// private readonly customersService: CustomersService, // private readonly customersService: CustomersService,
private readonly companiesService: CompaniesService, private readonly companiesService: CompaniesService,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
private readonly ruleEngineService: RuleEngineService, private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService, private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService, private readonly consolidationService: ConsolidationService,
@@ -146,13 +152,17 @@ export class BookingsService {
/** /**
* Enable consolidation when any container line leaves a wagon partially filled * 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( private async resolveConsolidation(
containers: CreateBookingContainerDto[], containers: CreateBookingContainerDto[],
explicit?: boolean, explicit?: boolean,
): Promise<boolean> { ): Promise<boolean> {
if (explicit === false) return false;
const needs = await this.consolidationService.needsConsolidation( const needs = await this.consolidationService.needsConsolidation(
containers.map((c) => ({ containers.map((c) => ({
containerTypeId: c.containerTypeId, containerTypeId: c.containerTypeId,
@@ -193,10 +203,11 @@ export class BookingsService {
return { booking: paired, messages }; return { booking: paired, messages };
} }
if (booking.status === 'DRAFT') { // No partner yet — park the booking so it waits. Applies both pre-submit
await this.bookingsRepository.update(booking.id, { // (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never
status: 'PENDING_CONSOLIDATION', // reach this method.
} as never); if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') {
await this.bookingsRepository.parkForConsolidation(booking.id);
} }
const pending = await this.findById(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 * Run consolidation right after a booking reaches SUBMITTED. If a complementary
* paired) booking plus whether it still needs a consolidation partner. * partner already exists, both are paired and moved (back) to SUBMITTED so staff
* When a booking needs consolidation and none is found, it is parked in * can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and
* PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept. * 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<{ async runConsolidationOnSubmit(bookingId: string): Promise<Booking> {
booking: Booking; const booking = await this.findById(bookingId);
blocked: boolean;
message?: string;
}> {
let booking = await this.findById(bookingId);
// Already paired — passes the gate. // Already paired (e.g. a partner submitted first) — nothing to do.
if (booking.consolidationPartnerId) { 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); const result = await this.tryAutoConsolidate(booking);
booking = result.booking; return 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),
};
} }
/** Create a new freight booking. */ /** Create a new freight booking. */
@@ -575,6 +565,7 @@ export class BookingsService {
/** Return a paginated list of bookings matching the filter. */ /** Return a paginated list of bookings matching the filter. */
async findAll( async findAll(
filter: FilterBookingDto, filter: FilterBookingDto,
forceCompanyId?: string,
): Promise<{ items: Booking[]; total: number }> { ): Promise<{ items: Booking[]; total: number }> {
const page = filter.page ?? 1; const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20; const pageSize = filter.pageSize ?? 20;
@@ -587,7 +578,9 @@ export class BookingsService {
...statusFilter, ...statusFilter,
...schedulingStatusFilter, ...schedulingStatusFilter,
assignedToSchedule: filter.assignedToSchedule, 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, contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId, serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId, 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<string | null> {
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<void> {
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<Freight.IBookingTracking> {
const booking = await this.findById(bookingId);
const empty: Freight.IBookingTracking = {
bookingId: booking.id,
bookingReference: booking.reference,
hasSchedule: false,
scheduleId: null,
trainNumber: null,
scheduleStatus: null,
direction: null,
origin: null,
destination: null,
stations: [],
checkpoints: [],
currentSequenceNo: -1,
actualDepartureAt: null,
actualArrivalAt: null,
scheduledDepartureAt: null,
scheduledArrivalAt: null,
};
if (!booking.trainScheduleId) {
return empty;
}
// Pull the live corridor + checkpoints for the assigned schedule. If the
// schedule was removed, fall back to the pre-dispatch state rather than 500.
let track: Awaited<
ReturnType<TrainSchedulingService['getScheduleCheckpoints']>
>;
try {
track = await this.trainSchedulingService.getScheduleCheckpoints(
booking.trainScheduleId,
);
} catch {
return empty;
}
return {
bookingId: booking.id,
bookingReference: booking.reference,
hasSchedule: true,
scheduleId: track.scheduleId,
trainNumber: track.trainNumber,
scheduleStatus: track.status as Freight.TrainScheduleStatus,
direction: track.direction,
origin: track.origin,
destination: track.destination,
stations: track.stations,
checkpoints: track.checkpoints as Freight.ITrackingCheckpoint[],
currentSequenceNo: track.currentSequenceNo,
actualDepartureAt: track.actualDepartureAt,
actualArrivalAt: track.actualArrivalAt,
scheduledDepartureAt: track.scheduledDepartureAt,
scheduledArrivalAt: track.scheduledArrivalAt,
};
}
/** Aggregate metrics and tab counts for the backoffice booking list. */ /** Aggregate metrics and tab counts for the backoffice booking list. */
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> { async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
const page = filter.page ?? 1; const page = filter.page ?? 1;

View File

@@ -18,7 +18,8 @@ import {
export class PaymentClientService { export class PaymentClientService {
private readonly logger = new Logger(PaymentClientService.name); private readonly logger = new Logger(PaymentClientService.name);
private readonly baseUrl = ( private readonly baseUrl = (
process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com" // process.env.PAYMENT_API_URL ??
"https://paymentcallback.triaplc.com"
).replace(/\/$/, ""); ).replace(/\/$/, "");
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";

View File

@@ -145,9 +145,7 @@ export class PaymentService {
.findOneBy({ id: dto.bookingId }); .findOneBy({ id: dto.bookingId });
if (!booking) throw new NotFoundException("Booking not found"); if (!booking) throw new NotFoundException("Booking not found");
console.log("bookingbooking",booking) const amountMinor = Math.round(Number(booking.totalAmount));
const amountMinor = Math.round(Number(booking.totalAmount) * 100);
console.log("amountminor",amountMinor)
const snapshot = await this.paymentClient.initiate({ const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT, service: PaymentServiceEnum.FREIGHT,
@@ -159,8 +157,8 @@ export class PaymentService {
provider: dto.method as unknown as ProviderMethod, provider: dto.method as unknown as ProviderMethod,
platform: dto.platform, platform: dto.platform,
payerAccount: dto.payerAccount, payerAccount: dto.payerAccount,
returnUrl: dto.returnUrl ?? process.env.PAYMENT_RETURN_URL, returnUrl:'https://edrfreight.triaplc.com/payment/success',
failureUrl: dto.failureUrl ?? process.env.PAYMENT_FAILURE_URL, failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
}); });
const intent = await this.syncIntentProjection(booking.id, booking, snapshot); const intent = await this.syncIntentProjection(booking.id, booking, snapshot);

View File

@@ -49,8 +49,17 @@ export class SchedulingRescheduleService {
if (!schedule) { if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`); 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) { 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 ?? []) const currentOnSchedule = (schedule.scheduleBookings ?? [])
@@ -156,10 +165,24 @@ export class SchedulingRescheduleService {
} }
} }
const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, { // A train can be rescheduled even with no bookings (e.g. moved for
bookingIds: dto.finalBookingIds, // maintenance). assignBookingsToSchedule requires at least one booking, so
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT', // 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({ await this.schedulingRescheduleRepository.createEvent({
trainScheduleId: scheduleId, trainScheduleId: scheduleId,

View File

@@ -721,27 +721,37 @@ export class TrainSchedulingService {
: null; : null;
if (route) { if (route) {
const origin = route.originYard; // `route.milestones` is the complete ordered corridor and already includes
const destination = route.destinationYard; // 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( const milestones = [...(route.milestones ?? [])].sort(
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo, (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({ stations.push({
sequenceNo: 0, sequenceNo: 0,
yardId: route.originYardId, yardId: route.originYardId,
label: origin?.label ?? origin?.code ?? 'Origin', label: origin?.label ?? origin?.code ?? 'Origin',
code: origin?.code ?? '', 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({ stations.push({
sequenceNo: milestones.length + 1, sequenceNo: 1,
yardId: route.destinationYardId, yardId: route.destinationYardId,
label: destination?.label ?? destination?.code ?? 'Destination', label: destination?.label ?? destination?.code ?? 'Destination',
code: destination?.code ?? '', code: destination?.code ?? '',
@@ -775,8 +785,16 @@ export class TrainSchedulingService {
const stations = await this.buildScheduleStations(schedule); const stations = await this.buildScheduleStations(schedule);
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId); 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 const currentSequenceNo = events.length
? Math.max(...events.map((e) => e.sequenceNo)) ? Math.max(...events.map(resolvedSeq))
: -1; : -1;
return { return {
@@ -790,13 +808,19 @@ export class TrainSchedulingService {
actualArrivalAt: schedule.actualArrivalAt actualArrivalAt: schedule.actualArrivalAt
? schedule.actualArrivalAt.toISOString() ? schedule.actualArrivalAt.toISOString()
: null, : null,
scheduledDepartureAt: schedule.scheduledDepartureDate
? schedule.scheduledDepartureDate.toISOString()
: null,
scheduledArrivalAt: schedule.scheduledArrivalDate
? schedule.scheduledArrivalDate.toISOString()
: null,
origin: stations[0]?.label ?? null, origin: stations[0]?.label ?? null,
destination: stations[stations.length - 1]?.label ?? null, destination: stations[stations.length - 1]?.label ?? null,
stations, stations,
currentSequenceNo, currentSequenceNo,
checkpoints: events.map((e) => ({ checkpoints: events.map((e) => ({
id: e.id, id: e.id,
sequenceNo: e.sequenceNo, sequenceNo: resolvedSeq(e),
yardId: e.yardId, yardId: e.yardId,
label: e.yard?.label ?? e.yard?.code ?? null, label: e.yard?.label ?? e.yard?.code ?? null,
kind: e.kind, kind: e.kind,

View File

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

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateVehicleDto } from './create-vehicle.dto';
export class UpdateVehicleDto extends PartialType(CreateVehicleDto) {}

View File

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

View File

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

View File

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

View File

@@ -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<Vehicle> {
constructor(
@InjectRepository(Vehicle)
repository: Repository<Vehicle>,
) {
super(repository);
}
}

View File

@@ -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<Vehicle>,
) {}
async create(dto: CreateVehicleDto): Promise<Vehicle> {
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<Vehicle> {
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<Vehicle> {
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<void> {
await this.findById(id);
await this.vehicleRepo.softDelete(id);
}
}

View File

@@ -42,8 +42,13 @@ export class DemoFreightDataSeeder {
async run() { async run() {
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
await this.seedWagons(manager); // Demo freight data (wagons + approval rules) disabled — keep only the
await this.seedApprovalRules(manager); // 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); await this.seedStaffUsers(manager);
}); });
} }

View File

@@ -12,9 +12,11 @@ import {
Text, Text,
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { Badge as MantineBadge } from "@mantine/core";
import { import {
CheckCircle2, CheckCircle2,
CircleDollarSign, CircleDollarSign,
LayoutGrid,
Loader2, Loader2,
RotateCcw, RotateCcw,
Search, Search,
@@ -24,6 +26,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs"; import Breadcrumbs from "@/components/ui/Breadcrumbs";
import "@/components/overview/overview.css";
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments"; import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
import type { import type {
PaymentMethod, PaymentMethod,
@@ -39,11 +42,16 @@ import {
} from "@edr/ui-common"; } from "@edr/ui-common";
const STATUS_TABS = [ const STATUS_TABS = [
{ key: "all", label: "All", statuses: undefined as string | undefined }, { key: "all", label: "All", statuses: undefined as string | undefined, icon: LayoutGrid },
{ key: "success", label: "Success", statuses: "success" }, { key: "success", label: "Success", statuses: "success", icon: CheckCircle2 },
{ key: "processing", label: "Processing", statuses: "processing,action-required" }, {
{ key: "failed", label: "Failed", statuses: "failed,canceled" }, key: "processing",
{ key: "refunded", label: "Refunded", statuses: "refunded" }, 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; ] as const;
type StatusTabKey = (typeof STATUS_TABS)[number]["key"]; type StatusTabKey = (typeof STATUS_TABS)[number]["key"];
@@ -166,6 +174,20 @@ export default function PaymentsPage() {
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0)); const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
const tabCounts: Record<StatusTabKey, number | undefined> = {
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<PaymentRow>[] = [ const columns: ColumnDef<PaymentRow>[] = [
{ {
id: "order", id: "order",
@@ -274,13 +296,48 @@ export default function PaymentsPage() {
setStatusTab((value as StatusTabKey) ?? "all"); setStatusTab((value as StatusTabKey) ?? "all");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}} }}
variant="pills"
color="green"
keepMounted={false}
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
> >
<Tabs.List> <Tabs.List>
{STATUS_TABS.map((t) => ( {STATUS_TABS.map((t) => {
<Tabs.Tab key={t.key} value={t.key}> const isActive = statusTab === t.key;
{t.label} const count = tabCounts[t.key];
</Tabs.Tab> const Icon = t.icon;
))} return (
<Tabs.Tab
key={t.key}
value={t.key}
leftSection={<Icon size={17} strokeWidth={1.85} />}
rightSection={
count !== undefined ? (
<MantineBadge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "green" : "gray"}
styles={
isActive
? {
root: {
background: "rgba(255,255,255,0.9)",
color: "#15805f",
},
}
: undefined
}
>
{count}
</MantineBadge>
) : undefined
}
>
{t.label}
</Tabs.Tab>
);
})}
</Tabs.List> </Tabs.List>
</Tabs> </Tabs>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -36,6 +36,8 @@ import EditBookingPage from "./pages/bookings/EditBookingPage";
import MyBookings from "./pages/bookings/MyBookings"; import MyBookings from "./pages/bookings/MyBookings";
import NewBookingPage from "./pages/bookings/NewBookingPage"; import NewBookingPage from "./pages/bookings/NewBookingPage";
import CheckPaymentPage from "./pages/payments/CheckPaymentPage"; import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
import TrackingPage from "./pages/tracking/TrackingPage"; import TrackingPage from "./pages/tracking/TrackingPage";
function FullScreenSpinner() { function FullScreenSpinner() {
@@ -158,6 +160,9 @@ const App = () => {
path="/booking/check-status/:orderId" path="/booking/check-status/:orderId"
element={<CheckPaymentPage />} element={<CheckPaymentPage />}
/> />
{/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */}
<Route path="/payment/success" element={<PaymentSuccessPage />} />
<Route path="/payment/failure" element={<PaymentFailurePage />} />
{/* Auth pages — inaccessible once logged in */} {/* Auth pages — inaccessible once logged in */}
<Route element={<RedirectIfAuthed />}> <Route element={<RedirectIfAuthed />}>

View File

@@ -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 = () => (
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
<svg
className="absolute -bottom-24 -left-24 h-[420px] w-[420px] text-white/[0.07]"
viewBox="0 0 400 400"
fill="none"
>
{[0, 1, 2, 3, 4, 5].map((ring) => (
<circle key={ring} cx="200" cy="200" r={60 + ring * 36} stroke="currentColor" strokeWidth="1" />
))}
</svg>
<div className="absolute right-0 top-0 h-40 w-40 rounded-full bg-white/[0.06] blur-2xl" />
</div>
);
const RightPanelDecor = () => (
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-primary/[0.06] blur-3xl" />
<div className="absolute -bottom-12 left-1/4 h-40 w-40 rounded-full bg-primary/[0.04] blur-2xl" />
<svg className="absolute inset-0 h-full w-full text-gray-200/40" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="auth-grid" width="28" height="28" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.75" fill="currentColor" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#auth-grid)" />
</svg>
</div>
);
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<AuthShellProps, "tagline" | "taglineBody">) => (
<div className="relative hidden shrink-0 flex-col overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.1)] lg:flex lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]">
<img
src={LOGIN_IMAGE}
alt="Ethio Djibouti Railway"
className="absolute inset-0 h-full w-full object-cover object-center"
/>
<div className="absolute inset-0 bg-gradient-to-br from-[#0a2e1a]/92 via-[#0f4a2a]/55 to-[#1a5c34]/45" />
<LeftPanelDecor />
<div className="relative z-10 flex shrink-0 items-center justify-between px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
<img src={EDR_LOGO} alt="EDR Freight" className="h-7 w-auto brightness-0 invert sm:h-9" />
<a
href="#"
className="flex items-center gap-1.5 rounded-full border border-white/70 bg-white/10 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-sm transition-colors hover:bg-white/20 sm:px-4 sm:py-2 sm:text-sm"
>
Support
<ArrowUpRight className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</a>
</div>
<div className="relative z-10 mt-auto hidden px-8 pb-8 lg:block">
<div className="max-w-md rounded-2xl border border-white/15 bg-black/30 p-5 backdrop-blur-md">
<div className="mb-2 flex items-center gap-2">
<div className="h-2 w-2 shrink-0 rounded-full bg-primary" />
<span className="text-sm font-semibold text-white">
{tagline ?? "Empower Your Freight Operations"}
</span>
</div>
<p className="text-sm leading-relaxed text-white/85">
{taglineBody ??
"Sign in to manage shipments, track cargo, and run logistics operations on the Ethio Djibouti Railway freight platform."}
</p>
</div>
</div>
</div>
);
const LanguageSelector = () => (
<div className="flex cursor-pointer items-center gap-1.5 rounded-full border border-gray-200/80 bg-white px-3 py-1.5 text-sm text-gray-600 shadow-sm">
<Globe className="h-4 w-4 text-gray-500" />
<span>Eng</span>
<ChevronDown className="h-4 w-4 text-gray-400" />
</div>
);
const FormFooter = () => (
<div className="relative z-10 flex shrink-0 flex-col items-center justify-between gap-3 border-t border-gray-100 px-4 py-4 text-xs text-gray-400 sm:flex-row sm:gap-4 sm:px-6 sm:py-4 lg:px-8 lg:pb-6">
<span className="shrink-0">© 2026 EDR Freight</span>
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-end sm:gap-6">
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
Terms &amp; Conditions
</a>
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
Privacy Policy
</a>
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
Help &amp; Support
</a>
</div>
</div>
);
export default function AuthShell({ children, tagline, taglineBody }: AuthShellProps) {
return (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<div
className="flex h-[100dvh] overflow-hidden bg-[#e8eaef] px-4 py-3 antialiased sm:px-6 sm:py-4 md:px-[70px]"
style={{ fontFamily: "'Outfit', var(--font-sans)" }}
>
<div className="flex h-full min-h-0 w-full flex-col gap-3 lg:flex-row lg:gap-4">
<LeftPanel tagline={tagline} taglineBody={taglineBody} />
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-2xl bg-[#f5f7fa] shadow-[0_8px_32px_rgba(15,23,42,0.08)] lg:basis-1/2 lg:rounded-[28px]">
<RightPanelDecor />
<div className="relative z-10 flex shrink-0 justify-end px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
<LanguageSelector />
</div>
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
<div className="flex min-h-full justify-center px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
<div className="my-auto w-full max-w-xl rounded-3xl border border-gray-100/80 bg-white px-5 py-6 shadow-[0_4px_24px_rgba(15,23,42,0.06)] sm:px-7 sm:py-8 lg:px-9 lg:py-9">
{children}
</div>
</div>
</div>
<FormFooter />
</div>
</div>
</div>
</>
);
}

View File

@@ -2,6 +2,7 @@ import { Box, Group, Stack, Text } from "@mantine/core";
import { memo } from "react"; import { memo } from "react";
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants"; import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
import { Stepper } from "./Stepper"; import { Stepper } from "./Stepper";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
interface BookingRowProps { interface BookingRowProps {
booking: any; booking: any;
@@ -18,6 +19,10 @@ export const BookingRow = memo(function BookingRow({
const Icon = cfg.icon; const Icon = cfg.icon;
const AIcon = cfg.action.icon; const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind]; 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 origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest = const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"; booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
@@ -78,25 +83,29 @@ export const BookingRow = memo(function BookingRow({
{cfg.badgeLabel} {cfg.badgeLabel}
</Text> </Text>
</Group> </Group>
<Group {canPay ? (
gap={5} <PayNowButton booking={booking} size="sm" />
align="center" ) : (
px={15} <Group
py={8} gap={5}
bg={ap.bg} align="center"
bd={ap.bd} px={15}
className="cursor-pointer rounded-[9px]" py={8}
> bg={ap.bg}
<Text fz={13} fw={700} c={ap.c}> bd={ap.bd}
{cfg.action.label} className="cursor-pointer rounded-[9px]"
</Text> >
{AIcon && ( <Text fz={13} fw={700} c={ap.c}>
<AIcon {cfg.action.label}
size={15} </Text>
color={ap.c === "white" ? "#fff" : cv("edr-text")} {AIcon && (
/> <AIcon
)} size={15}
</Group> color={ap.c === "white" ? "#fff" : cv("edr-text")}
/>
)}
</Group>
)}
</Stack> </Stack>
</Group> </Group>
</Box> </Box>

View File

@@ -54,7 +54,7 @@ export const InvoicesSection = memo(function InvoicesSection({
Outstanding balance Outstanding balance
</Text> </Text>
<Text fz={24} fw={800} mt={4} c="edr-text"> <Text fz={24} fw={800} mt={4} c="edr-text">
{formatCurrency(totalOutstanding || 377500, "ETB")} {formatCurrency(totalOutstanding || 0, "ETB")}
</Text> </Text>
<Group <Group
justify="space-between" justify="space-between"

View File

@@ -1,39 +1,51 @@
import { Alert, Box, Button, Group, PasswordInput, SegmentedControl, Stack, Text, TextInput } from "@mantine/core"; import { type FormEvent, useState } from "react";
import { ArrowRight, Mail, Phone } from "lucide-react"; import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
import AuthLayout from "@/components/auth/AuthLayout"; import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
import PhoneInput from "@/components/auth/PhoneInput";
const EDR_LOGO = "/assets/logo.svg";
type LoginMethod = "email" | "phone"; type LoginMethod = "email" | "phone";
const loginMethods: Array<{
value: LoginMethod;
label: string;
icon: typeof Mail;
placeholder: string;
}> = [
{ value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" },
{ value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" },
];
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { login } = useAuth(); const { login } = useAuth();
const [method, setMethod] = useState<LoginMethod>("email"); const [method, setMethod] = useState<LoginMethod>("email");
const [identifier, setIdentifier] = useState(""); const [identifier, setIdentifier] = useState("");
const [countryCode, setCountryCode] = useState("+251"); const [countryCode] = useState("+251");
const [phoneNumber, setPhoneNumber] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => { const currentMethod = loginMethods.find((item) => item.value === method)!;
e.preventDefault();
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
const loginId = const loginId =
method === "email" method === "email"
? identifier ? identifier
: `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`; : `${countryCode}${identifier.startsWith("0") ? identifier.slice(1) : identifier}`;
const result = await login({ email: loginId, password }); const result = await login({ email: loginId, password });
if (result.success) { if (result.success) {
const from = (location.state as { from?: { pathname: string } } | null) const from = (location.state as { from?: { pathname: string } } | null)?.from
?.from?.pathname; ?.pathname;
navigate(from ?? "/portal", { replace: true }); navigate(from ?? "/portal", { replace: true });
} else { } else {
setError(result.error.message); setError(result.error.message);
@@ -46,154 +58,105 @@ export default function LoginPage() {
}; };
return ( return (
<AuthLayout <AuthShell>
left={{ <form className="flex w-full flex-col" onSubmit={handleSubmit}>
badge: "Welcome Back", <div className="mb-4 flex justify-center sm:mb-6">
title: "Sign in to your freight operations account", <img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
description: </div>
"Access your dashboard to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows.",
features: [ <div className="mb-4 space-y-1.5 text-center sm:mb-5">
"Real-time shipment tracking", <h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
"Secure logistics management",
"Enterprise-grade operations",
"Multi-corridor freight monitoring",
],
stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" },
}}
>
<Stack gap="xs" mb="lg">
<Box
w={48}
h={48}
bg="edr-soft"
className="flex items-center justify-center rounded-2xl"
>
<Mail size={22} color="var(--mantine-color-edr-green-6)" />
</Box>
<Box>
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
Welcome back Welcome back
</Text> </h1>
<Text fz={15} c="edr-muted" mt={4}> <p className="text-sm leading-relaxed text-gray-500">
Enter your credentials to access your portal Enter your credentials to access your freight portal.
</Text> </p>
</Box> </div>
</Stack>
<form onSubmit={handleSubmit}> <div className="flex w-full flex-col gap-4">
<Stack gap="md"> <div className="space-y-1.5">
<SegmentedControl <label className="text-sm font-medium text-gray-800">Sign in method</label>
value={method} <div className="relative">
onChange={(v) => setMethod(v as LoginMethod)} <select
fullWidth value={method}
radius="md" onChange={(event) => setMethod(event.target.value as LoginMethod)}
data={[ disabled={loading}
{ className={`${fieldClass} appearance-none pr-10`}
label: (
<Group gap={6} justify="center" wrap="nowrap">
<Mail size={15} />
<Text size="sm">Email</Text>
</Group>
),
value: "email",
},
{
label: (
<Group gap={6} justify="center" wrap="nowrap">
<Phone size={15} />
<Text size="sm">Phone</Text>
</Group>
),
value: "phone",
},
]}
/>
{method === "email" ? (
<TextInput
label="Email Address"
placeholder="name@company.com"
type="email"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
required
disabled={loading}
/>
) : (
<PhoneInput
disabled={loading}
countryCode={{
value: countryCode,
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
setCountryCode(e.target.value),
}}
phone={{
value: phoneNumber,
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
setPhoneNumber(e.target.value),
}}
/>
)}
<Box>
<Group justify="space-between" mb={6}>
<Text size="sm" fw={500} c="edr-text">
Password
</Text>
<Button
variant="transparent"
size="xs"
c="edr-green.6"
p={0}
h="auto"
fz={12}
> >
Forgot password? {loginMethods.map((item) => (
</Button> <option key={item.value} value={item.value}>
</Group> {item.label}
<PasswordInput </option>
placeholder="••••••••" ))}
value={password} </select>
onChange={(e) => setPassword(e.target.value)} <ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
required </div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
{currentMethod.label} <span className="text-red-500">*</span>
</label>
<input
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
placeholder={currentMethod.placeholder}
disabled={loading} disabled={loading}
className={fieldClass}
/> />
</Box> </div>
{error && ( <div className="space-y-1.5">
<Alert color="red" variant="light" radius="md"> <div className="flex items-center justify-between">
<label className="text-sm font-medium text-gray-800">
Password <span className="text-red-500">*</span>
</label>
<a href="#" className="text-xs font-semibold text-primary hover:underline">
Forgot password?
</a>
</div>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Enter your password"
disabled={loading}
className={`${fieldClass} pr-11`}
/>
<button
type="button"
onClick={() => setShowPassword((current) => !current)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
</div>
{error ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
{error} {error}
</Alert> </div>
)} ) : null}
<Button <button type="submit" disabled={loading} className={primaryButtonClass}>
type="submit" {loading ? "Signing in..." : "Sign In"}
disabled={loading} </button>
loading={loading}
size="lg"
color="edr-green"
fullWidth
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
>
Sign In
</Button>
<Text size="sm" c="edr-muted" ta="center"> <p className="text-center text-sm text-gray-500">
Don't have an account?{" "} Don&apos;t have an account?{" "}
<Button <button
variant="transparent" type="button"
p={0}
h="auto"
c="edr-green.6"
fw={600}
fz="sm"
onClick={() => navigate("/signup")} onClick={() => navigate("/signup")}
className="font-semibold text-primary hover:underline"
> >
Create an account Create an account
</Button> </button>
</Text> </p>
</Stack> </div>
</form> </form>
</AuthLayout> </AuthShell>
); );
} }

View File

@@ -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 { 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 { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { z } from "zod"; import { z } from "zod";
@@ -9,8 +8,9 @@ import { z } from "zod";
import { userType } from "@/enums/userType"; import { userType } from "@/enums/userType";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
import type { SignupPayload } from "@/types/auth"; import type { SignupPayload } from "@/types/auth";
import AuthLayout from "@/components/auth/AuthLayout"; import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
import PhoneInput from "@/components/auth/PhoneInput";
const EDR_LOGO = "/assets/logo.svg";
const passwordRequirements = [ const passwordRequirements = [
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 }, { 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) }, { label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
] as const; ] 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 const userSchema = z
.object({ .object({
email: z.string().email("Invalid email address"), email: z.string().email("Invalid email address"),
countryCode: z.string().min(1, "Country code is required"), countryCode: z.literal(ETHIOPIA_COUNTRY_CODE),
phone: z.string().min(9, "Phone number is too short").max(9, "Phone number is too long"), phone: z
.string()
.min(1, "Phone number is required")
.refine(isValidEthiopianMobile, "Enter a valid mobile number (e.g. 0912345678)"),
userType: z.string(), userType: z.string(),
firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }), 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() }), 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<typeof userSchema>; type FormData = z.infer<typeof userSchema>;
const errorText = (msg?: string) =>
msg ? <p className="mt-1 text-xs text-red-600">{msg}</p> : null;
export default function SignupPage() { export default function SignupPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { signup } = useAuth(); const { signup } = useAuth();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const { const {
register, register,
@@ -59,7 +75,7 @@ export default function SignupPage() {
resolver: zodResolver(userSchema), resolver: zodResolver(userSchema),
defaultValues: { defaultValues: {
email: "", email: "",
countryCode: "+251", countryCode: ETHIOPIA_COUNTRY_CODE,
phone: "", phone: "",
userType: userType.individual, userType: userType.individual,
firstName: { en: "", am: "" }, firstName: { en: "", am: "" },
@@ -73,7 +89,8 @@ export default function SignupPage() {
setError(null); setError(null);
setLoading(true); setLoading(true);
try { 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 = { const payload: SignupPayload = {
email: data.email, email: data.email,
username: data.email, username: data.email,
@@ -102,145 +119,194 @@ export default function SignupPage() {
const passwordValue = watch("password") ?? ""; const passwordValue = watch("password") ?? "";
return ( return (
<AuthLayout <AuthShell
left={{ tagline="Smart Freight Operations"
badge: "Smart Freight Operations", taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
title: "Create your freight operations account",
description:
"Join EDR Freight to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows across Ethiopia and Djibouti.",
features: [
"Real-time shipment tracking",
"Secure logistics management",
"Enterprise-grade operations",
"Multi-corridor freight monitoring",
],
stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" },
}}
> >
<Stack gap="xs" mb="lg"> <form className="flex w-full flex-col" onSubmit={handleSubmit(onSubmit)}>
<Box w={48} h={48} bg="edr-soft" className="flex items-center justify-center rounded-2xl"> <div className="mb-4 flex justify-center sm:mb-6">
<UserPlus size={22} color="var(--mantine-color-edr-green-6)" /> <img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
</Box> </div>
<Box>
<Text fz={24} fw={800} c="edr-text" className="tracking-tight"> <div className="mb-4 space-y-1.5 text-center sm:mb-5">
Create Account <h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
</Text> Create account
<Text fz={15} c="edr-muted" mt={4}> </h1>
<p className="text-sm leading-relaxed text-gray-500">
Register to access EDR Freight services. Register to access EDR Freight services.
</Text> </p>
</Box> </div>
</Stack>
{error && ( <div className="flex w-full flex-col gap-4">
<Alert color="red" variant="light" radius="md" mb="md"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{error} <div className="space-y-1.5">
</Alert> <label className="text-sm font-medium text-gray-800">
)} First name <span className="text-red-500">*</span>
</label>
<input
placeholder="John"
disabled={loading}
className={fieldClass}
{...register("firstName.en")}
/>
{errorText(errors.firstName?.en?.message)}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Last name <span className="text-red-500">*</span>
</label>
<input
placeholder="Doe"
disabled={loading}
className={fieldClass}
{...register("lastName.en")}
/>
{errorText(errors.lastName?.en?.message)}
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)}> <div className="space-y-1.5">
<Stack gap="md"> <label className="text-sm font-medium text-gray-800">
<SimpleGrid cols={2} spacing="md"> Email <span className="text-red-500">*</span>
<TextInput </label>
label="First Name" <input
placeholder="John" type="email"
placeholder="john@example.com"
disabled={loading} disabled={loading}
error={errors.firstName?.en?.message} className={fieldClass}
{...register("firstName.en")} {...register("email")}
/> />
<TextInput {errorText(errors.email?.message)}
label="Last Name" </div>
placeholder="Doe"
disabled={loading}
error={errors.lastName?.en?.message}
{...register("lastName.en")}
/>
</SimpleGrid>
<TextInput <div className="space-y-1.5">
label="Email Address" <label htmlFor="signup-phone" className="text-sm font-medium text-gray-800">
placeholder="john@example.com" Phone <span className="text-red-500">*</span>
type="email" </label>
disabled={loading} <input type="hidden" {...register("countryCode")} />
error={errors.email?.message} <div
{...register("email")} className={`flex overflow-hidden rounded-xl border bg-white shadow-sm transition-all duration-200 hover:border-gray-300 focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/10 ${
/> errors.phone ? "border-red-300 focus-within:border-red-400 focus-within:ring-red-100" : "border-gray-200/90"
}`}
>
<span className="flex h-11 shrink-0 items-center border-r border-gray-200/90 bg-gray-50 px-3 text-sm font-medium text-gray-600">
{ETHIOPIA_COUNTRY_CODE}
</span>
<input
id="signup-phone"
type="tel"
inputMode="numeric"
autoComplete="tel-national"
placeholder="0912345678"
maxLength={10}
disabled={loading}
className="h-11 min-w-0 flex-1 border-0 bg-transparent px-4 text-sm text-gray-900 outline-none placeholder:text-gray-400"
{...register("phone", {
onChange: (event) => {
event.target.value = event.target.value.replace(/\D/g, "").slice(0, 10);
},
})}
/>
</div>
{errorText(errors.phone?.message)}
</div>
<PhoneInput <div className="space-y-1.5">
disabled={loading} <label className="text-sm font-medium text-gray-800">
countryCode={{ ...register("countryCode") }} Password <span className="text-red-500">*</span>
phone={{ ...register("phone") }} </label>
countryCodeError={errors.countryCode} <div className="relative">
phoneError={errors.phone} <input
/> type={showPassword ? "text" : "password"}
placeholder="Create a strong password"
<Box> disabled={loading}
<PasswordInput className={`${fieldClass} pr-11`}
label="Password" {...register("password")}
placeholder="Create a strong password" />
disabled={loading} <button
error={errors.password?.message} type="button"
{...register("password")} onClick={() => setShowPassword((current) => !current)}
/> className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
{passwordValue.length > 0 && ( aria-label={showPassword ? "Hide password" : "Show password"}
<Stack gap={4} mt={8}> >
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
{errorText(errors.password?.message)}
{passwordValue.length > 0 ? (
<div className="mt-2 space-y-1">
{passwordRequirements.map((req) => { {passwordRequirements.map((req) => {
const met = req.test(passwordValue); const met = req.test(passwordValue);
return ( return (
<Group key={req.label} gap={6} align="center" wrap="nowrap"> <div key={req.label} className="flex items-center gap-2">
<ThemeIcon <span
size={16} className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
radius="xl" met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500"
variant={met ? "filled" : "light"} }`}
color={met ? "edr-green" : "gray"}
> >
{met ? <Check size={10} /> : <X size={10} />} {met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
</ThemeIcon> </span>
<Text size="xs" c={met ? "edr-green.7" : "edr-muted"}> <span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
{req.label} {req.label}
</Text> </span>
</Group> </div>
); );
})} })}
</Stack> </div>
)} ) : null}
</Box> </div>
<PasswordInput <div className="space-y-1.5">
label="Confirm Password" <label className="text-sm font-medium text-gray-800">
placeholder="Re-enter your password" Confirm password <span className="text-red-500">*</span>
disabled={loading} </label>
error={errors.confirmPassword?.message} <div className="relative">
{...register("confirmPassword")} <input
/> type={showConfirm ? "text" : "password"}
placeholder="Re-enter your password"
disabled={loading}
className={`${fieldClass} pr-11`}
{...register("confirmPassword")}
/>
<button
type="button"
onClick={() => setShowConfirm((current) => !current)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
aria-label={showConfirm ? "Hide password" : "Show password"}
>
{showConfirm ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
{errorText(errors.confirmPassword?.message)}
</div>
<Button {error ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
{error}
</div>
) : null}
<button
type="submit" type="submit"
disabled={loading} disabled={loading}
loading={loading} className={`${primaryButtonClass} flex items-center justify-center gap-2`}
size="lg"
color="edr-green"
fullWidth
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
> >
Create Account {loading ? "Creating account..." : "Create Account"}
</Button> {!loading ? <ArrowRight className="h-4 w-4" /> : null}
</button>
<Text size="sm" c="edr-muted" ta="center"> <p className="text-center text-sm text-gray-500">
Already have an account?{" "} Already have an account?{" "}
<Button <button
variant="transparent" type="button"
p={0}
h="auto"
c="edr-green.6"
fw={600}
fz="sm"
onClick={() => navigate("/login")} onClick={() => navigate("/login")}
className="font-semibold text-primary hover:underline"
> >
Sign In Sign In
</Button> </button>
</Text> </p>
</Stack> </div>
</form> </form>
</AuthLayout> </AuthShell>
); );
} }

View File

@@ -23,6 +23,7 @@ import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { SubmitBookingResponse } from "@/services/bookings.service";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { REQUIRED_DOC_FIELDS } from "./constants"; import { REQUIRED_DOC_FIELDS } from "./constants";
@@ -60,6 +61,8 @@ export function DraftBookingView({
const [cancelDialogOpen, setCancelDialogOpen] = useState(false); const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState(""); const [cancelReason, setCancelReason] = useState("");
const [docError, setDocError] = useState(""); const [docError, setDocError] = useState("");
const [priceChangeModal, setPriceChangeModal] =
useState<SubmitBookingResponse | null>(null);
const anyFileSelected = Object.values(selectedFiles).some(Boolean); const anyFileSelected = Object.values(selectedFiles).some(Boolean);
const uploadedCodes = useMemo( const uploadedCodes = useMemo(
@@ -72,9 +75,11 @@ export function DraftBookingView({
const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length; const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length;
const { data: generatedPricing } = useQuery( const { data: generatedPricing } = useQuery(
api.bookings.generatePrice.queryOptions({ input: { id: booking.id }, api.bookings.generatePrice.queryOptions({
input: { id: booking.id },
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown, enabled:
(booking.status === "DRAFT" || booking.status === "CHANGES_REQUESTED") &&
!booking.pricingBreakdown,
}), }),
); );
const pricing = (booking.pricingBreakdown ?? const pricing = (booking.pricingBreakdown ??
@@ -82,8 +87,17 @@ export function DraftBookingView({
null) as Freight.PricingBreakdown | null; null) as Freight.PricingBreakdown | null;
const uploadMutation = useMutation({ const uploadMutation = useMutation({
mutationFn: (files: Record<string, File | File[] | null>) => mutationFn: async (files: Record<string, File | File[] | null>) => {
api.bookings.uploadDocuments.call({ id: booking.id, files }), 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: () => { onSuccess: () => {
setSelectedFiles({}); setSelectedFiles({});
setDocError(""); setDocError("");
@@ -93,7 +107,20 @@ export function DraftBookingView({
const submitMutation = useMutation({ const submitMutation = useMutation({
mutationFn: () => api.bookings.submit.call({ id: booking.id }), 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: () => { onSuccess: () => {
setPriceChangeModal(null);
onBookingUpdated(); onBookingUpdated();
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
}, },
@@ -155,7 +182,12 @@ export function DraftBookingView({
/> />
<MutationErrors <MutationErrors
mutations={[uploadMutation, submitMutation, cancelMutation]} mutations={[
uploadMutation,
submitMutation,
confirmSubmitMutation,
cancelMutation,
]}
/> />
<StatusHero booking={booking}> <StatusHero booking={booking}>
@@ -163,7 +195,9 @@ export function DraftBookingView({
booking.latestChangeRequestNote ? ( booking.latestChangeRequestNote ? (
<ActionRequiredBanner <ActionRequiredBanner
title="Review the requested changes, then resubmit." title="Review the requested changes, then resubmit."
onAction={() => navigate(`/bookings/${booking.id}/edit`)} onAction={() =>
navigate(`/bookings/${booking.id}/edit?section=documents`)
}
> >
{booking.latestChangeRequestNote} {booking.latestChangeRequestNote}
</ActionRequiredBanner> </ActionRequiredBanner>
@@ -260,6 +294,8 @@ export function DraftBookingView({
const isUploaded = uploadedCodes.has(doc.key); const isUploaded = uploadedCodes.has(doc.key);
const selected = selectedFiles[doc.key]; const selected = selectedFiles[doc.key];
const file = booking.files?.find((f) => f.code === doc.key); const file = booking.files?.find((f) => f.code === doc.key);
const allowReplace =
!isUploaded || booking.status === "CHANGES_REQUESTED";
return ( return (
<DocRow <DocRow
key={doc.key} key={doc.key}
@@ -276,13 +312,19 @@ export function DraftBookingView({
isUploaded ? "verified" : selected ? "ready" : "missing" isUploaded ? "verified" : selected ? "ready" : "missing"
} }
action={ action={
isUploaded ? ( isUploaded && !allowReplace ? (
<IconSquare <IconSquare
href={file?.signedUrl ?? file?.url} href={file?.signedUrl ?? file?.url}
icon={<Download size={16} />} icon={<Download size={16} />}
/> />
) : ( ) : (
<> <>
{isUploaded && (
<IconSquare
href={file?.signedUrl ?? file?.url}
icon={<Download size={16} />}
/>
)}
<input <input
ref={(el) => { ref={(el) => {
fileInputRefs.current[doc.key] = el; fileInputRefs.current[doc.key] = el;
@@ -318,7 +360,7 @@ export function DraftBookingView({
}, },
}} }}
> >
{selected ? "Change" : "Add"} {selected ? "Change" : isUploaded ? "Replace" : "Add"}
</Button> </Button>
{selected && ( {selected && (
<ActionIcon <ActionIcon
@@ -375,6 +417,72 @@ export function DraftBookingView({
} }
/> />
<Modal
opened={priceChangeModal !== null}
onClose={() => setPriceChangeModal(null)}
title={<Text fw={700}>Price has changed</Text>}
radius="lg"
centered
>
{priceChangeModal && (
<Stack gap="md">
<Text size="sm" c="dimmed">
{priceChangeModal.message ??
"The booking price has been updated. Confirm to submit with the new total."}
</Text>
{priceChangeModal.previousTotalAmount !== undefined && (
<Group justify="space-between">
<Text size="sm" c="dimmed">
Previous total
</Text>
<Text size="sm" td="line-through">
{priceChangeModal.previousTotalAmount.toLocaleString()}{" "}
{priceChangeModal.currency}
</Text>
</Group>
)}
<Group justify="space-between">
<Text fw={700}>New total</Text>
<Text fw={800} c="edr-green">
{priceChangeModal.totalAmount.toLocaleString()}{" "}
{priceChangeModal.currency}
</Text>
</Group>
{priceChangeModal.lineItems && priceChangeModal.lineItems.length > 0 && (
<Stack gap={4}>
{priceChangeModal.lineItems.map((item) => (
<Group key={item.code} justify="space-between">
<Text size="sm" c="dimmed">
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setPriceChangeModal(null)}
>
Review later
</Button>
<Button
color="edr-green"
radius="md"
loading={confirmSubmitMutation.isPending}
onClick={() => confirmSubmitMutation.mutate()}
>
Confirm & submit
</Button>
</Group>
</Stack>
)}
</Modal>
<Modal <Modal
opened={cancelDialogOpen} opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)} onClose={() => setCancelDialogOpen(false)}

View File

@@ -163,6 +163,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
} }
}} }}
amountLabel={pricing ? priceTotal(pricing) : undefined} amountLabel={pricing ? priceTotal(pricing) : undefined}
currency={pricing?.currency ?? booking.paymentCurrency}
processing={payMutation.isPending} processing={payMutation.isPending}
error={ error={
payMutation.isError payMutation.isError

View File

@@ -69,11 +69,18 @@ export function PaymentDeadlineCard({
return () => clearInterval(interval); return () => clearInterval(interval);
}, [deadlineMs]); }, [deadlineMs]);
const accentBg = remaining.expired ? "#FBEAE7" : "#FDF3E0"; const accentBg = remaining.expired ? "#FBEAE7" : "#FEF6E6";
const accentFg = remaining.expired ? "#C0392B" : "#9A5B00"; const accentFg = remaining.expired ? "#C0392B" : "#B07D14";
return ( return (
<SectionCard p={22}> <SectionCard
p={22}
style={
remaining.expired
? undefined
: { borderColor: "#F2E4C4", boxShadow: "0 0 0 1px #FBEAC2" }
}
>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<CardTitle>Payment deadline</CardTitle> <CardTitle>Payment deadline</CardTitle>
<Group <Group

View File

@@ -1,6 +1,6 @@
import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core"; import { Box, Button, Group, Image, Modal, Stack, Text } from "@mantine/core";
import { Smartphone, type LucideIcon } from "lucide-react"; import { Check, ShieldCheck } from "lucide-react";
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { PaymentMethod } from "@/services/payments.service"; import type { PaymentMethod } from "@/services/payments.service";
@@ -8,7 +8,10 @@ interface ProviderOption {
method: PaymentMethod; method: PaymentMethod;
label: string; label: string;
description: string; description: string;
icon: LucideIcon; logo: string;
/** Currencies this provider settles in. */
currencies: string[];
accent: string;
} }
// Only Telebirr and Waafi are enabled for now. // Only Telebirr and Waafi are enabled for now.
@@ -16,17 +19,32 @@ const PROVIDERS: ProviderOption[] = [
{ {
method: "TELEBIRR", method: "TELEBIRR",
label: "telebirr", label: "telebirr",
description: "Ethiopian mobile money", description: "Ethiopian mobile money · ETB",
icon: Smartphone, logo: "/assets/telebirr.jpeg",
currencies: ["ETB"],
accent: "#0A6F4D",
}, },
{ {
method: "WAAFI", method: "WAAFI",
label: "Waafi", label: "Waafi",
description: "Djibouti mobile money", description: "Djibouti mobile money · USD",
icon: Smartphone, logo: "/assets/waafi.jpeg",
currencies: ["USD"],
accent: "#2E5B96",
}, },
]; ];
/**
* Pick the provider that settles in the booking's currency. USD → Waafi,
* ETB → Telebirr. Falls back to the first provider when unknown.
*/
function providersForCurrency(currency?: string | null): ProviderOption[] {
const cur = currency?.trim().toUpperCase();
if (!cur) return PROVIDERS;
const matched = PROVIDERS.filter((p) => p.currencies.includes(cur));
return matched.length > 0 ? matched : PROVIDERS;
}
function ProviderRow({ function ProviderRow({
option, option,
selected, selected,
@@ -36,55 +54,75 @@ function ProviderRow({
selected: boolean; selected: boolean;
onSelect: () => void; onSelect: () => void;
}) { }) {
const Icon = option.icon;
return ( return (
<Group <Group
onClick={onSelect} onClick={onSelect}
gap={12} role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
gap={14}
wrap="nowrap" wrap="nowrap"
align="center"
style={{ style={{
cursor: "pointer", cursor: "pointer",
borderRadius: 12, borderRadius: 14,
padding: "13px 14px", padding: "14px 16px",
border: `1.5px solid ${selected ? "#0A6F4D" : "#E6ECF1"}`, border: `1.5px solid ${selected ? option.accent : "#E6ECF1"}`,
backgroundColor: selected ? "#ECF6F1" : "#fff", backgroundColor: selected ? "#F6FBF8" : "#fff",
transition: "border-color .12s, background-color .12s", boxShadow: selected
? `0 0 0 1px ${option.accent}, 0 6px 18px rgba(16,24,40,0.06)`
: "none",
transition: "border-color .14s, box-shadow .14s, background-color .14s",
}} }}
> >
<Box <Box
style={{ style={{
width: 40, width: 52,
height: 40, height: 52,
flexShrink: 0, flexShrink: 0,
display: "flex", borderRadius: 12,
alignItems: "center", overflow: "hidden",
justifyContent: "center", border: "1px solid #EEF2F6",
borderRadius: 10, backgroundColor: "#fff",
backgroundColor: selected ? "#0A6F4D" : "#F1F4F7",
color: selected ? "#fff" : "#475569",
}} }}
> >
<Icon size={19} /> <Image
src={option.logo}
alt={`${option.label} logo`}
w={52}
h={52}
fit="cover"
/>
</Box> </Box>
<Box style={{ flex: 1 }}> <Box style={{ flex: 1, minWidth: 0 }}>
<Text fz="14px" fw={700} c="#10202F"> <Text fz="15px" fw={800} c="#10202F" tt="capitalize">
{option.label} {option.label}
</Text> </Text>
<Text fz="12.5px" c="#9AA8B5"> <Text fz="12.5px" c="#7A8794" truncate>
{option.description} {option.description}
</Text> </Text>
</Box> </Box>
<Box <Box
style={{ style={{
width: 18, width: 22,
height: 18, height: 22,
flexShrink: 0, flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "50%", borderRadius: "50%",
border: `2px solid ${selected ? "#0A6F4D" : "#CBD5E1"}`, border: `2px solid ${selected ? option.accent : "#CBD5E1"}`,
backgroundColor: selected ? "#0A6F4D" : "transparent", backgroundColor: selected ? option.accent : "transparent",
boxShadow: selected ? "inset 0 0 0 3px #fff" : undefined, transition: "all .14s",
}} }}
/> >
{selected && <Check size={13} color="#fff" strokeWidth={3} />}
</Box>
</Group> </Group>
); );
} }
@@ -93,6 +131,7 @@ export function PaymentMethodModal({
opened, opened,
onClose, onClose,
amountLabel, amountLabel,
currency,
onConfirm, onConfirm,
processing, processing,
error, error,
@@ -101,67 +140,126 @@ export function PaymentMethodModal({
onClose: () => void; onClose: () => void;
/** Human-readable total, e.g. "ETB 12,500". */ /** Human-readable total, e.g. "ETB 12,500". */
amountLabel?: string; amountLabel?: string;
/** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
currency?: string | null;
onConfirm: (method: PaymentMethod) => void; onConfirm: (method: PaymentMethod) => void;
processing?: boolean; processing?: boolean;
error?: string | null; error?: string | null;
}) { }) {
const [method, setMethod] = useState<PaymentMethod>(PROVIDERS[0].method); const providers = useMemo(() => providersForCurrency(currency), [currency]);
const [method, setMethod] = useState<PaymentMethod>(providers[0].method);
// Keep the selection valid when the currency (and therefore provider list) changes.
useEffect(() => {
if (!providers.some((p) => p.method === method)) {
setMethod(providers[0].method);
}
}, [providers, method]);
return ( return (
<Modal <Modal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
centered centered
radius="lg" radius={18}
size={460} size={480}
title={ padding={0}
<Stack gap={2}> withCloseButton={false}
<Text fw={800} fz="17px" c="#10202F"> overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
Choose a payment method
</Text>
{amountLabel && (
<Text fz="12.5px" c="#9AA8B5">
Amount due: {amountLabel}
</Text>
)}
</Stack>
}
> >
<Stack gap={10}> {/* Header */}
{PROVIDERS.map((option) => ( <Box px={24} pt={24} pb={18}>
<ProviderRow <Text fw={800} fz="19px" c="#10202F" lh={1.2}>
key={option.method} Complete your payment
option={option} </Text>
selected={method === option.method} <Text mt={4} fz="13px" c="#7A8794">
onSelect={() => setMethod(option.method)} Choose how you'd like to pay for this booking.
/> </Text>
))}
{amountLabel && (
<Group
mt={16}
justify="space-between"
align="center"
px={16}
py={13}
style={{
borderRadius: 12,
background:
"linear-gradient(135deg, #FEF8EC 0%, #F4FAF7 100%)",
border: "1px solid #F2E4C4",
}}
>
<Text fz="12.5px" fw={700} c="#B07D14" tt="uppercase" style={{ letterSpacing: 0.5 }}>
Amount due
</Text>
<Text fz="20px" fw={800} c="#10202F">
{amountLabel}
</Text>
</Group>
)}
</Box>
{/* Provider options */}
<Box px={24} pb={4}>
<Text fz="11.5px" fw={700} c="#9AA8B5" tt="uppercase" mb={10} style={{ letterSpacing: 0.6 }}>
Payment method
</Text>
<Stack gap={10}>
{providers.map((option) => (
<ProviderRow
key={option.method}
option={option}
selected={method === option.method}
onSelect={() => setMethod(option.method)}
/>
))}
</Stack>
</Box>
{/* Footer */}
<Box px={24} pt={16} pb={22}>
{error && ( {error && (
<Text fz="12.5px" c="#C0392B" fw={600}> <Text fz="12.5px" c="#C0392B" fw={600} mb={10}>
{error} {error}
</Text> </Text>
)} )}
<Button <Group gap={6} align="center" justify="center" mb={12}>
fullWidth <ShieldCheck size={14} color="#0A8A5F" />
mt={6} <Text fz="11.5px" c="#7A8794">
radius={10} Secured · you'll be redirected to your provider to pay
color="edr-green" </Text>
disabled={processing} </Group>
loading={processing}
onClick={() => onConfirm(method)} <Group gap={10} wrap="nowrap">
styles={{ <Button
root: { height: 46 }, variant="default"
label: { fontSize: 14, fontWeight: 800 }, radius={12}
}} onClick={onClose}
> disabled={processing}
{processing ? "Redirecting…" : "Continue to payment"} styles={{
</Button> root: { height: 48, flex: "0 0 38%" },
<Text fz="11.5px" c="#9AA8B5" ta="center"> label: { fontSize: 14, fontWeight: 700, color: "#475569" },
You'll be redirected to your provider to complete payment securely. }}
</Text> >
</Stack> Cancel
</Button>
<Button
radius={12}
color="edr-green"
disabled={processing}
loading={processing}
onClick={() => onConfirm(method)}
styles={{
root: { height: 48, flex: 1 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
{processing ? "Redirecting…" : "Continue to payment"}
</Button>
</Group>
</Box>
</Modal> </Modal>
); );
} }

View File

@@ -1,12 +1,92 @@
import { Box, Group, Text } from "@mantine/core"; 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 type { Freight } from "@edr/types";
import { PROGRESS_STAGES, STATUS_MAP } from "../constants"; import { PROGRESS_STAGES, STATUS_MAP } from "../constants";
import { fmtDate, isDraftLike, isNegative } from "../utils"; import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
import { SectionCard } from "./layout"; 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 (
<Box
mb={22}
px={18}
py={14}
className="rounded-2xl"
style={{
background:
"linear-gradient(135deg, #FEF8EC 0%, #FBFCFD 60%, #F4FAF7 100%)",
border: "1px solid #F2E4C4",
}}
>
<Group justify="space-between" align="center" wrap="nowrap" gap="md">
<RouteEndpoint label="Origin" value={origin} />
<Box
className="flex items-center justify-center rounded-full shrink-0"
style={{
width: 34,
height: 34,
backgroundColor: "#fff",
border: `1px solid ${ACCENT}33`,
color: ACCENT,
}}
>
<MoveRight size={18} />
</Box>
<RouteEndpoint label="Destination" value={destination} alignRight />
</Group>
</Box>
);
}
function RouteEndpoint({
label,
value,
alignRight,
}: {
label: string;
value: string;
alignRight?: boolean;
}) {
return (
<Box miw={0} style={{ textAlign: alignRight ? "right" : "left", flex: 1 }}>
<Group
gap={5}
align="center"
wrap="nowrap"
justify={alignRight ? "flex-end" : "flex-start"}
>
<MapPin size={12} color={ACCENT} />
<Text
fz="10.5px"
fw={700}
c="#B07D14"
tt="uppercase"
className="tracking-[0.6px]"
>
{label}
</Text>
</Group>
<Text mt={3} fz="15px" fw={800} c="#10202F" truncate>
{value}
</Text>
</Box>
);
}
export function StatusHero({ export function StatusHero({
booking, booking,
children, children,
@@ -91,6 +171,8 @@ export function StatusHero({
<Box my={26} h={1} w="100%" bg="#EEF2F6" /> <Box my={26} h={1} w="100%" bg="#EEF2F6" />
{!negative && <RouteStrip booking={booking} />}
{children ?? ( {children ?? (
<ProgressTracker <ProgressTracker
current={cfg.stage} current={cfg.stage}

View File

@@ -70,11 +70,11 @@ export function EstimateCard({
mb={4} mb={4}
style={{ style={{
borderRadius: 6, borderRadius: 6,
backgroundColor: "#F1F4F7", backgroundColor: "#FEF6E6",
padding: "3px 7px", padding: "3px 7px",
fontSize: 11, fontSize: 11,
fontWeight: 700, fontWeight: 700,
color: "#6B7C8E", color: "#B07D14",
}} }}
> >
est. est.

View File

@@ -15,6 +15,7 @@ import {
SimpleGrid, SimpleGrid,
Stack, Stack,
Switch, Switch,
Tabs,
Text, Text,
Textarea, Textarea,
TextInput, TextInput,
@@ -36,7 +37,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useMemo, useRef, type ReactNode } from "react"; import { useMemo, useRef, type ReactNode } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { import {
CountChip, CountChip,
DocRow, DocRow,
@@ -52,12 +53,33 @@ import {
type BookingFormValues, type BookingFormValues,
} from "./new-booking-form/schema"; } from "./new-booking-form/schema";
import { SelectField } from "./new-booking-form/shared"; 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( const EDIT_SECTIONS = [
yard: { label?: string; code?: string; name?: string } | undefined | null, "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 { ): 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 /** Fallback container type for a size, used only when a booking row has no
@@ -108,14 +130,20 @@ function mapBookingToFormValues(
booking.equipmentReturn === "WITH_RETURN" booking.equipmentReturn === "WITH_RETURN"
? "with_return" ? "with_return"
: "without_return", : "without_return",
originYard: yardNameFromBooking(booking.originYard), originYard: yardIdFromBooking(booking.originYard, referenceData),
destinationYard: yardNameFromBooking(booking.destinationYard), destinationYard: yardIdFromBooking(booking.destinationYard, referenceData),
cargoType: booking.freightType === "BULK" ? "bulk" : "container", cargoType: booking.freightType === "BULK" ? "bulk" : "container",
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""), cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
isHazardous: booking.isHazardous ?? false, isHazardous: booking.isHazardous ?? false,
isRefrigerated: booking.isRefrigerated ?? false, isRefrigerated: booking.isRefrigerated ?? false,
shippingLine: (booking as any).shippingLine?.name ?? "", shippingLine: (booking as any).shippingLine?.name ?? "",
consolidationEnabled: booking.allowConsolidation ?? false, 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: "", notes: "",
containers: [], containers: [],
} as BookingFormInputValues; } as BookingFormInputValues;
@@ -237,9 +265,20 @@ const DIRECTION_LABEL: Record<string, string> = {
export default function EditBookingPage() { export default function EditBookingPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const docInputRefs = useRef<Record<string, HTMLInputElement | null>>({}); const docInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
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( const bookingQuery = useQuery(
api.bookings.get.queryOptions({ api.bookings.get.queryOptions({
input: { id: id! }, input: { id: id! },
@@ -269,18 +308,17 @@ export default function EditBookingPage() {
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: async (payload: Partial<CreateBookingPayload>) => { mutationFn: async (payload: Partial<CreateBookingPayload>) => {
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 documents = (form.getValues("documents") ?? {}) as BookingDocuments;
const hasDocuments = Object.values(documents).some((value) => const newDocuments: BookingDocuments = {};
Array.isArray(value) ? value.length > 0 : Boolean(value), for (const [key, value] of Object.entries(documents)) {
); if (value) newDocuments[key] = value;
if (hasDocuments) {
await api.bookings.uploadDocuments.call({ id: id!, files: documents });
} }
return api.bookings.update.call({
return result; id: id!,
dto: payload,
documents:
Object.keys(newDocuments).length > 0 ? newDocuments : undefined,
});
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
@@ -304,9 +342,9 @@ export default function EditBookingPage() {
); );
const direction = useMemo(() => { 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( const destination = referenceData?.yard.find(
(y) => y.name === destinationYard, (y) => y.id === destinationYard,
); );
return getRouteDirection(origin, destination); return getRouteDirection(origin, destination);
}, [originYard, destinationYard, referenceData]); }, [originYard, destinationYard, referenceData]);
@@ -314,7 +352,7 @@ export default function EditBookingPage() {
const yardOptions = useMemo(() => { const yardOptions = useMemo(() => {
if (!referenceData?.yard) return []; if (!referenceData?.yard) return [];
return referenceData.yard.map((y) => ({ return referenceData.yard.map((y) => ({
value: y.name, value: y.id,
label: y.name, label: y.name,
country: y.country, country: y.country,
})); }));
@@ -338,14 +376,9 @@ export default function EditBookingPage() {
}; };
const handleSubmit = form.handleSubmit((data) => { const handleSubmit = form.handleSubmit((data) => {
const yards = referenceData?.yard ?? [];
const services = referenceData?.service ?? [];
const shippingLines = referenceData?.shipping_line ?? []; const shippingLines = referenceData?.shipping_line ?? [];
const containerGroups = referenceData?.containers ?? []; const containerGroups = referenceData?.containers ?? [];
const findYardId = (name: string): string =>
yards.find((y) => y.name === name)?.id ?? "";
const findShippingLineId = (name: string): string | undefined => const findShippingLineId = (name: string): string | undefined =>
shippingLines.find((l) => l.name === name)?.id; shippingLines.find((l) => l.name === name)?.id;
@@ -369,10 +402,15 @@ export default function EditBookingPage() {
) )
: Number(data.cargoWeight || 0); : 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<CreateBookingPayload> = { const apiPayload: Partial<CreateBookingPayload> = {
scheduledDate: new Date().toISOString().slice(0, 10), scheduledDate: data.scheduledDate
? new Date(data.scheduledDate).toISOString()
: undefined,
trainScheduleId: data.trainScheduleId || undefined,
contractType: contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"], data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: data.serviceTypeId, serviceTypeId: data.serviceTypeId,
@@ -380,8 +418,8 @@ export default function EditBookingPage() {
data.equipmentReturn === "with_return" data.equipmentReturn === "with_return"
? "WITH_RETURN" ? "WITH_RETURN"
: "WITHOUT_RETURN", : "WITHOUT_RETURN",
originYardId: findYardId(data.originYard), originYardId: data.originYard,
destinationYardId: findYardId(data.destinationYard), destinationYardId: data.destinationYard,
tradeDirection: tradeDirection:
direction === "EXPORT" direction === "EXPORT"
? "EXPORT" ? "EXPORT"
@@ -391,9 +429,8 @@ export default function EditBookingPage() {
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId, cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
cargoTotalWeightVgm: totalWeight, cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous, isHazardous: data.isHazardous,
paymentCurrency: "USD", paymentCurrency: data.paymentCurrency,
allowConsolidation: data.consolidationEnabled, allowConsolidation: data.consolidationEnabled,
// @ts-ignore
freightType: freightType:
data.cargoType === "container" data.cargoType === "container"
? ("CONTAINER" as const) ? ("CONTAINER" as const)
@@ -505,9 +542,34 @@ export default function EditBookingPage() {
</Alert> </Alert>
)} )}
<Stack gap={36} mt="xl"> {booking.status === "CHANGES_REQUESTED" && (
{/* ── Section 1: Service ── */} <Alert color="orange" icon={<AlertCircle size={16} />} radius="md" mt="lg">
<Stack gap="md"> <Text size="sm" fw={600}>
Staff requested changes
</Text>
<Text size="sm" mt={4}>
Update the sections below and save. Then return to the booking page to
resubmit for review.
</Text>
</Alert>
)}
<Tabs
value={activeSection}
onChange={(value) => value && setSection(value as EditSection)}
mt="xl"
>
<Tabs.List mb="lg" style={{ flexWrap: "wrap" }}>
<Tabs.Tab value="service">Service</Tabs.Tab>
<Tabs.Tab value="route">Route</Tabs.Tab>
<Tabs.Tab value="cargo">Cargo</Tabs.Tab>
<Tabs.Tab value="schedule">Schedule</Tabs.Tab>
<Tabs.Tab value="documents">Documents</Tabs.Tab>
<Tabs.Tab value="notes">Notes</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="service">
<Stack gap="md">
<SectionHeading <SectionHeading
title="Service" title="Service"
description="Select the service combination and configure trucking options." description="Select the service combination and configure trucking options."
@@ -548,6 +610,8 @@ export default function EditBookingPage() {
/> />
</SimpleGrid> </SimpleGrid>
<PaymentCurrencyField control={form.control} />
{(selectedService?.includesFirstMile || {(selectedService?.includesFirstMile ||
selectedService?.includesLastMile || selectedService?.includesLastMile ||
selectedService?.includesCustoms) && ( selectedService?.includesCustoms) && (
@@ -648,10 +712,9 @@ export default function EditBookingPage() {
</Paper> </Paper>
)} )}
</Stack> </Stack>
</Tabs.Panel>
<Divider /> <Tabs.Panel value="route">
{/* ── Section 3: Route ── */}
<Stack gap="md"> <Stack gap="md">
<SectionHeading <SectionHeading
title="Route" title="Route"
@@ -746,10 +809,9 @@ export default function EditBookingPage() {
/> />
</Paper> </Paper>
</Stack> </Stack>
</Tabs.Panel>
<Divider /> <Tabs.Panel value="cargo">
{/* ── Section 4: Cargo ── */}
<Box> <Box>
<Step5CargoDetails <Step5CargoDetails
form={form} form={form}
@@ -758,10 +820,13 @@ export default function EditBookingPage() {
isLoading={!referenceData} isLoading={!referenceData}
/> />
</Box> </Box>
</Tabs.Panel>
<Divider /> <Tabs.Panel value="schedule">
<StepScheduling form={form} referenceData={referenceData} />
</Tabs.Panel>
{/* ── Section 5: Documents ── */} <Tabs.Panel value="documents">
<Stack gap="md"> <Stack gap="md">
<SectionHeading <SectionHeading
title="Documents" title="Documents"
@@ -855,10 +920,9 @@ export default function EditBookingPage() {
</Box> </Box>
</Paper> </Paper>
</Stack> </Stack>
</Tabs.Panel>
<Divider /> <Tabs.Panel value="notes">
{/* ── Section 6: Notes ── */}
<Stack gap="md"> <Stack gap="md">
<SectionHeading <SectionHeading
title="Notes" title="Notes"
@@ -878,7 +942,8 @@ export default function EditBookingPage() {
)} )}
/> />
</Stack> </Stack>
</Stack> </Tabs.Panel>
</Tabs>
{/* ── Submit ── */} {/* ── Submit ── */}
<Group <Group

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
@@ -8,14 +8,44 @@ import {
Card, Card,
Group, Group,
Menu, Menu,
Paper,
Select,
SimpleGrid,
Stack, Stack,
Text, Text,
TextInput,
ThemeIcon, ThemeIcon,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { ArrowUpDown, Download, Filter, MoreVertical, Package, Plus } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import {
ArrowRight,
CheckCircle2,
FileEdit,
LayoutList,
MoreVertical,
Package,
Plus,
Search,
Train,
Wallet,
X,
} from "lucide-react";
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
// Bookings that have left (or are leaving) the yard can be tracked live.
const TRACKABLE_STATUSES = new Set([
"PAID",
"IN_TRANSIT",
"COMPLETED",
"DELIVERED",
]);
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { import {
DataTable, DataTable,
@@ -24,25 +54,86 @@ import {
usePagination, usePagination,
} from "@edr/ui-common"; } from "@edr/ui-common";
// ── Status badge ────────────────────────────────────────────────────────────── // ── Status filter options (grouped by lifecycle) ──────────────────────────────
const STATUS_CONFIG: Record<string, { bg: string; dot: string; color: string; label: string }> = { const STATUS_FILTERS = [
DRAFT: { bg: "#F1F4F7", dot: "#94A3B8", color: "#475569", label: "Draft" }, { key: "all", label: "All bookings", statuses: undefined as string | undefined },
REVIEWING: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Reviewing" }, {
AWAITING_PAYMENT: { bg: "#FDF3E0", dot: "#F2A516", color: "#9A5B00", label: "Awaiting Payment" }, key: "active",
CONFIRMED: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "Confirmed" }, label: "In progress",
IN_TRANSIT: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "In Transit" }, statuses:
DELIVERED: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Delivered" }, "SUBMITTED,CHANGES_REQUESTED,PENDING_APPROVAL,APPROVED_PENDING_SIGNATURE,APPROVED,CONTRACT_READY,SIGNED_CUSTOMER,FULLY_EXECUTED,PENDING_CONSOLIDATION,CONSOLIDATED",
CANCELLED: { bg: "#FBEAE7", dot: "#C0392B", color: "#C0392B", label: "Cancelled" }, },
}; { 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 }) { function StatusBadge({ status }: { status: string }) {
const cfg = STATUS_CONFIG[status] ?? { const cfg = STATUS_CONFIG[status];
bg: "#F1F4F7", const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
dot: "#94A3B8", const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
color: "#475569", const text = cfg ? `var(--mantine-color-${cfg.badgeText}-7, #475569)` : "#475569";
label: status.replace(/_/g, " "), const dot = cfg ? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)` : "#94A3B8";
};
return ( return (
<Group <Group
gap={6} gap={6}
@@ -51,7 +142,7 @@ function StatusBadge({ status }: { status: string }) {
style={{ style={{
display: "inline-flex", display: "inline-flex",
borderRadius: 999, borderRadius: 999,
backgroundColor: cfg.bg, backgroundColor: bg,
padding: "5px 11px", padding: "5px 11px",
}} }}
> >
@@ -60,12 +151,12 @@ function StatusBadge({ status }: { status: string }) {
width: 6, width: 6,
height: 6, height: 6,
borderRadius: "50%", borderRadius: "50%",
backgroundColor: cfg.dot, backgroundColor: dot,
flexShrink: 0, flexShrink: 0,
}} }}
/> />
<Text fz={11} fw={700} style={{ color: cfg.color, whiteSpace: "nowrap" }}> <Text fz={11} fw={700} style={{ color: text, whiteSpace: "nowrap" }}>
{cfg.label} {label}
</Text> </Text>
</Group> </Group>
); );
@@ -74,14 +165,14 @@ function StatusBadge({ status }: { status: string }) {
// ── Context-sensitive action button ─────────────────────────────────────────── // ── Context-sensitive action button ───────────────────────────────────────────
function PrimaryAction({ function PrimaryAction({
status, booking,
id,
onNavigate, onNavigate,
}: { }: {
status: string; booking: Freight.IBooking;
id: string;
onNavigate: (path: string) => void; onNavigate: (path: string) => void;
}) { }) {
const { status, id } = booking;
const go = () => onNavigate(`/bookings/${id}`);
if (status === "DRAFT") { if (status === "DRAFT") {
return ( return (
<Button <Button
@@ -89,57 +180,39 @@ function PrimaryAction({
radius="md" radius="md"
fw={700} fw={700}
fz={13} fz={13}
rightSection={<ArrowRight size={14} />}
style={{ backgroundColor: "var(--mantine-color-edr-ink-0)", color: "#fff" }} style={{ backgroundColor: "var(--mantine-color-edr-ink-0)", color: "#fff" }}
onClick={() => onNavigate(`/bookings/${id}`)} onClick={go}
> >
Continue Continue
</Button> </Button>
); );
} }
if (status === "AWAITING_PAYMENT") { if (status === "CHANGES_REQUESTED") {
return ( return (
<Button <Button
size="xs" size="xs"
radius="md" radius="md"
fw={700} fw={700}
fz={13} fz={13}
style={{ backgroundColor: "var(--mantine-color-edr-accent-0)", color: "#fff" }} color="orange"
onClick={() => onNavigate(`/bookings/${id}`)} rightSection={<ArrowRight size={14} />}
onClick={() => onNavigate(`/bookings/${id}/edit?section=documents`)}
> >
Pay Review changes
</Button> </Button>
); );
} }
if (status === "IN_TRANSIT") { if (status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID") {
return ( return <PayNowButton booking={booking} />;
<Button
size="xs"
radius="md"
variant="default"
fw={600}
fz={13}
onClick={() => onNavigate(`/bookings/${id}`)}
>
Track
</Button>
);
} }
return ( return (
<Button <Button size="xs" radius="md" variant="default" fw={600} fz={13} onClick={go}>
size="xs"
radius="md"
variant="default"
fw={600}
fz={13}
onClick={() => onNavigate(`/bookings/${id}`)}
>
View View
</Button> </Button>
); );
} }
// ── Column header label ───────────────────────────────────────────────────────
function ColHeader({ label }: { label: string }) { function ColHeader({ label }: { label: string }) {
return ( return (
<Text <Text
@@ -155,20 +228,164 @@ function ColHeader({ label }: { label: string }) {
const hMeta = { headerClassName: "bg-[#F4F7FA]" }; const hMeta = { headerClassName: "bg-[#F4F7FA]" };
function fmtDate(iso?: string | null): string {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? ""
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
// ── Main component ──────────────────────────────────────────────────────────── // ── Main component ────────────────────────────────────────────────────────────
// Lightweight count query for a single lifecycle filter (reads only `total`).
function useStatusCount(statuses: string | undefined): number | undefined {
const { data } = useQuery(
api.bookings.list.queryOptions({
input: { statuses, page: 1, pageSize: 1 },
staleTime: 30_000,
}),
);
return data?.meta?.total;
}
function StatCard({
card,
active,
count,
onSelect,
}: {
card: (typeof STAT_CARDS)[number];
active: boolean;
count: number | undefined;
onSelect: () => void;
}) {
const Icon = card.icon;
return (
<Paper
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={(e) => {
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",
}}
>
<Group gap={12} wrap="nowrap" align="center">
<Box
style={{
width: 42,
height: 42,
borderRadius: 11,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: card.iconBg,
color: card.iconColor,
}}
>
<Icon size={20} strokeWidth={2} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz={24} fw={800} lh={1.05} c="edr-text">
{count ?? "—"}
</Text>
<Text fz={12} fw={600} c="edr-muted" truncate>
{card.label}
</Text>
</Box>
</Group>
</Paper>
);
}
export default function MyBookings() { export default function MyBookings() {
const navigate = useNavigate(); const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>("all");
const [query, setQuery] = useState("");
const [trackingBooking, setTrackingBooking] = useState<Freight.IBooking | null>(
null,
);
const { data, isLoading, isError } = useQuery(api.bookings.list.queryOptions()); const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
const bookings = data?.items ?? [];
const total = bookings.length; const selectFilter = (key: StatusFilterKey) => {
const pageCount = Math.ceil(total / pagination.pageSize); setStatusFilter(key);
const start = pagination.pageIndex * pagination.pageSize; setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const end = Math.min(start + pagination.pageSize, total); };
const paginatedData = useMemo(() => bookings.slice(start, end), [bookings, start, end]);
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<StatusFilterKey, number | undefined> = {
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<Freight.IBooking>[] = [ const columns: ColumnDef<Freight.IBooking>[] = [
{ {
@@ -178,8 +395,7 @@ export default function MyBookings() {
header: () => <ColHeader label="Booking" />, header: () => <ColHeader label="Booking" />,
cell: ({ row }) => { cell: ({ row }) => {
const b = row.original; const b = row.original;
const cargoLabel = const cargoLabel = b.freightType === "BULK" ? "Bulk cargo" : "Container";
b.freightType === "BULK" ? "Bulk Cargo" : "Cargo";
return ( return (
<Group gap={12} wrap="nowrap" align="center"> <Group gap={12} wrap="nowrap" align="center">
<Box <Box
@@ -217,7 +433,7 @@ export default function MyBookings() {
const b = row.original; const b = row.original;
const origin = b.originYard?.label ?? b.originYard?.code ?? "—"; const origin = b.originYard?.label ?? b.originYard?.code ?? "—";
const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—"; const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—";
const sub = b.scheduledDate ?? b.createdAt ?? ""; const sub = fmtDate(b.scheduledDate ?? b.createdAt);
return ( return (
<Box> <Box>
<Text fz={13} fw={600} c="edr-text"> <Text fz={13} fw={600} c="edr-text">
@@ -245,7 +461,10 @@ export default function MyBookings() {
meta: hMeta, meta: hMeta,
header: () => <ColHeader label="Amount" />, header: () => <ColHeader label="Amount" />,
cell: ({ row }) => { 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; const amount = b.totalAmount ?? b.amount ?? null;
if (!amount) { if (!amount) {
return ( return (
@@ -267,24 +486,42 @@ export default function MyBookings() {
header: () => null, header: () => null,
cell: ({ row }) => { cell: ({ row }) => {
const booking = row.original; const booking = row.original;
const trackable = TRACKABLE_STATUSES.has(booking.status);
return ( return (
<Group justify="flex-end" gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}> <Group justify="flex-end" gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<PrimaryAction status={booking.status} id={booking.id} onNavigate={navigate} /> {trackable && (
<Button
size="xs"
radius="md"
variant="light"
color="edr-green"
fw={700}
fz={13}
leftSection={<Train size={14} />}
onClick={() => setTrackingBooking(booking)}
>
Track
</Button>
)}
<PrimaryAction booking={booking} onNavigate={navigate} />
<Menu position="bottom-end" withinPortal shadow="md" radius="md"> <Menu position="bottom-end" withinPortal shadow="md" radius="md">
<Menu.Target> <Menu.Target>
<ActionIcon <ActionIcon variant="transparent" size={30} radius="md" aria-label="More options">
variant="transparent"
size={30}
radius="md"
aria-label="More options"
>
<MoreVertical size={16} color="#9AA8B5" /> <MoreVertical size={16} color="#9AA8B5" />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item onClick={() => navigate(`/bookings/${booking.id}`)}> <Menu.Item onClick={() => navigate(`/bookings/${booking.id}`)}>
View Details View details
</Menu.Item> </Menu.Item>
{trackable && (
<Menu.Item
leftSection={<Train size={15} />}
onClick={() => setTrackingBooking(booking)}
>
Track shipment
</Menu.Item>
)}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
</Group> </Group>
@@ -293,8 +530,6 @@ export default function MyBookings() {
}, },
]; ];
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
return ( return (
<Box style={{ padding: "28px 32px 32px" }}> <Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg"> <Stack gap="lg">
@@ -305,81 +540,112 @@ export default function MyBookings() {
Bookings Bookings
</Title> </Title>
<Text size="sm" c="edr-muted" mt={4}> <Text size="sm" c="edr-muted" mt={4}>
Manage every cargo booking from draft to delivery. Track every cargo booking from draft to delivery.
</Text> </Text>
</Box> </Box>
<Group gap={12}> <Button
<Button variant="default" radius="md" leftSection={<Download size={16} />}> component={Link}
Export to="/bookings/new"
</Button> color="edr-green"
<Button radius="md"
component={Link} leftSection={<Plus size={16} />}
to="/bookings/new" >
color="edr-green" New booking
radius="md" </Button>
leftSection={<Plus size={16} />}
>
New Booking
</Button>
</Group>
</Group> </Group>
{/* ── Summary stat cards ──────────────────────────────────────── */}
<SimpleGrid cols={{ base: 2, sm: 3, lg: 5 }} spacing="md">
{STAT_CARDS.map((card) => (
<StatCard
key={card.key}
card={card}
active={statusFilter === card.key}
count={cardCounts[card.key]}
onSelect={() => selectFilter(card.key)}
/>
))}
</SimpleGrid>
{/* ── Bookings table card ──────────────────────────────────────── */} {/* ── Bookings table card ──────────────────────────────────────── */}
<Card p={0} style={{ overflow: "hidden" }}> <Card p={0} style={{ overflow: "hidden" }}>
{/* Toolbar */}
<Group <Group
justify="flex-end" justify="space-between"
gap={8} gap={12}
px={20} px={20}
py={14} py={14}
wrap="wrap"
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }} style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
> >
<Button <Group gap={10} wrap="wrap" style={{ flex: 1, minWidth: 260 }}>
variant="default" <TextInput
size="sm" placeholder="Search reference or route…"
radius="md" leftSection={<Search size={16} />}
leftSection={<ArrowUpDown size={14} />} value={query}
> onChange={(e) => setQuery(e.currentTarget.value)}
Sort rightSection={
</Button> query ? (
<Button <ActionIcon
variant="default" size="sm"
size="sm" variant="transparent"
radius="md" color="gray"
leftSection={<Filter size={14} />} onClick={() => setQuery("")}
> >
Filter <X size={14} />
</Button> </ActionIcon>
) : null
}
radius="md"
style={{ flex: 1, minWidth: 200, maxWidth: 340 }}
/>
<Select
data={SELECT_DATA}
value={statusFilter}
onChange={(value) => selectFilter((value as StatusFilterKey) ?? "all")}
allowDeselect={false}
radius="md"
checkIconPosition="right"
comboboxProps={{ withinPortal: true }}
style={{ width: 200 }}
aria-label="Filter by status"
/>
</Group>
<Text fz={12} c="edr-muted">
{total} booking{total !== 1 ? "s" : ""}
</Text>
</Group> </Group>
{/* Empty state */} {showEmpty ? (
{total === 0 && dataTableStatus === "success" ? (
<Stack align="center" gap={4} px="lg" py={64} ta="center"> <Stack align="center" gap={4} px="lg" py={64} ta="center">
<ThemeIcon size={56} radius="lg" color="edr-green" variant="light" mb="xs"> <ThemeIcon size={56} radius="lg" color="edr-green" variant="light" mb="xs">
<Package size={28} /> <Package size={28} />
</ThemeIcon> </ThemeIcon>
<Text size="sm" fw={600} c="edr-text"> <Text size="sm" fw={600} c="edr-text">
No bookings yet {query ? "No bookings match your search" : "No bookings here yet"}
</Text> </Text>
<Text size="xs" c="edr-muted" maw={320}> <Text size="xs" c="edr-muted" maw={320}>
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."}
</Text> </Text>
<Button {!query && (
component={Link} <Button
to="/bookings/new" component={Link}
size="sm" to="/bookings/new"
color="edr-green" size="sm"
radius="md" color="edr-green"
mt="md" radius="md"
leftSection={<Plus size={15} />} mt="md"
> leftSection={<Plus size={15} />}
Create first booking >
</Button> Create first booking
</Button>
)}
</Stack> </Stack>
) : ( ) : (
<DataTable <DataTable
columns={columns} columns={columns}
data={paginatedData} data={rows}
status={dataTableStatus} status={dataTableStatus}
onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)} onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)}
pagination={{ pagination={{
@@ -391,6 +657,8 @@ export default function MyBookings() {
tableOptions={{ tableOptions={{
state: { pagination }, state: { pagination },
onPaginationChange: setPagination, onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}} }}
containerClassName="border-0 shadow-none rounded-none" containerClassName="border-0 shadow-none rounded-none"
footer={DataTableFooter} footer={DataTableFooter}
@@ -398,6 +666,20 @@ export default function MyBookings() {
)} )}
</Card> </Card>
</Stack> </Stack>
<ShipmentTrackingModal
opened={trackingBooking !== null}
onClose={() => setTrackingBooking(null)}
bookingId={trackingBooking?.id ?? ""}
bookingReference={trackingBooking?.reference ?? ""}
originLabel={
trackingBooking?.originYard?.label ?? trackingBooking?.originYard?.code
}
destinationLabel={
trackingBooking?.destinationYard?.label ??
trackingBooking?.destinationYard?.code
}
/>
</Box> </Box>
); );
} }

View File

@@ -1,7 +1,9 @@
import { api } from "@/services/api"; import { api } from "@/services/api";
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
import type { import type {
CreateBookingPayload, CreateBookingPayload,
GeneratePriceResponse, GeneratePriceResponse,
SubmitBookingResponse,
} from "@/services/bookings.service"; } from "@/services/bookings.service";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { import {
@@ -35,6 +37,7 @@ import {
getRouteDirection, getRouteDirection,
initialBookingFormValues, initialBookingFormValues,
stepFields, stepFields,
type BookingDocuments,
type BookingFormValues, type BookingFormValues,
} from "./new-booking-form/schema"; } from "./new-booking-form/schema";
import { StepIndicator } from "./new-booking-form/StepIndicator"; import { StepIndicator } from "./new-booking-form/StepIndicator";
@@ -48,6 +51,8 @@ import {
StepScheduling, StepScheduling,
} from "./new-booking-form/steps"; } from "./new-booking-form/steps";
type PriceModalMode = "submit" | "draft";
export default function NewBookingPage() { export default function NewBookingPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -91,68 +96,61 @@ export default function NewBookingPage() {
); );
} }
const createMutation = useMutation({ const persistAndPriceMutation = useMutation({
mutationFn: async (payload: CreateBookingPayload) => { mutationFn: async ({
const booking = await api.bookings.create.call(payload); 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 if (bookingId) {
// new booking id once it exists. Optional here; the booking detail page await api.bookings.update.call({ id: bookingId, dto: payload, documents });
// remains the catch-all for any docs the user skips. } else {
const documents = form.getValues("documents") ?? {}; const booking = await api.bookings.create.call({ payload, documents });
const hasDocuments = Object.values(documents).some((value) => bookingId = booking.id;
Array.isArray(value) ? value.length > 0 : Boolean(value),
);
if (hasDocuments) {
await api.bookings.uploadDocuments.call({
id: booking.id,
files: documents,
});
} }
return booking; const pricing = await api.bookings.generatePrice.call({ id: bookingId });
return { bookingId, pricing, mode };
}, },
onSuccess: (booking) => { onSuccess: ({ bookingId, pricing, mode }) => {
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 }) => {
setPriceBookingId(bookingId); setPriceBookingId(bookingId);
setPricingData(pricing); setPricingData(pricing);
setPricingPhase("ready"); setPriceModalMode(mode);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
}, },
onError: () => {
setPricingPhase("idle");
},
}); });
const confirmMutation = useMutation({ const confirmMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!priceBookingId) throw new Error("No booking to confirm"); 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: () => { onSuccess: () => {
setPriceChangeResult(null);
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${priceBookingId}`); navigate(`/bookings/${priceBookingId}`);
}, },
@@ -188,22 +186,15 @@ export default function NewBookingPage() {
return route; return route;
}, [originYard, destinationYard]); }, [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<GeneratePriceResponse | null>( const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
null, null,
); );
const [priceBookingId, setPriceBookingId] = useState<string | null>(null); const [priceBookingId, setPriceBookingId] = useState<string | null>(null);
const [priceModalMode, setPriceModalMode] = useState<PriceModalMode | null>(
null,
);
const [priceChangeResult, setPriceChangeResult] =
useState<SubmitBookingResponse | null>(null);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false); const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState(""); const [cancelReason, setCancelReason] = useState("");
@@ -211,6 +202,14 @@ export default function NewBookingPage() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true }); const valid = await form.trigger(stepFields[step], { shouldFocus: true });
if (!valid) return; 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)); setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
} }
@@ -265,7 +264,9 @@ export default function NewBookingPage() {
)!; )!;
return { return {
scheduledDate: new Date().toISOString(), scheduledDate: data.scheduledDate
? new Date(data.scheduledDate).toISOString()
: new Date().toISOString(),
contractType: contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"], data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: data.serviceTypeId, serviceTypeId: data.serviceTypeId,
@@ -273,7 +274,7 @@ export default function NewBookingPage() {
data.equipmentReturn === "with_return" data.equipmentReturn === "with_return"
? "WITH_RETURN" ? "WITH_RETURN"
: "WITHOUT_RETURN", : "WITHOUT_RETURN",
paymentCurrency: "USD", paymentCurrency: data.paymentCurrency,
originYardId: data.originYard, originYardId: data.originYard,
destinationYardId: data.destinationYard, destinationYardId: data.destinationYard,
tradeDirection: direction!, tradeDirection: direction!,
@@ -313,25 +314,55 @@ export default function NewBookingPage() {
}; };
} }
const handleDraftSubmit = form.handleSubmit((data) => { const handleSaveDraft = form.handleSubmit((data) => {
try { try {
const apiPayload = buildApiPayload(data); const apiPayload = buildApiPayload(data);
createMutation.mutate(apiPayload); persistAndPriceMutation.mutate({
payload: apiPayload,
mode: "draft",
existingBookingId: priceBookingId,
});
} catch { } catch {
// validation error already handled // 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 { try {
const apiPayload = buildApiPayload(data); const apiPayload = buildApiPayload(data);
setPricingPhase("generating"); persistAndPriceMutation.mutate({
createAndPriceMutation.mutate(apiPayload); payload: apiPayload,
mode: "submit",
existingBookingId: priceBookingId,
});
} catch { } catch {
// validation error already handled // 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 ( return (
<Box <Box
style={{ style={{
@@ -376,14 +407,14 @@ export default function NewBookingPage() {
id="new-booking-form" id="new-booking-form"
className="flex flex-col" className="flex flex-col"
style={{ flex: 1 }} style={{ flex: 1 }}
onSubmit={handleDraftSubmit} onSubmit={(e) => e.preventDefault()}
> >
<Box flex={1} p="24px"> <Box flex={1} p="24px">
<Box mb="lg"> <Box mb="lg">
<StepIndicator step={step} /> <StepIndicator step={step} />
</Box> </Box>
{createMutation.isError && ( {persistAndPriceMutation.isError && (
<Alert <Alert
color="red" color="red"
icon={<AlertCircle size={16} />} icon={<AlertCircle size={16} />}
@@ -391,29 +422,11 @@ export default function NewBookingPage() {
mb="lg" mb="lg"
> >
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
Failed to save draft Failed to save booking or generate price
</Text> </Text>
<Text size="sm" mt={4} c="red.7"> <Text size="sm" mt={4} c="red.7">
{createMutation.error instanceof Error {persistAndPriceMutation.error instanceof Error
? createMutation.error.message ? persistAndPriceMutation.error.message
: "An unexpected error occurred. Please try again."}
</Text>
</Alert>
)}
{createAndPriceMutation.isError && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
radius="md"
mb="lg"
>
<Text size="sm" fw={600}>
Failed to generate price estimate
</Text>
<Text size="sm" mt={4} c="red.7">
{createAndPriceMutation.error instanceof Error
? createAndPriceMutation.error.message
: "An unexpected error occurred. Please try again."} : "An unexpected error occurred. Please try again."}
</Text> </Text>
</Alert> </Alert>
@@ -450,17 +463,16 @@ export default function NewBookingPage() {
setStep={setStep} setStep={setStep}
direction={direction!} direction={direction!}
referenceData={referenceData} referenceData={referenceData}
pricingPhase={pricingPhase} onSaveDraft={handleSaveDraft}
pricingData={pricingData} onSubmit={handleSubmitBooking}
onConfirm={() => confirmMutation.mutate()} saveDraftPending={
onContinueLater={ persistAndPriceMutation.isPending &&
priceBookingId persistAndPriceMutation.variables?.mode === "draft"
? () => navigate(`/bookings/${priceBookingId}`) }
: undefined submitPending={
persistAndPriceMutation.isPending &&
persistAndPriceMutation.variables?.mode === "submit"
} }
onAbort={() => setCancelDialogOpen(true)}
confirmPending={confirmMutation.isPending}
abortPending={abortMutation.isPending}
/> />
)} )}
</Box> </Box>
@@ -501,51 +513,151 @@ export default function NewBookingPage() {
> >
Continue Continue
</Button> </Button>
) : pricingPhase === "idle" ? ( ) : (
<Group> <Button
<Button type="button"
type="submit" color="edr-green"
form="new-booking-form" radius="md"
variant={hasDocuments ? "outline" : "filled"} leftSection={<Send size={16} />}
color="edr-green" onClick={handleSubmitBooking}
radius="md" loading={isPricing}
loading={createMutation.isPending} >
leftSection={ Submit
createMutation.isPending ? undefined : <Check size={16} />
}
>
{createMutation.isPending
? "Saving Draft..."
: "Save as Draft"}
</Button>
{hasDocuments && (
<Button
type="button"
color="edr-green"
radius="md"
loading={createAndPriceMutation.isPending}
leftSection={
createAndPriceMutation.isPending ? undefined : (
<Send size={16} />
)
}
onClick={() => handleGeneratePrice()}
>
{createAndPriceMutation.isPending
? "Generating price…"
: "Submit"}
</Button>
)}
</Group>
) : pricingPhase === "generating" ? (
<Button type="button" color="edr-green" radius="md" loading>
Generating price estimate
</Button> </Button>
) : null} )}
</Group> </Group>
</Box> </Box>
</form> </form>
<Modal
opened={priceModalMode !== null && pricingData !== null}
onClose={closePriceModal}
title={
<Text fw={700}>
{priceModalMode === "submit"
? "Confirm booking submission"
: "Draft saved — price estimate"}
</Text>
}
radius="lg"
centered
size="md"
>
{pricingData && (
<Stack gap="md">
<Text size="sm" c="dimmed">
{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."}
</Text>
<Stack gap="xs">
{pricingData.lineItems.map((item) => (
<Group key={item.code} justify="space-between">
<Text size="sm" c="dimmed">
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
<Group justify="space-between" pt="xs">
<Text fw={800} size="md">
Total
</Text>
<Text fw={800} size="lg" c="edr-green">
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
</Text>
</Group>
{pricingData.warnings.length > 0 && (
<Text size="xs" c="orange.7" p="xs" className="rounded bg-orange-50">
{pricingData.warnings.join(", ")}
</Text>
)}
<Group justify="flex-end" gap="sm" mt="md">
{priceModalMode === "submit" ? (
<>
<Button
variant="default"
radius="md"
onClick={closePriceModal}
disabled={confirmMutation.isPending}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Check size={16} />}
onClick={() => confirmMutation.mutate()}
loading={confirmMutation.isPending}
>
Confirm & submit
</Button>
</>
) : (
<Button color="edr-green" radius="md" onClick={handleDraftModalOk}>
OK
</Button>
)}
</Group>
</Stack>
)}
</Modal>
<Modal
opened={priceChangeResult !== null}
onClose={() => setPriceChangeResult(null)}
title={<Text fw={700}>Price has changed</Text>}
radius="lg"
centered
>
{priceChangeResult && (
<Stack gap="md">
<Text size="sm" c="dimmed">
{priceChangeResult.message ??
"The booking price has been updated. Confirm to submit with the new total."}
</Text>
{priceChangeResult.previousTotalAmount !== undefined && (
<Group justify="space-between">
<Text size="sm" c="dimmed">
Previous total
</Text>
<Text size="sm" td="line-through">
{priceChangeResult.previousTotalAmount.toLocaleString()}{" "}
{priceChangeResult.currency}
</Text>
</Group>
)}
<Group justify="space-between">
<Text fw={700}>New total</Text>
<Text fw={800} c="edr-green">
{priceChangeResult.totalAmount.toLocaleString()}{" "}
{priceChangeResult.currency}
</Text>
</Group>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setPriceChangeResult(null)}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
loading={confirmSubmitMutation.isPending}
onClick={() => confirmSubmitMutation.mutate()}
>
Confirm & submit
</Button>
</Group>
</Stack>
)}
</Modal>
<Modal <Modal
opened={cancelDialogOpen} opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)} onClose={() => setCancelDialogOpen(false)}

View File

@@ -2,84 +2,88 @@ import { Check } from "lucide-react";
import { Fragment } from "react"; import { Fragment } from "react";
import { STEPS } from "./schema"; 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 }) { export function StepIndicator({ step }: { step: number }) {
return ( return (
<div className="flex items-center"> <div className="flex items-start">
{STEPS.map((item, index) => ( {STEPS.map((item, index) => {
<Fragment key={item.id}> const done = step > item.id;
<div className="flex shrink-0 flex-col items-center gap-1"> const active = step === item.id;
<div return (
style={{ <Fragment key={item.id}>
width: 28, <div className="flex shrink-0 flex-col items-center gap-2" style={{ minWidth: 34 }}>
height: 28, <div
borderRadius: "50%", style={{
display: "flex", width: 34,
alignItems: "center", height: 34,
justifyContent: "center", borderRadius: "50%",
fontSize: 12, display: "flex",
fontWeight: 600, alignItems: "center",
flexShrink: 0, justifyContent: "center",
transition: "all 0.2s", fontSize: 13,
...(step > item.id fontWeight: 700,
? { flexShrink: 0,
backgroundColor: "var(--mantine-color-edr-green-5)", transition: "all 0.2s",
color: "#fff", ...(done
boxShadow: "0 2px 8px rgba(14,163,113,0.4)",
}
: step === item.id
? { ? {
border: "2.5px solid var(--mantine-color-edr-green-5)", background: "linear-gradient(135deg, #12B981, #0A8A5F)",
color: "var(--mantine-color-edr-green-7)", color: "#fff",
backgroundColor: "#fff", boxShadow: "0 4px 10px rgba(14,163,113,0.35)",
boxShadow: "0 0 0 3px rgba(14,163,113,0.12)",
} }
: { : active
backgroundColor: "#fff", ? {
color: "var(--mantine-color-edr-muted-0)", border: `2.5px solid ${GREEN}`,
border: "2px solid var(--mantine-color-edr-border-0)", color: GREEN_DEEP,
}), backgroundColor: "#fff",
}} boxShadow: "0 0 0 4px rgba(14,163,113,0.12)",
> }
{step > item.id ? ( : {
<Check style={{ width: 13, height: 13 }} /> backgroundColor: "#fff",
) : ( color: MUTED,
item.id border: `2px solid ${BORDER}`,
)} }),
}}
>
{done ? <Check style={{ width: 15, height: 15 }} strokeWidth={3} /> : item.id}
</div>
<span
style={{
fontSize: 11,
fontWeight: active ? 700 : 500,
textAlign: "center",
lineHeight: 1.2,
maxWidth: 72,
display: "none",
transition: "color 0.2s",
color: step >= item.id ? INK : MUTED,
}}
className="md:!block"
>
{item.short}
</span>
</div> </div>
<span {index < STEPS.length - 1 && (
style={{ <div
fontSize: 10, style={{
fontWeight: 500, flex: 1,
display: "none", height: 3,
transition: "color 0.2s", borderRadius: 999,
color: margin: "16px 8px 0",
step >= item.id transition: "background 0.3s",
? "var(--mantine-color-edr-text-0)" background: done
: "var(--mantine-color-edr-muted-0)", ? "linear-gradient(90deg, #0A8A5F, #12B981)"
}} : BORDER,
className="lg:!block" }}
> />
{item.short} )}
</span> </Fragment>
</div> );
{index < STEPS.length - 1 && ( })}
<div
style={{
flex: 1,
height: 2,
borderRadius: 999,
margin: "0 6px",
marginBottom: 14,
transition: "background-color 0.3s",
backgroundColor:
step > item.id
? "var(--mantine-color-edr-green-5)"
: "var(--mantine-color-edr-border-0)",
}}
/>
)}
</Fragment>
))}
</div> </div>
); );
} }

View File

@@ -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<BookingFormInputValues, any, BookingFormValues>;
}) {
return (
<Box mt={24}>
<StepLabel>Payment currency</StepLabel>
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
Choose the currency for your freight quote and invoices.
</Text>
<Controller
name="paymentCurrency"
control={control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-4 md:grid-cols-2">
{PAYMENT_CURRENCY_OPTIONS.map((option) => {
const Icon = CURRENCY_ICONS[option.value].icon;
return (
<OptionCard
key={option.value}
selected={field.value === option.value}
onClick={() => field.onChange(option.value)}
icon={<Icon className="h-5 w-5" />}
iconBg={CURRENCY_ICONS[option.value].bg}
iconColor={CURRENCY_ICONS[option.value].color}
title={option.label}
description={option.description}
/>
);
})}
</div>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
</Box>
);
}

View File

@@ -14,9 +14,7 @@ export const STEPS = [
/** /**
* Shipment documents collected during booking creation. The fileKeys mirror * Shipment documents collected during booking creation. The fileKeys mirror
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts so anything attached * `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts.
* 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.
*/ */
const DOC_SETTING_TS = "2024-01-01T00:00:00.000Z"; const DOC_SETTING_TS = "2024-01-01T00:00:00.000Z";
@@ -34,7 +32,7 @@ function docField(
fileKey, fileKey,
fileLabel, fileLabel,
helpText: null, helpText: null,
isRequired: false, isRequired: true,
isMultiple: false, isMultiple: false,
maxFiles: 1, maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"], allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
@@ -51,7 +49,7 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
code: "booking_documents", code: "booking_documents",
label: "Booking Documents", label: "Booking Documents",
description: 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", entity: "booking",
fields: [ fields: [
docField("commercial_invoice", "Commercial Invoice", 1), docField("commercial_invoice", "Commercial Invoice", 1),
@@ -63,11 +61,32 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
export type BookingDocuments = Record<string, File | File[] | null>; export type BookingDocuments = Record<string, File | File[] | null>;
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 export const bookingFormSchema = z
.object({ .object({
contractType: z.enum(["new", "renewal"], "Select a contract type."), contractType: z.enum(["new", "renewal"], "Select a contract type."),
previousContractRef: z.string(), previousContractRef: z.string(),
serviceTypeId: z.string("Select a service type."), serviceTypeId: z.string("Select a service type."),
paymentCurrency: z.enum(PAYMENT_CURRENCIES, "Select a payment currency."),
firstMile: z firstMile: z
.object({ .object({
@@ -202,6 +221,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
previousContractRef: "", previousContractRef: "",
serviceTypeId: "", serviceTypeId: "",
paymentCurrency: "USD",
firstMile: { firstMile: {
enabled: false, enabled: false,
pickUpAddress: "", pickUpAddress: "",
@@ -232,6 +252,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
1: ["contractType", "previousContractRef"], 1: ["contractType", "previousContractRef"],
2: [ 2: [
"serviceTypeId", "serviceTypeId",
"paymentCurrency",
"firstMile", "firstMile",
"lastMile", "lastMile",
"equipmentReturn", "equipmentReturn",

View File

@@ -1,48 +1,167 @@
import { Alert, Combobox, Input, InputBase, Select, Text, Title, useCombobox } from "@mantine/core"; import {
import { AlertTriangle, Check, CheckCircle2, Info, Loader, XCircle } from "lucide-react"; 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 type { ReactNode } from "react";
import { useMemo } 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"; 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 } }) { export function OptionFieldError({ error }: { error?: { message?: string } }) {
if (!error?.message) return null; if (!error?.message) return null;
return ( return (
<Text size="xs" c="red" mt={4}> <Text size="xs" c="red" mt={6}>
{error.message} {error.message}
</Text> </Text>
); );
} }
/**
* 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({ export function OptionCard({
selected, selected,
onClick, onClick,
disabled, disabled,
icon,
iconBg = "#ECF6F1",
iconColor = GREEN_DARK,
title,
description,
children, children,
}: { }: {
selected: boolean; selected: boolean;
onClick?: () => void; onClick?: () => void;
disabled?: boolean; disabled?: boolean;
children: ReactNode; icon?: ReactNode;
iconBg?: string;
iconColor?: string;
title?: ReactNode;
description?: ReactNode;
children?: ReactNode;
}) { }) {
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className={`relative w-full rounded-xl border-2 p-4 text-left transition-all duration-150 ${ style={{
disabled position: "relative",
? "cursor-not-allowed border-gray-200 bg-gray-100 opacity-60" width: "100%",
textAlign: "left",
borderRadius: 16,
padding: 18,
cursor: disabled ? "not-allowed" : "pointer",
transition: "all 150ms ease",
border: `1.5px solid ${
disabled ? BORDER : selected ? GREEN : BORDER
}`,
background: disabled
? "#F6F8FA"
: selected : selected
? "border-emerald-500 bg-emerald-50 shadow-sm shadow-emerald-500/20" ? "linear-gradient(135deg, #F4FBF7 0%, #FFFFFF 70%)"
: "border-gray-200 bg-white hover:border-emerald-300 hover:shadow-sm" : "#FFFFFF",
}`} boxShadow: selected
? `0 0 0 1px ${GREEN}, 0 8px 20px rgba(14,163,113,0.10)`
: "0 1px 2px rgba(16,24,40,0.04)",
opacity: disabled ? 0.65 : 1,
}}
onMouseEnter={(e) => {
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 && ( {selected && !disabled && (
<span className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500"> <span
<Check className="h-3 w-3 text-white" /> style={{
position: "absolute",
right: 14,
top: 14,
display: "flex",
height: 22,
width: 22,
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
background: GREEN,
boxShadow: "0 2px 6px rgba(14,163,113,0.45)",
}}
>
<Check style={{ width: 13, height: 13, color: "#fff" }} strokeWidth={3} />
</span> </span>
)} )}
{/* Structured form (icon + title + description) */}
{(icon || title || description) && (
<Box>
{icon && (
<Box
style={{
marginBottom: 12,
display: "flex",
height: 42,
width: 42,
alignItems: "center",
justifyContent: "center",
borderRadius: 12,
background: iconBg,
color: iconColor,
}}
>
{icon}
</Box>
)}
{title && (
<Text fz={15} fw={800} c={INK}>
{title}
</Text>
)}
{description && (
<Text fz={12.5} c={MUTED} mt={3} style={{ lineHeight: 1.5 }}>
{description}
</Text>
)}
</Box>
)}
{children} {children}
</button> </button>
); );
@@ -63,7 +182,7 @@ export function AlertBox({
}; };
const { color, icon } = map[tone]; const { color, icon } = map[tone];
return ( return (
<Alert color={color} icon={icon} radius="md" fz="sm"> <Alert color={color} icon={icon} radius="lg" fz="sm">
{children} {children}
</Alert> </Alert>
); );
@@ -71,31 +190,89 @@ export function AlertBox({
export function StepLabel({ children }: { children: ReactNode }) { export function StepLabel({ children }: { children: ReactNode }) {
return ( return (
<Text size="sm" fw={600} tt="uppercase" c="dimmed" className="tracking-wide"> <Text
fz={11}
fw={700}
tt="uppercase"
c={MUTED}
style={{ letterSpacing: "0.07em" }}
>
{children} {children}
</Text> </Text>
); );
} }
/**
* 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 (
<Paper
radius={20}
p={{ base: "lg", sm: 28 }}
withBorder
bg="white"
style={{ borderColor: BORDER, boxShadow: "0 2px 14px rgba(16,24,40,0.04)" }}
>
{eyebrow}
{children}
</Paper>
);
}
export function StepHeader({ export function StepHeader({
title, title,
description, description,
icon,
}: { }: {
title: string; title: string;
description: string; description: string;
icon?: ReactNode;
}) { }) {
return ( return (
<div> <Group gap={14} align="flex-start" wrap="nowrap" mb={22}>
<Title order={3} className="tracking-tight"> {icon && (
{title} <Box
</Title> style={{
<Text size="sm" c="dimmed" mt={4}> flexShrink: 0,
{description} width: 44,
</Text> height: 44,
</div> borderRadius: 13,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #ECF6F1, #E4F3EC)",
color: GREEN_DARK,
}}
>
{icon}
</Box>
)}
<Box>
<Title order={3} fz={20} fw={800} c={INK} style={{ letterSpacing: "-0.01em" }}>
{title}
</Title>
<Text size="sm" c={MUTED} mt={4} style={{ lineHeight: 1.5 }}>
{description}
</Text>
</Box>
</Group>
); );
} }
/** 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({ export function SelectField({
field, field,
error, error,
@@ -103,6 +280,7 @@ export function SelectField({
placeholder, placeholder,
disabled, disabled,
data, data,
leftSection,
}: { }: {
field: ControllerRenderProps<BookingFormInputValues>; field: ControllerRenderProps<BookingFormInputValues>;
error?: RhfFieldError; error?: RhfFieldError;
@@ -110,6 +288,7 @@ export function SelectField({
placeholder: string; placeholder: string;
disabled?: boolean; disabled?: boolean;
data: string[] | { value: string; label: string }[]; data: string[] | { value: string; label: string }[];
leftSection?: ReactNode;
}) { }) {
return ( return (
<Select <Select
@@ -122,6 +301,11 @@ export function SelectField({
onBlur={field.onBlur} onBlur={field.onBlur}
error={error?.message} error={error?.message}
allowDeselect={false} allowDeselect={false}
radius={10}
checkIconPosition="right"
leftSection={leftSection}
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
styles={fieldStyles}
/> />
); );
} }
@@ -166,12 +350,14 @@ export function AsyncComboboxField({
}; };
return ( return (
<Input.Wrapper label={label} error={error?.message}> <Input.Wrapper label={label} error={error?.message} styles={fieldStyles}>
<Combobox store={combobox} disabled={disabled}> <Combobox store={combobox} disabled={disabled} shadow="md" radius="md" withinPortal>
<Combobox.Target> <Combobox.Target>
<InputBase <InputBase
placeholder={placeholder} placeholder={placeholder}
disabled={disabled} disabled={disabled}
radius={10}
styles={fieldStyles}
value={searchQuery || selectedLabel} value={searchQuery || selectedLabel}
onChange={(e) => { onChange={(e) => {
onSearchChange(e.currentTarget.value); onSearchChange(e.currentTarget.value);

View File

@@ -1,6 +1,6 @@
import { Box, Group, Text } from "@mantine/core"; import { Box, Group, Text } from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common"; 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 { Controller, type UseFormReturn } from "react-hook-form";
import { import {
@@ -9,7 +9,7 @@ import {
type BookingDocuments, type BookingDocuments,
type BookingFormValues, type BookingFormValues,
} from "./schema"; } from "./schema";
import { StepHeader } from "./shared"; import { StepCard, StepHeader } from "./shared";
type BookingForm = UseFormReturn< type BookingForm = UseFormReturn<
BookingFormInputValues, BookingFormInputValues,
@@ -30,10 +30,11 @@ export function StepDocuments({ form }: { form: BookingForm }) {
const total = BOOKING_DOCS_SETTING.fields.length; const total = BOOKING_DOCS_SETTING.fields.length;
return ( return (
<div className="space-y-6"> <StepCard>
<StepHeader <StepHeader
icon={<FileUp size={22} />}
title="Shipment Documents" 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."
/> />
<Group <Group
@@ -86,6 +87,6 @@ export function StepDocuments({ form }: { form: BookingForm }) {
/> />
)} )}
/> />
</div> </StepCard>
); );
} }

View File

@@ -385,64 +385,147 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
<Modal <Modal
opened={!!selectedDayForModal} opened={!!selectedDayForModal}
onClose={() => setSelectedDayForModal(null)} onClose={() => setSelectedDayForModal(null)}
title={selectedDayForModal ? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEE, MMM d yyyy") : ""}
centered centered
size="sm" size={520}
styles={{ radius={18}
header: { borderBottom: `1px solid ${theme.colors["edr-border"][0]}` }, padding={0}
body: { padding: 24 }, withCloseButton={false}
}} overlayProps={{ backgroundOpacity: 0.5, blur: 3 }}
> >
<Stack gap={12}> {/* Header */}
<Text fz={13} c="edr-muted" fw={500}> <Box
Choose a departure time px={24}
py={20}
style={{
background: "linear-gradient(120deg, #0C1A2B 0%, #123047 70%, #0A6F4D 150%)",
}}
>
<Group gap={7} align="center" mb={6}>
<CalendarIcon size={15} color="#9FE9CC" />
<Text fz={11} fw={700} tt="uppercase" c="#9FE9CC" style={{ letterSpacing: 0.6 }}>
Available departures
</Text>
</Group>
<Text fw={800} fz={19} c="#fff">
{selectedDayForModal
? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEEE, MMM d yyyy")
: ""}
</Text> </Text>
{selectedDayForModal?.schedules.map((schedule) => ( <Text fz={12.5} c="#A9BBCB" mt={2}>
<Button {selectedDayForModal?.schedules.length ?? 0} train
key={schedule.id} {(selectedDayForModal?.schedules.length ?? 0) !== 1 ? "s" : ""} on{" "}
variant="outline" {originName} {destinationName}
fullWidth </Text>
onClick={() => handleSelectScheduleFromModal(schedule.id)} </Box>
style={{ height: 64, justifyContent: "flex-start" }}
styles={{ {/* Schedule list */}
inner: { justifyContent: "flex-start" }, <Stack gap={12} p={24}>
root: { {selectedDayForModal?.schedules.map((schedule) => {
borderColor: theme.colors["edr-border"][0], 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 (
<Box
key={schedule.id}
role="button"
tabIndex={0}
onClick={() => 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", transition: "all 150ms ease",
"&:hover": { }}
borderColor: theme.colors["edr-green"][5], >
backgroundColor: theme.colors["edr-soft"][0], <Group gap={14} wrap="nowrap" align="center">
}, <Box
}, style={{
}} width: 50,
> height: 50,
<Group gap={16} w="100%"> flexShrink: 0,
<Box borderRadius: 13,
style={{ background: "linear-gradient(135deg, #ECF6F1, #E0F1E9)",
width: 48, display: "flex",
height: 48, alignItems: "center",
borderRadius: theme.radius.md, justifyContent: "center",
backgroundColor: theme.colors["edr-soft"][0], }}
display: "flex", >
alignItems: "center", <Train size={24} color={theme.colors["edr-green"][6]} />
justifyContent: "center", </Box>
}} <Box style={{ flex: 1, minWidth: 0 }}>
> <Group gap={8} align="baseline">
<Train size={24} color={theme.colors["edr-green"][5]} /> <Text fw={800} fz={18} c="edr-text.0">
</Box> {format(new Date(schedule.scheduleDate), "HH:mm")}
<Stack gap={3} style={{ flex: 1, alignItems: "flex-start" }}> </Text>
<Text fw={700} fz={18} c="edr-text.0"> <Text fz={12.5} c="edr-muted">
{format(new Date(schedule.scheduleDate), "HH:mm")} {schedule.trainNumber
</Text> ? `Train ${schedule.trainNumber}`
{schedule.trainNumber && ( : `#${schedule.id.slice(0, 6)}`}
<Text fz={12} c="edr-muted"> </Text>
Train {schedule.trainNumber} </Group>
</Text> {/* capacity bar */}
)} <Box mt={8}>
</Stack> <Group justify="space-between" mb={4}>
</Group> <Text fz={11} fw={600} c="edr-muted">
</Button> {remaining} / {max} wagons free
))} </Text>
<Text fz={11} fw={700} c={pct > 25 ? "edr-green.7" : "#C77F09"}>
{pct}%
</Text>
</Group>
<Box
style={{
height: 6,
borderRadius: 999,
background: "#EEF2F6",
overflow: "hidden",
}}
>
<Box
style={{
width: `${pct}%`,
height: "100%",
borderRadius: 999,
background:
pct > 25
? `linear-gradient(90deg, ${theme.colors["edr-green"][7]}, ${theme.colors["edr-green"][5]})`
: "#F2A516",
}}
/>
</Box>
</Box>
</Box>
<Box
style={{
width: 26,
height: 26,
flexShrink: 0,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: `2px solid ${isSelected ? theme.colors["edr-green"][5] : "#CBD5E1"}`,
background: isSelected ? theme.colors["edr-green"][5] : "transparent",
}}
>
{isSelected && <Check size={14} color="#fff" strokeWidth={3} />}
</Box>
</Group>
</Box>
);
})}
</Stack> </Stack>
</Modal> </Modal>
</Group> </Group>
@@ -561,36 +644,42 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
)} )}
</Group> </Group>
{/* Schedule times */} {/* Availability marker — a dot + count, never the schedule list itself. */}
{d.hasSchedule && ( {d.hasSchedule && (
<Stack gap={3} style={{ flex: 1, overflow: "hidden", minWidth: 0 }}> <Box style={{ flex: 1, display: "flex", alignItems: "flex-end" }}>
{d.schedules.slice(0, 2).map((s) => ( <Group
<Group key={s.id} gap={6} align="center" style={{ minWidth: 0 }}> gap={6}
<Box align="center"
style={{ wrap="nowrap"
width: 4, px={9}
height: 4, py={4}
borderRadius: "50%", style={{
backgroundColor: theme.colors["edr-green"][5], borderRadius: 999,
flexShrink: 0, backgroundColor: d.isSelectedDate
}} ? "#fff"
/> : theme.colors["edr-soft"][0],
<Text border: `1px solid ${
fz={12} d.isSelectedDate
fw={700} ? theme.colors["edr-green"][2]
c="edr-text.0" : "transparent"
style={{ flex: 1, minWidth: 0 }} }`,
> }}
{format(new Date(s.scheduleDate), "HH:mm")} >
</Text> <Box
</Group> style={{
))} width: 7,
{d.schedules.length > 2 && ( height: 7,
<Text fz={11} fw={600} c="edr-green.7" style={{ paddingTop: 2 }}> borderRadius: "50%",
+{d.schedules.length - 2} more backgroundColor: theme.colors["edr-green"][5],
flexShrink: 0,
boxShadow: `0 0 0 3px ${theme.colors["edr-green"][0]}`,
}}
/>
<Text fz={11} fw={700} c="edr-green.7" style={{ whiteSpace: "nowrap" }}>
{d.schedules.length} departure{d.schedules.length !== 1 ? "s" : ""}
</Text> </Text>
)} </Group>
</Stack> </Box>
)} )}
</Box> </Box>
); );

View File

@@ -10,8 +10,11 @@ import {
AsyncComboboxField, AsyncComboboxField,
OptionCard, OptionCard,
OptionFieldError, OptionFieldError,
StepCard,
StepHeader, StepHeader,
} from "./shared"; } from "./shared";
import { FileSignature } from "lucide-react";
import { Stack } from "@mantine/core";
type BookingForm = UseFormReturn< type BookingForm = UseFormReturn<
BookingFormInputValues, BookingFormInputValues,
@@ -52,7 +55,6 @@ export function Step1ContractType({
); );
const contractOptions = useMemo<PreviousContractOption[]>(() => { const contractOptions = useMemo<PreviousContractOption[]>(() => {
console.log("Bookings data:", bookings);
if (!bookings) return []; if (!bookings) return [];
return bookings?.items return bookings?.items
@@ -182,10 +184,11 @@ export function Step1ContractType({
}; };
return ( return (
<div className="space-y-6"> <StepCard>
<StepHeader <StepHeader
icon={<FileSignature size={22} />}
title="Contract Type" 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."
/> />
<Controller <Controller
@@ -193,40 +196,33 @@ export function Step1ContractType({
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<div> <div>
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<OptionCard <OptionCard
selected={field.value === "new"} selected={field.value === "new"}
icon={<FileText className="h-5 w-5" />}
iconBg="#ECF6F1"
iconColor="#0A6F4D"
title="New Contract"
description="Create a fresh freight contract from scratch."
onClick={() => { onClick={() => {
field.onChange("new"); field.onChange("new");
form.clearErrors(["contractType", "previousContractRef"]); form.clearErrors(["contractType", "previousContractRef"]);
form.setValue("previousContractRef", ""); form.setValue("previousContractRef", "");
}} }}
> />
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
<FileText className="h-4 w-4 text-emerald-600" />
</div>
<p className="font-semibold">New Contract</p>
<p className="mt-0.5 text-xs text-gray-500">
Create a new contract.
</p>
</OptionCard>
<OptionCard <OptionCard
selected={field.value === "renewal"} selected={field.value === "renewal"}
icon={<RefreshCw className="h-5 w-5" />}
iconBg="#EAF1FB"
iconColor="#2E5B96"
title="Contract Renewal"
description="Pick a previous reference to auto-fill historical parameters."
onClick={() => { onClick={() => {
field.onChange("renewal"); field.onChange("renewal");
form.clearErrors("contractType"); form.clearErrors("contractType");
}} }}
> />
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-sky-100">
<RefreshCw className="h-4 w-4 text-sky-600" />
</div>
<p className="font-semibold">Contract Renewal</p>
<p className="mt-0.5 text-xs text-gray-500">
Select a previous reference to auto-populate historical
parameters.
</p>
</OptionCard>
</div> </div>
<OptionFieldError error={fieldState.error} /> <OptionFieldError error={fieldState.error} />
</div> </div>
@@ -234,7 +230,7 @@ export function Step1ContractType({
/> />
{contractType === "renewal" && ( {contractType === "renewal" && (
<div className="space-y-3 pt-1"> <Stack gap={12} mt={22}>
{error && ( {error && (
<AlertBox tone="error"> <AlertBox tone="error">
Failed to load previous contracts. Please try again later. Failed to load previous contracts. Please try again later.
@@ -263,8 +259,8 @@ export function Step1ContractType({
details will be pre-filled. details will be pre-filled.
</AlertBox> </AlertBox>
)} )}
</div> </Stack>
)} )}
</div> </StepCard>
); );
} }

View File

@@ -1,9 +1,18 @@
import { Switch, TextInput } from "@mantine/core"; import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
import { FileText, Train, Truck } from "lucide-react"; import type { ReactNode } from "react";
import { FileText, Layers, Train, Truck } from "lucide-react";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form"; import { Controller, type UseFormReturn } from "react-hook-form";
import { BookingFormInputValues, type BookingFormValues } from "./schema"; 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"; import type { Freight } from "@edr/types";
@@ -61,10 +70,11 @@ export function Step2ServiceType({
const showServiceSections = const showServiceSections =
includesCustoms || includesFirstMile || includesLastMile; includesCustoms || includesFirstMile || includesLastMile;
return ( return (
<div className="space-y-6"> <StepCard>
<StepHeader <StepHeader
icon={<Layers size={22} />}
title="Service Type" title="Service Type"
description="Select the service combination and configure trucking options." description="Choose the service combination, then configure your trucking options."
/> />
<Controller <Controller
@@ -72,211 +82,225 @@ export function Step2ServiceType({
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<div> <div>
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{referenceData?.service {referenceData?.service
.filter((s) => s.canBeBookedAlone) .filter((s) => s.canBeBookedAlone)
.map((s) => { .map((s) => (
return ( <OptionCard
<OptionCard key={s.id}
selected={field.value === s.id} selected={field.value === s.id}
onClick={() => field.onChange(s.id)} onClick={() => field.onChange(s.id)}
> icon={<Train className="h-5 w-5" />}
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-100"> iconBg="#EEF0FB"
<Train className="h-4 w-4 text-indigo-600" /> iconColor="#4F46E5"
</div> title={s.serviceName}
<p className="font-semibold">{s.serviceName}</p> description={s.description}
<p className="mt-0.5 text-xs text-gray-500"> />
{s.description} ))}
</p>
</OptionCard>
);
})}
</div> </div>
<OptionFieldError error={fieldState.error} /> <OptionFieldError error={fieldState.error} />
</div> </div>
)} )}
/> />
<PaymentCurrencyField control={form.control} />
{showServiceSections && ( {showServiceSections && (
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200"> <Stack gap={12} mt={24}>
<StepLabel>Trucking & customs options</StepLabel>
{/* First Mile */} {/* First Mile */}
{includesFirstMile && ( {includesFirstMile && (
<div className="p-4"> <Controller
<Controller name="firstMile.enabled"
name="firstMile.enabled" control={form.control}
control={form.control} render={({ field }) => (
render={({ field }) => ( <ServiceToggle
<div className="flex items-start justify-between gap-4"> icon={<Truck size={18} />}
<div className="flex items-start gap-3"> title="First Mile — Pick-up"
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" /> description="Truck pick-up from your premises (Door to Port) to the origin rail yard."
<div> checked={field.value ?? false}
<p className="text-sm font-medium"> onChange={(value) => {
First Mile Pick-up field.onChange(value);
</p> if (!value) {
<p className="mt-0.5 text-xs text-gray-500"> form.setValue("firstMile.pickUpAddress", "", {
Truck pick-up from your premises (Door to Port) to the shouldDirty: true,
origin rail yard. shouldValidate: true,
</p> });
</div> }
</div> }}
<Switch >
checked={field.value} {firstMileEnabled && (
onChange={(e) => { <Controller
const value = e.currentTarget.checked; name="firstMile.pickUpAddress"
field.onChange(value); control={form.control}
if (!value) { render={({ field: af, fieldState }) => (
form.setValue("firstMile.pickUpAddress", "", { <TextInput
shouldDirty: true, {...af}
shouldValidate: true, mt="sm"
}); placeholder="Pick-up address *"
} error={fieldState.error?.message}
}} radius={10}
color="edr-green" styles={fieldStyles}
/> />
</div> )}
)}
/>
{firstMileEnabled && (
<Controller
name="firstMile.pickUpAddress"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
mt="sm"
placeholder="Pick-up address *"
error={fieldState.error?.message}
radius="md"
/> />
)} )}
/> </ServiceToggle>
)} )}
</div> />
)} )}
{/* Last Mile */} {/* Last Mile */}
{includesLastMile && ( {includesLastMile && (
<div className="p-4"> <Controller
<Controller name="lastMile.enabled"
name="lastMile.enabled" control={form.control}
control={form.control} render={({ field }) => (
render={({ field }) => ( <ServiceToggle
<div className="flex items-start justify-between gap-4"> icon={<Truck size={18} />}
<div className="flex items-start gap-3"> title="Last Mile — Delivery"
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" /> description="Truck delivery from the destination rail yard to the final address (Port to Door)."
<div> checked={field.value ?? false}
<p className="text-sm font-medium"> onChange={(value) => {
Last Mile Delivery field.onChange(value);
</p> if (!value) {
<p className="mt-0.5 text-xs text-gray-500"> form.setValue("lastMile.deliveryAddress", "", {
Truck delivery from the destination rail yard to the shouldDirty: true,
final address (Port to Door). shouldValidate: true,
</p> });
</div> form.setValue("equipmentReturn", "with_return", {
</div> shouldDirty: true,
<Switch });
checked={field.value} }
onChange={(e) => { }}
const value = e.currentTarget.checked; >
field.onChange(value); {lastMileEnabled && (
if (!value) { <Controller
form.setValue("lastMile.deliveryAddress", "", { name="lastMile.deliveryAddress"
shouldDirty: true, control={form.control}
shouldValidate: true, render={({ field: af, fieldState }) => (
}); <TextInput
form.setValue("equipmentReturn", "with_return", { {...af}
shouldDirty: true, mt="sm"
}); placeholder="Delivery address *"
} error={fieldState.error?.message}
}} radius={10}
color="edr-green" styles={fieldStyles}
/> />
</div> )}
)}
/>
{lastMileEnabled && (
<Controller
name="lastMile.deliveryAddress"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
mt="sm"
placeholder="Delivery address *"
error={fieldState.error?.message}
radius="md"
/> />
)} )}
/> </ServiceToggle>
)} )}
</div> />
)} )}
{/* Equipment Return */} {/* Equipment Return */}
{includesLastMile && lastMileEnabled && ( {includesLastMile && lastMileEnabled && (
<div className="p-4"> <Controller
<Controller name="equipmentReturn"
name="equipmentReturn" control={form.control}
control={form.control} render={({ field }) => (
render={({ field }) => ( <ServiceToggle
<div className="flex items-start justify-between gap-4"> icon={<Truck size={18} />}
<div> title="Equipment Return"
<p className="text-sm font-medium">Equipment Return</p> description={
<p className="mt-0.5 text-xs text-gray-500"> field.value === "with_return"
{field.value === "with_return" ? "Container returned to EDR after unloading."
? "Container returned to EDR after unloading." : "Container retained by the customer after delivery."
: "Container retained by the customer after delivery."} }
</p> checked={field.value === "with_return"}
</div> onChange={(v) =>
<Switch field.onChange(v ? "with_return" : "without_return")
checked={field.value === "with_return"} }
onChange={(e) => { />
field.onChange( )}
e.currentTarget.checked />
? "with_return"
: "without_return",
);
}}
color="edr-green"
/>
</div>
)}
/>
</div>
)} )}
{/* Customs Clearing */} {/* Customs Clearing */}
{includesCustoms && ( {includesCustoms && (
<div className="p-4"> <Controller
<Controller name="customsClearingEnabled"
name="customsClearingEnabled" control={form.control}
control={form.control} render={({ field }) => (
render={({ field }) => ( <ServiceToggle
<div className="flex items-start justify-between gap-4"> icon={<FileText size={18} />}
<div className="flex items-start gap-3"> title="Customs Clearing Service"
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" /> description="EDR handles customs documentation and clearance on your behalf."
<div> checked={field.value ?? false}
<p className="text-sm font-medium"> onChange={(v) => field.onChange(v)}
Customs Clearing Service />
</p> )}
<p className="mt-0.5 text-xs text-gray-500"> />
EDR handles customs documentation and clearance on
your behalf.
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
)}
/>
</div>
)} )}
</div> </Stack>
)} )}
</div> </StepCard>
);
}
function ServiceToggle({
icon,
title,
description,
checked,
onChange,
children,
}: {
icon: ReactNode;
title: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
children?: ReactNode;
}) {
return (
<Box
px={16}
py={14}
style={{
borderRadius: 14,
border: `1.5px solid ${checked ? "#CDEBDD" : "#E6ECF2"}`,
background: checked ? "#F6FBF8" : "#fff",
transition: "all 150ms ease",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap" gap={12}>
<Group gap={13} align="flex-start" wrap="nowrap">
<Box
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: checked ? "#ECF6F1" : "#F1F4F7",
color: checked ? "#0A6F4D" : "#64748B",
}}
>
{icon}
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
{title}
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
{description}
</Text>
</Box>
</Group>
<Switch
checked={checked}
onChange={(e) => onChange(e.currentTarget.checked)}
color="edr-green"
size="md"
style={{ flexShrink: 0 }}
/>
</Group>
{children}
</Box>
); );
} }

View File

@@ -1,6 +1,6 @@
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { Divider, Skeleton, Stack, Switch } from "@mantine/core"; import { Box, Divider, Group, Skeleton, Stack, Switch, Text } from "@mantine/core";
import { Flame, MapPin, Snowflake } from "lucide-react"; import { Flame, MapPin, Route as RouteIcon, Snowflake } from "lucide-react";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Controller, type UseFormReturn } from "react-hook-form"; import { Controller, type UseFormReturn } from "react-hook-form";
import { import {
@@ -8,7 +8,7 @@ import {
type BookingFormValues, type BookingFormValues,
getRouteDirection, getRouteDirection,
} from "./schema"; } from "./schema";
import { SelectField, StepHeader, StepLabel } from "./shared"; import { SelectField, StepCard, StepHeader, StepLabel } from "./shared";
type BookingForm = UseFormReturn< type BookingForm = UseFormReturn<
BookingFormInputValues, BookingFormInputValues,
@@ -63,7 +63,6 @@ export function Step4Route({
const origin = referenceData?.yard.find((y) => y.id === originYard); const origin = referenceData?.yard.find((y) => y.id === originYard);
const dest = referenceData?.yard.find((y) => y.id === destinationYard); const dest = referenceData?.yard.find((y) => y.id === destinationYard);
const direction = getRouteDirection(origin, dest); const direction = getRouteDirection(origin, dest);
console.log({ yardOptions, originYard, destinationYard, direction, origin, dest });
const directionStyle: Record<string, string> = { const directionStyle: Record<string, string> = {
EXPORT: "bg-sky-50 text-sky-800 border-sky-200", EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
@@ -85,10 +84,11 @@ export function Step4Route({
const stationSelectDisabled = yardOptions.length === 0; const stationSelectDisabled = yardOptions.length === 0;
return ( return (
<div className="space-y-6"> <StepCard>
<StepHeader <StepHeader
icon={<RouteIcon size={22} />}
title="Route" title="Route"
description="Select the origin and destination yards." description="Choose the origin and destination yards for your shipment."
/> />
{isLoading ? ( {isLoading ? (
@@ -96,7 +96,7 @@ export function Step4Route({
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
<StepLabel>Route</StepLabel> <StepLabel>Route</StepLabel>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Controller <Controller
name="originYard" name="originYard"
control={form.control} control={form.control}
@@ -153,56 +153,108 @@ export function Step4Route({
/> />
)} )}
<Divider /> <Divider my={22} color="#EEF2F6" />
<div className="divide-y divide-gray-200"> <StepLabel>Cargo handling</StepLabel>
<Stack gap={12} mt={12}>
<Controller <Controller
name="isHazardous" name="isHazardous"
control={form.control} control={form.control}
render={({ field }) => ( render={({ field }) => (
<div className="flex items-center justify-between py-3"> <ToggleRow
<div className="flex items-center gap-3"> icon={<Flame size={18} />}
<Flame className="h-4 w-4 shrink-0 text-red-500" /> iconBg="#FBEAE7"
<div> iconColor="#C0392B"
<p className="text-sm font-medium">Hazardous Material</p> title="Hazardous Material"
<p className="text-xs text-gray-500"> description="Applies a hazard surcharge to the final bill."
Applies a Hazard Surcharge to the final bill. checked={field.value}
</p> onChange={(v) => field.onChange(v)}
</div> />
</div>
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
)} )}
/> />
<Controller <Controller
name="isRefrigerated" name="isRefrigerated"
control={form.control} control={form.control}
render={({ field }) => ( render={({ field }) => (
<div className="flex items-center justify-between py-3"> <ToggleRow
<div className="flex items-center gap-3"> icon={<Snowflake size={18} />}
<Snowflake className="h-4 w-4 shrink-0 text-sky-500" /> iconBg="#E9F0F8"
<div> iconColor="#2E5B96"
<p className="text-sm font-medium">Refrigerated Cargo</p> title="Refrigerated Cargo"
<p className="text-xs text-gray-500"> description="Temperature-controlled transport applies a refrigeration surcharge."
Temperature-controlled transport applies a Refrigerator checked={field.value}
Surcharge. onChange={(v) => field.onChange(v)}
</p> />
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
)} )}
/> />
</div> </Stack>
</div> </StepCard>
);
}
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 (
<Group
justify="space-between"
align="center"
wrap="nowrap"
px={16}
py={13}
style={{
borderRadius: 14,
border: `1.5px solid ${checked ? "#CDEBDD" : "#E6ECF2"}`,
background: checked ? "#F6FBF8" : "#fff",
transition: "all 150ms ease",
}}
>
<Group gap={13} align="center" wrap="nowrap">
<Box
style={{
width: 38,
height: 38,
borderRadius: 11,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: iconBg,
color: iconColor,
}}
>
{icon}
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
{title}
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
{description}
</Text>
</Box>
</Group>
<Switch
checked={checked}
onChange={(e) => onChange(e.currentTarget.checked)}
color="edr-green"
size="md"
/>
</Group>
); );
} }

View File

@@ -5,7 +5,6 @@ import {
ActionIcon, ActionIcon,
Button, Button,
Skeleton, Skeleton,
InputLabel,
Text, Text,
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
@@ -17,9 +16,11 @@ import {
} from "./schema"; } from "./schema";
import { import {
AlertBox, AlertBox,
fieldStyles,
OptionCard, OptionCard,
OptionFieldError, OptionFieldError,
SelectField, SelectField,
StepCard,
StepHeader, StepHeader,
StepLabel, StepLabel,
} from "./shared"; } from "./shared";
@@ -116,70 +117,66 @@ export function Step5CargoDetails({
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-6"> <StepCard>
<StepHeader <StepHeader
icon={<Package size={22} />}
title="Cargo Details" title="Cargo Details"
description="Define your cargo type, weight, and container configuration." description="Define your cargo type, weight, and container configuration."
/> />
<div className="space-y-4 rounded-xl border border-gray-200 p-4"> <div className="space-y-4">
<Skeleton height={14} w={96} radius="sm" /> <Skeleton height={14} w={96} radius="sm" />
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Skeleton height={96} radius="xl" /> <Skeleton height={96} radius="lg" />
<Skeleton height={96} radius="xl" /> <Skeleton height={96} radius="lg" />
</div> </div>
<Skeleton height={40} radius="md" /> <Skeleton height={44} radius="md" />
<Skeleton height={40} w="33%" radius="md" /> <Skeleton height={44} w="33%" radius="md" />
</div> </div>
</div> </StepCard>
); );
} }
return ( return (
<div className="space-y-6"> <StepCard>
<StepHeader <StepHeader
icon={<Package size={22} />}
title="Cargo Details" title="Cargo Details"
description="Define your cargo type, weight, and container configuration." description="Define your cargo type, weight, and container configuration."
/> />
{/* Cargo Type */} {/* Cargo Type */}
<div className="space-y-3"> <div className="space-y-3">
<InputLabel>Cargo Type *</InputLabel> <StepLabel>Cargo Type *</StepLabel>
<Controller <Controller
name="cargoType" name="cargoType"
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<div> <div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<OptionCard <OptionCard
selected={cargoType === "container"} selected={cargoType === "container"}
icon={<Package className="h-5 w-5" />}
iconBg="#ECF6F1"
iconColor="#0A6F4D"
title="Containerized"
description="Pre-packed containerized cargo (20ft / 40ft)."
onClick={() => { onClick={() => {
field.onChange("container"); field.onChange("container");
form.setValue("cargoTypePath", [], { shouldDirty: true }); form.setValue("cargoTypePath", [], { shouldDirty: true });
}} }}
> />
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
<Package className="h-4 w-4 text-emerald-600" />
</div>
<p className="font-semibold">Containerized</p>
<p className="mt-0.5 text-xs text-gray-500">
Pre-packed containerized cargo (20ft / 40ft).
</p>
</OptionCard>
<OptionCard <OptionCard
selected={cargoType === "bulk"} selected={cargoType === "bulk"}
icon={<Weight className="h-5 w-5" />}
iconBg="#FDF3E0"
iconColor="#C77F09"
title="General Cargo"
description="Bulk commodities or break-bulk cargo."
onClick={() => { onClick={() => {
field.onChange("bulk"); field.onChange("bulk");
form.setValue("containers", [], { shouldDirty: true }); form.setValue("containers", [], { shouldDirty: true });
}} }}
> />
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100">
<Weight className="h-4 w-4 text-amber-600" />
</div>
<p className="font-semibold">General Cargo</p>
<p className="mt-0.5 text-xs text-gray-500">
Bulk commodities or break-bulk cargo.
</p>
</OptionCard>
</div> </div>
<OptionFieldError error={fieldState.error} /> <OptionFieldError error={fieldState.error} />
</div> </div>
@@ -201,7 +198,8 @@ export function Step5CargoDetails({
placeholder="0.00" placeholder="0.00"
leftSection={<Weight className="h-4 w-4" />} leftSection={<Weight className="h-4 w-4" />}
error={fieldState.error?.message} error={fieldState.error?.message}
radius="md" radius={10}
styles={fieldStyles}
min={0} min={0}
step={0.01} step={0.01}
/> />
@@ -484,6 +482,6 @@ export function Step5CargoDetails({
})()} })()}
</> </>
)} )}
</div> </StepCard>
); );
} }

View File

@@ -1,26 +1,46 @@
import { Controller, type UseFormReturn } from "react-hook-form"; import { Controller, type UseFormReturn } from "react-hook-form";
import { import {
Badge,
Box, Box,
Button, Button,
Card,
Divider,
Group, Group,
Loader, Paper,
SimpleGrid,
Stack, Stack,
Table,
Text, Text,
Textarea, Textarea,
} from "@mantine/core"; } 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 { import {
BookingFormInputValues,
BOOKING_DOCS_SETTING, BOOKING_DOCS_SETTING,
type BookingDocuments, type BookingDocuments,
type BookingFormInputValues,
type BookingFormValues, type BookingFormValues,
} from "./schema"; } from "./schema";
import { StepHeader } from "./shared"; 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< type BookingForm = UseFormReturn<
BookingFormInputValues, BookingFormInputValues,
@@ -28,113 +48,135 @@ type BookingForm = UseFormReturn<
BookingFormValues BookingFormValues
>; >;
function OverviewSection({
icon,
title,
onEdit,
children,
}: {
icon: React.ReactNode;
title: string;
onEdit: () => void;
children: React.ReactNode;
}) {
return (
<Paper radius={16} p="lg" withBorder className="border-gray-200 bg-white">
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<Box
className="flex items-center justify-center rounded-lg"
style={{
width: 36,
height: 36,
backgroundColor: "var(--mantine-color-edr-green-0)",
color: "var(--mantine-color-edr-green-7)",
}}
>
{icon}
</Box>
<Text fw={700} size="sm" c="#10202F">
{title}
</Text>
</Group>
<Button
type="button"
variant="subtle"
color="edr-green"
size="compact-xs"
leftSection={<Pencil size={13} />}
onClick={onEdit}
>
Edit
</Button>
</Group>
{children}
</Paper>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<Group justify="space-between" align="flex-start" wrap="nowrap" py={4}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" className="tracking-wide">
{label}
</Text>
<Text size="sm" fw={500} ta="right" maw="60%">
{value || "—"}
</Text>
</Group>
);
}
function ReadinessItem({
done,
label,
}: {
done: boolean;
label: string;
}) {
return (
<Group gap="sm" wrap="nowrap">
{done ? (
<CheckCircle2 size={18} className="shrink-0 text-emerald-600" />
) : (
<Circle size={18} className="shrink-0 text-gray-300" />
)}
<Text size="sm" c={done ? "dark" : "dimmed"}>
{label}
</Text>
</Group>
);
}
export function Step8Review({ export function Step8Review({
form, form,
setStep, setStep,
direction, direction,
referenceData, referenceData,
pricingPhase = "idle", onSaveDraft,
pricingData, onSubmit,
onConfirm, saveDraftPending = false,
onContinueLater, submitPending = false,
onAbort,
confirmPending = false,
abortPending = false,
}: { }: {
form: BookingForm; form: BookingForm;
setStep: (step: number) => void; setStep: (step: number) => void;
direction: Freight.ScheduleTradeDirection; direction: Freight.ScheduleTradeDirection;
referenceData?: Freight.BookingReferenceData; referenceData?: Freight.BookingReferenceData;
pricingPhase?: "idle" | "generating" | "ready"; onSaveDraft?: () => void;
pricingData?: GeneratePriceResponse | null; onSubmit?: () => void;
onConfirm?: () => void; saveDraftPending?: boolean;
onContinueLater?: () => void; submitPending?: boolean;
onAbort?: () => void;
confirmPending?: boolean;
abortPending?: boolean;
}) { }) {
const values = form.watch(); const values = form.watch();
const serviceType = referenceData?.service.find( const serviceType = referenceData?.service.find(
(s) => s.id === values.serviceTypeId, (s) => s.id === values.serviceTypeId,
); );
function CompactRow({
label,
value,
target,
}: {
label: string;
value: string;
target: number;
}) {
return (
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
{label}
</Text>
<Text size="sm" fw={500} className="truncate">
{value || "—"}
</Text>
</div>
<button
type="button"
onClick={() => setStep(target)}
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
>
Edit
</button>
</div>
);
}
function CompactCard({
icon: Icon,
title,
children,
}: {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
}) {
return (
<Card radius="md" p="sm" withBorder className="border-gray-200 bg-white hover:shadow-sm transition-shadow">
<Group gap="xs" mb="xs" wrap="nowrap">
<Box c="edr-green">{Icon}</Box>
<Text size="xs" fw={700} tt="uppercase" c="dimmed" className="tracking-wider">
{title}
</Text>
</Group>
<Stack gap="xs">{children}</Stack>
</Card>
);
}
const containerSummary = const containerSummary =
values.cargoType === "container" && values.containers.length > 0 values.cargoType === "container" && values.containers.length > 0
? values.containers ? values.containers
.filter((c) => +c.qty > 0) .filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`) .map((c) => `${c.qty} × ${c.containerType || c.type}`)
.join(", ") .join(", ")
: ""; : "";
const totalVgm = const totalVgm =
values.cargoType === "container" values.cargoType === "container"
? values.containers.reduce( ? values.containers.reduce(
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0), (sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
0, 0,
) )
: 0; : Number(values.cargoWeight || 0);
const documents = (values.documents ?? {}) as BookingDocuments; const documents = (values.documents ?? {}) as BookingDocuments;
const docsAttached = BOOKING_DOCS_SETTING.fields.filter((f) => { const docsAttached = BOOKING_DOCS_SETTING.fields.filter((f) => {
const value = documents[f.fileKey]; const value = documents[f.fileKey];
return Array.isArray(value) ? value.length > 0 : Boolean(value); return Array.isArray(value) ? value.length > 0 : Boolean(value);
}).length; }).length;
const docsTotal = BOOKING_DOCS_SETTING.fields.length; const allDocsReady = hasAllRequiredDocuments(documents);
const cargoValue = (() => { const cargoValue = (() => {
if (values.cargoType === "container") return containerSummary; if (values.cargoType === "container") return "Container freight";
if (!referenceData) return ""; if (!referenceData) return "";
const path = values.cargoTypePath ?? []; const path = values.cargoTypePath ?? [];
const group = referenceData.cargo_type.find((g) => g.id === path[0]); 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; return child ? `${group.name}${child.name}` : group.name;
})(); })();
const originYardName = referenceData?.yard.find( const originYardName =
(y) => y.id === values.originYard, referenceData?.yard.find((y) => y.id === values.originYard)?.name ??
)?.name ?? values.originYard; values.originYard;
const destinationYardName = referenceData?.yard.find( const destinationYardName =
(y) => y.id === values.destinationYard, referenceData?.yard.find((y) => y.id === values.destinationYard)?.name ??
)?.name ?? values.destinationYard; 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 ( return (
<Stack gap="md"> <Stack gap="lg">
<StepHeader <StepHeader
icon={<ClipboardCheck size={22} />}
title="Review & Submit" 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 */} <div className="flex flex-col gap-6 lg:flex-row lg:items-start">
{pricingPhase === "generating" && ( {/* Left — booking summary */}
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2"> <Stack gap="md" className="min-w-0 flex-1">
<Group justify="center" py="lg"> <Paper
<Loader size="sm" /> radius={20}
<Text size="sm" c="dimmed"> p="lg"
Generating price estimate className="border border-emerald-100 bg-gradient-to-br from-white to-emerald-50/40"
</Text> >
</Group> <Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
</Card> <Stack gap={4}>
)} <Text size="xs" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
Booking overview
{pricingPhase === "ready" && pricingData && ( </Text>
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2 bg-gradient-to-br from-white to-emerald-50/30"> <Text fw={800} size="xl" c="#10202F">
<Stack gap="sm"> {values.contractType === "new" ? "New Contract" : "Contract Renewal"}
<Text size="sm" fw={700} tt="uppercase" c="edr-green" className="tracking-wider"> </Text>
💳 Price Breakdown <Text size="sm" c="dimmed">
</Text> {serviceType?.name ?? "—"} · {originYardName} {destinationYardName}
<Stack gap="xs"> </Text>
{pricingData.lineItems.map((item) => ( </Stack>
<Group key={item.code} justify="space-between" py={2}> <Badge size="lg" variant="light" color="edr-green" radius="md">
<Text size="sm" c="dimmed"> {directionLabel}
{item.description} </Badge>
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
<Divider my="xs" />
<Group justify="space-between" py={2}>
<Text fw={700} size="md">
Total
</Text>
<Text fw={800} size="lg" c="edr-green">
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
</Text>
</Group> </Group>
{pricingData.warnings.length > 0 && ( </Paper>
<Text size="xs" c="orange.7" mt="xs" p="xs" className="bg-orange-50 rounded">
{pricingData.warnings.join(", ")} <OverviewSection
</Text> icon={<Package size={18} />}
title="Contract & Service"
onEdit={() => setStep(REVIEW_STEP_TARGETS.contract)}
>
<DetailRow
label="Contract"
value={values.contractType === "new" ? "New Contract" : "Renewal"}
/>
{values.contractType === "renewal" && values.previousContractRef && (
<DetailRow label="Previous ref" value={values.previousContractRef} />
)} )}
<Group mt="md"> <DetailRow label="Service" value={serviceType?.name ?? ""} />
<Button <DetailRow
color="edr-green" label="Payment currency"
radius="md" value={values.paymentCurrency ?? "USD"}
leftSection={<Check size={16} />} />
onClick={onConfirm} <Button
loading={confirmPending}
className="flex-1"
>
{confirmPending ? "Confirming…" : "Confirm"}
</Button>
<Button
variant="outline"
color="edr-green"
radius="md"
leftSection={<Send size={16} />}
onClick={onContinueLater}
className="flex-1"
>
Continue later
</Button>
<Button
variant="outline"
color="red"
radius="md"
leftSection={!abortPending ? <XCircle size={16} /> : undefined}
onClick={onAbort}
loading={abortPending}
>
Abort
</Button>
</Group>
</Stack>
</Card>
)}
{/* Review Details - Compact Cards Grid */}
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="sm" mt="md">
<CompactCard icon={<Package size={16} />} title="Contract & Service">
<CompactRow
label="Type"
value={values.contractType === "new" ? "New Contract" : "Renewal"}
target={1}
/>
<CompactRow label="Service" value={serviceType?.name ?? ""} target={2} />
</CompactCard>
<CompactCard icon={<Route size={16} />} title="Route">
<CompactRow
label="Origin → Destination"
value={`${originYardName}${destinationYardName}`}
target={3}
/>
<CompactRow
label="Workflow"
value={
direction ? direction.charAt(0).toUpperCase() + direction.slice(1) : ""
}
target={3}
/>
</CompactCard>
<CompactCard icon={<Truck size={16} />} title="Logistics">
<CompactRow
label="First Mile"
value={
values.firstMile.enabled ? values.firstMile.pickUpAddress : "Not requested"
}
target={2}
/>
<CompactRow
label="Last Mile"
value={
values.lastMile.enabled ? values.lastMile.deliveryAddress : "Not requested"
}
target={2}
/>
<CompactRow
label="Equipment Return"
value={
values.equipmentReturn === "with_return" ? "With Return" : "Without Return"
}
target={2}
/>
<CompactRow
label="Customs Clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
target={2}
/>
</CompactCard>
<CompactCard icon={<Package size={16} />} title="Cargo Details">
<CompactRow
label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={4}
/>
<CompactRow label="Cargo Type" value={cargoValue} target={4} />
<CompactRow
label="Modifiers"
value={
[values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
.filter(Boolean)
.join(", ") || "None"
}
target={3}
/>
</CompactCard>
<CompactCard icon={<Package size={16} />} title="Containers">
<CompactRow
label="Count & Type"
value={containerSummary || "—"}
target={4}
/>
<CompactRow
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
target={4}
/>
</CompactCard>
<CompactCard icon={<FileText size={16} />} title="Documents">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
Attached
</Text>
<Text size="sm" fw={500}>
{docsAttached > 0
? `${docsAttached} of ${docsTotal}`
: "None"}
</Text>
</div>
<button
type="button" type="button"
onClick={() => setStep(5)} variant="subtle"
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2" size="compact-xs"
color="gray"
mt={4}
onClick={() => setStep(REVIEW_STEP_TARGETS.service)}
> >
Edit Edit service options
</button> </Button>
</div> </OverviewSection>
</CompactCard>
</SimpleGrid>
{/* Notes */} <OverviewSection
<Controller icon={<Route size={18} />}
name="notes" title="Route"
control={form.control} onEdit={() => setStep(REVIEW_STEP_TARGETS.route)}
render={({ field }) => ( >
<Textarea <DetailRow
{...field} label="Corridor"
id="notes" value={`${originYardName}${destinationYardName}`}
label="Additional Notes" />
placeholder="Any special instructions or notes for EDR operations…" <DetailRow label="Trade direction" value={directionLabel} />
rows={2} <DetailRow label="Shipping line" value={values.shippingLine || "—"} />
radius="md" <DetailRow
size="sm" label="Modifiers"
value={
[values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
.filter(Boolean)
.join(", ") || "None"
}
/>
</OverviewSection>
<OverviewSection
icon={<Truck size={18} />}
title="Logistics"
onEdit={() => setStep(REVIEW_STEP_TARGETS.service)}
>
<DetailRow
label="First mile"
value={
values.firstMile.enabled
? values.firstMile.pickUpAddress
: "Not requested"
}
/>
<DetailRow
label="Last mile"
value={
values.lastMile.enabled
? values.lastMile.deliveryAddress
: "Not requested"
}
/>
<DetailRow
label="Equipment return"
value={
values.equipmentReturn === "with_return"
? "With return"
: "Without return"
}
/>
<DetailRow
label="Customs clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
/>
</OverviewSection>
<OverviewSection
icon={<Calendar size={18} />}
title="Schedule"
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
>
<DetailRow label="Shipment date" value={scheduleLabel} />
<DetailRow
label="Train schedule"
value={values.trainScheduleId ? "Selected" : "—"}
/>
</OverviewSection>
<OverviewSection
icon={<Package size={18} />}
title="Cargo"
onEdit={() => setStep(REVIEW_STEP_TARGETS.cargo)}
>
<DetailRow label="Freight type" value={cargoValue} />
<DetailRow
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
/>
<DetailRow
label="Consolidation"
value={values.consolidationEnabled ? "Allowed" : "Not allowed"}
/>
{values.cargoType === "container" && values.containers.length > 0 && (
<Table mt="sm" withTableBorder withColumnBorders fz="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>VGM (t)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{values.containers
.filter((c) => +c.qty > 0)
.map((c, i) => (
<Table.Tr key={i}>
<Table.Td>{c.containerType || c.type}</Table.Td>
<Table.Td>{c.qty}</Table.Td>
<Table.Td>{c.vgm}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
{containerSummary && (
<DetailRow label="Summary" value={containerSummary} />
)}
</OverviewSection>
<OverviewSection
icon={<FileText size={18} />}
title="Documents"
onEdit={() => setStep(REVIEW_STEP_TARGETS.documents)}
>
<Stack gap="xs">
{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 (
<Group key={field.fileKey} justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
{attached ? (
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
) : (
<Circle size={16} className="text-red-400 shrink-0" />
)}
<Text size="sm">{field.fileLabel}</Text>
</Group>
<Text size="xs" c={attached ? "dimmed" : "red"} className="truncate max-w-[45%]">
{fileName ?? "Missing"}
</Text>
</Group>
);
})}
</Stack>
<Text size="xs" c="dimmed" mt="sm">
{docsAttached} of {BOOKING_DOCS_SETTING.fields.length} attached
</Text>
</OverviewSection>
<Controller
name="notes"
control={form.control}
render={({ field }) => (
<Textarea
{...field}
label="Additional notes"
placeholder="Any special instructions for EDR operations…"
rows={3}
radius="md"
/>
)}
/> />
)} </Stack>
/>
{/* Right — sticky actions */}
<Box className="w-full shrink-0 lg:w-[340px] lg:sticky lg:top-24">
<Stack gap="md">
<Paper radius={20} p="lg" withBorder bg="white">
<Text fw={800} size="sm" mb="md" c="#10202F">
Submission readiness
</Text>
<Stack gap="sm">
<ReadinessItem done={Boolean(values.serviceTypeId)} label="Service configured" />
<ReadinessItem
done={Boolean(values.paymentCurrency)}
label="Payment currency selected"
/>
<ReadinessItem
done={Boolean(values.originYard && values.destinationYard)}
label="Route selected"
/>
<ReadinessItem
done={Boolean(values.scheduledDate && values.trainScheduleId)}
label="Schedule selected"
/>
<ReadinessItem
done={
values.cargoType === "container"
? values.containers.some((c) => +c.qty > 0)
: Boolean(values.cargoWeight)
}
label="Cargo details complete"
/>
<ReadinessItem
done={allDocsReady}
label="All 4 documents attached"
/>
</Stack>
</Paper>
<Paper radius={20} p="lg" withBorder bg="white">
<Text size="sm" c="dimmed" mb="md">
{allDocsReady
? "Ready to submit. You'll review the price estimate before final submission."
: "Upload all four documents to enable submission."}
</Text>
<Stack gap="sm">
<Button
type="button"
color="edr-green"
radius="md"
fullWidth
size="md"
leftSection={<Send size={16} />}
onClick={onSubmit}
loading={submitPending}
disabled={!allDocsReady || submitPending}
>
Submit
</Button>
<Button
type="button"
variant="outline"
color="edr-green"
radius="md"
fullWidth
onClick={onSaveDraft}
loading={saveDraftPending}
disabled={submitPending}
>
Save as draft
</Button>
</Stack>
</Paper>
</Stack>
</Box>
</div>
</Stack> </Stack>
); );
} }

View File

@@ -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 (
<>
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
fullWidth={fullWidth}
leftSection={<CreditCard size={14} />}
onClick={(e) => {
// Don't let a surrounding row-click handler fire.
e.stopPropagation();
pay.open();
}}
>
{label}
</Button>
<PaymentMethodModal
opened={pay.modalOpen}
onClose={pay.close}
amountLabel={pricing ? priceTotal(pricing) : undefined}
currency={pricing?.currency ?? booking.paymentCurrency}
processing={pay.processing}
error={pay.error}
onConfirm={pay.confirm}
/>
</>
);
}

View File

@@ -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),
};
}

View File

@@ -0,0 +1,752 @@
import { Box, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
CheckCircle2,
Clock,
Flag,
MapPin,
PackageX,
RefreshCw,
Train,
} from "lucide-react";
import { api } from "@/services/api";
import { Freight } from "@edr/types";
import {
checkpointKindLabel,
corridorProgress,
isArrived,
isDispatched,
shipmentStatusLabel,
} from "./trackingStages";
const GREEN = "#0EA371";
const GREEN_DARK = "#0A6F4D";
const ACCENT = "#F2A516";
const INK = "#10202F";
const MUTED = "#6B7C8E";
interface ShipmentTrackingModalProps {
opened: boolean;
onClose: () => void;
bookingId: string;
bookingReference: string;
originLabel?: string;
destinationLabel?: string;
}
export function ShipmentTrackingModal({
opened,
onClose,
bookingId,
bookingReference,
originLabel,
destinationLabel,
}: ShipmentTrackingModalProps) {
const { data, isLoading, isError, refetch, isFetching } = useQuery({
...api.bookings.tracking.queryOptions({ input: { id: bookingId } }),
enabled: opened && Boolean(bookingId),
refetchInterval: opened ? 30_000 : false,
});
const hasSchedule = data?.hasSchedule ?? false;
return (
<Modal
opened={opened}
onClose={onClose}
centered
size={900}
radius={20}
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.5, blur: 4 }}
styles={{ content: { overflow: "hidden" } }}
>
<Header
bookingReference={data?.bookingReference ?? bookingReference}
trainNumber={data?.trainNumber ?? null}
status={data?.scheduleStatus ?? null}
currentSequenceNo={data?.currentSequenceNo ?? -1}
onClose={onClose}
onRefresh={() => refetch()}
refreshing={isFetching}
/>
<Box px={28} py={24}>
{isLoading ? (
<Center mih={280}>
<Stack align="center" gap="sm">
<Loader color="edr-green" />
<Text fz="sm" c={MUTED}>
Locating your train
</Text>
</Stack>
</Center>
) : isError ? (
<ErrorState onRetry={() => refetch()} />
) : !hasSchedule ? (
<NotDispatchedState
origin={originLabel ?? "Origin"}
destination={destinationLabel ?? "Destination"}
/>
) : data ? (
<Stack gap={26}>
<SummaryBar data={data} />
<Corridor data={data} />
<CheckpointFeed data={data} />
</Stack>
) : null}
</Box>
</Modal>
);
}
// ── Header ────────────────────────────────────────────────────────────────────
function Header({
bookingReference,
trainNumber,
status,
currentSequenceNo,
onClose,
onRefresh,
refreshing,
}: {
bookingReference: string;
trainNumber: string | null;
status: Freight.TrainScheduleStatus | null;
currentSequenceNo: number;
onClose: () => void;
onRefresh: () => void;
refreshing: boolean;
}) {
return (
<Box
px={28}
py={22}
style={{
background:
"linear-gradient(120deg, #0C1A2B 0%, #123047 60%, #0A6F4D 140%)",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={14} align="center" wrap="nowrap">
<Box
style={{
width: 48,
height: 48,
borderRadius: 13,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(255,255,255,0.12)",
color: "#fff",
}}
>
<Train size={24} />
</Box>
<Box>
<Text
fz="11px"
fw={700}
tt="uppercase"
c="#9FE9CC"
style={{ letterSpacing: 0.7 }}
>
Live shipment tracking
</Text>
<Text fz="20px" fw={800} c="#fff" lh={1.2}>
{bookingReference}
</Text>
{trainNumber && (
<Text fz="12px" c="#A9BBCB">
Train {trainNumber}
</Text>
)}
</Box>
</Group>
<Group gap={10} align="center" wrap="nowrap">
<HeaderStatusPill status={status} currentSequenceNo={currentSequenceNo} />
<IconButton title="Refresh" onClick={onRefresh} spinning={refreshing}>
<RefreshCw size={16} />
</IconButton>
<IconButton title="Close" onClick={onClose}>
<span style={{ fontSize: 18, lineHeight: 1, fontWeight: 600 }}>×</span>
</IconButton>
</Group>
</Group>
</Box>
);
}
function IconButton({
children,
onClick,
title,
spinning,
}: {
children: React.ReactNode;
onClick: () => void;
title: string;
spinning?: boolean;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onClick={onClick}
style={{
width: 34,
height: 34,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 9,
border: "1px solid rgba(255,255,255,0.18)",
backgroundColor: "rgba(255,255,255,0.08)",
color: "#fff",
cursor: "pointer",
animation: spinning ? "edr-spin 0.9s linear infinite" : undefined,
}}
>
{children}
</button>
);
}
function HeaderStatusPill({
status,
currentSequenceNo,
}: {
status: Freight.TrainScheduleStatus | null;
currentSequenceNo: number;
}) {
const arrived = isArrived(status);
const moving = isDispatched(status);
const bg = arrived
? "rgba(14,163,113,0.22)"
: moving
? "rgba(242,165,22,0.20)"
: "rgba(255,255,255,0.12)";
const dot = arrived ? "#5BE3B0" : moving ? ACCENT : "#CBD5E1";
return (
<Group
gap={7}
align="center"
wrap="nowrap"
px={12}
py={7}
style={{ borderRadius: 999, backgroundColor: bg }}
>
<Box
style={{
width: 7,
height: 7,
borderRadius: "50%",
backgroundColor: dot,
animation: moving ? "edr-pulse 1.4s ease-in-out infinite" : undefined,
}}
/>
<Text fz="12px" fw={700} c="#fff">
{shipmentStatusLabel(status, currentSequenceNo)}
</Text>
</Group>
);
}
// ── Summary bar (ETA / departure / arrival) ────────────────────────────────────
function SummaryBar({ data }: { data: Freight.IBookingTracking }) {
const arrived = isArrived(data.scheduleStatus);
const items: Array<{ label: string; value: string; accent?: boolean }> = [
{
label: "Departed",
value: fmtTime(data.actualDepartureAt ?? data.scheduledDepartureAt),
},
{
label: arrived ? "Arrived" : "Est. arrival",
value: fmtTime(data.actualArrivalAt ?? data.scheduledArrivalAt),
accent: !arrived,
},
{
label: "Stations",
value: `${Math.max(0, data.currentSequenceNo + (data.currentSequenceNo >= 0 ? 1 : 0))} / ${data.stations.length}`,
},
];
return (
<Group
gap={0}
wrap="nowrap"
style={{
borderRadius: 14,
border: "1px solid #E6ECF2",
overflow: "hidden",
}}
>
{items.map((it, i) => (
<Box
key={it.label}
style={{
flex: 1,
padding: "14px 16px",
borderLeft: i > 0 ? "1px solid #EEF2F6" : undefined,
background: it.accent ? "#FEFBF3" : "#FBFCFD",
}}
>
<Text
fz="10.5px"
fw={700}
tt="uppercase"
c={it.accent ? "#B07D14" : MUTED}
style={{ letterSpacing: 0.5 }}
>
{it.label}
</Text>
<Text fz="15px" fw={800} c={INK} mt={2}>
{it.value}
</Text>
</Box>
))}
</Group>
);
}
// ── Corridor: stations + train marker ──────────────────────────────────────────
function Corridor({ data }: { data: Freight.IBookingTracking }) {
const arrived = isArrived(data.scheduleStatus);
const moving = isDispatched(data.scheduleStatus);
const stations = data.stations;
const current = data.currentSequenceNo;
const progress = corridorProgress(stations.length, current, arrived);
// Map sequenceNo → latest checkpoint at that station for captions.
const checkpointBySeq = new Map<number, Freight.ITrackingCheckpoint>();
for (const c of data.checkpoints) checkpointBySeq.set(c.sequenceNo, c);
return (
<Box>
<Group gap={8} align="center" mb={16}>
<MapPin size={15} color={GREEN_DARK} />
<Text fz="14px" fw={800} c={INK}>
Where is your train
</Text>
<Text fz="12.5px" c={MUTED}>
· {progress}% of the route
</Text>
</Group>
{/* Horizontal rail */}
<Box style={{ position: "relative", paddingTop: 44, paddingBottom: 4 }}>
{/* base rail */}
<Box
style={{
position: "absolute",
top: 54,
left: 16,
right: 16,
height: 5,
borderRadius: 999,
background: "#EAF0F5",
}}
/>
{/* filled rail */}
<Box
style={{
position: "absolute",
top: 54,
left: 16,
width: `calc((100% - 32px) * ${progress / 100})`,
height: 5,
borderRadius: 999,
background: `linear-gradient(90deg, ${GREEN_DARK}, ${GREEN})`,
transition: "width 600ms ease",
}}
/>
{/* train marker riding the filled rail */}
<Box
style={{
position: "absolute",
top: 18,
left: `calc(16px + (100% - 32px) * ${progress / 100})`,
transform: "translateX(-50%)",
transition: "left 600ms ease",
zIndex: 3,
}}
>
<Box
style={{
width: 38,
height: 38,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: arrived
? `linear-gradient(135deg, ${GREEN}, ${GREEN_DARK})`
: `linear-gradient(135deg, ${ACCENT}, #D98A06)`,
color: "#fff",
boxShadow: "0 6px 16px rgba(16,24,40,0.20)",
border: "3px solid #fff",
animation: moving ? "edr-bob 1.8s ease-in-out infinite" : undefined,
}}
>
{arrived ? <CheckCircle2 size={18} /> : <Train size={18} />}
</Box>
</Box>
{/* station nodes */}
<Box
style={{
position: "relative",
display: "flex",
justifyContent: "space-between",
zIndex: 2,
}}
>
{stations.map((s, i) => {
const reached = arrived || (current >= 0 && i <= current);
const isCurrent = !arrived && i === current;
const isLast = i === stations.length - 1;
const cp = checkpointBySeq.get(s.sequenceNo);
return (
<StationNode
key={`${s.yardId}-${i}`}
label={s.label}
reached={reached}
isCurrent={isCurrent}
isEndpoint={i === 0 || isLast}
arrivedHere={isLast && arrived}
time={cp ? fmtTime(cp.occurredAt) : null}
align={i === 0 ? "left" : isLast ? "right" : "center"}
/>
);
})}
</Box>
</Box>
</Box>
);
}
function StationNode({
label,
reached,
isCurrent,
isEndpoint,
arrivedHere,
time,
align,
}: {
label: string;
reached: boolean;
isCurrent: boolean;
isEndpoint: boolean;
arrivedHere: boolean;
time: string | null;
align: "left" | "center" | "right";
}) {
const color = arrivedHere ? GREEN : isCurrent ? ACCENT : reached ? GREEN : "#CBD5E1";
return (
<Box
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
flex: isEndpoint ? "0 0 auto" : 1,
minWidth: 0,
maxWidth: 120,
}}
>
<Box
style={{
width: isCurrent ? 18 : 14,
height: isCurrent ? 18 : 14,
borderRadius: "50%",
background: "#fff",
border: `3px solid ${color}`,
boxShadow: isCurrent ? `0 0 0 4px ${ACCENT}22` : undefined,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Box
style={{
width: isCurrent ? 7 : 5,
height: isCurrent ? 7 : 5,
borderRadius: "50%",
background: color,
}}
/>
</Box>
<Text
fz="11.5px"
fw={reached ? 700 : 600}
c={reached ? INK : "#9AA8B5"}
mt={8}
ta={align}
truncate
style={{ maxWidth: 110 }}
title={label}
>
{label}
</Text>
{time && (
<Text fz="10px" c={MUTED} mt={1}>
{time}
</Text>
)}
</Box>
);
}
// ── Checkpoint feed ────────────────────────────────────────────────────────────
function CheckpointFeed({ data }: { data: Freight.IBookingTracking }) {
// Newest first.
const ordered = [...data.checkpoints].sort(
(a, b) =>
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
);
return (
<Box
p={20}
style={{
borderRadius: 16,
border: "1px solid #E6ECF2",
background: "#FBFCFD",
}}
>
<Group gap={8} align="center" mb={ordered.length ? 16 : 0}>
<Clock size={15} color={GREEN_DARK} />
<Text fz="14px" fw={800} c={INK}>
Journey log
</Text>
</Group>
{ordered.length === 0 ? (
<Text fz="13px" c={MUTED}>
No checkpoints logged yet. Updates appear here as the train passes each
station along the corridor.
</Text>
) : (
<Box>
{ordered.map((cp, i) => {
const isLatest = i === 0;
const last = i === ordered.length - 1;
const Icon =
cp.kind === Freight.TrainCheckpointKind.Arrived
? CheckCircle2
: cp.kind === Freight.TrainCheckpointKind.Departed
? Flag
: Train;
return (
<Group key={cp.id} gap={14} wrap="nowrap" align="flex-start">
<Box
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
alignSelf: "stretch",
}}
>
<Box
style={{
width: 30,
height: 30,
borderRadius: 9,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: isLatest ? "#ECF6F1" : "#F1F4F7",
color: isLatest ? GREEN_DARK : "#64748B",
flexShrink: 0,
}}
>
<Icon size={15} />
</Box>
{!last && (
<Box
style={{
flex: 1,
width: 2,
marginTop: 4,
marginBottom: 4,
background: "#E1E7EE",
}}
/>
)}
</Box>
<Box pb={last ? 0 : 16} style={{ flex: 1, minWidth: 0 }}>
<Group gap={8} align="center" wrap="wrap">
<Text fz="13.5px" fw={700} c={INK}>
{cp.label ?? "Checkpoint"}
</Text>
<Box
component="span"
style={{
borderRadius: 999,
padding: "2px 9px",
fontSize: 10.5,
fontWeight: 700,
background: isLatest ? "#ECF6F1" : "#F1F4F7",
color: isLatest ? GREEN_DARK : "#475569",
}}
>
{checkpointKindLabel(cp.kind)}
</Box>
{isLatest && (
<Box
component="span"
style={{
borderRadius: 999,
padding: "2px 9px",
fontSize: 10.5,
fontWeight: 700,
background: "#FEF6E6",
color: "#B07D14",
}}
>
Latest
</Box>
)}
</Group>
{cp.note && (
<Text fz="12.5px" c={MUTED} mt={2}>
{cp.note}
</Text>
)}
<Text fz="11.5px" c="#9AA8B5" mt={3}>
{fmtTime(cp.occurredAt)}
</Text>
</Box>
</Group>
);
})}
</Box>
)}
</Box>
);
}
// ── Empty / error states ───────────────────────────────────────────────────────
function NotDispatchedState({
origin,
destination,
}: {
origin: string;
destination: string;
}) {
return (
<Stack align="center" gap={6} py={40} ta="center">
<Box
style={{
width: 64,
height: 64,
borderRadius: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#FEF6E6",
color: ACCENT,
}}
>
<PackageX size={30} />
</Box>
<Text fz="18px" fw={800} c={INK} mt={4}>
Not on the rails yet
</Text>
<Text fz="13.5px" c={MUTED} maw={440}>
Your shipment from <b>{origin}</b> to <b>{destination}</b> hasn't been
assigned to a train. Live tracking begins the moment it's dispatched and
starts moving along the corridor.
</Text>
</Stack>
);
}
function ErrorState({ onRetry }: { onRetry: () => void }) {
return (
<Stack align="center" gap={8} py={40} ta="center">
<Box
style={{
width: 60,
height: 60,
borderRadius: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#FBEAE7",
color: "#C0392B",
}}
>
<AlertTriangle size={28} />
</Box>
<Text fz="16px" fw={800} c={INK}>
Couldn't load tracking
</Text>
<Text fz="13px" c={MUTED}>
Something went wrong fetching your shipment status.
</Text>
<button
type="button"
onClick={onRetry}
style={{
marginTop: 6,
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "9px 16px",
borderRadius: 10,
border: "1px solid #E6ECF2",
background: "#fff",
color: INK,
fontWeight: 700,
fontSize: 13,
cursor: "pointer",
}}
>
<RefreshCw size={15} /> Try again
</button>
</Stack>
);
}
// ── helpers ────────────────────────────────────────────────────────────────────
function fmtTime(iso?: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// keyframes (injected once)
if (
typeof document !== "undefined" &&
!document.getElementById("edr-tracking-kf")
) {
const style = document.createElement("style");
style.id = "edr-tracking-kf";
style.textContent = `
@keyframes edr-spin { to { transform: rotate(360deg); } }
@keyframes edr-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
@keyframes edr-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
`;
document.head.appendChild(style);
}

View File

@@ -0,0 +1,64 @@
import { Freight } from "@edr/types";
const { TrainScheduleStatus } = Freight;
export function isArrived(
status?: Freight.TrainScheduleStatus | null,
): boolean {
return status === TrainScheduleStatus.Arrived;
}
export function isDispatched(
status?: Freight.TrainScheduleStatus | null,
): boolean {
return status === TrainScheduleStatus.Dispatched;
}
/** Human label for the schedule status, from the rider's point of view. */
export function shipmentStatusLabel(
status?: Freight.TrainScheduleStatus | null,
currentSequenceNo = -1,
): string {
switch (status) {
case TrainScheduleStatus.Arrived:
return "Arrived";
case TrainScheduleStatus.Dispatched:
return currentSequenceNo <= 0 ? "Departed" : "In transit";
case TrainScheduleStatus.Scheduled:
return "Scheduled";
case TrainScheduleStatus.Cancelled:
return "Cancelled";
case TrainScheduleStatus.Draft:
return "Preparing";
default:
return "Not dispatched";
}
}
/**
* 0100 progress across the corridor, derived from how many stations the train
* has reached. Arrived → 100. Not departed → 0.
*/
export function corridorProgress(
stationCount: number,
currentSequenceNo: number,
arrived: boolean,
): number {
if (arrived) return 100;
if (stationCount <= 1 || currentSequenceNo < 0) return 0;
const lastSeq = stationCount - 1;
return Math.round((Math.min(currentSequenceNo, lastSeq) / lastSeq) * 100);
}
/** Caption for a checkpoint kind. */
export function checkpointKindLabel(kind: Freight.TrainCheckpointKind): string {
switch (kind) {
case Freight.TrainCheckpointKind.Departed:
return "Departed";
case Freight.TrainCheckpointKind.Arrived:
return "Arrived";
case Freight.TrainCheckpointKind.Passed:
default:
return "Passed";
}
}

View File

@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
<div className="flex flex-col items-center gap-4">
<div className="flex size-16 items-center justify-center rounded-full bg-destructive/10">
<XCircle className="size-9 text-destructive" />
</div>
<p className="text-xl font-bold text-foreground">
Payment was not completed
</p>
<p className="text-sm text-muted-foreground">
Your payment didn't go through and you haven't been charged. You can
try again from your booking using "Pay now".
</p>
<div className="mt-2 flex w-full flex-col gap-2">
<Button type="button" onClick={() => navigate("/bookings")}>
Back to My Bookings
</Button>
<Button
type="button"
variant="outline"
onClick={() => navigate("/")}
>
Back to home
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
<div className="flex flex-col items-center gap-4">
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10">
<CheckCircle2 className="size-9 text-primary" />
</div>
<p className="text-xl font-bold text-foreground">
Payment successful
</p>
<p className="text-sm text-muted-foreground">
Thank you your payment has been received. Your booking will be
updated shortly and is now confirmed for scheduling.
</p>
<div className="mt-2 flex w-full flex-col gap-2">
<Button type="button" onClick={() => navigate("/bookings")}>
Go to My Bookings
</Button>
<Button
type="button"
variant="outline"
onClick={() => navigate("/")}
>
Back to home
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -13,7 +13,9 @@ import {
BookingListFilter, BookingListFilter,
CreateBookingPayload, CreateBookingPayload,
GeneratePriceResponse, GeneratePriceResponse,
SubmitBookingResponse,
} from "./bookings.service"; } from "./bookings.service";
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
import { import {
paymentsService, paymentsService,
InitiatePaymentPayload, InitiatePaymentPayload,
@@ -147,16 +149,29 @@ export const api = {
({ id }) => bookingsService.get(id), ({ id }) => bookingsService.get(id),
), ),
create: endpoint<CreateBookingPayload, Freight.IBooking>( tracking: endpoint<{ id: string }, Freight.IBookingTracking>(
"bookings", "bookings",
"create", "tracking",
bookingsService.create, ({ id }) => bookingsService.tracking(id),
),
create: endpoint<
{ payload: CreateBookingPayload; documents?: BookingDocuments },
Freight.IBooking
>("bookings", "create", ({ payload, documents }) =>
bookingsService.create(payload, documents),
), ),
update: endpoint< update: endpoint<
{ id: string; dto: Partial<CreateBookingPayload> }, {
id: string;
dto: Partial<CreateBookingPayload>;
documents?: BookingDocuments;
},
{ booking: Freight.IBooking; warnings: string[] } { 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<void, Freight.BookingReferenceData>( referenceData: endpoint<void, Freight.BookingReferenceData>(
"bookings", "bookings",
@@ -180,12 +195,18 @@ export const api = {
({ id }) => bookingsService.generatePrice(id), ({ id }) => bookingsService.generatePrice(id),
), ),
submit: endpoint<{ id: string }, Freight.IBooking>( submit: endpoint<{ id: string }, SubmitBookingResponse>(
"bookings", "bookings",
"submit", "submit",
({ id }) => bookingsService.submit(id), ({ id }) => bookingsService.submit(id),
), ),
confirmSubmit: endpoint<{ id: string }, SubmitBookingResponse>(
"bookings",
"confirmSubmit",
({ id }) => bookingsService.confirmSubmit(id),
),
uploadDocuments: endpoint< uploadDocuments: endpoint<
{ id: string; files: Record<string, File | File[] | null> }, { id: string; files: Record<string, File | File[] | null> },
Freight.IBooking Freight.IBooking

View File

@@ -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<CreateBookingPayload["containers"]>,
) {
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<string, File | File[] | null>,
) {
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<CreateBookingPayload>,
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);
});
}

View File

@@ -1,6 +1,8 @@
import type { Freight, PaginatedResponse } from "@edr/types"; import type { Freight, PaginatedResponse } from "@edr/types";
import { URL_CONSTANTS } from "@/constants/URLS"; 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"; import { client } from "../utils/api";
const B = URL_CONSTANTS.BOOKINGS; const B = URL_CONSTANTS.BOOKINGS;
@@ -45,6 +47,17 @@ export interface GeneratePriceResponse {
warnings: string[]; warnings: string[];
} }
export interface SubmitBookingResponse {
bookingId: string;
status: string;
priceChanged: boolean;
previousTotalAmount?: number;
totalAmount: number;
currency: string;
lineItems?: PriceLineItem[];
message?: string;
}
export interface SignContractPayload { export interface SignContractPayload {
role: "CUSTOMER" | "STAFF"; role: "CUSTOMER" | "STAFF";
signatureImageBase64: string; signatureImageBase64: string;
@@ -54,6 +67,8 @@ export interface SignContractPayload {
export interface BookingListFilter { export interface BookingListFilter {
status?: string; status?: string;
/** Comma-separated statuses (overrides `status` when set). */
statuses?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
sortBy?: string; sortBy?: string;
@@ -71,8 +86,18 @@ export const bookingsService = {
const { data } = await client.get(`/api/bookings/${id}`); const { data } = await client.get(`/api/bookings/${id}`);
return data.data; return data.data;
}, },
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => { tracking: async (id: string): Promise<Freight.IBookingTracking> => {
const { data } = await client.post("/api/bookings", payload); const { data } = await client.get(`/api/bookings/${id}/tracking`);
return data.data;
},
create: async (
payload: CreateBookingPayload,
documents?: BookingDocuments,
): Promise<Freight.IBooking> => {
const formData = buildBookingFormData(payload, documents);
const { data } = await client.post("/api/bookings", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return data.data.booking; return data.data.booking;
}, },
getReferenceData: async (): Promise<Freight.BookingReferenceData> => { getReferenceData: async (): Promise<Freight.BookingReferenceData> => {
@@ -82,8 +107,12 @@ export const bookingsService = {
update: async ( update: async (
id: string, id: string,
payload: Partial<CreateBookingPayload>, payload: Partial<CreateBookingPayload>,
documents?: BookingDocuments,
): Promise<{ booking: Freight.IBooking; warnings: string[] }> => { ): 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; return data.data;
}, },
@@ -101,11 +130,16 @@ export const bookingsService = {
return data.data; return data.data;
}, },
submit: async (id: string): Promise<Freight.IBooking> => { submit: async (id: string): Promise<SubmitBookingResponse> => {
const { data } = await client.post(`/api/bookings/${id}/submit`); const { data } = await client.post(`/api/bookings/${id}/submit`);
return data.data; return data.data;
}, },
confirmSubmit: async (id: string): Promise<SubmitBookingResponse> => {
const { data } = await client.post(`/api/bookings/${id}/confirm-submit`);
return data.data;
},
uploadDocuments: async ( uploadDocuments: async (
id: string, id: string,
files: Record<string, File | File[] | null>, files: Record<string, File | File[] | null>,

View File

@@ -130,6 +130,13 @@ FAYDA_SESSION_TTL_MINUTES=10
GITHUB_PACKAGE_TOKEN= GITHUB_PACKAGE_TOKEN=
# --- Notification broker (RabbitMQ) -----------------------------------------------------------------
# Set RABBITMQ_ENABLED=false to skip connection entirely (dev without a local broker).
RABBITMQ_ENABLED=false
RABBITMQ_URL=amqp://localhost:5672
EMAIL_QUEUE=email_queue
SMS_QUEUE=sms_queue
# --- Payment event consumer (RabbitMQ) ------------------------------------------------------- # --- Payment event consumer (RabbitMQ) -------------------------------------------------------
# Consumes payment.succeeded / payment.failed events from the payment microservice. Separate # Consumes payment.succeeded / payment.failed events from the payment microservice. Separate
# from any RABBITMQ_URL used by the IAM/notification modules so the two connections are # from any RABBITMQ_URL used by the IAM/notification modules so the two connections are

View File

@@ -0,0 +1,18 @@
-- CreateEnum
CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED');
-- AlterTable: add return leg tracking columns to Booking
ALTER TABLE "passenger"."Booking"
ADD COLUMN "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
ADD COLUMN "outboundBoardedAt" TIMESTAMP(3),
ADD COLUMN "returnBoardedAt" TIMESTAMP(3);
-- Set NEITHER_USED for existing confirmed round-trip bookings
UPDATE "passenger"."Booking"
SET "returnLegStatus" = 'NEITHER_USED'
WHERE "bookingType" = 'ROUND_TRIP'
AND "status" IN ('CONFIRMED', 'COMPLETED');
-- AlterTable: add leg column to GateValidationLog
ALTER TABLE "passenger"."GateValidationLog"
ADD COLUMN "leg" TEXT;

View File

@@ -0,0 +1,38 @@
-- Fix missing columns from 20260617 migration (failed due to missing schema prefix)
ALTER TABLE "passenger"."Booking"
ADD COLUMN IF NOT EXISTS "returnScheduleId" TEXT,
ADD COLUMN IF NOT EXISTS "returnOriginStationId" TEXT,
ADD COLUMN IF NOT EXISTS "returnDestinationStationId" TEXT,
ADD COLUMN IF NOT EXISTS "returnHoldId" TEXT,
ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT;
ALTER TABLE "passenger"."SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
CREATE INDEX IF NOT EXISTS "Booking_bookingType_idx" ON "passenger"."Booking"("bookingType");
-- Transit leg-2 columns (never migrated)
ALTER TABLE "passenger"."Booking"
ADD COLUMN IF NOT EXISTS "leg2ScheduleId" TEXT,
ADD COLUMN IF NOT EXISTS "leg2OriginStationId" TEXT,
ADD COLUMN IF NOT EXISTS "leg2DestinationStationId" TEXT,
ADD COLUMN IF NOT EXISTS "leg2SeatClassId" TEXT,
ADD COLUMN IF NOT EXISTS "returnLeg2ScheduleId" TEXT,
ADD COLUMN IF NOT EXISTS "returnLeg2OriginStationId" TEXT,
ADD COLUMN IF NOT EXISTS "returnLeg2DestStationId" TEXT,
ADD COLUMN IF NOT EXISTS "returnLeg2SeatClassId" TEXT;
-- ReturnLegStatus enum + columns (from 20260625 migration, may have also failed)
DO $$ BEGIN
CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM (
'NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
ALTER TABLE "passenger"."Booking"
ADD COLUMN IF NOT EXISTS "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
ADD COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3);
ALTER TABLE "passenger"."GateValidationLog"
ADD COLUMN IF NOT EXISTS "leg" TEXT;

View File

@@ -115,6 +115,16 @@ enum BookingStatus {
@@schema("passenger") @@schema("passenger")
} }
enum ReturnLegStatus {
NOT_APPLICABLE // one-way booking
BOTH_USED // passenger used both legs
OUTBOUND_ONLY // return leg not used (no-show on return)
INBOUND_ONLY // outbound leg not used, return leg used
NEITHER_USED // neither leg boarded yet
@@schema("passenger")
}
enum PaymentRegion { enum PaymentRegion {
ETHIOPIA ETHIOPIA
DJIBOUTI DJIBOUTI
@@ -364,7 +374,8 @@ model TrainSchedule {
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id]) originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id]) destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id])
coachAssignments CoachAssignment[] coachAssignments CoachAssignment[]
bookings Booking[] bookings Booking[] @relation("OutboundSchedule")
returnBookings Booking[] @relation("ReturnSchedule")
stopTimes TripStopTime[] stopTimes TripStopTime[]
liveStatus TripLiveStatus? liveStatus TripLiveStatus?
menuItems MenuItem[] menuItems MenuItem[]
@@ -511,6 +522,19 @@ model Booking {
returnDestinationStationId String? returnDestinationStationId String?
returnHoldId String? returnHoldId String?
returnSeatClassId String? returnSeatClassId String?
returnLegStatus ReturnLegStatus @default(NOT_APPLICABLE)
// Transit leg-2 fields (single-booking transit)
leg2ScheduleId String?
leg2OriginStationId String?
leg2DestinationStationId String?
leg2SeatClassId String?
// Round-trip transit: return journey transit fields
returnLeg2ScheduleId String?
returnLeg2OriginStationId String?
returnLeg2DestStationId String?
returnLeg2SeatClassId String?
outboundBoardedAt DateTime?
returnBoardedAt DateTime?
contactEmail String? contactEmail String?
contactPhone String? contactPhone String?
userAgent String? userAgent String?
@@ -520,7 +544,8 @@ model Booking {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
passenger Passenger @relation(fields: [passengerId], references: [id]) passenger Passenger @relation(fields: [passengerId], references: [id])
schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], references: [id])
returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id])
seats BookingSeat[] seats BookingSeat[]
paymentIntent PaymentIntent? paymentIntent PaymentIntent?
ticket Ticket? ticket Ticket?
@@ -539,6 +564,8 @@ model BookingSeat {
id String @id @default(uuid()) id String @id @default(uuid())
bookingId String bookingId String
seatId String seatId String
leg Int @default(1) // 1=outbound/leg-1, 2=return/leg-2
scheduleId String? // which schedule this seat belongs to
passengerName String passengerName String
dateOfBirth DateTime? dateOfBirth DateTime?
passengerCategory PassengerCategory @default(ADULT) passengerCategory PassengerCategory @default(ADULT)
@@ -1159,6 +1186,7 @@ model GateValidationLog {
ticketId String ticketId String
validatorId String validatorId String
gateId String? gateId String?
leg String? // 'OUTBOUND' | 'RETURN' — for round-trip tickets
status String status String
reason String? reason String?
validatedAt DateTime @default(now()) validatedAt DateTime @default(now())

View File

@@ -34,19 +34,25 @@ async function bootstrap() {
## Overview ## Overview
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM. Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
## 🆕 Latest Updates ## Latest Updates
- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display - **TRANSIT & ROUND_TRIP_TRANSIT Booking Types:** Full multi-leg booking support. TRANSIT = single journey via connecting train (single PNR). ROUND_TRIP_TRANSIT = round trip where one or both directions use a connecting train (4 holds, 4 seat sets).
- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles - **returnSeatId on Passenger Payloads:** For ROUND_TRIP and ROUND_TRIP_TRANSIT bookings each passenger object must include \`returnSeatId\` (the seat on the return leg-1). Guest and authenticated booking endpoints both enforce this.
- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing - **Unified Booking Type Matrix:** bookingType field on Booking now accepts ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT across all create endpoints (POST /bookings and POST /bookings/guest).
- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories - **Round-Trip Leg Tracking:** returnLegStatus on every booking tracks outbound/return leg usage (NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED). Gate validation accepts a leg field (OUTBOUND | RETURN | LEG1 | LEG2 | OUTBOUND_LEG1 | OUTBOUND_LEG2 | RETURN_LEG1 | RETURN_LEG2).
- **Multi-Currency Display:** Bookings track display currency and converted amounts - **Auto No-Show Detection:** Cron marks OUTBOUND_ONLY 30 min after return departure when return leg was never scanned.
- **Ticket Lifecycle:** Tickets now include validatedAt and boardedAt timestamps for complete audit trail - **Offline Batch Validation:** validateOfflineBatch now accepts leg per entry and handles both legs of a round-trip in one batch.
- **Booking Filters:** GET /bookings now accepts ?returnLegStatus= to filter no-show/inbound-only cases in back-office.
- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display.
- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles.
- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing.
- **Multi-Currency Display:** Bookings track display currency and converted amounts.
- **Ticket Lifecycle:** Tickets now include validatedAt, outboundBoardedAt, returnBoardedAt for complete audit trail.
## Key Features ## Key Features
### 🎫 Booking Lifecycle ### Booking Lifecycle
- Search trips with real-time availability - Search trips with real-time availability
- Age-based passenger categorization (Adult 5 years, Child <5 years) - Age-based passenger categorization (Adult 5+ years, Child under 5)
- Nationality-based verification (Ethiopian Fayda, International Passport) - Nationality-based verification (Ethiopian Fayda, International Passport)
- Passenger information collection with verification - Passenger information collection with verification
- Coach and seat selection with real-time availability - Coach and seat selection with real-time availability
@@ -55,130 +61,143 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Modify bookings (seat changes, passenger updates) - Modify bookings (seat changes, passenger updates)
- Cancel bookings with automatic refunds - Cancel bookings with automatic refunds
- Multi-segment journey support - Multi-segment journey support
- Cross-border journeys via Dire Dawa transit (Ethiopia Djibouti) - Cross-border journeys via Dire Dawa transit (Ethiopia to Djibouti)
- Round-trip booking with return journey scheduling - Round-trip booking with return journey scheduling
- Transit booking (single journey via connecting train, single PNR, single ticket)
- Round-trip transit booking (round trip where one or both directions use a connecting train)
- Coach type selection with seat class and pricing options - Coach type selection with seat class and pricing options
- **NEW:** Booking type tracking (ONE_WAY vs ROUND_TRIP) - Booking type field: ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT
- **NEW:** Display currency and converted pricing per booking - Display currency and converted pricing per booking
- returnLegStatus field tracks which legs of a round-trip were used
- GET /bookings?returnLegStatus=OUTBOUND_ONLY filters no-show returns in back-office
### 👤 Passenger Verification ### Passenger Verification
1. **Ethiopian Nationals:** 1. Ethiopian Nationals:
- Automatic Fayda verification for adults (5 years) - Automatic Fayda verification for adults (5+ years)
- Real-time national ID verification via government database - Real-time national ID verification via government database
- Retrieves verified passenger data (name, DOB, gender) - Retrieves verified passenger data (name, DOB, gender)
- National IDs not stored (policy compliant) - National IDs not stored (policy compliant)
2. **International Passengers:** 2. International Passengers:
- Passport information collection - Passport information collection
- Manual verification for Djiboutian and other nationals - Manual verification for Djiboutian and other nationals
- No government database verification required - No government database verification required
### 💰 Age-Based Pricing ### Age-Based Pricing
- **ADULT** (≥5 years): Pay 100% of base fare - ADULT (5+ years): Pay 100% of base fare
- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100% - CHILD (under 5): First child travels FREE, subsequent children pay 100%
- Automatic age calculation from date of birth - Automatic age calculation from date of birth
- Example: 2 adults + 3 children = 4× base fare (first child free) - Example: 2 adults + 3 children = 4x base fare (first child free)
- **NEW:** Premium charges and insurance fees per seat class - NEW: Premium charges and insurance fees per seat class
- **NEW:** Transparent fee breakdown in pricing calculations - NEW: Transparent fee breakdown in pricing calculations
### 💳 Payment Integration ### Payment Integration
1. **Ethiopian Payment Methods:** 1. Ethiopian Payment Methods: Telebirr, CBE Birr
- **Telebirr** - Ethiopia's leading mobile money 2. Djiboutian Payment Methods: Waafi
- **CBE Birr** - Commercial Bank of Ethiopia 3. International Payment Methods: Card, Wallet
2. **Djiboutian Payment Methods:** ### Seat Management
- **Waafi** - Djibouti's mobile money service
3. **International Payment Methods:**
- **Card** - International card payments (Visa, Mastercard)
- **Wallet** - Internal wallet system
### 🪑 Seat Management
- Real-time seat availability by coach and class - Real-time seat availability by coach and class
- Seat holds with 15-minute expiry - Seat holds with 15-minute expiry
- Auto-assign seats with contiguous algorithm - Auto-assign seats with contiguous algorithm
- Seat blocking for maintenance - Seat blocking for maintenance
- Coach-level seat maps (ordered by sequence) - Coach-level seat maps (ordered by sequence)
- Class-based seating (Economy Regular, Economy Bed, VIP Bed) - Class-based seating (Economy Regular, Economy Bed, VIP Bed)
- **NEW:** Sequence-based coach ordering for consistent display - NEW: Sequence-based coach ordering for consistent display
### 🎟️ Ticketing ### Ticketing
- QR code and barcode generation - QR code and barcode generation
- PDF ticket generation - PDF ticket generation
- Gate validation with audit logs - Gate validation with audit logs
- Offline validation support - Offline validation support
- Multi-passenger tickets - Multi-passenger tickets
- **NEW:** Ticket lifecycle tracking (validatedAt, boardedAt timestamps) - NEW: Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps)
- **NEW:** Complete audit trail for compliance and reporting - NEW: Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
- NEW: Complete audit trail per leg for compliance and reporting
### 🏆 Loyalty Program ### Booking Type Matrix
| bookingType | Holds required | Passenger seat fields | Legs in DB |
|---|---|---|---|
| ONE_WAY | holdId | seatId | 1 |
| ROUND_TRIP | holdId + returnHoldId | seatId + returnSeatId | 2 (leg=1 outbound, leg=2 return) |
| TRANSIT | holdId + leg2HoldId | seatId + leg2SeatId | 2 (leg=1, leg=2 on same direction) |
| ROUND_TRIP_TRANSIT | holdId + leg2HoldId + returnHoldId + returnLeg2HoldId | seatId + leg2SeatId + returnSeatId + returnLeg2SeatId | 4 |
### Round-Trip Leg Tracking
- returnLegStatus on Booking: NOT_APPLICABLE, NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED
- Gate validation POST /tickets/:ref/validate accepts optional leg field:
- ONE_WAY: omit
- TRANSIT: LEG1 | LEG2
- ROUND_TRIP: OUTBOUND | RETURN
- ROUND_TRIP_TRANSIT: OUTBOUND_LEG1 | OUTBOUND_LEG2 | RETURN_LEG1 | RETURN_LEG2
- Auto no-show cron: sets OUTBOUND_ONLY 30 min after return departure when return leg unscanned
- Back-office filter: GET /bookings?returnLegStatus=OUTBOUND_ONLY surfaces no-shows
- Offline batch: validateOfflineBatch accepts leg per entry, handles both legs of same booking
### Round-Trip & Transit Bookings
- ONE_WAY and ROUND_TRIP for direct routes
- TRANSIT for single connecting journey (Dire Dawa hub), single PNR
- ROUND_TRIP_TRANSIT for round trips via connecting trains
- Combined pricing: total = sum of all leg base fares, single promo/loyalty deduction
- Separate seat management per leg; each leg stored with its scheduleId and leg number
- returnLegStatus tracks which legs have been boarded for no-show management
### Loyalty Program
- 4 tiers: Bronze, Silver, Gold, Platinum - 4 tiers: Bronze, Silver, Gold, Platinum
- Points accumulation on trips - Points accumulation on trips
- Reward redemption - Reward redemption
- Tier-based benefits - Tier-based benefits
### 💰 Wallet System ### Wallet System
- Top-up via payment methods - Top-up via payment methods
- Pay with wallet balance - Pay with wallet balance
- Transaction ledger - Transaction ledger
- Refund to wallet - Refund to wallet
### 📍 Live Tracking ### Live Tracking
- Real-time trip status - Real-time trip status
- Location updates - Location updates
- Delay notifications - Delay notifications
- Station crowd signals - Station crowd signals
### 🔒 Fraud Detection ### Fraud Detection
- Velocity checks (multiple bookings) - Velocity checks (multiple bookings)
- High-value transaction monitoring - High-value transaction monitoring
- Failed payment pattern detection - Failed payment pattern detection
- Automatic user blocking - Automatic user blocking
### 👤 Passenger Profiles ### Passenger Profiles
- Comprehensive profile data: gender, date of birth, nationality - Comprehensive profile data: gender, date of birth, nationality
- National ID for Ethiopian citizens (Fayda verified) - National ID for Ethiopian citizens (Fayda verified)
- Passport information for international passengers - Passport information for international passengers
- **NEW:** Complete demographic data for personalized services - NEW: Complete demographic data for personalized services
- **NEW:** Improved user targeting and communications
### 🌍 Internationalization ### Internationalization
- Multi-language support (English, Amharic, French, Oromo) - Multi-language support (English, Amharic, French, Oromo)
- Locale-based responses - Locale-based responses
- Currency formatting (ETB, DJF, USD) - Currency formatting (ETB, DJF, USD)
- **NEW:** Multi-currency display per booking (ETB, DJF, USD) - NEW: Multi-currency display per booking (ETB, DJF, USD)
### 🚌 Transit Stop Management ### Transit Stop Management
- Automatic detection of cross-border journeys (Ethiopia Djibouti) - Automatic detection of cross-border journeys (Ethiopia to Djibouti)
- Dire Dawa as mandatory transit hub for international journeys - Dire Dawa as mandatory transit hub for international journeys
- Dual-leg fare calculation (domestic + international) - Dual-leg fare calculation (domestic + international)
- Age-based pricing applied independently per leg - Age-based pricing applied independently per leg
- Seamless multi-segment booking workflow - Seamless multi-segment booking workflow
- Transit stop optimization and route planning
### 🔄 Round-Trip Booking ### Coach Type & Class Selection
- One-way and round-trip journey options - Browse available coach types per route
- Flexible return date selection
- Combined pricing for outbound + return legs
- Separate seat management per leg
- Independent modification/cancellation per leg
- Return journey tracking and notifications
- **NEW:** Booking type stored for analytics and reporting
### 🚐 Coach Type & Class Selection
- Browse available coach types per route (standard coaches, premium coaches)
- View seat classes per coach (Economy Regular, Economy Bed, VIP Bed) - View seat classes per coach (Economy Regular, Economy Bed, VIP Bed)
- Compare base prices by coach type and class - Compare base prices by coach type and class
- Real-time availability per coach configuration - Real-time availability per coach configuration
- Deferred pricing at seat selection stage - NEW: Sequence-based coach ordering for consistent UI
- Coach amenities and features display - NEW: Premium and insurance fee transparency per class
- **NEW:** Sequence-based coach ordering for consistent UI
- **NEW:** Premium and insurance fee transparency per class
### 📊 Data Organization ### Data Organization
- **Stations:** Ordered by sequence (1-15) for consistent route display - Stations ordered by sequence (1-15) for consistent route display
- **Coaches:** Ordered by sequence (1+) per type for predictable configuration - Coaches ordered by sequence (1+) per type for predictable configuration
- **Booking History:** Sorted chronologically with filtering options - Booking history sorted chronologically with filtering options
## Authentication ## Authentication
@@ -195,26 +214,37 @@ Used for agent, fraud, and reporting endpoints. Requires corporate IAM token.
## Passenger Booking Flow ## Passenger Booking Flow
### Step 1: Search Trips ### Step 1: Search Trips
\`POST /search\` with origin, destination, date, passenger counts, and nationality \`POST /search\` with origin, destination, date, passenger counts, and nationality.
For round-trips also pass \`journeyType=ROUND_TRIP\` and \`returnDate\`.
### Step 2: Get Fare Quote ### Step 2: Get Fare Quote
\`POST /search/fare-quote\` with passenger counts and display currency \`POST /search/fare-quote\` with passenger counts and display currency.
For round-trips also pass \`returnScheduleId\`, \`returnOriginStationId\`, \`returnDestinationStationId\`.
### Step 3: Passenger Information & Verification ### Step 3: Passenger Information & Verification
**For Ethiopian Passengers:** **For Ethiopian Passengers:**
\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (5 years) \`POST /passengers/verify-fayda\` Automatic Fayda verification for adults (5+ years)
**For International Passengers:** **For International Passengers:**
\`POST /passengers/register-international\` - Passport information collection \`POST /passengers/register-international\` Passport information collection
### Step 4: View Seat Map ### Step 4: View Seat Map
\`GET /seats/seatmap/{scheduleId}\` - Show available coaches and seats \`GET /seats/seatmap/{scheduleId}\` Show available coaches and seats.
For round-trips, call this twice: once for outbound scheduleId, once for return scheduleId.
### Step 5: Login & Hold Seats ### Step 5: Hold Seats
\`POST /auth/login\` then \`POST /seats/hold\` to reserve seats for 15 minutes \`POST /seats/hold\` to reserve seats for 15 minutes.
- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\`
- TRANSIT leg-2: second hold call → \`leg2HoldId\`
- ROUND_TRIP return: second hold call → \`returnHoldId\`
- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\`
### Step 6: Create Booking ### Step 6: Create Booking
\`POST /bookings/guest\` with verified passenger details and held seats Choose the right endpoint and bookingType:
- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\`
- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\`
- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\`
- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\`
### Step 7: Process Payment ### Step 7: Process Payment
\`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian) \`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian)
@@ -265,7 +295,7 @@ Payment providers send notifications to:
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation") .addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails") .addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
.addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management") .addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management")
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout") .addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT booking types. returnLegStatus filter for round-trip no-show management")
.addTag("Config", "System settings, feature flags, and configuration management") .addTag("Config", "System settings, feature flags, and configuration management")
.addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion") .addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion")
.addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications") .addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications")
@@ -282,7 +312,6 @@ Payment providers send notifications to:
.addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation") .addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation")
.addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking") .addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking")
.addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards") .addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards")
.addTag("Round Trip", "Round-trip bookings, return scheduling, combined pricing, and management (NEW)")
.addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance") .addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance")
.addTag("Schedule", "Trip schedules, availability windows, status tracking, and timing") .addTag("Schedule", "Trip schedules, availability windows, status tracking, and timing")
.addTag("Search", "Trip search, fare quotes, coach types, and real-time availability") .addTag("Search", "Trip search, fare quotes, coach types, and real-time availability")
@@ -291,8 +320,8 @@ Payment providers send notifications to:
.addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability") .addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability")
.addTag("Stations", "Station directory, location data, baggage facilities, and amenities") .addTag("Stations", "Station directory, location data, baggage facilities, and amenities")
.addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution") .addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution")
.addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation, and audit trails") .addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation with per-leg tracking (OUTBOUND/RETURN/LEG1/LEG2/OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2), and audit trails")
.addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, multi-leg routing (NEW)") .addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, TRANSIT and ROUND_TRIP_TRANSIT bookings")
.addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger") .addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger")
//.addServer('http://localhost:4000', 'Development') //.addServer('http://localhost:4000', 'Development')
// .addServer("https://api.edr-platform.com", "Production") // .addServer("https://api.edr-platform.com", "Production")

View File

@@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger';
import { BookingsService } from './bookings.service'; import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service'; import { GuestBookingService } from './guest-booking.service';
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto'; import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
@@ -75,21 +75,24 @@ export class BookingsController {
@Get() @Get()
@ApiOperation({ @ApiOperation({
summary: 'List all bookings with filters (Admin/Agent)', summary: 'List all bookings with filters (Admin/Agent)',
description: 'Returns paginated list of bookings with search and status filters' description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.'
}) })
@ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' }) @ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' }) @ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' })
@ApiQuery({ name: 'returnLegStatus', required: false, description: 'Filter round-trip leg usage: NEITHER_USED | OUTBOUND_ONLY | INBOUND_ONLY | BOTH_USED | NOT_APPLICABLE' })
@ApiQuery({ name: 'page', required: false, description: 'Page number' }) @ApiQuery({ name: 'page', required: false, description: 'Page number' })
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' }) @ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' })
findAll( findAll(
@Query('search') search?: string, @Query('search') search?: string,
@Query('status') status?: string, @Query('status') status?: string,
@Query('returnLegStatus') returnLegStatus?: string,
@Query('page') page?: string, @Query('page') page?: string,
@Query('pageSize') pageSize?: string, @Query('pageSize') pageSize?: string,
) { ) {
return this.service.findAll({ return this.service.findAll({
search, search,
status, status,
returnLegStatus,
page: page ? parseInt(page) : 1, page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20 pageSize: pageSize ? parseInt(pageSize) : 20
}); });
@@ -97,35 +100,153 @@ export class BookingsController {
@Post('guest') @Post('guest')
@ApiOperation({ @ApiOperation({
summary: 'Create guest booking without login (optional account creation)', summary: 'Create guest booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT (no login required)',
description: `Creates a booking without requiring login. Features: description: `Creates a booking without requiring login. Supports all four booking types.
**Guest Checkout:** **bookingType: ONE_WAY (default)**
- No login required - scheduleId, holdId, originStationId, destinationStationId, seatClassId
- Contact details from first passenger - passengers[]: { seatId, passengerName, dateOfBirth, idDocumentType, … }
- Booking confirmation sent to email/phone
**Optional Account Creation:** **bookingType: ROUND_TRIP**
- Set createAccount=true with password - Above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
- Account created using first passenger details - passengers[]: each must include returnSeatId (seat on the return leg)
- Automatic login after booking
- Loyalty points and wallet created
**Passenger Details Storage:** **bookingType: TRANSIT**
- savePassengerDetails=true: Save for future bookings - scheduleId/holdId (leg-1) + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId
- Stored by userId (if account created) or deviceId - passengers[]: each must include leg2SeatId
- Retrieve saved passengers for quick booking
**Verifayda Verification:** **bookingType: ROUND_TRIP_TRANSIT**
- Ethiopian nationals: National ID verified via Verifayda - All TRANSIT outbound fields + returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId/returnLeg2ScheduleId/returnLeg2HoldId/returnTransitStationId/returnLeg2DestinationStationId
- Other nationals: Passport details (no verification) - passengers[]: each must include leg2SeatId, returnSeatId, returnLeg2SeatId
**Age-Based Pricing:** **Optional account creation:** set createAccount=true with password — creates account from first passenger details, loyalty + wallet initialised.
- ADULT (≥5 years): Full fare
- CHILD (<5 years): First child FREE, subsequent children full fare` **Verifayda:** Ethiopian nationals verified; international passengers require passportNumber + passportCountry.`
}) })
@ApiResponse({ status: 201, description: 'Booking created successfully' }) @ApiBody({
@ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid data' }) type: CreateGuestBookingDto,
examples: {
ONE_WAY: {
summary: 'ONE_WAY — single direct journey (guest)',
value: {
scheduleId: 'schedule-uuid',
holdId: 'hold-uuid',
originStationId: 'station-uuid',
destinationStationId: 'station-uuid',
seatClassId: 'seat-class-uuid',
bookingType: 'ONE_WAY',
displayCurrency: 'ETB',
passengers: [{
seatId: 'seat-uuid',
passengerName: 'Abebe Kebede',
dateOfBirth: '1990-05-15',
idDocumentType: 'NATIONAL_ID',
idDocumentNumber: 'ET123456789',
nationality: 'Ethiopian',
phone: '+251911234567',
email: 'abebe@email.com',
}],
savePassengerDetails: true,
deviceId: 'device-uuid-123',
},
},
ROUND_TRIP: {
summary: 'ROUND_TRIP — outbound + return, single PNR (guest)',
value: {
scheduleId: 'outbound-schedule-uuid',
holdId: 'outbound-hold-uuid',
originStationId: 'addis-station-uuid',
destinationStationId: 'djibouti-station-uuid',
seatClassId: 'seat-class-uuid',
bookingType: 'ROUND_TRIP',
returnScheduleId: 'return-schedule-uuid',
returnHoldId: 'return-hold-uuid',
returnOriginStationId: 'djibouti-station-uuid',
returnDestinationStationId: 'addis-station-uuid',
returnSeatClassId: 'seat-class-uuid',
displayCurrency: 'ETB',
passengers: [{
seatId: 'outbound-seat-uuid',
returnSeatId: 'return-seat-uuid',
passengerName: 'Abebe Kebede',
dateOfBirth: '1990-05-15',
idDocumentType: 'NATIONAL_ID',
idDocumentNumber: 'ET123456789',
nationality: 'Ethiopian',
phone: '+251911234567',
}],
savePassengerDetails: true,
deviceId: 'device-uuid-123',
},
},
TRANSIT: {
summary: 'TRANSIT — connecting train, single PNR (guest)',
value: {
scheduleId: 'leg1-schedule-uuid',
holdId: 'leg1-hold-uuid',
originStationId: 'addis-station-uuid',
destinationStationId: 'diredawa-station-uuid',
seatClassId: 'seat-class-uuid',
bookingType: 'TRANSIT',
leg2ScheduleId: 'leg2-schedule-uuid',
leg2HoldId: 'leg2-hold-uuid',
transitStationId: 'diredawa-station-uuid',
leg2DestinationStationId: 'djibouti-station-uuid',
displayCurrency: 'ETB',
passengers: [{
seatId: 'leg1-seat-uuid',
leg2SeatId: 'leg2-seat-uuid',
passengerName: 'Abebe Kebede',
dateOfBirth: '1990-05-15',
idDocumentType: 'NATIONAL_ID',
idDocumentNumber: 'ET123456789',
nationality: 'Ethiopian',
phone: '+251911234567',
}],
deviceId: 'device-uuid-123',
},
},
ROUND_TRIP_TRANSIT: {
summary: 'ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds (guest)',
value: {
scheduleId: 'ob-leg1-schedule-uuid',
holdId: 'ob-leg1-hold-uuid',
originStationId: 'addis-station-uuid',
destinationStationId: 'diredawa-station-uuid',
seatClassId: 'seat-class-uuid',
bookingType: 'ROUND_TRIP_TRANSIT',
leg2ScheduleId: 'ob-leg2-schedule-uuid',
leg2HoldId: 'ob-leg2-hold-uuid',
transitStationId: 'diredawa-station-uuid',
leg2DestinationStationId: 'djibouti-station-uuid',
returnScheduleId: 'ret-leg1-schedule-uuid',
returnHoldId: 'ret-leg1-hold-uuid',
returnOriginStationId: 'djibouti-station-uuid',
returnDestinationStationId: 'diredawa-station-uuid',
returnLeg2ScheduleId: 'ret-leg2-schedule-uuid',
returnLeg2HoldId: 'ret-leg2-hold-uuid',
returnTransitStationId: 'diredawa-station-uuid',
returnLeg2DestinationStationId: 'addis-station-uuid',
displayCurrency: 'ETB',
passengers: [{
seatId: 'ob-leg1-seat-uuid',
leg2SeatId: 'ob-leg2-seat-uuid',
returnSeatId: 'ret-leg1-seat-uuid',
returnLeg2SeatId: 'ret-leg2-seat-uuid',
passengerName: 'Abebe Kebede',
dateOfBirth: '1990-05-15',
idDocumentType: 'NATIONAL_ID',
idDocumentNumber: 'ET123456789',
nationality: 'Ethiopian',
phone: '+251911234567',
}],
deviceId: 'device-uuid-123',
},
},
},
})
@ApiResponse({ status: 201, description: 'Booking created successfully with fareBreakdown' })
@ApiResponse({ status: 400, description: 'Missing required seat IDs for bookingType, or Verifayda verification failed' })
createGuest(@Body() dto: CreateGuestBookingDto) { createGuest(@Body() dto: CreateGuestBookingDto) {
return this.guestService.createGuestBooking(dto); return this.guestService.createGuestBooking(dto);
} }
@@ -144,24 +265,149 @@ export class BookingsController {
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ @ApiOperation({
summary: 'Create booking (one-way or round-trip)', summary: 'Create booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT',
description: `Creates a one-way or round-trip booking for logged-in users. description: `Creates a booking for a logged-in passenger. bookingType controls which fields are required.
ONE_WAY booking: **ONE_WAY**
- scheduleId, holdId, originStationId, destinationStationId - scheduleId, holdId, originStationId, destinationStationId, seatClassId
- passengers: array of PassengerInputDto with seatId - passengers[]: { seatId, passengerName, dateOfBirth, idDocumentType, … }
- Single PNR, single payment
ROUND_TRIP booking: **ROUND_TRIP**
- Outbound: scheduleId, holdId, originStationId, destinationStationId, seatClassId - Above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
- Return: returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId - passengers[]: { seatId (outbound), returnSeatId (return), passengerName, … }
- passengers: array of RoundTripPassengerDto with outboundSeatId and returnSeatId - Combined fare = outbound fare + return fare; single promo/loyalty deduction
- Combined PNR, single payment for both legs
- Fare = outbound_fare + return_fare, single total, single promo, single loyalty deduction` **TRANSIT** (connecting train, single PNR)
- scheduleId/holdId for leg-1 + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId
- passengers[]: { seatId (leg-1), leg2SeatId (leg-2), passengerName, … }
**ROUND_TRIP_TRANSIT** (round trip, each direction via connecting train)
- All TRANSIT outbound fields + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnLeg2ScheduleId, returnLeg2HoldId, returnTransitStationId, returnLeg2DestinationStationId
- passengers[]: { seatId, leg2SeatId, returnSeatId, returnLeg2SeatId, passengerName, … }
- 4 holds required, 4 seat sets per passenger, single PNR, single payment
**Age-Based Pricing (all types)**
- ADULT (≥5 years): full fare per leg
- CHILD (<5 years): first child FREE per booking, subsequent children full fare`
})
@ApiBody({
type: CreateBookingDto,
examples: {
ONE_WAY: {
summary: 'ONE_WAY — single direct journey',
value: {
passengerId: 'passenger-uuid',
scheduleId: 'schedule-uuid',
holdId: 'hold-uuid',
originStationId: 'station-uuid',
destinationStationId: 'station-uuid',
seatClassId: 'seat-class-uuid',
bookingType: 'ONE_WAY',
displayCurrency: 'ETB',
passengers: [{
seatId: 'seat-uuid',
passengerName: 'Abebe Kebede',
dateOfBirth: '1990-05-15',
idDocumentType: 'NATIONAL_ID',
idDocumentNumber: 'ET123456789',
nationality: 'Ethiopian',
}],
},
},
ROUND_TRIP: {
summary: 'ROUND_TRIP — outbound + return, single PNR',
value: {
passengerId: 'passenger-uuid',
scheduleId: 'outbound-schedule-uuid',
holdId: 'outbound-hold-uuid',
originStationId: 'addis-station-uuid',
destinationStationId: 'djibouti-station-uuid',
seatClassId: 'seat-class-uuid',
bookingType: 'ROUND_TRIP',
returnScheduleId: 'return-schedule-uuid',
returnHoldId: 'return-hold-uuid',
returnOriginStationId: 'djibouti-station-uuid',
returnDestinationStationId: 'addis-station-uuid',
returnSeatClassId: 'seat-class-uuid',
displayCurrency: 'ETB',
passengers: [{
seatId: 'outbound-seat-uuid',
returnSeatId: 'return-seat-uuid',
passengerName: 'Abebe Kebede',
dateOfBirth: '1990-05-15',
idDocumentType: 'NATIONAL_ID',
idDocumentNumber: 'ET123456789',
nationality: 'Ethiopian',
}],
},
},
TRANSIT: {
summary: 'TRANSIT — connecting train, single PNR',
value: {
passengerId: 'passenger-uuid',
scheduleId: 'leg1-schedule-uuid',
holdId: 'leg1-hold-uuid',
originStationId: 'addis-station-uuid',
destinationStationId: 'diredawa-station-uuid',
seatClassId: 'seat-class-uuid',
bookingType: 'TRANSIT',
leg2ScheduleId: 'leg2-schedule-uuid',
leg2HoldId: 'leg2-hold-uuid',
transitStationId: 'diredawa-station-uuid',
leg2DestinationStationId: 'djibouti-station-uuid',
displayCurrency: 'ETB',
passengers: [{
seatId: 'leg1-seat-uuid',
leg2SeatId: 'leg2-seat-uuid',
passengerName: 'Abebe Kebede',
dateOfBirth: '1990-05-15',
idDocumentType: 'NATIONAL_ID',
idDocumentNumber: 'ET123456789',
nationality: 'Ethiopian',
}],
},
},
ROUND_TRIP_TRANSIT: {
summary: 'ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds',
value: {
passengerId: 'passenger-uuid',
scheduleId: 'ob-leg1-schedule-uuid',
holdId: 'ob-leg1-hold-uuid',
originStationId: 'addis-station-uuid',
destinationStationId: 'diredawa-station-uuid',
seatClassId: 'seat-class-uuid',
bookingType: 'ROUND_TRIP_TRANSIT',
leg2ScheduleId: 'ob-leg2-schedule-uuid',
leg2HoldId: 'ob-leg2-hold-uuid',
transitStationId: 'diredawa-station-uuid',
leg2DestinationStationId: 'djibouti-station-uuid',
returnScheduleId: 'ret-leg1-schedule-uuid',
returnHoldId: 'ret-leg1-hold-uuid',
returnOriginStationId: 'djibouti-station-uuid',
returnDestinationStationId: 'diredawa-station-uuid',
returnLeg2ScheduleId: 'ret-leg2-schedule-uuid',
returnLeg2HoldId: 'ret-leg2-hold-uuid',
returnTransitStationId: 'diredawa-station-uuid',
returnLeg2DestinationStationId: 'addis-station-uuid',
displayCurrency: 'ETB',
passengers: [{
seatId: 'ob-leg1-seat-uuid',
leg2SeatId: 'ob-leg2-seat-uuid',
returnSeatId: 'ret-leg1-seat-uuid',
returnLeg2SeatId: 'ret-leg2-seat-uuid',
passengerName: 'Abebe Kebede',
dateOfBirth: '1990-05-15',
idDocumentType: 'NATIONAL_ID',
idDocumentNumber: 'ET123456789',
nationality: 'Ethiopian',
}],
},
},
},
}) })
@ApiResponse({ status: 201, description: 'Booking created with fare breakdown' }) @ApiResponse({ status: 201, description: 'Booking created with fare breakdown' })
@ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' }) @ApiResponse({ status: 400, description: 'Missing required fields for bookingType, or Verifayda verification failed' })
@ApiResponse({ status: 404, description: 'Trip or seat hold not found' }) @ApiResponse({ status: 404, description: 'Schedule or seat hold not found' })
create(@Body() dto: CreateBookingDto) { create(@Body() dto: CreateBookingDto) {
return this.service.create(dto); return this.service.create(dto);
} }

View File

@@ -4,7 +4,10 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client'; import { Currency, IdDocumentType } from '@prisma/client';
export class PassengerInputDto { export class PassengerInputDto {
@ApiProperty() @IsString() seatId: string; @ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' }) @IsString() seatId: string;
@ApiPropertyOptional({ example: 'leg2-seat-uuid', description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID' }) @IsOptional() @IsString() leg2SeatId?: string;
@ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID' }) @IsOptional() @IsString() returnSeatId?: string;
@ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) @IsOptional() @IsString() returnLeg2SeatId?: string;
@ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string; @ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string;
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first free), Age ≥5 = ADULT (full fare)' }) @IsDateString() dateOfBirth: string; @ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first free), Age ≥5 = ADULT (full fare)' }) @IsDateString() dateOfBirth: string;
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
@@ -15,18 +18,18 @@ export class PassengerInputDto {
} }
export class RoundTripPassengerDto { export class RoundTripPassengerDto {
@ApiProperty({ @ApiProperty({ description: 'Outbound journey seat ID', example: 'seat-uuid-outbound' })
description: 'Outbound journey seat ID',
example: 'seat-uuid-outbound'
})
@IsString() outboundSeatId: string; @IsString() outboundSeatId: string;
@ApiProperty({ @ApiProperty({ description: 'Return journey seat ID', example: 'seat-uuid-return' })
description: 'Return journey seat ID',
example: 'seat-uuid-return'
})
@IsString() returnSeatId: string; @IsString() returnSeatId: string;
@ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID' })
@IsOptional() @IsString() outboundLeg2SeatId?: string;
@ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' })
@IsOptional() @IsString() returnLeg2SeatId?: string;
@ApiProperty({ @ApiProperty({
example: 'Abebe Kebede', example: 'Abebe Kebede',
description: 'Full passenger name (will be verified via Verifayda for Ethiopian nationals)' description: 'Full passenger name (will be verified via Verifayda for Ethiopian nationals)'
@@ -75,16 +78,16 @@ export class CreateBookingDto {
@ApiProperty({ description: 'Passenger ID' }) @ApiProperty({ description: 'Passenger ID' })
@IsString() passengerId: string; @IsString() passengerId: string;
@ApiProperty({ description: 'Outbound schedule ID' }) @ApiProperty({ description: 'Outbound / leg-1 schedule ID' })
@IsString() scheduleId: string; @IsString() scheduleId: string;
@ApiProperty({ description: 'Outbound seat hold ID' }) @ApiProperty({ description: 'Outbound / leg-1 seat hold ID' })
@IsString() holdId: string; @IsString() holdId: string;
@ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID (must match the hold)' }) @ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID' })
@IsString() originStationId: string; @IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID (must match the hold)' }) @ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID' })
@IsString() destinationStationId: string; @IsString() destinationStationId: string;
@ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID (Economy Regular, Economy Bed, VIP Bed)' }) @ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID (Economy Regular, Economy Bed, VIP Bed)' })
@@ -92,15 +95,33 @@ export class CreateBookingDto {
@ApiProperty({ @ApiProperty({
example: 'ONE_WAY', example: 'ONE_WAY',
enum: ['ONE_WAY', 'ROUND_TRIP'], enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'],
description: `Booking type:\n\n**ONE_WAY:**\n- Single journey from origin to destination\n- Uses: scheduleId, holdId, originStationId, destinationStationId, seatClassId\n- passengers: PassengerInputDto[] with seatId\n\n**ROUND_TRIP:**\n- Outbound + return journey with single PNR\n- Uses all outbound fields PLUS return fields\n- passengers: RoundTripPassengerDto[] with outboundSeatId and returnSeatId\n- Combined fare calculation with single payment`, description: `Booking type:
**ONE_WAY:** Single direct journey — needs: scheduleId, holdId. Passenger: seatId.
**ROUND_TRIP:** Outbound + return, single PNR — needs above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId. Passenger: seatId + returnSeatId.
**TRANSIT:** Single journey via connecting train, single PNR — needs above + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId. Passenger: seatId + leg2SeatId.
**ROUND_TRIP_TRANSIT:** Round trip via connecting trains — needs all 4 hold sets + all station fields. Passenger: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId.`,
default: 'ONE_WAY' default: 'ONE_WAY'
}) })
@IsOptional() @IsString() bookingType?: string; @IsOptional() @IsString() bookingType?: string;
@ApiProperty({ @ApiProperty({
type: [PassengerInputDto], type: [PassengerInputDto],
description: `Passenger array - type depends on bookingType:\n\n**For ONE_WAY:** PassengerInputDto[]\n- Each passenger has: seatId, passengerName, dateOfBirth, etc.\n\n**For ROUND_TRIP:** RoundTripPassengerDto[]\n- Each passenger has: outboundSeatId, returnSeatId, passengerName, dateOfBirth, etc.\n\n**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare` description: `Passenger array — required seat fields vary by bookingType:
**ONE_WAY:** { seatId, passengerName, dateOfBirth, idDocumentType, … }
**ROUND_TRIP:** { seatId (outbound leg-1), returnSeatId (return leg-1), passengerName, … }
**TRANSIT:** { seatId (leg-1), leg2SeatId (leg-2), passengerName, … }
**ROUND_TRIP_TRANSIT:** { seatId, leg2SeatId, returnSeatId, returnLeg2SeatId, passengerName, … }
**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare.`
}) })
@IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto)
passengers: PassengerInputDto[]; passengers: PassengerInputDto[];
@@ -114,31 +135,52 @@ export class CreateBookingDto {
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) @ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' })
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency; @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
// Round-trip specific fields // Transit-specific fields
@ApiPropertyOptional({ @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 schedule ID' })
description: '**ROUND_TRIP ONLY:** Return schedule ID (required when bookingType=ROUND_TRIP)' @IsOptional() @IsString() leg2ScheduleId?: string;
})
@ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 seat hold ID' })
@IsOptional() @IsString() leg2HoldId?: string;
@ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Transit (connecting) station UUID' })
@IsOptional() @IsString() transitStationId?: string;
@ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 destination station UUID' })
@IsOptional() @IsString() leg2DestinationStationId?: string;
@ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 seat class ID (defaults to outbound seatClassId)' })
@IsOptional() @IsString() leg2SeatClassId?: string;
// Round-trip transit: return direction transit fields
@ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-1 schedule ID' })
@IsOptional() @IsString() returnScheduleId?: string; @IsOptional() @IsString() returnScheduleId?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return origin station ID' })
description: '**ROUND_TRIP ONLY:** Return origin station ID (usually same as outbound destination)'
})
@IsOptional() @IsString() returnOriginStationId?: string; @IsOptional() @IsString() returnOriginStationId?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return destination station ID' })
description: '**ROUND_TRIP ONLY:** Return destination station ID (usually same as outbound origin)'
})
@IsOptional() @IsString() returnDestinationStationId?: string; @IsOptional() @IsString() returnDestinationStationId?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return seat hold ID' })
description: '**ROUND_TRIP ONLY:** Return seat hold ID (required when bookingType=ROUND_TRIP)'
})
@IsOptional() @IsString() returnHoldId?: string; @IsOptional() @IsString() returnHoldId?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return seat class ID' })
description: '**ROUND_TRIP ONLY:** Return seat class ID (optional, defaults to outbound seatClassId if not provided)'
})
@IsOptional() @IsString() returnSeatClassId?: string; @IsOptional() @IsString() returnSeatClassId?: string;
@ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 schedule ID' })
@IsOptional() @IsString() returnLeg2ScheduleId?: string;
@ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat hold ID' })
@IsOptional() @IsString() returnLeg2HoldId?: string;
@ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return transit (connecting) station UUID' })
@IsOptional() @IsString() returnTransitStationId?: string;
@ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 destination station UUID' })
@IsOptional() @IsString() returnLeg2DestinationStationId?: string;
@ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat class ID' })
@IsOptional() @IsString() returnLeg2SeatClassId?: string;
} }
export class ModifyBookingDto { export class ModifyBookingDto {

View File

@@ -7,9 +7,10 @@ import { GuestBookingService } from './guest-booking.service';
import { SeatsModule } from '../seats/seats.module'; import { SeatsModule } from '../seats/seats.module';
import { VerifaydaModule } from '../verifayda/verifayda.module'; import { VerifaydaModule } from '../verifayda/verifayda.module';
import { CurrencyModule } from '../currency/currency.module'; import { CurrencyModule } from '../currency/currency.module';
import { FareEngineModule } from '../fare-engine/fare-engine.module';
@Module({ @Module({
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, HttpModule], imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule],
controllers: [BookingsController], controllers: [BookingsController],
providers: [BookingsService, GuestBookingService], providers: [BookingsService, GuestBookingService],
exports: [BookingsService, GuestBookingService] exports: [BookingsService, GuestBookingService]

View File

@@ -6,6 +6,7 @@ import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { VerifaydaService } from '../verifayda/verifayda.service'; import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service'; import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
function generateRef(): string { function generateRef(): string {
@@ -24,6 +25,7 @@ function calculateAge(dateOfBirth: Date): number {
interface BookingFilters { interface BookingFilters {
search?: string; search?: string;
status?: string; status?: string;
returnLegStatus?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
} }
@@ -36,6 +38,7 @@ export class BookingsService {
private eventEmitter: EventEmitter2, private eventEmitter: EventEmitter2,
private verifaydaService: VerifaydaService, private verifaydaService: VerifaydaService,
private currencyService: CurrencyService, private currencyService: CurrencyService,
private fareEngine: FareEngineService,
) {} ) {}
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) { async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
@@ -82,6 +85,8 @@ export class BookingsService {
displayTotalMinor: booking.displayTotalMinor, displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount, adultCount: booking.adultCount,
childCount: booking.childCount, childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt, createdAt: booking.createdAt,
schedule: { schedule: {
train: booking.schedule.train, train: booking.schedule.train,
@@ -159,6 +164,8 @@ export class BookingsService {
displayTotalMinor: booking.displayTotalMinor, displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount, adultCount: booking.adultCount,
childCount: booking.childCount, childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt, createdAt: booking.createdAt,
schedule: { schedule: {
train: booking.schedule.train, train: booking.schedule.train,
@@ -180,7 +187,7 @@ export class BookingsService {
} }
async findAll(filters: BookingFilters = {}) { async findAll(filters: BookingFilters = {}) {
const { search, status, page = 1, pageSize = 20 } = filters; const { search, status, returnLegStatus, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
const where: any = {}; const where: any = {};
@@ -194,9 +201,8 @@ export class BookingsService {
]; ];
} }
if (status) { if (status) where.status = status;
where.status = status; if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
}
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.booking.findMany({ this.prisma.booking.findMany({
@@ -225,6 +231,10 @@ export class BookingsService {
displayTotalMinor: booking.displayTotalMinor, displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail, contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone, contactPhone: booking.contactPhone,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
adultCount: booking.adultCount,
childCount: booking.childCount,
createdAt: booking.createdAt, createdAt: booking.createdAt,
passenger: booking.passenger?.user, passenger: booking.passenger?.user,
schedule: { schedule: {
@@ -246,9 +256,9 @@ export class BookingsService {
} }
async create(dto: CreateBookingDto) { async create(dto: CreateBookingDto) {
if (dto.bookingType === 'ROUND_TRIP') { if (dto.bookingType === 'ROUND_TRIP') return this.createRoundTripBooking(dto);
return this.createRoundTripBooking(dto); if (dto.bookingType === 'TRANSIT') return this.createTransitBooking(dto);
} if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createRoundTripTransitBooking(dto);
return this.createOneWayBooking(dto); return this.createOneWayBooking(dto);
} }
@@ -391,22 +401,42 @@ export class BookingsService {
returnDestinationStationId: dto.returnDestinationStationId, returnDestinationStationId: dto.returnDestinationStationId,
returnHoldId: dto.returnHoldId, returnHoldId: dto.returnHoldId,
returnSeatClassId: dto.returnSeatClassId, returnSeatClassId: dto.returnSeatClassId,
returnLegStatus: 'NEITHER_USED',
seats: { seats: {
create: passengersData.map(p => ({ create: [
seat: { connect: { id: p.outboundSeatId } }, ...passengersData.map(p => ({
passengerName: p.passengerName, seat: { connect: { id: p.outboundSeatId } },
dateOfBirth: p.dateOfBirth, leg: 1,
passengerCategory: p.category, scheduleId: dto.scheduleId,
idDocumentType: p.idDocumentType, passengerName: p.passengerName,
passportNumber: p.passportNumber, dateOfBirth: p.dateOfBirth,
passportCountry: p.passportCountry, passengerCategory: p.category,
verifaydaVerified: p.verifaydaVerified, idDocumentType: p.idDocumentType,
verifaydaData: p.verifaydaData, passportNumber: p.passportNumber,
fareMinor: p.category === PassengerCategory.ADULT ? (outboundFare.baseFareMinor + returnFare.baseFareMinor) : 0, passportCountry: p.passportCountry,
displayCurrency verifaydaVerified: p.verifaydaVerified,
})) verifaydaData: p.verifaydaData,
} fareMinor: p.category === PassengerCategory.ADULT ? outboundFare.baseFareMinor : (outboundFare.paidChildrenCount > 0 ? outboundFare.baseFareMinor : 0),
}, displayCurrency,
})),
...passengersData.map(p => ({
seat: { connect: { id: p.returnSeatId } },
leg: 2,
scheduleId: dto.returnScheduleId,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? returnFare.baseFareMinor : (returnFare.paidChildrenCount > 0 ? returnFare.baseFareMinor : 0),
displayCurrency,
})),
],
},
} as any,
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } } include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
}); });
@@ -436,6 +466,302 @@ export class BookingsService {
}; };
} }
private async createTransitBooking(dto: CreateBookingDto) {
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) {
throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
}
const [leg1Hold, leg2Hold] = await Promise.all([
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
]);
if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired');
if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired');
const [leg1Schedule, leg2Schedule] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
}),
this.prisma.trainSchedule.findUnique({
where: { id: dto.leg2ScheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
}),
]);
if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination station not found on leg-2 schedule');
const passengersData = await this.processPassengers(dto.passengers as any[]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const leg2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId;
const [leg1Fare, leg2Fare] = await Promise.all([
this.calculateFare(dto.scheduleId, dto.seatClassId, leg1OriginStop, leg1DestStop, passengersData[0]?.nationality, adultCount, childCount),
this.calculateFare(dto.leg2ScheduleId, leg2SeatClassId, leg2OriginStop, leg2DestStop, passengersData[0]?.nationality, adultCount, childCount),
]);
const combinedBase = leg1Fare.totalBaseFareMinor + leg2Fare.totalBaseFareMinor;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBase * 0.05);
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
bookingType: 'TRANSIT',
totalMinor,
adultCount,
childCount,
displayCurrency,
displayTotalMinor,
leg2ScheduleId: dto.leg2ScheduleId,
leg2OriginStationId: dto.transitStationId,
leg2DestinationStationId: dto.leg2DestinationStationId,
leg2SeatClassId,
seats: {
create: [
...passengersData.map(p => ({
seat: { connect: { id: p.seatId } },
leg: 1,
scheduleId: dto.scheduleId,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? leg1Fare.baseFareMinor : (leg1Fare.paidChildrenCount > 0 ? leg1Fare.baseFareMinor : 0),
displayCurrency,
})),
...passengersData.map(p => ({
seat: { connect: { id: p.leg2SeatId ?? p.seatId } },
leg: 2,
scheduleId: dto.leg2ScheduleId,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? leg2Fare.baseFareMinor : (leg2Fare.paidChildrenCount > 0 ? leg2Fare.baseFareMinor : 0),
displayCurrency,
})),
],
},
} as any,
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
});
await Promise.all([
this.seatsService.confirmSeats(passengersData.map(p => p.seatId)),
this.seatsService.confirmSeats(passengersData.map(p => p.leg2SeatId ?? p.seatId)),
]);
this.eventEmitter.emit('booking.created', { booking });
return {
...booking,
fareBreakdown: {
leg1BaseFareMinor: leg1Fare.baseFareMinor,
leg2BaseFareMinor: leg2Fare.baseFareMinor,
adultCount, childCount,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount: leg1Fare.paidChildrenCount,
combinedBaseFareMinor: combinedBase,
discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor, totalMinor,
currency: 'ETB', displayCurrency, displayTotalMinor,
},
};
}
private async createRoundTripTransitBooking(dto: CreateBookingDto) {
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
!dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
throw new BadRequestException(
'ROUND_TRIP_TRANSIT requires outbound transit fields (leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId) ' +
'AND return transit fields (returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, ' +
'returnLeg2ScheduleId, returnLeg2HoldId, returnTransitStationId, returnLeg2DestinationStationId)',
);
}
// Validate all 4 holds
const [obL1Hold, obL2Hold, retL1Hold, retL2Hold] = await Promise.all([
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }),
]);
const now = new Date();
if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 seat hold expired');
if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 seat hold expired');
if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 seat hold expired');
if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 seat hold expired');
// Load all 4 schedules
const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
]);
if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found');
if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found');
if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
const retL1Origin = retL1Sched.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
const retL2Origin = retL2Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId);
if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit station not found');
if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination not found');
if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit station not found');
if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination not found');
const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const nat = passengersData[0]?.nationality;
const obL2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId;
const retL1SeatClassId = dto.returnSeatClassId ?? dto.seatClassId;
const retL2SeatClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
this.calculateFare(dto.scheduleId, dto.seatClassId, obL1Origin, obL1Dest, nat, adultCount, childCount),
this.calculateFare(dto.leg2ScheduleId, obL2SeatClassId, obL2Origin, obL2Dest, nat, adultCount, childCount),
this.calculateFare(dto.returnScheduleId, retL1SeatClassId, retL1Origin, retL1Dest, nat, adultCount, childCount),
this.calculateFare(dto.returnLeg2ScheduleId, retL2SeatClassId, retL2Origin, retL2Dest, nat, adultCount, childCount),
]);
const combinedBase = obL1Fare.totalBaseFareMinor + obL2Fare.totalBaseFareMinor +
retL1Fare.totalBaseFareMinor + retL2Fare.totalBaseFareMinor;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(combinedBase * 0.05);
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: Awaited<ReturnType<BookingsService['calculateFare']>>) => ({
seat: { connect: { id: seatId } },
leg,
scheduleId,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData,
fareMinor: p.category === PassengerCategory.ADULT ? fare.baseFareMinor : (fare.paidChildrenCount > 0 ? fare.baseFareMinor : 0),
displayCurrency,
});
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: dto.passengerId,
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP_TRANSIT',
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
// Outbound transit leg-2
leg2ScheduleId: dto.leg2ScheduleId,
leg2OriginStationId: dto.transitStationId,
leg2DestinationStationId: dto.leg2DestinationStationId,
leg2SeatClassId: obL2SeatClassId,
// Return transit
returnScheduleId: dto.returnScheduleId,
returnOriginStationId: dto.returnOriginStationId,
returnDestinationStationId: dto.returnDestinationStationId,
returnSeatClassId: retL1SeatClassId,
returnLeg2ScheduleId: dto.returnLeg2ScheduleId,
returnLeg2OriginStationId: dto.returnTransitStationId,
returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
returnLeg2SeatClassId: retL2SeatClassId,
returnLegStatus: 'NEITHER_USED',
seats: {
create: [
// Outbound leg-1 (sequence 1)
...passengersData.map(p => makeSeat(p, p.outboundSeatId, 1, dto.scheduleId, obL1Fare)),
// Outbound leg-2 (sequence 2)
...passengersData.map(p => makeSeat(p, p.outboundLeg2SeatId ?? p.outboundSeatId, 2, dto.leg2ScheduleId!, obL2Fare)),
// Return leg-1 (sequence 3)
...passengersData.map(p => makeSeat(p, p.returnSeatId, 3, dto.returnScheduleId!, retL1Fare)),
// Return leg-2 (sequence 4)
...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId ?? p.returnSeatId, 4, dto.returnLeg2ScheduleId!, retL2Fare)),
],
},
} as any,
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
});
await Promise.all([
this.seatsService.confirmSeats(passengersData.map(p => p.outboundSeatId)),
this.seatsService.confirmSeats(passengersData.map(p => p.outboundLeg2SeatId ?? p.outboundSeatId)),
this.seatsService.confirmSeats(passengersData.map(p => p.returnSeatId)),
this.seatsService.confirmSeats(passengersData.map(p => p.returnLeg2SeatId ?? p.returnSeatId)),
]);
this.eventEmitter.emit('booking.created', { booking });
return {
...booking,
fareBreakdown: {
outboundLeg1FareMinor: obL1Fare.baseFareMinor,
outboundLeg2FareMinor: obL2Fare.baseFareMinor,
returnLeg1FareMinor: retL1Fare.baseFareMinor,
returnLeg2FareMinor: retL2Fare.baseFareMinor,
adultCount, childCount,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount: obL1Fare.paidChildrenCount,
combinedBaseFareMinor: combinedBase,
discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor, totalMinor,
currency: 'ETB', displayCurrency, displayTotalMinor,
},
};
}
private async processPassengers(passengers: any[]) { private async processPassengers(passengers: any[]) {
const processedPassengers = []; const processedPassengers = [];
for (const passenger of passengers) { for (const passenger of passengers) {
@@ -561,75 +887,68 @@ export class BookingsService {
): Promise<number> { ): Promise<number> {
const now = new Date(); const now = new Date();
// Get schedule with route info // 1. SegmentFareRule — most specific explicit price
const schedule = await this.prisma.trainSchedule.findUnique({ const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId }, where: { id: scheduleId },
include: { route: true }, select: { routeId: true, originStationId: true, destinationStationId: true },
}); });
// Try segment fare rule first (most specific) if route info available
if (schedule?.routeId && originStopSeq !== undefined && destStopSeq !== undefined) { if (schedule?.routeId && originStopSeq !== undefined && destStopSeq !== undefined) {
// Try with nationality first
const segmentFare = await this.prisma.segmentFareRule.findFirst({ const segmentFare = await this.prisma.segmentFareRule.findFirst({
where: { where: {
routeId: schedule.routeId, routeId: schedule.routeId,
originStopSequence: originStopSeq, originStopSequence: originStopSeq,
destinationStopSequence: destStopSeq, destinationStopSequence: destStopSeq,
seatClassId, seatClassId,
nationality: nationality || null, nationality: nationality ?? null,
validFrom: { lte: now }, validFrom: { lte: now },
OR: [ OR: [{ validUntil: null }, { validUntil: { gte: now } }],
{ validUntil: null },
{ validUntil: { gte: now } },
],
}, },
}); }) ?? (nationality ? await this.prisma.segmentFareRule.findFirst({
where: {
routeId: schedule.routeId,
originStopSequence: originStopSeq,
destinationStopSequence: destStopSeq,
seatClassId,
nationality: null,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
}) : null);
if (segmentFare) { if (segmentFare) return segmentFare.baseFareMinor;
return segmentFare.baseFareMinor;
}
// If no segment fare with nationality, try without nationality filter
if (nationality) {
const segmentFareAny = await this.prisma.segmentFareRule.findFirst({
where: {
routeId: schedule.routeId,
originStopSequence: originStopSeq,
destinationStopSequence: destStopSeq,
seatClassId,
nationality: null,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
});
if (segmentFareAny) return segmentFareAny.baseFareMinor;
}
} }
// Fall back to fare rules if no segment fare found // 2. FareRule table — explicit override rules
const candidates = await this.prisma.fareRule.findMany({ const candidates = await this.prisma.fareRule.findMany({
where: { where: {
seatClassId, seatClassId,
validFrom: { lte: now }, validFrom: { lte: now },
OR: [ OR: [{ validUntil: null }, { validUntil: { gte: now } }],
{ validUntil: null },
{ validUntil: { gte: now } },
],
}, },
}); });
const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
if (bestMatch) return bestMatch.baseFareMinor;
const bestMatch = this.selectBestFareRule( // 3. FareEngine — distance × rate-per-km from the schedule's route
candidates, if (schedule?.routeId) {
scheduleId, try {
segmentRoute, const fare = await this.fareEngine.calculate({
fullRoute, routeId: schedule.routeId,
nationality, originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
seatClassId,
nationality,
});
return fare.baseFarePerPassengerMinor;
} catch {
// FareEngine throws if distanceKm is missing; fall through to error
}
}
throw new BadRequestException(
`No fare configured for this schedule and seat class. Please set up fare rules or route distances.`,
); );
return bestMatch?.baseFareMinor ?? 35000;
} }
async getByRef(bookingRef: string) { async getByRef(bookingRef: string) {
@@ -646,7 +965,11 @@ export class BookingsService {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status, id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount, totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount,
displayCurrency: booking.displayCurrency, displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined, displayCurrency: booking.displayCurrency, displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined,
bookingType: booking.bookingType, createdAt: booking.createdAt, bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
returnBoardedAt: (booking as any).returnBoardedAt ?? null,
createdAt: booking.createdAt,
schedule: { schedule: {
number: booking.schedule.train.number, number: booking.schedule.train.number,
origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city }, origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city },
@@ -751,6 +1074,37 @@ export class BookingsService {
} }
} }
// Mark round-trip bookings where the return train has departed but the return leg
// was never scanned. Runs every minute; only acts on CONFIRMED bookings whose
// returnSchedule.departureAt is in the past and returnBoardedAt is still null.
@Cron(CronExpression.EVERY_MINUTE)
async markReturnLegNoShows() {
const now = new Date();
const graceCutoff = new Date(now.getTime() - 30 * 60 * 1000);
const candidates = await this.prisma.booking.findMany({
where: {
bookingType: 'ROUND_TRIP',
status: 'CONFIRMED',
returnLegStatus: 'NEITHER_USED' as any,
outboundBoardedAt: { not: null },
returnBoardedAt: null,
returnScheduleId: { not: null },
},
include: { returnSchedule: { select: { departureAt: true } } },
} as any);
for (const b of candidates) {
const returnDep: Date | undefined = (b as any).returnSchedule?.departureAt;
if (returnDep && returnDep < graceCutoff) {
await this.prisma.booking.update({
where: { id: b.id },
data: { returnLegStatus: 'OUTBOUND_ONLY' } as any,
});
}
}
}
private selectBestFareRule( private selectBestFareRule(
candidates: any[], candidates: any[],
scheduleId: string, scheduleId: string,

View File

@@ -4,9 +4,18 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client'; import { Currency, IdDocumentType } from '@prisma/client';
export class GuestPassengerDto { export class GuestPassengerDto {
@ApiProperty({ example: 'seat-id-uuid' }) @ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' })
@IsString() seatId: string; @IsString() seatId: string;
@ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID. Required for ROUND_TRIP and ROUND_TRIP_TRANSIT.' })
@IsOptional() @IsString() returnSeatId?: string;
@ApiPropertyOptional({ example: 'leg2-seat-uuid', description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID. Required for TRANSIT and ROUND_TRIP_TRANSIT.' })
@IsOptional() @IsString() leg2SeatId?: string;
@ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID. Required for ROUND_TRIP_TRANSIT.' })
@IsOptional() @IsString() returnLeg2SeatId?: string;
@ApiProperty({ example: 'Abebe Kebede' }) @ApiProperty({ example: 'Abebe Kebede' })
@IsString() passengerName: string; @IsString() passengerName: string;
@@ -36,24 +45,88 @@ export class GuestPassengerDto {
} }
export class CreateGuestBookingDto { export class CreateGuestBookingDto {
@ApiProperty({ example: 'schedule-uuid' }) @ApiPropertyOptional({
example: 'ONE_WAY',
enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'],
default: 'ONE_WAY',
description: `Booking type:
**ONE_WAY:** scheduleId + holdId. Passenger: seatId.
**ROUND_TRIP:** above + returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId. Passenger: seatId + returnSeatId.
**TRANSIT:** above + leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId. Passenger: seatId + leg2SeatId.
**ROUND_TRIP_TRANSIT:** all 4 hold sets + all station fields. Passenger: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId.`
})
@IsOptional() @IsString() bookingType?: 'ONE_WAY' | 'ROUND_TRIP' | 'TRANSIT' | 'ROUND_TRIP_TRANSIT';
@ApiProperty({ example: 'schedule-uuid', description: 'Outbound / leg-1 schedule UUID' })
@IsString() scheduleId: string; @IsString() scheduleId: string;
@ApiProperty({ example: 'hold-uuid' }) @ApiProperty({ example: 'hold-uuid', description: 'Outbound seat hold UUID' })
@IsString() holdId: string; @IsString() holdId: string;
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' }) @ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID' })
@IsString() originStationId: string; @IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' }) @ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID' })
@IsString() destinationStationId: string; @IsString() destinationStationId: string;
@ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. First passenger details used for contact.' }) @ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID' })
@IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[];
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' })
@IsString() seatClassId: string; @IsString() seatClassId: string;
@ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return seat class UUID' })
@IsOptional() @IsString() returnSeatClassId?: string;
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 schedule UUID' })
@IsOptional() @IsString() leg2ScheduleId?: string;
@ApiPropertyOptional({ example: 'hold-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 seat hold UUID' })
@IsOptional() @IsString() leg2HoldId?: string;
@ApiPropertyOptional({ example: 'station-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: connecting station UUID' })
@IsOptional() @IsString() transitStationId?: string;
@ApiPropertyOptional({ example: 'station-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 destination station UUID' })
@IsOptional() @IsString() leg2DestinationStationId?: string;
@ApiPropertyOptional({ example: 'seat-class-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 seat class UUID' })
@IsOptional() @IsString() leg2SeatClassId?: string;
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return leg-1 schedule UUID' })
@IsOptional() @IsString() returnScheduleId?: string;
@ApiPropertyOptional({ example: 'hold-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return seat hold UUID' })
@IsOptional() @IsString() returnHoldId?: string;
@ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return origin station UUID' })
@IsOptional() @IsString() returnOriginStationId?: string;
@ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return destination station UUID' })
@IsOptional() @IsString() returnDestinationStationId?: string;
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 schedule UUID' })
@IsOptional() @IsString() returnLeg2ScheduleId?: string;
@ApiPropertyOptional({ example: 'hold-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 seat hold UUID' })
@IsOptional() @IsString() returnLeg2HoldId?: string;
@ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP_TRANSIT: return transit station UUID' })
@IsOptional() @IsString() returnTransitStationId?: string;
@ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 destination station UUID' })
@IsOptional() @IsString() returnLeg2DestinationStationId?: string;
@ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 seat class UUID' })
@IsOptional() @IsString() returnLeg2SeatClassId?: string;
@ApiProperty({
type: [GuestPassengerDto],
description: `Passenger array. Required seat fields vary by bookingType:
- ONE_WAY: seatId
- ROUND_TRIP: seatId + returnSeatId
- TRANSIT: seatId + leg2SeatId
- ROUND_TRIP_TRANSIT: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId`
})
@IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[];
@ApiPropertyOptional({ example: 'WEEKEND15' }) @ApiPropertyOptional({ example: 'WEEKEND15' })
@IsOptional() @IsString() promoCode?: string; @IsOptional() @IsString() promoCode?: string;
@@ -66,7 +139,7 @@ export class CreateGuestBookingDto {
@ApiPropertyOptional({ example: 'password123', description: 'Password if createAccount is true' }) @ApiPropertyOptional({ example: 'password123', description: 'Password if createAccount is true' })
@IsOptional() @IsString() password?: string; @IsOptional() @IsString() password?: string;
@ApiPropertyOptional({ example: true, description: 'Save passenger details for future bookings (requires createAccount)' }) @ApiPropertyOptional({ example: true, description: 'Save passenger details for future bookings' })
@IsOptional() @IsBoolean() savePassengerDetails?: boolean; @IsOptional() @IsBoolean() savePassengerDetails?: boolean;
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' }) @ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' })

View File

@@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service'; import { SeatsService } from '../seats/seats.service';
import { VerifaydaService } from '../verifayda/verifayda.service'; import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service'; import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto'; import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
@@ -28,10 +29,18 @@ export class GuestBookingService {
private seatsService: SeatsService, private seatsService: SeatsService,
private verifaydaService: VerifaydaService, private verifaydaService: VerifaydaService,
private currencyService: CurrencyService, private currencyService: CurrencyService,
private fareEngine: FareEngineService,
private eventEmitter: EventEmitter2, private eventEmitter: EventEmitter2,
) {} ) {}
async createGuestBooking(dto: CreateGuestBookingDto) { async createGuestBooking(dto: CreateGuestBookingDto) {
if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto);
if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto);
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto);
return this.createGuestOneWayBooking(dto);
}
private async createGuestOneWayBooking(dto: CreateGuestBookingDto) {
// Validate hold // Validate hold
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) { if (!hold || hold.expiresAt < new Date()) {
@@ -157,76 +166,7 @@ export class GuestBookingService {
// Create or get guest passenger // Create or get guest passenger
const firstPassenger = passengersData[0]; const firstPassenger = passengersData[0];
let guestPassenger = null; const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger);
let userId = null;
let createdAccount = false;
// Optional account creation
if (dto.createAccount && firstPassenger.email && dto.password) {
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
if (existingUser) {
throw new BadRequestException('Email already registered. Please login instead.');
}
let accountPhone = firstPassenger.phone || null;
if (accountPhone) {
const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
}
if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const passwordHash = await bcrypt.hash(dto.password, 10);
const user = await this.prisma.user.create({
data: {
fullName: firstPassenger.passengerName,
email: firstPassenger.email,
phone: accountPhone,
passwordHash,
nationality: firstPassenger.nationality,
nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
passportNumber: firstPassenger.passportNumber,
},
});
guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } });
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
userId = user.id;
createdAccount = true;
} else {
// Create anonymous guest passenger with minimal data
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
// Check if email exists and use a unique guest email if it does
let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
if (firstPassenger.email) {
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
if (existingUser) {
// Email exists, use guest email instead for anonymous booking
guestEmail = `guest-${uniqueId}@edr-platform.com`;
}
}
// Use a guaranteed-unique guest phone to avoid constraint collisions
let guestPhone = firstPassenger.phone || null;
if (guestPhone) {
const existingPhone = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
if (existingPhone) guestPhone = null;
}
if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
const tempUser = await this.prisma.user.create({
data: {
fullName: firstPassenger.passengerName,
email: guestEmail,
phone: guestPhone,
passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
role: 'PASSENGER',
},
});
guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } });
}
// Save passenger details for future use (if requested) // Save passenger details for future use (if requested)
if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) { if (dto.savePassengerDetails && (dto.createAccount || dto.deviceId)) {
@@ -302,6 +242,649 @@ export class GuestBookingService {
}; };
} }
private async createGuestRoundTripBooking(dto: CreateGuestBookingDto) {
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
}
// Validate both holds
const [outboundHold, returnHold] = await Promise.all([
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
]);
if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired or not found');
if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired or not found');
// Validate passengers have returnSeatId
for (const p of dto.passengers) {
if (!p.returnSeatId) throw new BadRequestException(`returnSeatId is required for each passenger in a ROUND_TRIP booking (missing for ${p.passengerName})`);
}
// Load both schedules
const [outboundSchedule, returnSchedule] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
}),
this.prisma.trainSchedule.findUnique({
where: { id: dto.returnScheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
}),
]);
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
if (!returnSchedule) throw new NotFoundException('Return schedule not found');
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId);
if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule');
if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');
const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`;
const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`;
const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`;
const returnFullRoute = `${returnSchedule.originStation.code}-${returnSchedule.destinationStation.code}`;
// Process passengers (verify identity once — same person travels both legs)
const passengersData: any[] = [];
let adultCount = 0, childCount = 0;
for (const passenger of dto.passengers) {
const dateOfBirth = new Date(passenger.dateOfBirth);
const age = calculateAge(dateOfBirth);
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
let passengerName = passenger.passengerName;
let verifaydaVerified = false;
let verifaydaData: Record<string, any> | undefined;
let nationality = passenger.nationality;
const isEthiopian = passenger.nationality === 'Ethiopian' ||
passenger.nationality === 'ETHIOPIAN' ||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
if (passenger.idDocumentNumber) {
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
passengerName = verification.passengerData?.fullName || passengerName;
verifaydaVerified = true;
verifaydaData = verification.passengerData?.profileData;
}
nationality = 'Ethiopian';
} else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
} else if (isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
nationality = 'Ethiopian';
} else {
nationality = nationality || 'Other';
}
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
// Calculate fares for both legs
const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId;
const primaryNationality = passengersData[0]?.nationality;
const [outboundBaseFare, returnBaseFare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality),
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality),
]);
const paidChildrenCount = Math.max(0, childCount - 1);
const outboundTotalBase = outboundBaseFare * adultCount + outboundBaseFare * paidChildrenCount;
const returnTotalBase = returnBaseFare * adultCount + returnBaseFare * paidChildrenCount;
const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff
? Math.round(combinedBaseFareMinor * promo.percentOff / 100)
: (promo.amountOffMinor ?? 0);
}
}
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
// Create or resolve guest passenger (same as one-way)
const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]);
// Create booking with outbound seats; return seats confirmed separately
const outboundSeatIds = dto.passengers.map(p => p.seatId);
const returnSeatIds = dto.passengers.map(p => p.returnSeatId!);
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: guestPassenger.id,
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP',
totalMinor,
adultCount,
childCount,
displayCurrency,
displayTotalMinor,
returnScheduleId: dto.returnScheduleId,
returnOriginStationId: dto.returnOriginStationId,
returnDestinationStationId: dto.returnDestinationStationId,
returnHoldId: dto.returnHoldId,
returnSeatClassId,
returnLegStatus: 'NEITHER_USED',
userAgent: dto.deviceId,
seats: {
create: [
...passengersData.map((p) => ({
seat: { connect: { id: p.seatId } },
leg: 1,
scheduleId: dto.scheduleId,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : (paidChildrenCount > 0 ? outboundBaseFare : 0),
displayCurrency,
})),
...passengersData.map((p) => ({
seat: { connect: { id: p.returnSeatId } },
leg: 2,
scheduleId: dto.returnScheduleId,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : (paidChildrenCount > 0 ? returnBaseFare : 0),
displayCurrency,
})),
],
},
} as any,
include: {
seats: { include: { seat: { include: { coach: true } } } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
},
});
await Promise.all([
this.seatsService.confirmSeats(outboundSeatIds),
this.seatsService.confirmSeats(returnSeatIds),
]);
this.eventEmitter.emit('booking.created', { booking });
return {
...booking,
createdAccount,
userId,
fareBreakdown: {
outboundBaseFareMinor: outboundBaseFare,
returnBaseFareMinor: returnBaseFare,
adultCount,
childCount,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount,
combinedBaseFareMinor,
discountMinor,
taxesFeesMinor: taxesMinor,
totalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor,
},
};
}
private async createGuestTransitBooking(dto: CreateGuestBookingDto) {
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) {
throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
}
const [leg1Hold, leg2Hold] = await Promise.all([
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
]);
if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired or not found');
if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired or not found');
for (const p of dto.passengers) {
if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId is required for each passenger in a TRANSIT booking (missing for ${p.passengerName})`);
}
const [leg1Schedule, leg2Schedule] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
}),
this.prisma.trainSchedule.findUnique({
where: { id: dto.leg2ScheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
}),
]);
if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination not found on leg-2 schedule');
// Process passengers (verify identity once)
const passengersData: any[] = [];
let adultCount = 0, childCount = 0;
for (const passenger of dto.passengers) {
const dateOfBirth = new Date(passenger.dateOfBirth);
const age = calculateAge(dateOfBirth);
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
let passengerName = passenger.passengerName;
let verifaydaVerified = false;
let verifaydaData: Record<string, any> | undefined;
let nationality = passenger.nationality;
const isEthiopian = passenger.nationality === 'Ethiopian' || passenger.nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
passengerName = verification.passengerData?.fullName || passengerName;
verifaydaVerified = true;
verifaydaData = verification.passengerData?.profileData;
nationality = 'Ethiopian';
} else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport details required for ${passenger.passengerName}`);
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
} else {
nationality = nationality || 'Other';
}
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
const leg2SeatClassId = dto.leg2SeatClassId || dto.seatClassId;
const primaryNationality = passengersData[0]?.nationality;
const paidChildrenCount = Math.max(0, childCount - 1);
const [leg1BaseFare, leg2BaseFare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId,
`${leg1OriginStop.station.code}-${leg1DestStop.station.code}`,
`${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`,
primaryNationality),
this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId,
`${leg2OriginStop.station.code}-${leg2DestStop.station.code}`,
`${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`,
primaryNationality),
]);
const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount;
const leg2Total = leg2BaseFare * adultCount + leg2BaseFare * paidChildrenCount;
const combinedBase = leg1Total + leg2Total;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
const taxesMinor = Math.round(combinedBase * 0.05);
const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]);
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: guestPassenger.id,
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
bookingType: 'TRANSIT',
totalMinor,
adultCount,
childCount,
displayCurrency,
displayTotalMinor,
leg2ScheduleId: dto.leg2ScheduleId,
leg2OriginStationId: dto.transitStationId,
leg2DestinationStationId: dto.leg2DestinationStationId,
leg2SeatClassId: leg2SeatClassId,
userAgent: dto.deviceId,
seats: {
create: [
...passengersData.map(p => ({
seat: { connect: { id: p.seatId } },
leg: 1,
scheduleId: dto.scheduleId,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? leg1BaseFare : (paidChildrenCount > 0 ? leg1BaseFare : 0),
displayCurrency,
})),
...passengersData.map(p => ({
seat: { connect: { id: p.leg2SeatId! } },
leg: 2,
scheduleId: dto.leg2ScheduleId,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? leg2BaseFare : (paidChildrenCount > 0 ? leg2BaseFare : 0),
displayCurrency,
})),
],
},
} as any,
include: {
seats: { include: { seat: { include: { coach: true } } } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
},
});
await Promise.all([
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
]);
this.eventEmitter.emit('booking.created', { booking });
return {
...booking,
createdAccount,
userId,
fareBreakdown: {
leg1BaseFareMinor: leg1BaseFare,
leg2BaseFareMinor: leg2BaseFare,
adultCount, childCount,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount,
combinedBaseFareMinor: combinedBase,
discountMinor, taxesFeesMinor: taxesMinor, totalMinor,
currency: 'ETB', displayCurrency, displayTotalMinor,
},
};
}
private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto) {
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
!dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
throw new BadRequestException(
'ROUND_TRIP_TRANSIT requires all 4 holds and all transit/return station fields',
);
}
for (const p of dto.passengers) {
if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId required for ${p.passengerName}`);
if (!p.returnSeatId) throw new BadRequestException(`returnSeatId required for ${p.passengerName}`);
if (!p.returnLeg2SeatId) throw new BadRequestException(`returnLeg2SeatId required for ${p.passengerName}`);
}
const now = new Date();
const [obL1Hold, obL2Hold, retL1Hold, retL2Hold] = await Promise.all([
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }),
]);
if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 hold expired');
if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 hold expired');
if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 hold expired');
if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired');
const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId },include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
]);
if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found');
if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found');
if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
const retL1Origin = retL1Sched.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
const retL2Origin = retL2Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId);
if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit stop not found');
if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination stop not found');
if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit stop not found');
if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination stop not found');
// Process passengers (verify once)
const passengersData: any[] = [];
let adultCount = 0, childCount = 0;
for (const passenger of dto.passengers) {
const dateOfBirth = new Date(passenger.dateOfBirth);
const category: PassengerCategory = calculateAge(dateOfBirth) < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
let passengerName = passenger.passengerName;
let verifaydaVerified = false;
let verifaydaData: Record<string, any> | undefined;
let nationality = passenger.nationality;
const isEthiopian = nationality === 'Ethiopian' || nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
const v = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!v.verified) throw new BadRequestException(`Verifayda failed for ${passenger.passengerName}: ${v.failureReason}`);
passengerName = v.passengerData?.fullName || passengerName;
verifaydaVerified = true;
verifaydaData = v.passengerData?.profileData;
nationality = 'Ethiopian';
} else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport details required for ${passenger.passengerName}`);
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
} else {
nationality = nationality || 'Other';
}
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
const nat = passengersData[0]?.nationality;
const paidChildren = Math.max(0, childCount - 1);
const obL2ClassId = dto.leg2SeatClassId ?? dto.seatClassId;
const retL1ClassId = dto.returnSeatClassId ?? dto.seatClassId;
const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat),
this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat),
]);
const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount +
(obL1Fare + obL2Fare + retL1Fare + retL2Fare) * paidChildren;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
const taxesMinor = Math.round(combinedBase * 0.05);
const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]);
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({
seat: { connect: { id: seatId } },
leg,
scheduleId,
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
idDocumentType: p.idDocumentType,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? fare : (paidChildren > 0 ? fare : 0),
displayCurrency,
});
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: guestPassenger.id,
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP_TRANSIT',
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
leg2ScheduleId: dto.leg2ScheduleId,
leg2OriginStationId: dto.transitStationId,
leg2DestinationStationId: dto.leg2DestinationStationId,
leg2SeatClassId: obL2ClassId,
returnScheduleId: dto.returnScheduleId,
returnOriginStationId: dto.returnOriginStationId,
returnDestinationStationId: dto.returnDestinationStationId,
returnSeatClassId: retL1ClassId,
returnLeg2ScheduleId: dto.returnLeg2ScheduleId,
returnLeg2OriginStationId: dto.returnTransitStationId,
returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
returnLeg2SeatClassId: retL2ClassId,
returnLegStatus: 'NEITHER_USED',
userAgent: dto.deviceId,
seats: {
create: [
...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)),
...passengersData.map(p => makeSeat(p, p.leg2SeatId!, 2, dto.leg2ScheduleId!, obL2Fare)),
...passengersData.map(p => makeSeat(p, p.returnSeatId!, 3, dto.returnScheduleId!, retL1Fare)),
...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId!,4, dto.returnLeg2ScheduleId!,retL2Fare)),
],
},
} as any,
include: {
seats: { include: { seat: { include: { coach: true } } } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
},
});
await Promise.all([
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
this.seatsService.confirmSeats(dto.passengers.map(p => p.returnSeatId!)),
this.seatsService.confirmSeats(dto.passengers.map(p => p.returnLeg2SeatId!)),
]);
this.eventEmitter.emit('booking.created', { booking });
return {
...booking,
createdAccount,
userId,
fareBreakdown: {
outboundLeg1FareMinor: obL1Fare,
outboundLeg2FareMinor: obL2Fare,
returnLeg1FareMinor: retL1Fare,
returnLeg2FareMinor: retL2Fare,
adultCount, childCount,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount: paidChildren,
combinedBaseFareMinor: combinedBase,
discountMinor, taxesFeesMinor: taxesMinor, totalMinor,
currency: 'ETB', displayCurrency, displayTotalMinor,
},
};
}
private async resolveGuestPassenger(
dto: Pick<CreateGuestBookingDto, 'createAccount' | 'password' | 'deviceId'>,
firstPassenger: any,
): Promise<{ guestPassenger: any; userId: string | null; createdAccount: boolean }> {
if (dto.createAccount && firstPassenger.email && dto.password) {
const existingUser = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
if (existingUser) throw new BadRequestException('Email already registered. Please login instead.');
let accountPhone = firstPassenger.phone || null;
if (accountPhone) {
const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
}
if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const user = await this.prisma.user.create({
data: {
fullName: firstPassenger.passengerName,
email: firstPassenger.email,
phone: accountPhone,
passwordHash: await bcrypt.hash(dto.password, 10),
nationality: firstPassenger.nationality,
nationalId: firstPassenger.idDocumentType === IdDocumentType.NATIONAL_ID ? firstPassenger.idDocumentNumber : undefined,
passportNumber: firstPassenger.passportNumber,
},
});
const guestPassenger = await this.prisma.passenger.create({ data: { userId: user.id } });
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
return { guestPassenger, userId: user.id, createdAccount: true };
}
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
if (firstPassenger.email) {
const existing = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
if (existing) guestEmail = `guest-${uniqueId}@edr-platform.com`;
}
let guestPhone = firstPassenger.phone || null;
if (guestPhone) {
const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
if (existing) guestPhone = null;
}
if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
const tempUser = await this.prisma.user.create({
data: {
fullName: firstPassenger.passengerName,
email: guestEmail,
phone: guestPhone,
passwordHash: await bcrypt.hash(Math.random().toString(36), 10),
role: 'PASSENGER',
},
});
const guestPassenger = await this.prisma.passenger.create({ data: { userId: tempUser.id } });
return { guestPassenger, userId: null, createdAccount: false };
}
async getSavedPassengers(userId?: string, deviceId?: string): Promise<SavedPassengerProfileDto[]> { async getSavedPassengers(userId?: string, deviceId?: string): Promise<SavedPassengerProfileDto[]> {
if (!userId && !deviceId) { if (!userId && !deviceId) {
throw new BadRequestException('Either userId or deviceId is required'); throw new BadRequestException('Either userId or deviceId is required');
@@ -343,6 +926,8 @@ export class GuestBookingService {
nationality?: string, nationality?: string,
): Promise<number> { ): Promise<number> {
const now = new Date(); const now = new Date();
// 1. FareRule table — explicit override rules
const candidates = await this.prisma.fareRule.findMany({ const candidates = await this.prisma.fareRule.findMany({
where: { where: {
seatClassId, seatClassId,
@@ -368,14 +953,34 @@ export class GuestBookingService {
for (const priority of priorities) { for (const priority of priorities) {
const match = candidates.find( const match = candidates.find(
(c) => (c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality,
c.tripId === priority.tripId &&
c.route === priority.route &&
c.nationality === priority.nationality,
); );
if (match) return match.baseFareMinor; if (match) return match.baseFareMinor;
} }
return 35000; // Default fallback // 2. FareEngine — distance × rate-per-km from the schedule's route
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
if (schedule?.routeId) {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
seatClassId,
nationality,
});
return fare.baseFarePerPassengerMinor;
} catch {
// FareEngine throws if distanceKm is missing; fall through to error
}
}
throw new BadRequestException(
`No fare configured for this schedule and seat class. Please set up fare rules or route distances.`,
);
} }
} }

View File

@@ -1,8 +1,57 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class SendEmail { export class SendEmail {
@ApiProperty()
@IsEmail()
@IsNotEmpty()
to: string; to: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
sourceId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
sourceName?: string;
@ApiProperty()
@IsNotEmpty()
@IsString()
subject: string; subject: string;
body: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
html?: string; html?: string;
templateKey?: string;
context?: Record<string, unknown>; @ApiPropertyOptional()
@IsOptional()
@IsString()
text?: string;
@ApiPropertyOptional()
@IsOptional()
body?: string;
@ApiPropertyOptional()
@IsOptional()
context?: Record<string, any>;
@ApiPropertyOptional()
@IsOptional()
@IsString()
templateName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsEmail()
from?: string;
@ApiPropertyOptional()
@IsOptional()
@IsEmail()
replyTo?: string;
} }

View File

@@ -1,9 +1,46 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class SendMessage { export class SendMessage {
@ApiProperty()
@IsNotEmpty()
@IsString()
to: string; to: string;
@ApiProperty()
@IsNotEmpty()
@IsString()
message: string; message: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
from?: string; from?: string;
} }
export class SingleMessageDto {
@ApiProperty({
description: 'Recipient phone number',
example: '+1234567890',
})
@IsString()
@IsNotEmpty()
to: string;
@ApiProperty({
description: 'Message content',
example: 'Test Single SMS from',
})
@IsString()
@IsNotEmpty()
sms: string;
}
export class BulkMessagesDto { export class BulkMessagesDto {
@ApiProperty({ type: [SendMessage] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => SendMessage)
messages: SendMessage[]; messages: SendMessage[];
} }

View File

@@ -1,27 +1,38 @@
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import {
import { ClientProxy } from '@nestjs/microservices'; Inject,
import { SendEmail } from './dtos/email.dto'; Injectable,
Logger,
OnApplicationBootstrap,
} from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { SendEmail } from "./dtos/email.dto";
@Injectable() @Injectable()
export class EmailClientService implements OnApplicationBootstrap { export class EmailClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(EmailClientService.name); private readonly logger = new Logger(EmailClientService.name);
constructor( constructor(
@Inject('EMAIL_SERVICE') @Inject("EMAIL_SERVICE")
private readonly emailServiceClient: ClientProxy, private readonly emailServiceClient: ClientProxy,
) {} ) {}
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
async onApplicationBootstrap() { async onApplicationBootstrap() {
if (!this.enabled) return;
this.emailServiceClient this.emailServiceClient
.connect() .connect()
.then(() => this.logger.log('Connected to Email service')) .then(() => this.logger.log("Connected to Email service"))
.catch((err) => this.logger.error('Error connecting to Email service', err)); .catch((err) =>
this.logger.error("Error connecting to Email service", err),
);
} }
async sendEmail(dto: SendEmail) { async sendEmail(dto: SendEmail) {
this.emailServiceClient.emit('send-email', { if (!this.enabled) return {};
this.emailServiceClient.emit("send-email", {
...dto, ...dto,
appKey: 'EDR-PASSENGER-API', appKey: "IFHCRS-LICENSE-MANAGEMENT",
}); });
return {}; return {};
} }

View File

@@ -7,7 +7,7 @@ import { TestNotificationDto } from './notifications.dto';
import { EmailClientService } from './email-client.service'; import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service'; import { SmsClientService } from './sms-client.service';
import { SendEmail } from './dtos/email.dto'; import { SendEmail } from './dtos/email.dto';
import { SendMessage } from './dtos/sms.dto'; import { BulkMessagesDto, SingleMessageDto } from './dtos/sms.dto';
@ApiTags('Notifications') @ApiTags('Notifications')
@Controller('notifications') @Controller('notifications')
@@ -51,11 +51,20 @@ export class NotificationsController {
@UseGuards(IamGuard) @UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF') @IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' }) @ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' })
@ApiBody({ type: SendMessage }) @ApiBody({ type: SingleMessageDto })
sendSms(@Body() dto: SendMessage) { sendSms(@Body() dto: SingleMessageDto) {
return this.smsClient.sendSms(dto); return this.smsClient.sendSms(dto);
} }
@Post('send/sms/bulk')
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Send bulk SMS messages via the SMS microservice' })
@ApiBody({ type: BulkMessagesDto })
sendBulkSms(@Body() dto: BulkMessagesDto) {
return this.smsClient.sendBulkMessages(dto);
}
@Post('test') @Post('test')
@UseGuards(IamGuard) @UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF') @IamRoles('ADMIN', 'STAFF')

View File

@@ -1,6 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios'; import { HttpModule } from '@nestjs/axios';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices'; import { ClientsModule, Transport } from '@nestjs/microservices';
import { NotificationsController } from './notifications.controller'; import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service'; import { NotificationsService } from './notifications.service';
@@ -11,34 +10,24 @@ import { SmsClientService } from './sms-client.service';
@Module({ @Module({
imports: [ imports: [
HttpModule.register({ timeout: 10_000 }), HttpModule.register({ timeout: 10_000 }),
ClientsModule.registerAsync([ ClientsModule.register([
{ {
name: 'EMAIL_SERVICE', name: 'EMAIL_SERVICE',
imports: [ConfigModule], transport: Transport.RMQ,
inject: [ConfigService], options: {
useFactory: (config: ConfigService) => ({ urls: [process.env.RABBITMQ_URL as string],
transport: Transport.RMQ, queue: process.env.EMAIL_QUEUE ?? 'email_queue',
options: { queueOptions: { durable: true },
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'], },
queue: config.get<string>('EMAIL_QUEUE') ?? 'email_queue',
queueOptions: { durable: true },
noAck: true,
},
}),
}, },
{ {
name: 'SMS_SERVICE', name: 'SMS_SERVICE',
imports: [ConfigModule], transport: Transport.RMQ,
inject: [ConfigService], options: {
useFactory: (config: ConfigService) => ({ urls: [process.env.RABBITMQ_URL as string],
transport: Transport.RMQ, queue: process.env.SMS_QUEUE ?? 'sms_queue',
options: { queueOptions: { durable: true },
urls: [config.get<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'], },
queue: config.get<string>('SMS_QUEUE') ?? 'sms_queue',
queueOptions: { durable: true },
noAck: true,
},
}),
}, },
]), ]),
], ],

View File

@@ -20,8 +20,8 @@ export class NotificationsService {
private pushAdapter: PushAdapter, private pushAdapter: PushAdapter,
) { ) {
this.channels = new Map<NotificationChannelType, NotificationChannel>([ this.channels = new Map<NotificationChannelType, NotificationChannel>([
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, body }).then(() => true) }], ['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then(() => true) }],
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }], ['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, sms: body }).then(() => true) }],
['PUSH', this.pushAdapter as NotificationChannel], ['PUSH', this.pushAdapter as NotificationChannel],
]); ]);
} }
@@ -107,7 +107,7 @@ export class NotificationsService {
await this.emailClient.sendEmail({ await this.emailClient.sendEmail({
to: passenger.user.email, to: passenger.user.email,
subject: this.sanitize(dto.title), subject: this.sanitize(dto.title),
body: this.sanitize(dto.body), text: this.sanitize(dto.body),
}); });
} }

View File

@@ -1,35 +1,49 @@
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import {
import { ClientProxy } from '@nestjs/microservices'; Inject,
import { BulkMessagesDto, SendMessage } from './dtos/sms.dto'; Injectable,
Logger,
OnApplicationBootstrap,
} from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { BulkMessagesDto, SingleMessageDto } from "./dtos/sms.dto";
@Injectable() @Injectable()
export class SmsClientService implements OnApplicationBootstrap { export class SmsClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(SmsClientService.name); private readonly logger = new Logger(SmsClientService.name);
constructor( constructor(
@Inject('SMS_SERVICE') @Inject("SMS_SERVICE")
private readonly smsClient: ClientProxy, private smsClient: ClientProxy,
) {} ) {}
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
async onApplicationBootstrap() { async onApplicationBootstrap() {
if (!this.enabled) return;
this.smsClient this.smsClient
.connect() .connect()
.then(() => this.logger.log('Connected to SMS service')) .then(() => {
.catch((err) => this.logger.error('Error connecting to SMS service', err)); this.logger.log("connected to SMS service");
})
.catch((err) => {
console.error("Error happened at SMS service", err);
});
} }
async sendSms(dto: SendMessage) { async sendSms(dto: SingleMessageDto) {
this.smsClient.emit('send-sms', { if (!this.enabled) return {};
this.smsClient.emit("send-sms", {
...dto, ...dto,
appKey: 'EDR-PASSENGER-API', appKey: "IFHCRS-LICENSE-MANAGEMENT",
}); });
return {}; return {};
} }
async sendBulkMessages(dto: BulkMessagesDto) { async sendBulkMessages(dto: BulkMessagesDto) {
this.smsClient.emit('ozeking-bulk-sms', { if (!this.enabled) return {};
this.smsClient.emit("ozeking-bulk-sms", {
...dto, ...dto,
appKey: 'EDR-PASSENGER-API', appKey: "IFHCRS-LICENSE-MANAGEMENT",
}); });
return {}; return {};
} }

View File

@@ -21,10 +21,11 @@ export class PassengersService {
const { search, verified, page = 1, pageSize = 20 } = filters; const { search, verified, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
const where: any = {}; const where: any = { user: { role: 'PASSENGER' } };
if (search) { if (search) {
where.user = { where.user = {
...where.user,
OR: [ OR: [
{ fullName: { contains: search, mode: 'insensitive' } }, { fullName: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } }, { email: { contains: search, mode: 'insensitive' } },
@@ -68,7 +69,7 @@ export class PassengersService {
userId: passenger.userId, userId: passenger.userId,
fullName: user.fullName, fullName: user.fullName,
email: user.email, email: user.email,
phone: user.phone, phone: user.phone?.startsWith('+guest-') ? null : user.phone,
nationalId: user.nationalId, nationalId: user.nationalId,
nationality: user.nationality, nationality: user.nationality,
dateOfBirth: user.dateOfBirth ?? null, dateOfBirth: user.dateOfBirth ?? null,

View File

@@ -79,6 +79,28 @@ export class PaymentsController {
return this.service.getIntentByBookingId(bookingId); return this.service.getIntentByBookingId(bookingId);
} }
@Get("waafi/return")
@ApiOperation({
summary:
"DEMO ONLY — confirm a Waafi payment from the browser-return params and return JSON for the " +
"UI to display. The frontend success page forwards the Waafi query params here. Gated by " +
"WAAFI_DEMO_TRUST_RETURN (INSECURE; real confirmation is the webhook/HPP_GETTRANINFO).",
})
@ApiQuery({ name: "referenceId", required: true })
@ApiQuery({ name: "state", required: true })
@ApiQuery({ name: "transactionId", required: false })
waafiReturn(
@Query("referenceId") referenceId: string,
@Query("state") state: string,
@Query("transactionId") transactionId: string,
) {
return this.service.confirmWaafiReturnDemo({
referenceId,
state,
transactionId,
});
}
@Post("refund") @Post("refund")
@UseGuards(JwtGuard, RolesGuard) @UseGuards(JwtGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT) @Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT)

View File

@@ -44,6 +44,8 @@ export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name); private readonly logger = new Logger(PaymentsService.name);
private readonly walletDemoAutoSucceed = true; private readonly walletDemoAutoSucceed = true;
private readonly waafiDemoTrustReturn = true;
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private seatsService: SeatsService, private seatsService: SeatsService,
@@ -192,6 +194,41 @@ export class PaymentsService {
return { returnUrl, failureUrl }; return { returnUrl, failureUrl };
} }
async confirmWaafiReturnDemo(params: {
referenceId?: string;
state?: string;
transactionId?: string;
}): Promise<{ confirmed: boolean; bookingId?: string; reason?: string }> {
if (!this.waafiDemoTrustReturn) {
return { confirmed: false, reason: "demo-disabled" };
}
if ((params.state ?? "").toUpperCase() !== "APPROVED") {
return { confirmed: false, reason: `not-approved (${params.state})` };
}
if (!params.referenceId) {
return { confirmed: false, reason: "missing-referenceId" };
}
const intent = await this.prisma.paymentIntent.findFirst({
where: { merchantOrderId: params.referenceId },
});
if (!intent) {
this.logger.warn(
`waafi demo return: no local intent for referenceId ${params.referenceId}`,
);
return { confirmed: false, reason: "intent-not-found" };
}
this.logger.warn(
`WAAFI_DEMO_TRUST_RETURN enabled — confirming booking ${intent.bookingId} from browser return (INSECURE, demo only)`,
);
await this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: params.transactionId,
});
return { confirmed: true, bookingId: intent.bookingId };
}
private async syncIntentProjection( private async syncIntentProjection(
bookingId: string, bookingId: string,
snapshot: PaymentIntentSnapshot, snapshot: PaymentIntentSnapshot,
@@ -453,6 +490,30 @@ export class PaymentsService {
}); });
} }
/**
* Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as
* ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the
* whole confirmation. Falls back to "now" for missing/invalid/far-future/ancient values so the
* booking still confirms.
*/
private sanitizePaidAt(value?: Date): Date {
const now = new Date();
if (!value) return now;
const t = value.getTime();
const oneDayMs = 86_400_000;
if (
Number.isNaN(t) ||
t > now.getTime() + oneDayMs ||
t < Date.UTC(2000, 0, 1)
) {
this.logger.warn(
`finalizePaymentSuccess: implausible paidAt (epoch=${t}); using current time instead`,
);
return now;
}
return value;
}
async finalizePaymentSuccess(input: { async finalizePaymentSuccess(input: {
intentId: string; intentId: string;
providerTxnId?: string; providerTxnId?: string;
@@ -477,7 +538,7 @@ export class PaymentsService {
}); });
if (!booking) throw new NotFoundException("Booking not found"); if (!booking) throw new NotFoundException("Booking not found");
const paidAt = input.paidAt ?? new Date(); const paidAt = this.sanitizePaidAt(input.paidAt);
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await tx.paymentIntent.update({ await tx.paymentIntent.update({
where: { id: intent.id }, where: { id: intent.id },

View File

@@ -98,6 +98,10 @@ export class BulkCreateSchedulesDto {
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' }) @ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' })
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: PlannedStopTimeDto[]; plannedTimes?: PlannedStopTimeDto[];
@ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' })
@IsOptional() @IsArray() @IsString({ each: true })
coachIds?: string[];
} }
export class BulkSchedulesResponseDto { export class BulkSchedulesResponseDto {

View File

@@ -44,6 +44,15 @@ export class SchedulesService {
const schedule = await this.createSchedule(createDto); const schedule = await this.createSchedule(createDto);
scheduleIds.push(schedule.id); scheduleIds.push(schedule.id);
// Assign coaches if provided
if (dto.coachIds && dto.coachIds.length > 0) {
await this.assignCoaches(
schedule.id,
dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })),
);
}
scheduleCount++; scheduleCount++;
} catch (error) { } catch (error) {
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`); errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);

View File

@@ -71,27 +71,38 @@ export class FareQuoteDto {
} }
export class CoachTypeOptionClass { export class CoachTypeOptionClass {
@ApiProperty({ example: 'Economy Regular', description: 'Seat class name' }) @ApiProperty({ example: 'Economy Regular' }) name: string;
name: string; @ApiProperty({ example: 35000 }) baseFareMinor: number;
@ApiProperty({ example: 35000, description: 'Base fare in ETB minor units per passenger' })
baseFareMinor: number;
} }
export class CoachTypeOption { export class CoachTypeOption {
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach type unique identifier' }) @ApiProperty({ example: 'coach-type-uuid' }) coachTypeId: string;
coachTypeId: string; @ApiProperty({ example: 'Economy' }) coachTypeName: string;
@ApiProperty({ example: 'ECO' }) coachTypeCode: string;
@ApiProperty({ example: 'Economy', description: 'Coach type display name' }) @ApiProperty({ type: 'array', items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' } }) classes: CoachTypeOptionClass[];
coachTypeName: string; }
@ApiProperty({ example: 'ECO', description: 'Coach type code' }) export class TransitLegDto {
coachTypeCode: string; @ApiProperty({ example: 'schedule-uuid' }) scheduleId: string;
@ApiProperty() trainNumber: string;
@ApiProperty({ @ApiProperty() trainName: string;
type: 'array', @ApiProperty() origin: object;
items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' }, @ApiProperty() destination: object;
description: 'Available seat classes within this coach type with base fares. User selects specific class at seat selection page.', @ApiProperty() departureAt: Date;
}) @ApiProperty() arrivalAt: Date;
classes: CoachTypeOptionClass[]; @ApiProperty() durationMinutes: number;
@ApiProperty() availabilityByClass: object;
@ApiProperty() faresByClass: object[];
@ApiProperty() coachTypes: CoachTypeOption[];
}
export class TransitResultDto {
@ApiProperty({ example: 'TRANSIT' }) type: string;
@ApiProperty({ example: 'station-uuid' }) transitStationId: string;
@ApiProperty({ example: 'Dire Dawa' }) transitStationName: string;
@ApiProperty({ description: 'Connection wait time in minutes' }) connectionMinutes: number;
@ApiProperty({ type: TransitLegDto }) leg1: TransitLegDto;
@ApiProperty({ type: TransitLegDto }) leg2: TransitLegDto;
@ApiProperty({ description: 'Combined minimum fare across all shared classes', example: 70000 }) combinedMinFareMinor: number;
@ApiProperty({ description: 'Total travel time including connection in minutes' }) totalDurationMinutes: number;
} }

View File

@@ -18,31 +18,54 @@ export class SearchService {
) {} ) {}
async searchTrips(dto: SearchTripsDto) { async searchTrips(dto: SearchTripsDto) {
const outbound = await this.searchSchedules( const [direct, transit] = await Promise.all([
dto.originStationId, this.searchSchedules(
dto.destinationStationId,
dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
);
if (dto.journeyType === 'ROUND_TRIP') {
const allInbound = await this.searchSchedules(
dto.destinationStationId,
dto.originStationId, dto.originStationId,
dto.returnDate ?? dto.date, dto.destinationStationId,
dto.date,
dto.adultCount, dto.adultCount,
dto.childCount, dto.childCount,
dto.nationality, dto.nationality,
); ),
this.searchTransitOptions(
dto.originStationId,
dto.destinationStationId,
dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
),
]);
const outbound = [...direct, ...transit];
if (dto.journeyType === 'ROUND_TRIP') {
const [returnDirect, returnTransit] = await Promise.all([
this.searchSchedules(
dto.destinationStationId,
dto.originStationId,
dto.returnDate ?? dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
),
this.searchTransitOptions(
dto.destinationStationId,
dto.originStationId,
dto.returnDate ?? dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
),
]);
const allReturn = [...returnDirect, ...returnTransit];
const latestOutboundArrival = outbound.length > 0 const latestOutboundArrival = outbound.length > 0
? Math.max(...outbound.map((s) => new Date(s.arrivalAt).getTime())) ? Math.max(...outbound.map((s: any) => new Date(s.arrivalAt ?? s.leg2?.arrivalAt).getTime()))
: Date.now(); : Date.now();
const inbound = allInbound.filter((schedule) => const inbound = allReturn.filter((s: any) =>
new Date(schedule.departureAt).getTime() > latestOutboundArrival new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival
); );
return { journeyType: 'ROUND_TRIP', outbound, inbound }; return { journeyType: 'ROUND_TRIP', outbound, inbound };
@@ -60,7 +83,7 @@ export class SearchService {
nationality?: string, nationality?: string,
) { ) {
const [y, m, d] = dateStr.split('-').map(Number); const [y, m, d] = dateStr.split('-').map(Number);
const date = new Date(y, m - 1, d, 0, 0, 0, 0); const date = new Date(y, m - 1, d, 0, 0, 0, 0);
const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0); const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
const totalPassengers = adultCount + (childCount ?? 0); const totalPassengers = adultCount + (childCount ?? 0);
@@ -81,126 +104,211 @@ export class SearchService {
}, },
}); });
const results = []; const results: any[] = [];
for (const schedule of schedules) { for (const schedule of schedules) {
const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId); const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId); if (result) results.push(result);
}
return results;
}
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue; // ── Transit search ─────────────────────────────────────────────────────────
// Finds pairs of schedules (leg1: origin→transit, leg2: transit→destination)
// where the passenger has between MIN_CONNECTION and MAX_CONNECTION minutes
// to change trains at the transit station.
private readonly MIN_CONNECTION_MINUTES = 30;
private readonly MAX_CONNECTION_MINUTES = 360;
const availabilityByClass: Record<string, number> = {}; private async searchTransitOptions(
originStationId: string,
destinationStationId: string,
dateStr: string,
adultCount: number,
childCount?: number,
nationality?: string,
) {
// Find all stations that can serve as transit points:
// they must be a stop after origin on some schedule AND
// a stop before destination on another schedule on the same day.
const [y, m, d] = dateStr.split('-').map(Number);
const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0);
const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
const totalPassengers = adultCount + (childCount ?? 0);
for (const assignment of schedule.coachAssignments) { // Load all schedules on this date that pass through origin
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard']; const leg1Schedules = await this.prisma.trainSchedule.findMany({
const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition); where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
departureAt: { gte: dayStart, lt: dayEnd },
stopTimes: { some: { stationId: originStationId } },
},
include: {
train: true,
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
},
},
});
if (isBedCoach) { const results: any[] = [];
const bedPositions = ['upper', 'middle', 'lower'];
for (const bedPosition of bedPositions) {
let count = 0;
for (const seat of assignment.coach.seats) {
if (seat.bedPosition !== bedPosition) continue;
if (seat.status === 'BLOCKED') continue;
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg( for (const leg1 of leg1Schedules) {
schedule.id, seat.id, const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId);
originStop.sequence, destStop.sequence, if (!originStop) continue;
);
if (free) count++;
}
if (count > 0) { // Every stop after origin on leg1 is a candidate transit station
const matchingClass = seatClassNames.find((className: string) => { const candidateTransitStops = leg1.stopTimes.filter(
const classNameLower = className.toLowerCase(); (s: any) => s.sequence > originStop.sequence,
return (
(bedPosition === 'upper' && classNameLower.includes('upper')) ||
(bedPosition === 'middle' && classNameLower.includes('middle')) ||
(bedPosition === 'lower' && classNameLower.includes('lower'))
);
});
if (matchingClass) {
if (!availabilityByClass[matchingClass]) availabilityByClass[matchingClass] = 0;
availabilityByClass[matchingClass] += count;
}
}
}
} else {
let availableSeatsInCoach = 0;
for (const seat of assignment.coach.seats) {
if (seat.status === 'BLOCKED') continue;
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) availableSeatsInCoach++;
}
for (const seatClassName of seatClassNames) {
if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
availabilityByClass[seatClassName] += availableSeatsInCoach;
}
}
}
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
const faresByClass = await this.calculateFaresForSegment(
schedule,
originStationId,
destinationStationId,
nationality,
); );
const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass); for (const transitStop of candidateTransitStops) {
// leg1 must NOT already contain the final destination
const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId);
if (leg1HasDest) continue; // direct route exists — already returned by searchSchedules
results.push({ const transitStationId = transitStop.stationId;
scheduleId: schedule.id, const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt;
trainNumber: schedule.train.number,
trainName: schedule.train.name, // Find leg2 schedules departing from the transit station within the connection window,
origin: { // and reaching the final destination. Search up to the next calendar day to handle
id: originStop.stationId, // overnight connections.
code: originStop.station.code, const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000);
name: originStop.station.name, const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000);
city: originStop.station.city,
sequence: originStop.sequence, const leg2Schedules = await this.prisma.trainSchedule.findMany({
}, where: {
destination: { status: { in: ['SCHEDULED', 'BOARDING'] },
id: destStop.stationId, departureAt: { gte: connWindowStart, lte: connWindowEnd },
code: destStop.station.code, stopTimes: { some: { stationId: transitStationId } },
name: destStop.station.name, },
city: destStop.station.city, include: {
sequence: destStop.sequence, train: true,
}, originStation: true,
departureAt: legDepartureAt, destinationStation: true,
arrivalAt: legArrivalAt, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
durationMinutes: Math.round( coachAssignments: {
(new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000, include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
), },
status: schedule.status, },
stops: schedule.stopTimes });
.filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
.map((st: any) => ({ for (const leg2 of leg2Schedules) {
stationId: st.stationId, const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId);
stationName: st.station.name, const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId);
sequence: st.sequence,
plannedArrivalAt: st.plannedArrivalAt, if (!leg2TransitStop || !leg2DestStop) continue;
plannedDepartureAt: st.plannedDepartureAt, if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue;
})),
availabilityByClass, // Build individual leg result objects (reuse existing per-schedule logic)
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), const [leg1Result, leg2Result] = await Promise.all([
faresByClass, this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality),
coachTypes, this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality),
}); ]);
if (!leg1Result || !leg2Result) continue;
if (!leg1Result.hasAvailability || !leg2Result.hasAvailability) continue;
const leg2DepartureAt = leg2TransitStop.plannedDepartureAt ?? leg2.departureAt;
const connectionMinutes = Math.round(
(new Date(leg2DepartureAt).getTime() - new Date(leg1ArrivalAt).getTime()) / 60_000,
);
const leg1MinFare = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
const leg2MinFare = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0);
results.push({
type: 'TRANSIT',
transitStationId,
transitStationName: transitStop.station.name,
connectionMinutes,
leg1: leg1Result,
leg2: leg2Result,
combinedMinFareMinor,
// Convenience top-level fields so round-trip filter can read them uniformly
departureAt: leg1Result.departureAt,
arrivalAt: leg2Result.arrivalAt,
totalDurationMinutes:
leg1Result.durationMinutes + connectionMinutes + leg2Result.durationMinutes,
});
}
}
} }
return results; return results;
} }
// Builds the same result shape as searchSchedules for a single schedule+leg,
// extracted so both direct and transit paths share identical output.
private async buildScheduleResult(
schedule: any,
originStationId: string,
destinationStationId: string,
totalPassengers: number,
nationality?: string,
) {
const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null;
const availabilityByClass: Record<string, number> = {};
for (const assignment of schedule.coachAssignments) {
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);
if (isBedCoach) {
for (const bedPosition of ['upper', 'middle', 'lower']) {
let count = 0;
for (const seat of assignment.coach.seats) {
if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence);
if (free) count++;
}
if (count > 0) {
const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition));
if (matchingClass) availabilityByClass[matchingClass] = (availabilityByClass[matchingClass] ?? 0) + count;
}
}
} else {
let available = 0;
for (const seat of assignment.coach.seats) {
if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence);
if (free) available++;
}
for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available;
}
}
const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality);
const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
return {
type: 'DIRECT',
scheduleId: schedule.id,
trainNumber: schedule.train.number,
trainName: schedule.train.name,
origin: { id: originStop.stationId, code: originStop.station.code, name: originStop.station.name, city: originStop.station.city, sequence: originStop.sequence },
destination: { id: destStop.stationId, code: destStop.station.code, name: destStop.station.name, city: destStop.station.city, sequence: destStop.sequence },
departureAt: legDepartureAt,
arrivalAt: legArrivalAt,
durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000),
status: schedule.status,
stops: schedule.stopTimes
.filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
.map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })),
availabilityByClass,
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
faresByClass,
coachTypes,
};
}
async getFareQuote(dto: FareQuoteDto) { async getFareQuote(dto: FareQuoteDto) {
const schedule = await this.prisma.trainSchedule.findUnique({ const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId }, where: { id: dto.scheduleId },
@@ -244,7 +352,8 @@ export class SearchService {
nationality, nationality,
); );
const baseFareMinor = bestMatch?.baseFareMinor ?? this.defaultFare(dto.seatClassName); const baseFareMinor = bestMatch?.baseFareMinor
?? await this.resolveScheduleFare(dto.scheduleId, seatClass?.id, dto.seatClassName);
const adultCount = dto.adultCount; const adultCount = dto.adultCount;
const childCount = dto.childCount ?? 0; const childCount = dto.childCount ?? 0;
@@ -379,11 +488,8 @@ export class SearchService {
} }
} }
console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`); console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`);
return seatClasses.map(sc => ({ return [];
seatClassName: sc.name,
baseFareMinor: this.getDefaultFareForClass(sc.name),
}));
} }
private async buildCoachTypeDetails( private async buildCoachTypeDetails(
@@ -420,11 +526,10 @@ export class SearchService {
const classes = Array.from(classNames) const classes = Array.from(classNames)
.map((className) => { .map((className) => {
const fareInfo = faresByClass.find((f) => f.seatClassName === className); const fareInfo = faresByClass.find((f) => f.seatClassName === className);
return { if (!fareInfo) return null;
name: className, return { name: className, baseFareMinor: fareInfo.baseFareMinor };
baseFareMinor: fareInfo?.baseFareMinor ?? this.getDefaultFareForClass(className),
};
}) })
.filter((c): c is { name: string; baseFareMinor: number } => c !== null)
.sort((a, b) => a.baseFareMinor - b.baseFareMinor); .sort((a, b) => a.baseFareMinor - b.baseFareMinor);
result.push({ result.push({
@@ -442,22 +547,28 @@ export class SearchService {
}); });
} }
private getDefaultFareForClass(className: string): number { private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise<number> {
const defaults: Record<string, number> = { if (!seatClassId) throw new NotFoundException(`Seat class '${seatClassName}' not found`);
'Economy Regular': 35000, const schedule = await this.prisma.trainSchedule.findUnique({
'Economy Bed': 49000, where: { id: scheduleId },
'VIP Bed': 63000, select: { routeId: true, originStationId: true, destinationStationId: true },
}; });
return defaults[className] ?? 35000; if (!schedule?.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
seatClassId,
});
return fare.baseFarePerPassengerMinor;
} }
private defaultFare(seatClassName: string): number { private getDefaultFareForClass(_className: string): never {
const fares: Record<string, number> = { throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead');
'Economy Regular': 45000, }
'Economy Bed': 65000,
'VIP Bed': 95000, private defaultFare(_seatClassName: string): never {
}; throw new Error('defaultFare should not be called — use resolveScheduleFare instead');
return fares[seatClassName] ?? 45000;
} }
private selectBestFareRule( private selectBestFareRule(

View File

@@ -62,7 +62,7 @@ describe('SeatsService - Auto Assign', () => {
mockPrisma.seat.findMany.mockResolvedValue(mockSeats); mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR', 'ACCESSIBLE'); const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
}); });

View File

@@ -547,16 +547,14 @@ export class SeatsService {
@Cron(CronExpression.EVERY_MINUTE) @Cron(CronExpression.EVERY_MINUTE)
async expireHolds() { async expireHolds() {
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } }); const now = new Date();
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: now } } });
if (expired.length === 0) return;
const expiredIds = expired.map(h => h.id);
for (const hold of expired) { for (const hold of expired) {
await this.releaseSeats(hold.seatIds); await this.releaseSeats(hold.seatIds);
try {
await this.prisma.seatHold.delete({ where: { id: hold.id } });
} catch (err) {
if (err instanceof Error && !err.message.includes('P2025')) {
throw err;
}
}
} }
await this.prisma.seatHold.deleteMany({ where: { id: { in: expiredIds } } });
} }
} }

View File

@@ -1,5 +1,5 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
import { TicketsService } from './tickets.service'; import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
@@ -30,15 +30,28 @@ export class TicketsController {
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all tickets with optional filters' }) @ApiOperation({ summary: 'List all tickets with optional filters' })
@ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'status', required: false })
@ApiQuery({ name: 'originStationId', required: false })
@ApiQuery({ name: 'destinationStationId', required: false })
@ApiQuery({ name: 'arrivalDate', required: false })
@ApiQuery({ name: 'skip', required: false })
@ApiQuery({ name: 'take', required: false })
listTickets( listTickets(
@Query('search') search?: string, @Query('search') search?: string,
@Query('status') status?: string, @Query('status') status?: string,
@Query('originStationId') originStationId?: string,
@Query('destinationStationId') destinationStationId?: string,
@Query('arrivalDate') arrivalDate?: string,
@Query('skip') skip?: string, @Query('skip') skip?: string,
@Query('take') take?: string, @Query('take') take?: string,
) { ) {
return this.service.listTickets({ return this.service.listTickets({
search, search,
status, status,
originStationId,
destinationStationId,
arrivalDate,
skip: skip ? parseInt(skip) : 0, skip: skip ? parseInt(skip) : 0,
take: take ? parseInt(take) : 50, take: take ? parseInt(take) : 50,
}); });
@@ -56,17 +69,8 @@ export class TicketsController {
} }
@Get(':bookingRef') @Get(':bookingRef')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ @ApiOperation({
summary: 'Get ticket with QR code and passenger details', summary: 'Get ticket with QR code and passenger details (public)',
description: `Returns ticket information including:
- QR code for gate scanning
- Barcode for offline validation
- Passenger details (name, age category, nationality)
- Journey details (origin, destination, seat, coach)
- Fare breakdown with currency
- PDF download link`
}) })
getByRef(@Param('bookingRef') ref: string) { getByRef(@Param('bookingRef') ref: string) {
return this.service.getByRef(ref); return this.service.getByRef(ref);
@@ -77,14 +81,30 @@ export class TicketsController {
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ @ApiOperation({
summary: 'Validate ticket at gate with audit logging', summary: 'Validate ticket at gate with audit logging',
description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.' description: 'Validates ticket QR/barcode at station gate. For round-trip bookings, supply `leg` (OUTBOUND or RETURN) to record which leg is being used. Defaults to OUTBOUND if omitted. Records validation in audit log with timestamp, gate, and validator.'
})
@ApiBody({
schema: {
type: 'object',
required: ['validatorId'],
properties: {
validatorId: { type: 'string', example: 'agent-uuid' },
gateId: { type: 'string', example: 'gate-01' },
leg: {
type: 'string',
enum: ['OUTBOUND', 'RETURN', 'LEG1', 'LEG2', 'OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'],
description: 'ONE_WAY: omit | TRANSIT: LEG1/LEG2 | ROUND_TRIP: OUTBOUND/RETURN | ROUND_TRIP_TRANSIT: OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2',
},
},
},
}) })
validate( validate(
@Param('bookingRef') ref: string, @Param('bookingRef') ref: string,
@Body('validatorId') validatorId: string, @Body('validatorId') validatorId: string,
@Body('gateId') gateId?: string @Body('gateId') gateId?: string,
@Body('leg') leg?: string,
) { ) {
return this.service.validate(ref, validatorId, gateId); return this.service.validate(ref, validatorId, gateId, leg);
} }
@Get(':ticketId/validation-logs') @Get(':ticketId/validation-logs')
@@ -106,7 +126,31 @@ export class TicketsController {
@Post('validate/offline') @Post('validate/offline')
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Batch import offline validations' }) @ApiOperation({
summary: 'Batch import offline validations',
description: 'Processes validations collected offline. Each entry may include an optional `leg` field (OUTBOUND | RETURN) for round-trip tickets. Deduplication is per bookingRef+leg combination so both legs of the same booking can be submitted in one batch.'
})
@ApiBody({
schema: {
type: 'object',
properties: {
validations: {
type: 'array',
items: {
type: 'object',
required: ['bookingRef', 'validatorId', 'validatedAt'],
properties: {
bookingRef: { type: 'string' },
validatorId: { type: 'string' },
gateId: { type: 'string' },
validatedAt: { type: 'string', format: 'date-time' },
leg: { type: 'string', enum: ['OUTBOUND', 'RETURN', 'LEG1', 'LEG2', 'OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'] },
},
},
},
},
},
})
validateOfflineBatch(@Body() body: { validations: any[] }) { validateOfflineBatch(@Body() body: { validations: any[] }) {
return this.service.validateOfflineBatch(body.validations); return this.service.validateOfflineBatch(body.validations);
} }

View File

@@ -7,13 +7,14 @@ interface OfflineValidation {
validatorId: string; validatorId: string;
gateId?: string; gateId?: string;
validatedAt: string; validatedAt: string;
leg?: string;
} }
@Injectable() @Injectable()
export class TicketsService { export class TicketsService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
async listTickets(filters: { search?: string; status?: string; skip: number; take: number }) { async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) {
const where: any = {}; const where: any = {};
if (filters.search) { if (filters.search) {
where.OR = [ where.OR = [
@@ -23,7 +24,19 @@ export class TicketsService {
]; ];
} }
if (filters.status) { if (filters.status) {
where.booking = { status: filters.status }; where.booking = { ...where.booking, status: filters.status };
}
if (filters.originStationId) {
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } };
}
if (filters.destinationStationId) {
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } };
}
if (filters.arrivalDate) {
const start = new Date(filters.arrivalDate);
const end = new Date(filters.arrivalDate);
end.setDate(end.getDate() + 1);
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } };
} }
const tickets = await this.prisma.ticket.findMany({ const tickets = await this.prisma.ticket.findMany({
where, where,
@@ -31,7 +44,7 @@ export class TicketsService {
booking: { booking: {
include: { include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } }, seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } }, passenger: { include: { user: true } },
}, },
}, },
@@ -49,6 +62,10 @@ export class TicketsService {
booking: { booking: {
bookingRef: t.booking.bookingRef, bookingRef: t.booking.bookingRef,
status: t.booking.status, status: t.booking.status,
bookingType: t.booking.bookingType,
returnLegStatus: (t.booking as any).returnLegStatus ?? null,
outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null,
returnBoardedAt: (t.booking as any).returnBoardedAt ?? null,
totalMinor: t.booking.totalMinor, totalMinor: t.booking.totalMinor,
currency: t.booking.currency, currency: t.booking.currency,
displayCurrency: t.booking.displayCurrency, displayCurrency: t.booking.displayCurrency,
@@ -69,48 +86,65 @@ export class TicketsService {
} }
async generate(bookingId: string) { async generate(bookingId: string) {
if (!bookingId) { if (!bookingId) throw new BadRequestException('Booking ID is required');
throw new BadRequestException('Booking ID is required');
}
const booking = await this.prisma.booking.findUnique({ const booking = await this.prisma.booking.findUnique({
where: { id: bookingId }, where: { id: bookingId },
include: { include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } } seats: { include: { seat: { include: { coach: true } } } },
}, },
}); });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`); // Build a compact multi-leg payload for the QR so gate scanners see all legs
const legSummary = this.buildLegSummary(booking);
const qrData = JSON.stringify({
ref: booking.bookingRef,
type: booking.bookingType,
legs: legSummary,
});
const qrPayload = await QRCode.toDataURL(qrData);
const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`; const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
const ticket = await this.prisma.ticket.upsert({ const ticket = await this.prisma.ticket.upsert({
where: { bookingId }, where: { bookingId },
update: { qrPayload, barcodePayload }, update: { qrPayload, barcodePayload },
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload }, create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
}); });
// Update all booked seats from HELD to BOOKED and create permanent seat blocks // Block all seats across all legs
const seatIds = booking.seats.map(bs => bs.seatId); const seatIds = booking.seats.map(bs => bs.seatId);
for (const seatId of seatIds) { for (const seatId of seatIds) {
// Update seat status to BOOKED await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BOOKED' } });
await this.prisma.seat.update({
where: { id: seatId },
data: { status: 'BOOKED' },
});
// Create permanent seat blocks for all booked seats
await this.prisma.seatBlock.create({ await this.prisma.seatBlock.create({
data: { data: { seatId, reason: `Booked in ticket ${ticket.id}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' },
seatId, }).catch(() => null);
reason: `Permanently booked in ticket ${ticket.id}`,
blockedBy: 'SYSTEM',
approvedBy: 'SYSTEM',
}
}).catch(() => null); // Ignore if already exists
} }
return ticket; return { ...ticket, legs: legSummary };
}
private buildLegSummary(booking: any) {
const seatsByLeg = new Map<number, any[]>();
for (const bs of booking.seats) {
const leg = bs.leg ?? 1;
if (!seatsByLeg.has(leg)) seatsByLeg.set(leg, []);
seatsByLeg.get(leg)!.push(bs);
}
return Array.from(seatsByLeg.entries())
.sort(([a], [b]) => a - b)
.map(([leg, seats]) => ({
leg,
scheduleId: (seats[0] as any).scheduleId ?? booking.scheduleId,
passengers: seats.map(bs => ({
name: bs.passengerName,
category: bs.passengerCategory,
coach: bs.seat?.coach?.number,
seat: bs.seat?.seatNumber,
fareMinor: bs.fareMinor,
})),
}));
} }
async updateSeats(bookingId: string, newSeatIds: string[]) { async updateSeats(bookingId: string, newSeatIds: string[]) {
@@ -213,22 +247,122 @@ export class TicketsService {
}; };
} }
async validate(bookingRef: string, validatorId: string, gateId?: string) { async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) {
// Accept either a ticket UUID or a bookingRef
let bookingRef = ticketIdOrRef;
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ticketIdOrRef);
if (isUuid) {
const ticket = await this.prisma.ticket.findUnique({ where: { id: ticketIdOrRef }, select: { bookingRef: true } });
if (!ticket) throw new NotFoundException('Ticket not found');
bookingRef = ticket.bookingRef;
}
const resolvedValidatorId = validatorId || 'BACKOFFICE';
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } }); const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
if (!booking) throw new NotFoundException('Booking not found'); if (!booking) throw new NotFoundException('Booking not found');
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } }); const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
if (!ticket) throw new NotFoundException('Ticket not found'); if (!ticket) throw new NotFoundException('Ticket not found');
if (ticket.validatedAt) {
await this.prisma.gateValidationLog.create({ const type = booking.bookingType;
data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } const now = new Date();
});
throw new BadRequestException('Ticket already validated'); // ── ONE_WAY / TRANSIT (single scan) ───────────────────────────────────
if (type === 'ONE_WAY') {
if (ticket.validatedAt) {
return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
return { validated: true, ticketId: ticket.id, validatedAt: now };
} }
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
await this.prisma.gateValidationLog.create({ // ── TRANSIT — leg=LEG1 or leg=LEG2 ──────────────────────────────────
data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } if (type === 'TRANSIT') {
}); const resolvedLeg = (leg ?? 'LEG1').toUpperCase();
return { validated: true, ticketId: ticket.id, validatedAt: new Date() }; if (resolvedLeg !== 'LEG1' && resolvedLeg !== 'LEG2') {
throw new BadRequestException('For TRANSIT bookings supply leg=LEG1 or leg=LEG2');
}
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
const alreadyValidated = logs.some(l => l.leg === resolvedLeg);
if (alreadyValidated) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
throw new BadRequestException(`${resolvedLeg} already validated`);
}
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
// ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ────────────────────────
if (type === 'ROUND_TRIP') {
let resolvedLeg = (leg ?? '').toUpperCase();
// Auto-detect next unused leg when called from backoffice without a leg param
if (!resolvedLeg) {
resolvedLeg = !(booking as any).outboundBoardedAt ? 'OUTBOUND' : 'RETURN';
}
const bookingData: Record<string, any> = {};
if (resolvedLeg === 'OUTBOUND') {
if ((booking as any).outboundBoardedAt) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any });
throw new BadRequestException('Outbound leg already used');
}
bookingData.outboundBoardedAt = now;
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
} else if (resolvedLeg === 'RETURN') {
if ((booking as any).returnBoardedAt) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any });
throw new BadRequestException('Return leg already used');
}
bookingData.returnBoardedAt = now;
} else {
throw new BadRequestException('For ROUND_TRIP bookings supply leg=OUTBOUND or leg=RETURN');
}
const outboundUsed = resolvedLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt;
const returnUsed = resolvedLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt;
if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED';
else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
// ── ROUND_TRIP_TRANSIT — leg=OUTBOUND_LEG1|OUTBOUND_LEG2|RETURN_LEG1|RETURN_LEG2
if (type === 'ROUND_TRIP_TRANSIT') {
const validLegs = ['OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'];
const resolvedLeg = (leg ?? '').toUpperCase();
if (!validLegs.includes(resolvedLeg)) {
throw new BadRequestException(`For ROUND_TRIP_TRANSIT supply leg=${validLegs.join('|')}`);
}
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
if (logs.some(l => l.leg === resolvedLeg)) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
throw new BadRequestException(`${resolvedLeg} already validated`);
}
const bookingData: Record<string, any> = {};
if (resolvedLeg.startsWith('OUTBOUND') && !logs.some(l => l.leg?.startsWith('OUTBOUND') && l.status === 'APPROVED')) {
bookingData.outboundBoardedAt = now;
}
if (resolvedLeg.startsWith('RETURN') && !logs.some(l => l.leg?.startsWith('RETURN') && l.status === 'APPROVED')) {
bookingData.returnBoardedAt = now;
}
const allOutboundDone = ['OUTBOUND_LEG1','OUTBOUND_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED'));
const allReturnDone = ['RETURN_LEG1','RETURN_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED'));
if (allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'BOTH_USED';
else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_ONLY';
if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
// Fallback for unknown booking types — single scan
if (ticket.validatedAt) {
return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
return { validated: true, ticketId: ticket.id, validatedAt: now };
} }
async getValidationLogs(ticketId: string) { async getValidationLogs(ticketId: string) {
@@ -243,7 +377,7 @@ export class TicketsService {
where: { scheduleId: tripId, status: 'CONFIRMED' }, where: { scheduleId: tripId, status: 'CONFIRMED' },
include: { include: {
ticket: true, ticket: true,
seats: { include: { seat: { include: { coach: true } } } }, seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } }, passenger: { include: { user: true } },
}, },
}); });
@@ -256,6 +390,8 @@ export class TicketsService {
coachLabel: b.seats[0]?.seat.coach.number, coachLabel: b.seats[0]?.seat.coach.number,
qrPayload: b.ticket?.qrPayload, qrPayload: b.ticket?.qrPayload,
status: b.status, status: b.status,
bookingType: b.bookingType,
returnLegStatus: (b as any).returnLegStatus ?? null,
validatedAt: b.ticket?.validatedAt, validatedAt: b.ticket?.validatedAt,
})); }));
} }
@@ -265,11 +401,13 @@ export class TicketsService {
const processedRefs = new Set<string>(); const processedRefs = new Set<string>();
for (const v of validations) { for (const v of validations) {
if (processedRefs.has(v.bookingRef)) { const offlineLeg = v.leg;
const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef;
if (processedRefs.has(dedupKey)) {
results.duplicate++; results.duplicate++;
continue; continue;
} }
processedRefs.add(v.bookingRef); processedRefs.add(dedupKey);
try { try {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } }); const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } });
@@ -286,11 +424,24 @@ export class TicketsService {
continue; continue;
} }
if (ticket.validatedAt) { if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP' &&
booking.bookingType !== 'TRANSIT' && booking.bookingType !== 'ROUND_TRIP_TRANSIT') {
results.duplicate++; results.duplicate++;
continue; continue;
} }
// For multi-leg bookings, check per-leg duplication
const isMultiLeg = booking.bookingType === 'ROUND_TRIP' ||
booking.bookingType === 'TRANSIT' ||
booking.bookingType === 'ROUND_TRIP_TRANSIT';
if (isMultiLeg && offlineLeg) {
const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
if (existingLogs.some(l => l.leg === offlineLeg)) {
results.duplicate++;
continue;
}
}
await this.prisma.ticket.update({ await this.prisma.ticket.update({
where: { id: ticket.id }, where: { id: ticket.id },
data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId }, data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId },
@@ -301,11 +452,27 @@ export class TicketsService {
ticketId: ticket.id, ticketId: ticket.id,
validatorId: v.validatorId, validatorId: v.validatorId,
gateId: v.gateId, gateId: v.gateId,
leg: v.leg ?? null,
status: 'APPROVED', status: 'APPROVED',
validatedAt: new Date(v.validatedAt), validatedAt: new Date(v.validatedAt),
}, } as any,
}); });
// update boarding timestamps for multi-leg bookings
const isMultiLegBooking = booking.bookingType === 'ROUND_TRIP' ||
booking.bookingType === 'TRANSIT' ||
booking.bookingType === 'ROUND_TRIP_TRANSIT';
if (isMultiLegBooking && offlineLeg) {
const bookingData: Record<string, any> = {};
const isOutbound = (offlineLeg as string) === 'OUTBOUND' || (offlineLeg as string) === 'OUTBOUND_LEG1' || (offlineLeg as string) === 'LEG1';
const isReturn = (offlineLeg as string) === 'RETURN' || (offlineLeg as string) === 'RETURN_LEG1' || (offlineLeg as string) === 'RETURN_LEG2';
if (isOutbound && !(booking as any).outboundBoardedAt) bookingData.outboundBoardedAt = new Date(v.validatedAt);
if (isReturn && !(booking as any).returnBoardedAt) bookingData.returnBoardedAt = new Date(v.validatedAt);
if (Object.keys(bookingData).length) {
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
}
}
results.success++; results.success++;
} catch (err) { } catch (err) {
results.failed++; results.failed++;

View File

@@ -24,6 +24,19 @@ export default function BookingsPage() {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null); const [bookingToDelete, setBookingToDelete] = useState<any>(null);
const [successMessage, setSuccessMessage] = useState(''); const [successMessage, setSuccessMessage] = useState('');
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
bookingRef: true,
passenger: true,
status: true,
bookingType: false,
passengerCount: false,
totalMinor: true,
paymentStatus: true,
createdAt: true,
});
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -80,22 +93,23 @@ export default function BookingsPage() {
} }
}; };
const handleExportBookings = async () => { const confirmExport = () => {
const selectedColumns = prompt( const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
'Select columns to export (comma-separated):\n\n' + if (cols.length === 0) { alert('Please select at least one column'); return; }
'Available: bookingRef, passenger, status, bookingType, passengerCount, totalMinor, paymentStatus, createdAt\n\n' +
'Default: bookingRef, passenger, status, totalMinor, paymentStatus, createdAt',
'bookingRef, passenger, status, totalMinor, paymentStatus, createdAt'
);
if (!selectedColumns) return; const exportItems = (data?.items || []).filter((b: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const cols = selectedColumns.split(',').map(c => c.trim());
const csv = [ const csv = [
cols.join(','), cols.join(','),
...data?.items?.map((booking: any) => { ...exportItems.map((booking: any) => {
const values = cols.map(col => { const values = cols.map(col => {
switch(col) { switch (col) {
case 'bookingRef': return booking.bookingRef; case 'bookingRef': return booking.bookingRef;
case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest'; case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest';
case 'status': return booking.status; case 'status': return booking.status;
@@ -108,7 +122,7 @@ export default function BookingsPage() {
} }
}); });
return values.map(v => `"${v}"`).join(','); return values.map(v => `"${v}"`).join(',');
}) || [] }),
].join('\n'); ].join('\n');
const blob = new Blob([csv], { type: 'text/csv' }); const blob = new Blob([csv], { type: 'text/csv' });
@@ -117,6 +131,7 @@ export default function BookingsPage() {
a.href = url; a.href = url;
a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`; a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`;
a.click(); a.click();
setExportModalOpen(false);
}; };
const columns = [ const columns = [
@@ -140,14 +155,21 @@ export default function BookingsPage() {
}, },
{ {
key: 'bookingType', key: 'bookingType',
label: 'Class', label: 'Type',
sortable: true, sortable: true,
render: (booking: any) => booking.bookingType || 'ONE_WAY', render: (booking: any) => booking.bookingType || 'ONE_WAY',
}, },
{ {
key: 'passengerCount', key: 'passengerCount',
label: 'Passengers', label: 'Passengers',
render: (booking: any) => `${(booking.adultCount || 0) + (booking.childCount || 0)}`, render: (booking: any) => {
const adults = booking.adultCount || 0;
const children = booking.childCount || 0;
if (adults === 0 && children === 0) return '—';
const parts = [`Adult: ${adults}`];
if (children > 0) parts.push(`Child: ${children}`);
return parts.join(' / ');
},
}, },
{ {
key: 'status', key: 'status',
@@ -208,7 +230,7 @@ export default function BookingsPage() {
<h1 className="text-2xl font-bold">Bookings</h1> <h1 className="text-2xl font-bold">Bookings</h1>
<p className="text-muted-foreground">Manage all passenger bookings</p> <p className="text-muted-foreground">Manage all passenger bookings</p>
</div> </div>
<ActionButton variant="export" icon={Download} onClick={handleExportBookings}>Export</ActionButton> <ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div> </div>
<div className="card"> <div className="card">
@@ -264,15 +286,9 @@ export default function BookingsPage() {
</div> </div>
{/* Booking Details Modal */} {/* Booking Details Modal */}
<Modal <Modal isOpen={!!selectedBooking} onClose={() => setSelectedBooking(null)} title="Booking Details" size="xl">
isOpen={!!selectedBooking}
onClose={() => setSelectedBooking(null)}
title="Booking Details"
size="xl"
>
{selectedBooking && ( {selectedBooking && (
<div className="space-y-6"> <div className="space-y-6">
{/* Booking Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="text-sm font-medium text-muted-foreground">Booking Reference</label> <label className="text-sm font-medium text-muted-foreground">Booking Reference</label>
@@ -281,9 +297,7 @@ export default function BookingsPage() {
<div> <div>
<label className="text-sm font-medium text-muted-foreground">Status</label> <label className="text-sm font-medium text-muted-foreground">Status</label>
<div className="mt-1"> <div className="mt-1">
<Badge variant="status" status={selectedBooking.status}> <Badge variant="status" status={selectedBooking.status}>{selectedBooking.status}</Badge>
{selectedBooking.status}
</Badge>
</div> </div>
</div> </div>
<div> <div>
@@ -298,7 +312,6 @@ export default function BookingsPage() {
<hr className="border-muted" /> <hr className="border-muted" />
{/* Passenger Information */}
<div> <div>
<h3 className="text-lg font-semibold mb-3">Passenger Information</h3> <h3 className="text-lg font-semibold mb-3">Passenger Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -323,7 +336,6 @@ export default function BookingsPage() {
<hr className="border-muted" /> <hr className="border-muted" />
{/* Booking Details */}
<div> <div>
<h3 className="text-lg font-semibold mb-3">Journey Details</h3> <h3 className="text-lg font-semibold mb-3">Journey Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -348,7 +360,6 @@ export default function BookingsPage() {
<hr className="border-muted" /> <hr className="border-muted" />
{/* Payment Information */}
<div> <div>
<h3 className="text-lg font-semibold mb-3">Payment Information</h3> <h3 className="text-lg font-semibold mb-3">Payment Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -377,7 +388,6 @@ export default function BookingsPage() {
<hr className="border-muted" /> <hr className="border-muted" />
{/* Additional Information */}
<div> <div>
<h3 className="text-lg font-semibold mb-3">Additional Information</h3> <h3 className="text-lg font-semibold mb-3">Additional Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -393,12 +403,7 @@ export default function BookingsPage() {
</div> </div>
<div className="flex justify-end gap-2 pt-4"> <div className="flex justify-end gap-2 pt-4">
<ActionButton <ActionButton variant="secondary" onClick={() => setSelectedBooking(null)}>Close</ActionButton>
variant="secondary"
onClick={() => setSelectedBooking(null)}
>
Close
</ActionButton>
</div> </div>
</div> </div>
)} )}
@@ -407,10 +412,7 @@ export default function BookingsPage() {
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirmOpen} isOpen={deleteConfirmOpen}
onClose={() => { onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); }}
setDeleteConfirmOpen(false);
setBookingToDelete(null);
}}
onConfirm={handleConfirmDelete} onConfirm={handleConfirmDelete}
title="Delete Booking" title="Delete Booking"
message={`Are you sure you want to permanently delete booking ${bookingToDelete?.bookingRef}? This action cannot be undone and will release all associated seats.`} message={`Are you sure you want to permanently delete booking ${bookingToDelete?.bookingRef}? This action cannot be undone and will release all associated seats.`}
@@ -419,6 +421,53 @@ export default function BookingsPage() {
isLoading={deleteMutation.isPending} isLoading={deleteMutation.isPending}
isDanger={true} isDanger={true}
/> />
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Date From (Created)</label>
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Date To (Created)</label>
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Select Columns</p>
<div className="space-y-2 max-h-56 overflow-y-auto">
{[
{ key: 'bookingRef', label: 'Booking Reference' },
{ key: 'passenger', label: 'Passenger' },
{ key: 'status', label: 'Status' },
{ key: 'bookingType', label: 'Booking Type' },
{ key: 'passengerCount', label: 'Passenger Count' },
{ key: 'totalMinor', label: 'Amount' },
{ key: 'paymentStatus', label: 'Payment Status' },
{ key: 'createdAt', label: 'Created At' },
].map((col) => (
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
<input
type="checkbox"
checked={exportColumns[col.key] || false}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
</div>
</div>
</Modal>
</div> </div>
); );
} }

View File

@@ -145,7 +145,7 @@ export default function CoachesPage() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<any>(null); const [editingItem, setEditingItem] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null }>({ isOpen: false, item: null }); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Coach Types Queries // Coach Types Queries
@@ -252,12 +252,17 @@ export default function CoachesPage() {
}; };
const confirmDelete = async () => { const confirmDelete = async () => {
if (deleteConfirm.item?.isCoachType) { try {
await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id); if (deleteConfirm.item?.isCoachType) {
} else { await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id); } else {
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
}
setDeleteConfirm({ isOpen: false, item: null });
} catch (err: any) {
const msg = err?.response?.data?.message || err?.message || 'Delete failed';
setDeleteConfirm((prev) => ({ ...prev, error: msg }));
} }
setDeleteConfirm({ isOpen: false, item: null });
}; };
const coachTypesArray = Array.isArray(coachTypesData) ? coachTypesData : (coachTypesData as any)?.items || (coachTypesData as any)?.data || []; const coachTypesArray = Array.isArray(coachTypesData) ? coachTypesData : (coachTypesData as any)?.items || (coachTypesData as any)?.data || [];
@@ -536,6 +541,8 @@ export default function CoachesPage() {
message={`Are you sure you want to delete ${deleteConfirm.item?.name || deleteConfirm.item?.number}?`} message={`Are you sure you want to delete ${deleteConfirm.item?.name || deleteConfirm.item?.number}?`}
confirmText="Delete" confirmText="Delete"
isDanger={true} isDanger={true}
isLoading={deleteCoachTypeMutation.isPending || deleteCoachMutation.isPending}
error={deleteConfirm.error}
warning={ warning={
deleteConfirm.item?.isCoachType deleteConfirm.item?.isCoachType
? 'This coach type may have coaches assigned. Deleting it may impact these systems.' ? 'This coach type may have coaches assigned. Deleting it may impact these systems.'

View File

@@ -22,6 +22,12 @@ export default function PassengersPage() {
}); });
const [selectedPassenger, setSelectedPassenger] = useState<any>(null); const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null }); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
fullName: true, email: true, phone: true, gender: true, nationality: true, verified: true,
});
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -52,22 +58,23 @@ export default function PassengersPage() {
console.error('Passengers API Error:', error); console.error('Passengers API Error:', error);
} }
const handleExportPassengers = async () => { const confirmExportPassengers = () => {
const selectedColumns = prompt( const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
'Select columns to export (comma-separated):\n\n' + if (cols.length === 0) { alert('Please select at least one column'); return; }
'Available: fullName, email, phone, dateOfBirth, gender, nationality, verified\n\n' +
'Default: fullName, email, phone, gender, nationality, verified',
'fullName, email, phone, gender, nationality, verified'
);
if (!selectedColumns) return; const exportItems = (data?.items || []).filter((p: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const cols = selectedColumns.split(',').map(c => c.trim());
const csv = [ const csv = [
cols.join(','), cols.join(','),
...data?.items?.map((passenger: any) => { ...exportItems.map((passenger: any) => {
const values = cols.map(col => { const values = cols.map(col => {
switch(col) { switch (col) {
case 'fullName': return passenger.fullName; case 'fullName': return passenger.fullName;
case 'email': return passenger.email || ''; case 'email': return passenger.email || '';
case 'phone': return passenger.phone || ''; case 'phone': return passenger.phone || '';
@@ -79,7 +86,7 @@ export default function PassengersPage() {
} }
}); });
return values.map(v => `"${v}"`).join(','); return values.map(v => `"${v}"`).join(',');
}) || [] }),
].join('\n'); ].join('\n');
const blob = new Blob([csv], { type: 'text/csv' }); const blob = new Blob([csv], { type: 'text/csv' });
@@ -88,6 +95,7 @@ export default function PassengersPage() {
a.href = url; a.href = url;
a.download = `passengers-${new Date().toISOString().split('T')[0]}.csv`; a.download = `passengers-${new Date().toISOString().split('T')[0]}.csv`;
a.click(); a.click();
setExportModalOpen(false);
}; };
const columns = [ const columns = [
@@ -160,7 +168,7 @@ export default function PassengersPage() {
<p className="text-muted-foreground">Manage passenger profiles and verification</p> <p className="text-muted-foreground">Manage passenger profiles and verification</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<ActionButton variant="export" icon={Download} onClick={handleExportPassengers}>Export</ActionButton> <ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div> </div>
</div> </div>
@@ -381,6 +389,51 @@ export default function PassengersPage() {
</div> </div>
)} )}
</Modal> </Modal>
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Passengers" size="md">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Date From (Registered)</label>
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Date To (Registered)</label>
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Select Columns</p>
<div className="space-y-2 max-h-56 overflow-y-auto">
{[
{ key: 'fullName', label: 'Full Name' },
{ key: 'email', label: 'Email' },
{ key: 'phone', label: 'Phone' },
{ key: 'dateOfBirth', label: 'Date of Birth' },
{ key: 'gender', label: 'Gender' },
{ key: 'nationality', label: 'Nationality' },
{ key: 'verified', label: 'Verified' },
].map((col) => (
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
<input
type="checkbox"
checked={exportColumns[col.key] || false}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExportPassengers}>Export CSV</ActionButton>
</div>
</div>
</Modal>
</div> </div>
); );
} }

View File

@@ -6,27 +6,76 @@ import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { paymentsApi } from '@/lib/api'; import { paymentsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function PaymentsPage() { export default function PaymentsPage() {
const [filters, setFilters] = useState({ search: '', status: '', method: '' }); const [filters, setFilters] = useState({ search: '', status: '', method: '' });
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
reference: true, booking: true, amount: true, method: true, status: true, createdAt: true,
});
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['payments', filters], queryKey: ['payments', filters],
queryFn: () => paymentsApi.getAll(filters), queryFn: () => paymentsApi.getAll({
search: filters.search || undefined,
status: filters.status || undefined,
method: filters.method || undefined,
}),
}); });
const columns = [ const confirmExport = () => {
{ key: 'reference', label: 'Reference', render: (payment: any) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> }, const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
{ key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' }, if (cols.length === 0) { alert('Please select at least one column'); return; }
{ key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) },
{ key: 'method', label: 'Method', render: (payment: any) => <Badge>{payment.method}</Badge> },
{ key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
];
const actions: any[] = []; const items = ((data as any)?.items || (Array.isArray(data) ? data : [])) as any[];
const exportItems = items.filter((p: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
cols.join(','),
...exportItems.map((payment: any) => {
const values = cols.map(col => {
switch (col) {
case 'reference': return payment.reference || payment.id?.substring(0, 8) || '';
case 'booking': return payment.booking?.bookingRef || 'N/A';
case 'amount': return formatCurrency(payment.amountMinor, payment.currency);
case 'method': return payment.method || '';
case 'status': return payment.status || '';
case 'createdAt': return payment.createdAt || '';
default: return '';
}
});
return values.map(v => `"${v}"`).join(',');
}),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `payments-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
setExportModalOpen(false);
};
const columns = [
{ key: 'reference', label: 'Reference', render: (payment: any) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> },
{ key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' },
{ key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) },
{ key: 'method', label: 'Method', render: (payment: any) => <Badge>{payment.method}</Badge> },
{ key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -35,36 +84,91 @@ export default function PaymentsPage() {
<h1 className="text-2xl font-bold text-foreground">Payments</h1> <h1 className="text-2xl font-bold text-foreground">Payments</h1>
<p className="text-muted-foreground">Manage payment transactions and refunds</p> <p className="text-muted-foreground">Manage payment transactions and refunds</p>
</div> </div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton> <ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div> </div>
<div className="card"> <div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<div> <label className="label">Search</label>
<label className="label">Search</label> <input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} /> </div>
</div> <div>
<div> <label className="label">Status</label>
<label className="label">Status</label> <select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}> <option value="">All Status</option>
<option value="">All Status</option> <option value="PENDING">Pending</option>
<option value="PENDING">Pending</option> <option value="COMPLETED">Completed</option>
<option value="COMPLETED">Completed</option> <option value="FAILED">Failed</option>
<option value="FAILED">Failed</option> </select>
</select> </div>
</div> <div>
<label className="label">Method</label>
<select className="input" value={filters.method} onChange={(e) => setFilters({ ...filters, method: e.target.value })}>
<option value="">All Methods</option>
<option value="TELEBIRR">Telebirr</option>
<option value="CBE_BIRR">CBE Birr</option>
<option value="EBIRR">eBirr</option>
<option value="CARD">Card</option>
<option value="WALLET">Wallet</option>
<option value="CASH">Cash</option>
</select>
</div>
</div> </div>
</div> </div>
<DataTable <DataTable
data={(data as any)?.items || (Array.isArray(data) ? data : [])} data={(data as any)?.items || (Array.isArray(data) ? data : [])}
columns={columns} columns={columns}
actions={actions} actions={[]}
loading={isLoading} loading={isLoading}
emptyMessage="No payments found" emptyMessage="No payments found"
/> />
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Date From</label>
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Date To</label>
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Select Columns</p>
<div className="space-y-2">
{[
{ key: 'reference', label: 'Reference' },
{ key: 'booking', label: 'Booking Reference' },
{ key: 'amount', label: 'Amount' },
{ key: 'method', label: 'Payment Method' },
{ key: 'status', label: 'Status' },
{ key: 'createdAt', label: 'Created At' },
].map((col) => (
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
<input
type="checkbox"
checked={exportColumns[col.key] || false}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
</div>
</div>
</Modal>
</div> </div>
); );
} }

View File

@@ -362,12 +362,16 @@ export default function RoutesPage() {
type="text" type="text"
name="code" name="code"
className="input" className="input"
value={generateRouteCode(originStationId, destinationStationId)} defaultValue={editingRoute ? editingRoute.code : undefined}
readOnly key={editingRoute ? `code-edit-${editingRoute.id}` : `code-new-${originStationId}-${destinationStationId}`}
placeholder={generateRouteCode(originStationId, destinationStationId) || 'e.g. ADD-DJI'}
required required
placeholder="Select stations to generate"
disabled={!!editingRoute}
/> />
{!editingRoute && originStationId && destinationStationId && (
<p className="text-xs text-muted-foreground mt-1">
Suggested: <button type="button" className="text-primary underline" onClick={(e) => { const inp = (e.currentTarget.closest('.grid')?.querySelector('input[name=code]') as HTMLInputElement); if (inp) inp.value = generateRouteCode(originStationId, destinationStationId); }}>{generateRouteCode(originStationId, destinationStationId)}</button>
</p>
)}
</div> </div>
<div> <div>
<label className="label">Route Name *</label> <label className="label">Route Name *</label>
@@ -375,11 +379,16 @@ export default function RoutesPage() {
type="text" type="text"
name="name" name="name"
className="input" className="input"
value={generateRouteName(originStationId, destinationStationId)} defaultValue={editingRoute ? editingRoute.name : undefined}
readOnly key={editingRoute ? `name-edit-${editingRoute.id}` : `name-new-${originStationId}-${destinationStationId}`}
placeholder={generateRouteName(originStationId, destinationStationId) || 'e.g. Addis Ababa - Djibouti'}
required required
placeholder="Select stations to generate"
/> />
{!editingRoute && originStationId && destinationStationId && (
<p className="text-xs text-muted-foreground mt-1">
Suggested: <button type="button" className="text-primary underline" onClick={(e) => { const inp = (e.currentTarget.closest('.grid')?.querySelector('input[name=name]') as HTMLInputElement); if (inp) inp.value = generateRouteName(originStationId, destinationStationId); }}>{generateRouteName(originStationId, destinationStationId)}</button>
</p>
)}
</div> </div>
</div> </div>

View File

@@ -318,8 +318,8 @@ export default function SchedulesPage() {
label: 'Train', label: 'Train',
sortable: true, sortable: true,
render: (schedule: Schedule) => ( render: (schedule: Schedule) => (
<div className="font-medium"> <div className="font-medium font-mono">
{schedule.train?.name} ({schedule.train?.number}) {schedule.train?.number}
</div> </div>
), ),
}, },

View File

@@ -228,7 +228,7 @@ export default function SeatsPage() {
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
if (isBedCoach && hasBedPositionData) { if (isBedCoach) {
const arrangement = parseSeatArrangement(coach.seatArrangement); const arrangement = parseSeatArrangement(coach.seatArrangement);
const seatsPerRow = arrangement[0] + (arrangement[1] || 0); const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
const allSeatsForLayout = [...validSeats, ...removedSeats]; const allSeatsForLayout = [...validSeats, ...removedSeats];
@@ -548,8 +548,11 @@ export default function SeatsPage() {
{coachesWithSeats.map((coach: any, index: number) => { {coachesWithSeats.map((coach: any, index: number) => {
const coachData = coachTypesData?.items?.find((c: any) => c.id === coach.id) || coach; const coachData = coachTypesData?.items?.find((c: any) => c.id === coach.id) || coach;
const coachTypeName = coachData?.coachType?.type || 'Coach'; const coachTypeName = coachData?.coachType?.type || coachData?.coachType?.name || 'Coach';
const isBedCoach = coachTypeName.toLowerCase().includes('bed'); const seatClassName = coachData?.seatClass?.name || coach?.seatClass?.name || coach?.coachClass || '';
const isBedCoach = seatClassName.toLowerCase().includes('bed') ||
coachTypeName.toLowerCase().includes('bed') ||
(coach.seats || []).some((s: any) => s.bedPosition);
const seats = (coach.seats || []).filter((s: any) => s.seatNumber); const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
const isExpanded = expandedCoaches.has(coach.id); const isExpanded = expandedCoaches.has(coach.id);
const seatOrBedLabel = isBedCoach ? 'beds' : 'seats'; const seatOrBedLabel = isBedCoach ? 'beds' : 'seats';

View File

@@ -9,11 +9,11 @@ import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import { ticketsApi, apiClient, schedulesApi, stationsApi } from '@/lib/api'; import { ticketsApi, apiClient, stationsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function TicketsPage() { export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', tripDate: '' }); const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [ticketToDelete, setTicketToDelete] = useState<any>(null); const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false); const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
@@ -22,6 +22,8 @@ export default function TicketsPage() {
const [detailsModalOpen, setDetailsModalOpen] = useState(false); const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null); const [selectedTicket, setSelectedTicket] = useState<any>(null);
const [exportModalOpen, setExportModalOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [selectedColumns, setSelectedColumns] = useState<Record<string, boolean>>({ const [selectedColumns, setSelectedColumns] = useState<Record<string, boolean>>({
ticketNumber: true, ticketNumber: true,
booking: true, booking: true,
@@ -35,7 +37,15 @@ export default function TicketsPage() {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ['tickets', filters], queryKey: ['tickets', filters],
queryFn: () => ticketsApi.getAll({ ...filters, skip: 0, take: 50 }), queryFn: () => ticketsApi.getAll({
search: filters.search || undefined,
status: filters.status || undefined,
originStationId: filters.originStationId || undefined,
destinationStationId: filters.destinationStationId || undefined,
arrivalDate: filters.arrivalDate || undefined,
skip: 0,
take: 50,
}),
}); });
const { data: stationsData } = useQuery({ const { data: stationsData } = useQuery({
@@ -94,10 +104,6 @@ export default function TicketsPage() {
} }
}; };
const handleExportTickets = async () => {
setExportModalOpen(true);
};
const confirmExport = () => { const confirmExport = () => {
const cols = Object.entries(selectedColumns) const cols = Object.entries(selectedColumns)
.filter(([, selected]) => selected) .filter(([, selected]) => selected)
@@ -108,11 +114,21 @@ export default function TicketsPage() {
return; return;
} }
const exportItems = (data?.items || []).filter((ticket: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = ticket.schedule?.arrivalAt
? new Date(ticket.schedule.arrivalAt).toISOString().split('T')[0]
: null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [ const csv = [
cols.join(','), cols.join(','),
...data?.items?.map((ticket: any) => { ...exportItems.map((ticket: any) => {
const values = cols.map(col => { const values = cols.map(col => {
switch(col) { switch (col) {
case 'ticketNumber': return ticket.ticketNumber || ''; case 'ticketNumber': return ticket.ticketNumber || '';
case 'booking': return ticket.booking?.bookingRef || ''; case 'booking': return ticket.booking?.bookingRef || '';
case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`; case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`;
@@ -126,7 +142,7 @@ export default function TicketsPage() {
} }
}); });
return values.map(v => `"${v}"`).join(','); return values.map(v => `"${v}"`).join(',');
}) || [] }),
].join('\n'); ].join('\n');
const blob = new Blob([csv], { type: 'text/csv' }); const blob = new Blob([csv], { type: 'text/csv' });
@@ -175,12 +191,12 @@ export default function TicketsPage() {
}, },
{ {
key: 'seat', key: 'seat',
label: 'Seat', label: 'Seat/Bed',
sortable: true, sortable: true,
render: (ticket: any) => ( render: (ticket: any) => (
<div> <div>
<div className="font-mono font-semibold">Coach {ticket.seat?.coach?.number || 'N/A'} - Seat {ticket.seat?.seatNumber || 'N/A'}</div> <div className="font-mono font-semibold">{ticket.seat?.coach?.number || 'N/A'} - {ticket.seat?.seatNumber || 'N/A'}</div>
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.name || 'N/A'}</div> <div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.type || 'N/A'}</div>
</div> </div>
), ),
}, },
@@ -204,15 +220,31 @@ export default function TicketsPage() {
key: 'boarded', key: 'boarded',
label: 'Boarded', label: 'Boarded',
render: (ticket: any) => ( render: (ticket: any) => (
ticket.boardedAt ? ( ticket.validatedAt ? (
<div className="flex items-center gap-1 text-green-600 dark:text-green-400"> <div className="flex items-center gap-1 text-green-600 dark:text-green-400">
<span className="text-sm">{formatDateTime(ticket.boardedAt)}</span> <span className="text-sm">{formatDateTime(ticket.validatedAt)}</span>
</div> </div>
) : ( ) : (
<span className="text-sm text-muted-foreground">Not boarded</span> <span className="text-sm text-muted-foreground">Not boarded</span>
) )
), ),
}, },
{
key: 'returnLegStatus',
label: 'Return Leg',
render: (ticket: any) => {
const status = ticket.booking?.returnLegStatus;
if (!status || status === 'NOT_APPLICABLE') return <span className="text-xs text-muted-foreground"></span>;
const map: Record<string, { label: string; cls: string }> = {
NEITHER_USED: { label: 'Neither Used', cls: 'edr-badge-warning' },
OUTBOUND_ONLY: { label: 'Outbound Only', cls: 'edr-badge-info' },
INBOUND_ONLY: { label: 'Inbound Only', cls: 'edr-badge-danger' },
BOTH_USED: { label: 'Both Used', cls: 'edr-badge-success' },
};
const entry = map[status] ?? { label: status, cls: 'edr-badge-info' };
return <span className={`edr-badge ${entry.cls}`}>{entry.label}</span>;
},
},
]; ];
const actions = [ const actions = [
@@ -248,7 +280,7 @@ export default function TicketsPage() {
<h1 className="text-2xl font-bold text-foreground">Tickets</h1> <h1 className="text-2xl font-bold text-foreground">Tickets</h1>
<p className="text-muted-foreground">Manage tickets and validations</p> <p className="text-muted-foreground">Manage tickets and validations</p>
</div> </div>
<ActionButton icon={Download} variant="secondary" onClick={handleExportTickets}>Export</ActionButton> <ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div> </div>
{/* Filters */} {/* Filters */}
@@ -301,12 +333,12 @@ export default function TicketsPage() {
</select> </select>
</div> </div>
<div> <div>
<label className="label">Trip Date</label> <label className="label">Arrival Date</label>
<input <input
type="date" type="date"
className="input" className="input"
value={filters.tripDate} value={filters.arrivalDate}
onChange={(e) => setFilters({ ...filters, tripDate: e.target.value })} onChange={(e) => setFilters({ ...filters, arrivalDate: e.target.value })}
/> />
</div> </div>
<div> <div>
@@ -337,10 +369,7 @@ export default function TicketsPage() {
{/* Board Confirmation Dialog */} {/* Board Confirmation Dialog */}
<ConfirmDialog <ConfirmDialog
isOpen={boardConfirmOpen} isOpen={boardConfirmOpen}
onClose={() => { onClose={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}
setBoardConfirmOpen(false);
setTicketToBoard(null);
}}
onConfirm={handleConfirmBoard} onConfirm={handleConfirmBoard}
title="Board Ticket" title="Board Ticket"
message={`Are you sure you want to board ticket ${ticketToBoard?.ticketNumber}? This will mark the ticket as USED.`} message={`Are you sure you want to board ticket ${ticketToBoard?.ticketNumber}? This will mark the ticket as USED.`}
@@ -352,10 +381,7 @@ export default function TicketsPage() {
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirmOpen} isOpen={deleteConfirmOpen}
onClose={() => { onClose={() => { setDeleteConfirmOpen(false); setTicketToDelete(null); }}
setDeleteConfirmOpen(false);
setTicketToDelete(null);
}}
onConfirm={handleConfirmDelete} onConfirm={handleConfirmDelete}
title="Delete Ticket" title="Delete Ticket"
message={`Are you sure you want to permanently delete ticket ${ticketToDelete?.ticketNumber}? This action cannot be undone.`} message={`Are you sure you want to permanently delete ticket ${ticketToDelete?.ticketNumber}? This action cannot be undone.`}
@@ -368,10 +394,7 @@ export default function TicketsPage() {
{/* Ticket Details Modal */} {/* Ticket Details Modal */}
<Modal <Modal
isOpen={detailsModalOpen} isOpen={detailsModalOpen}
onClose={() => { onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}
setDetailsModalOpen(false);
setSelectedTicket(null);
}}
title="Ticket Details" title="Ticket Details"
size="lg" size="lg"
> >
@@ -444,21 +467,35 @@ export default function TicketsPage() {
</div> </div>
</div> </div>
{selectedTicket.boardedAt && ( {selectedTicket.validatedAt && (
<div className="border-t pt-4 bg-green-50 dark:bg-green-900/20 rounded-lg p-4"> <div className="border-t pt-4 bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Boarded At</p> <p className="text-sm text-muted-foreground">Validated At</p>
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.boardedAt)}</p> <p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.validatedAt)}</p>
</div>
)}
{selectedTicket.booking?.returnLegStatus && selectedTicket.booking.returnLegStatus !== 'NOT_APPLICABLE' && (
<div className="border-t pt-4">
<h3 className="font-semibold mb-3">Round-Trip Leg Status</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<p className="text-sm text-muted-foreground">Leg Status</p>
<p className="font-medium">{selectedTicket.booking.returnLegStatus.replace(/_/g, ' ')}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Outbound Boarded</p>
<p className="font-medium">{selectedTicket.booking.outboundBoardedAt ? formatDateTime(selectedTicket.booking.outboundBoardedAt) : '—'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Return Boarded</p>
<p className="font-medium">{selectedTicket.booking.returnBoardedAt ? formatDateTime(selectedTicket.booking.returnBoardedAt) : '—'}</p>
</div>
</div>
</div> </div>
)} )}
<div className="flex justify-end gap-2 pt-4"> <div className="flex justify-end gap-2 pt-4">
<ActionButton <ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}>
variant="secondary"
onClick={() => {
setDetailsModalOpen(false);
setSelectedTicket(null);
}}
>
Close Close
</ActionButton> </ActionButton>
</div> </div>
@@ -466,49 +503,55 @@ export default function TicketsPage() {
)} )}
</Modal> </Modal>
{/* Export Columns Modal */} {/* Export Modal */}
<Modal <Modal
isOpen={exportModalOpen} isOpen={exportModalOpen}
onClose={() => setExportModalOpen(false)} onClose={() => setExportModalOpen(false)}
title="Export Tickets - Select Columns" title="Export Tickets"
size="md" size="md"
> >
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-muted-foreground">Select which columns to include in the export</p> <div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Date From (Arrival)</label>
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Date To (Arrival)</label>
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
</div>
</div>
<div className="space-y-3 max-h-96 overflow-y-auto"> <div>
{[ <p className="text-sm font-medium mb-2">Select Columns</p>
{ key: 'ticketNumber', label: 'Ticket Number' }, <div className="space-y-2 max-h-56 overflow-y-auto">
{ key: 'booking', label: 'Booking Reference & Passenger' }, {[
{ key: 'trip', label: 'Trip (Origin → Destination)' }, { key: 'ticketNumber', label: 'Ticket Number' },
{ key: 'coach', label: 'Coach Number' }, { key: 'booking', label: 'Booking Reference & Passenger' },
{ key: 'seat', label: 'Seat Number' }, { key: 'trip', label: 'Trip (Origin → Destination)' },
{ key: 'seatClass', label: 'Seat Class' }, { key: 'coach', label: 'Coach Number' },
{ key: 'amount', label: 'Amount' }, { key: 'seat', label: 'Seat Number' },
{ key: 'status', label: 'Status' }, { key: 'seatClass', label: 'Seat Class' },
{ key: 'boarded', label: 'Boarded Status' }, { key: 'amount', label: 'Amount' },
].map((col) => ( { key: 'status', label: 'Status' },
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer"> { key: 'boarded', label: 'Boarded Status' },
<input ].map((col) => (
type="checkbox" <label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
checked={selectedColumns[col.key] || false} <input
onChange={(e) => type="checkbox"
setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked }) checked={selectedColumns[col.key] || false}
} onChange={(e) => setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked })}
className="w-4 h-4 rounded border-gray-300" className="w-4 h-4 rounded border-gray-300"
/> />
<span className="text-sm font-medium">{col.label}</span> <span className="text-sm font-medium">{col.label}</span>
</label> </label>
))} ))}
</div>
</div> </div>
<div className="flex justify-end gap-2 pt-4 border-t"> <div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}> <ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
Cancel <ActionButton onClick={confirmExport}>Export CSV</ActionButton>
</ActionButton>
<ActionButton onClick={confirmExport}>
Export CSV
</ActionButton>
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@@ -15,6 +15,7 @@ interface ConfirmDialogProps {
isLoading?: boolean; isLoading?: boolean;
isDanger?: boolean; isDanger?: boolean;
warning?: string; warning?: string;
error?: string;
} }
export default function ConfirmDialog({ export default function ConfirmDialog({
@@ -28,6 +29,7 @@ export default function ConfirmDialog({
isLoading = false, isLoading = false,
isDanger = false, isDanger = false,
warning, warning,
error,
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm"> <Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
@@ -47,6 +49,12 @@ export default function ConfirmDialog({
</div> </div>
</div> </div>
)} )}
{error && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 flex gap-3">
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-red-800 dark:text-red-300 text-sm">{error}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-4"> <div className="flex justify-end gap-2 pt-4">
<ActionButton variant="secondary" onClick={onClose} disabled={isLoading}> <ActionButton variant="secondary" onClick={onClose} disabled={isLoading}>
{cancelText} {cancelText}

View File

@@ -37,3 +37,4 @@ module.exports = {
}, },
}, },
plugins: [], plugins: [],
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Some files were not shown because too many files have changed in this diff Show More