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

@@ -19,6 +19,11 @@ TELEBIRR_TIMEOUT_EXPRESS=15m
TELEBIRR_PRIVATE_KEY=
TELEBIRR_PUBLIC_KEY=
TELEBIRR_INSECURE_TLS=false
# Portal pages the payment provider redirects the browser to after payment.
# Point these at the freight portal's public payment result routes.
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure
# JWT (used by @tria-plc/api-common SharedAuthModule)
JWT_SECRET=
JWT_ACCESS_TOKEN_SECRET=

View File

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

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 {
BadRequestException,
ConflictException,
forwardRef,
Inject,
Injectable,
@@ -69,7 +68,12 @@ export class BookingTransitionService {
priorityScore,
} as never);
const finalBooking = await this.bookingsService.findById(updated!.id);
// Auto-consolidate now: a partial-wagon booking either pairs with a waiting
// partner (both → SUBMITTED) or is parked as PENDING_CONSOLIDATION until one
// arrives. The returned status reflects that outcome.
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
updated!.id,
);
return {
bookingId: finalBooking.id,
status: finalBooking.status,
@@ -143,7 +147,10 @@ export class BookingTransitionService {
},
} as never);
const finalBooking = await this.bookingsService.findById(updated!.id);
// Same consolidation treatment as the direct submit path.
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
updated!.id,
);
return {
bookingId: finalBooking.id,
status: finalBooking.status,
@@ -188,18 +195,11 @@ export class BookingTransitionService {
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
// Only SUBMITTED bookings are acceptable. A booking that still needs
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
// is therefore never offered for accept until a partner moves it to SUBMITTED.
assertBookingStatus(booking, ['SUBMITTED']);
// Consolidation gate: a booking whose containers don't fill whole wagons
// cannot be accepted until it is paired with a complementary booking.
const gate = await this.bookingsService.resolveConsolidationGate(bookingId);
if (gate.blocked) {
throw new ConflictException(
gate.message ??
'Booking requires consolidation and cannot be accepted until a partner is found.',
);
}
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId,

View File

@@ -11,6 +11,7 @@ import {
Query,
Request,
Res,
UnauthorizedException,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
@@ -117,8 +118,22 @@ export class BookingsController {
@Get()
@ApiOperation({ summary: 'List freight bookings (paginated)' })
findAll(@Query() filter: FilterBookingDto) {
return this.bookingsService.findAll(filter);
async findAll(
@Query() filter: FilterBookingDto,
@CurrentUser() user: TCurrentUser,
) {
// Staff (backoffice) see every booking. Customers (portal) are always
// force-scoped to their own company, regardless of any companyId they pass.
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
return this.bookingsService.findAll(filter);
}
const userId = user?.id;
if (!userId) throw new UnauthorizedException('Authentication required');
const companyId =
await this.bookingsService.resolveCustomerCompanyId(userId);
// No linked company yet → no bookings to show (avoids leaking all bookings).
if (!companyId) return { items: [], total: 0 };
return this.bookingsService.findAll(filter, companyId);
}
@Get('list-summary')
@@ -166,18 +181,60 @@ export class BookingsController {
@Get('by-reference/:reference')
@ApiOperation({ summary: 'Get booking by reference' })
async findByReference(@Param('reference') reference: string) {
async findByReference(
@Param('reference') reference: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findByReference(reference);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id')
@ApiOperation({ summary: 'Get booking by ID' })
async findOne(@Param('id', ParseUUIDPipe) id: string) {
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/tracking')
@ApiOperation({
summary: 'Shipment tracking timeline for a booking',
description:
"Returns the booking's consignment (once dispatched) and its ordered " +
'tracking events. Scoped to the customer\'s own company.',
})
async findTracking(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.bookingsService.getBookingTracking(id);
}
@Delete(':id')
@HttpCode(204)
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })

View File

@@ -177,8 +177,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
.where('b.id != :bookingId', { bookingId: booking.id })
.andWhere('b.allowConsolidation = true')
.andWhere('b.consolidationPartnerId IS NULL')
// Only pair bookings the customer has committed (SUBMITTED) or that are
// already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so
// pairing never prematurely submits an unfinished/unpriced draft.
.andWhere('b.status IN (:...statuses)', {
statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'],
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
})
.andWhere('b.originYardId = :originYardId', {
originYardId: booking.originYardId,

View File

@@ -1,12 +1,16 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
forwardRef,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { SchedulingStatus } from '@edr/types';
import { Freight, SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
@@ -52,6 +56,8 @@ export class BookingsService {
private readonly minioService: MinioService,
// private readonly customersService: CustomersService,
private readonly companiesService: CompaniesService,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService,
@@ -146,13 +152,17 @@ export class BookingsService {
/**
* Enable consolidation when any container line leaves a wagon partially filled
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out.
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon).
*
* Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a
* half-empty wagon, so `explicit === false` is ignored when consolidation is
* actually needed. The opt-in flag only matters for cargo that already fills
* whole wagons (where consolidation is moot anyway).
*/
private async resolveConsolidation(
containers: CreateBookingContainerDto[],
explicit?: boolean,
): Promise<boolean> {
if (explicit === false) return false;
const needs = await this.consolidationService.needsConsolidation(
containers.map((c) => ({
containerTypeId: c.containerTypeId,
@@ -193,10 +203,11 @@ export class BookingsService {
return { booking: paired, messages };
}
if (booking.status === 'DRAFT') {
await this.bookingsRepository.update(booking.id, {
status: 'PENDING_CONSOLIDATION',
} as never);
// No partner yet — park the booking so it waits. Applies both pre-submit
// (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never
// reach this method.
if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') {
await this.bookingsRepository.parkForConsolidation(booking.id);
}
const pending = await this.findById(booking.id);
@@ -205,45 +216,24 @@ export class BookingsService {
}
/**
* Consolidation gate used at staff-accept time. Returns the (possibly newly
* paired) booking plus whether it still needs a consolidation partner.
* When a booking needs consolidation and none is found, it is parked in
* PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept.
* Run consolidation right after a booking reaches SUBMITTED. If a complementary
* partner already exists, both are paired and moved (back) to SUBMITTED so staff
* can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and
* waits for a later complementary booking to complete the wagon.
*
* Returns the re-fetched booking, so callers can reflect the resulting status
* (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting).
*/
async resolveConsolidationGate(bookingId: string): Promise<{
booking: Booking;
blocked: boolean;
message?: string;
}> {
let booking = await this.findById(bookingId);
async runConsolidationOnSubmit(bookingId: string): Promise<Booking> {
const booking = await this.findById(bookingId);
// Already paired — passes the gate.
// Already paired (e.g. a partner submitted first) — nothing to do.
if (booking.consolidationPartnerId) {
return { booking, blocked: false };
return booking;
}
const needs =
await this.consolidationService.needsConsolidationFromBooking(booking);
if (!needs) {
return { booking, blocked: false };
}
// A partner may have appeared since submission — try to pair now.
const result = await this.tryAutoConsolidate(booking);
booking = result.booking;
if (booking.consolidationPartnerId) {
return { booking, blocked: false, message: result.messages.join(' ') };
}
// Still no partner — park it and block the accept.
await this.bookingsRepository.parkForConsolidation(booking.id);
booking = await this.findById(booking.id);
const slots = await this.consolidationService.slotsFromBooking(booking);
return {
booking,
blocked: true,
message: this.consolidationService.describePending(booking, slots),
};
return result.booking;
}
/** Create a new freight booking. */
@@ -575,6 +565,7 @@ export class BookingsService {
/** Return a paginated list of bookings matching the filter. */
async findAll(
filter: FilterBookingDto,
forceCompanyId?: string,
): Promise<{ items: Booking[]; total: number }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
@@ -587,7 +578,9 @@ export class BookingsService {
...statusFilter,
...schedulingStatusFilter,
assignedToSchedule: filter.assignedToSchedule,
companyId: filter.companyId,
// A forced company scope (portal/customer) overrides any caller-provided
// companyId so a customer can only ever see their own company's bookings.
companyId: forceCompanyId ?? filter.companyId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
@@ -631,6 +624,109 @@ export class BookingsService {
});
}
/**
* Resolve the company a customer user belongs to, for scoping their own
* bookings. Returns null when no profile/company is linked yet.
*/
async resolveCustomerCompanyId(userId: string): Promise<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. */
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
const page = filter.page ?? 1;

View File

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

View File

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

View File

@@ -49,8 +49,17 @@ export class SchedulingRescheduleService {
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
// A train can be rescheduled (with or without bookings) at any time UNLESS it
// is already on the move (DISPATCHED), has completed its run (ARRIVED), or was
// cancelled. Only DRAFT / SCHEDULED trains are reschedulable.
if (schedule.status === TrainScheduleStatus.Dispatched) {
throw new BadRequestException('Cannot reschedule a dispatched train');
throw new BadRequestException('Cannot reschedule a train that is already dispatched');
}
if (schedule.status === TrainScheduleStatus.Arrived) {
throw new BadRequestException('Cannot reschedule a train that has already arrived');
}
if (schedule.status === TrainScheduleStatus.Cancelled) {
throw new BadRequestException('Cannot reschedule a cancelled train');
}
const currentOnSchedule = (schedule.scheduleBookings ?? [])
@@ -156,10 +165,24 @@ export class SchedulingRescheduleService {
}
}
const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
bookingIds: dto.finalBookingIds,
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
});
// A train can be rescheduled even with no bookings (e.g. moved for
// maintenance). assignBookingsToSchedule requires at least one booking, so
// only call it when something is actually being (re)assigned — the new
// departure date above is the meaningful change for an empty train. The
// empty-train branch returns the same schedule-detail shape as the assign
// path so callers get a consistent response.
const assignResult = dto.finalBookingIds.length
? await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
bookingIds: dto.finalBookingIds,
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
})
: {
...(await this.trainSchedulingService.getContainerTrainScheduleById(
scheduleId,
)),
warnings: [] as string[],
deferredBookings: [] as unknown[],
};
await this.schedulingRescheduleRepository.createEvent({
trainScheduleId: scheduleId,

View File

@@ -721,27 +721,37 @@ export class TrainSchedulingService {
: null;
if (route) {
const origin = route.originYard;
const destination = route.destinationYard;
// `route.milestones` is the complete ordered corridor and already includes
// the origin (first) and destination (last) yards — `route.originYardId`
// and `route.destinationYardId` are derived from them. Use the milestones
// directly so the endpoints aren't double-counted (Addis…Addis, Dire…Dire).
const milestones = [...(route.milestones ?? [])].sort(
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo,
);
if (milestones.length > 0) {
milestones.forEach((m, i) =>
stations.push({
sequenceNo: i,
yardId: m.yardId,
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
code: m.yard?.code ?? '',
}),
);
return stations;
}
// Route with no milestones recorded — fall back to its origin/destination.
const origin = route.originYard;
const destination = route.destinationYard;
stations.push({
sequenceNo: 0,
yardId: route.originYardId,
label: origin?.label ?? origin?.code ?? 'Origin',
code: origin?.code ?? '',
});
milestones.forEach((m, i) =>
stations.push({
sequenceNo: i + 1,
yardId: m.yardId,
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
code: m.yard?.code ?? '',
}),
);
stations.push({
sequenceNo: milestones.length + 1,
sequenceNo: 1,
yardId: route.destinationYardId,
label: destination?.label ?? destination?.code ?? 'Destination',
code: destination?.code ?? '',
@@ -775,8 +785,16 @@ export class TrainSchedulingService {
const stations = await this.buildScheduleStations(schedule);
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
// Resolve each checkpoint's position by its yard against the canonical
// corridor rather than the stored sequenceNo, so legacy checkpoints logged
// under an older station numbering still line up with the current stations.
const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
const resolvedSeq = (e: TrainCheckpointEvent) =>
seqByYard.get(e.yardId) ?? e.sequenceNo;
const currentSequenceNo = events.length
? Math.max(...events.map((e) => e.sequenceNo))
? Math.max(...events.map(resolvedSeq))
: -1;
return {
@@ -790,13 +808,19 @@ export class TrainSchedulingService {
actualArrivalAt: schedule.actualArrivalAt
? schedule.actualArrivalAt.toISOString()
: null,
scheduledDepartureAt: schedule.scheduledDepartureDate
? schedule.scheduledDepartureDate.toISOString()
: null,
scheduledArrivalAt: schedule.scheduledArrivalDate
? schedule.scheduledArrivalDate.toISOString()
: null,
origin: stations[0]?.label ?? null,
destination: stations[stations.length - 1]?.label ?? null,
stations,
currentSequenceNo,
checkpoints: events.map((e) => ({
id: e.id,
sequenceNo: e.sequenceNo,
sequenceNo: resolvedSeq(e),
yardId: e.yardId,
label: e.yard?.label ?? e.yard?.code ?? null,
kind: e.kind,

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() {
await this.dataSource.transaction(async (manager) => {
await this.seedWagons(manager);
await this.seedApprovalRules(manager);
// Demo freight data (wagons + approval rules) disabled — keep only the
// 4 staff users. The seeders are retained for easy re-enabling; flip
// SEED_DEMO_FREIGHT_DATA=true to run them.
if (process.env.SEED_DEMO_FREIGHT_DATA === 'true') {
await this.seedWagons(manager);
await this.seedApprovalRules(manager);
}
await this.seedStaffUsers(manager);
});
}

View File

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

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 NewBookingPage from "./pages/bookings/NewBookingPage";
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
import TrackingPage from "./pages/tracking/TrackingPage";
function FullScreenSpinner() {
@@ -158,6 +160,9 @@ const App = () => {
path="/booking/check-status/:orderId"
element={<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 */}
<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 { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
import { Stepper } from "./Stepper";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
interface BookingRowProps {
booking: any;
@@ -18,6 +19,10 @@ export const BookingRow = memo(function BookingRow({
const Icon = cfg.icon;
const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind];
// Payable bookings get an inline "Pay now" that opens the payment modal
// instead of navigating to the detail page.
const canPay =
booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
@@ -78,25 +83,29 @@ export const BookingRow = memo(function BookingRow({
{cfg.badgeLabel}
</Text>
</Group>
<Group
gap={5}
align="center"
px={15}
py={8}
bg={ap.bg}
bd={ap.bd}
className="cursor-pointer rounded-[9px]"
>
<Text fz={13} fw={700} c={ap.c}>
{cfg.action.label}
</Text>
{AIcon && (
<AIcon
size={15}
color={ap.c === "white" ? "#fff" : cv("edr-text")}
/>
)}
</Group>
{canPay ? (
<PayNowButton booking={booking} size="sm" />
) : (
<Group
gap={5}
align="center"
px={15}
py={8}
bg={ap.bg}
bd={ap.bd}
className="cursor-pointer rounded-[9px]"
>
<Text fz={13} fw={700} c={ap.c}>
{cfg.action.label}
</Text>
{AIcon && (
<AIcon
size={15}
color={ap.c === "white" ? "#fff" : cv("edr-text")}
/>
)}
</Group>
)}
</Stack>
</Group>
</Box>

View File

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

View File

@@ -1,39 +1,51 @@
import { Alert, Box, Button, Group, PasswordInput, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
import { ArrowRight, Mail, Phone } from "lucide-react";
import { useState } from "react";
import { type FormEvent, useState } from "react";
import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import AuthLayout from "@/components/auth/AuthLayout";
import PhoneInput from "@/components/auth/PhoneInput";
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
const EDR_LOGO = "/assets/logo.svg";
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() {
const navigate = useNavigate();
const location = useLocation();
const { login } = useAuth();
const [method, setMethod] = useState<LoginMethod>("email");
const [identifier, setIdentifier] = useState("");
const [countryCode, setCountryCode] = useState("+251");
const [phoneNumber, setPhoneNumber] = useState("");
const [countryCode] = useState("+251");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const currentMethod = loginMethods.find((item) => item.value === method)!;
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setLoading(true);
try {
const loginId =
method === "email"
? identifier
: `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`;
: `${countryCode}${identifier.startsWith("0") ? identifier.slice(1) : identifier}`;
const result = await login({ email: loginId, password });
if (result.success) {
const from = (location.state as { from?: { pathname: string } } | null)
?.from?.pathname;
const from = (location.state as { from?: { pathname: string } } | null)?.from
?.pathname;
navigate(from ?? "/portal", { replace: true });
} else {
setError(result.error.message);
@@ -46,154 +58,105 @@ export default function LoginPage() {
};
return (
<AuthLayout
left={{
badge: "Welcome Back",
title: "Sign in to your freight operations account",
description:
"Access your dashboard to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows.",
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">
<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">
<AuthShell>
<form className="flex w-full flex-col" onSubmit={handleSubmit}>
<div className="mb-4 flex justify-center sm:mb-6">
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
</div>
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Welcome back
</Text>
<Text fz={15} c="edr-muted" mt={4}>
Enter your credentials to access your portal
</Text>
</Box>
</Stack>
</h1>
<p className="text-sm leading-relaxed text-gray-500">
Enter your credentials to access your freight portal.
</p>
</div>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<SegmentedControl
value={method}
onChange={(v) => setMethod(v as LoginMethod)}
fullWidth
radius="md"
data={[
{
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}
<div className="flex w-full flex-col gap-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">Sign in method</label>
<div className="relative">
<select
value={method}
onChange={(event) => setMethod(event.target.value as LoginMethod)}
disabled={loading}
className={`${fieldClass} appearance-none pr-10`}
>
Forgot password?
</Button>
</Group>
<PasswordInput
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
{loginMethods.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
</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}
className={fieldClass}
/>
</Box>
</div>
{error && (
<Alert color="red" variant="light" radius="md">
<div className="space-y-1.5">
<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}
</Alert>
)}
</div>
) : null}
<Button
type="submit"
disabled={loading}
loading={loading}
size="lg"
color="edr-green"
fullWidth
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
>
Sign In
</Button>
<button type="submit" disabled={loading} className={primaryButtonClass}>
{loading ? "Signing in..." : "Sign In"}
</button>
<Text size="sm" c="edr-muted" ta="center">
Don't have an account?{" "}
<Button
variant="transparent"
p={0}
h="auto"
c="edr-green.6"
fw={600}
fz="sm"
<p className="text-center text-sm text-gray-500">
Don&apos;t have an account?{" "}
<button
type="button"
onClick={() => navigate("/signup")}
className="font-semibold text-primary hover:underline"
>
Create an account
</Button>
</Text>
</Stack>
</button>
</p>
</div>
</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 { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
@@ -9,8 +8,9 @@ import { z } from "zod";
import { userType } from "@/enums/userType";
import useAuth from "@/hooks/useAuth";
import type { SignupPayload } from "@/types/auth";
import AuthLayout from "@/components/auth/AuthLayout";
import PhoneInput from "@/components/auth/PhoneInput";
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
const EDR_LOGO = "/assets/logo.svg";
const passwordRequirements = [
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
@@ -20,11 +20,22 @@ const passwordRequirements = [
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
] as const;
const ETHIOPIA_COUNTRY_CODE = "+251";
const isValidEthiopianMobile = (value: string) => {
const digits = value.replace(/\D/g, "");
const normalized = digits.startsWith("0") ? digits.slice(1) : digits;
return /^9\d{8}$/.test(normalized);
};
const userSchema = z
.object({
email: z.string().email("Invalid email address"),
countryCode: z.string().min(1, "Country code is required"),
phone: z.string().min(9, "Phone number is too short").max(9, "Phone number is too long"),
countryCode: z.literal(ETHIOPIA_COUNTRY_CODE),
phone: z
.string()
.min(1, "Phone number is required")
.refine(isValidEthiopianMobile, "Enter a valid mobile number (e.g. 0912345678)"),
userType: z.string(),
firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
@@ -44,11 +55,16 @@ const userSchema = z
type FormData = z.infer<typeof userSchema>;
const errorText = (msg?: string) =>
msg ? <p className="mt-1 text-xs text-red-600">{msg}</p> : null;
export default function SignupPage() {
const navigate = useNavigate();
const { signup } = useAuth();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const {
register,
@@ -59,7 +75,7 @@ export default function SignupPage() {
resolver: zodResolver(userSchema),
defaultValues: {
email: "",
countryCode: "+251",
countryCode: ETHIOPIA_COUNTRY_CODE,
phone: "",
userType: userType.individual,
firstName: { en: "", am: "" },
@@ -73,7 +89,8 @@ export default function SignupPage() {
setError(null);
setLoading(true);
try {
const normalizedPhone = data.phone.startsWith("0") ? data.phone.slice(1) : data.phone;
const digits = data.phone.replace(/\D/g, "");
const normalizedPhone = digits.startsWith("0") ? digits.slice(1) : digits;
const payload: SignupPayload = {
email: data.email,
username: data.email,
@@ -102,145 +119,194 @@ export default function SignupPage() {
const passwordValue = watch("password") ?? "";
return (
<AuthLayout
left={{
badge: "Smart Freight Operations",
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%]" },
}}
<AuthShell
tagline="Smart Freight Operations"
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
>
<Stack gap="xs" mb="lg">
<Box w={48} h={48} bg="edr-soft" className="flex items-center justify-center rounded-2xl">
<UserPlus size={22} color="var(--mantine-color-edr-green-6)" />
</Box>
<Box>
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
Create Account
</Text>
<Text fz={15} c="edr-muted" mt={4}>
<form className="flex w-full flex-col" onSubmit={handleSubmit(onSubmit)}>
<div className="mb-4 flex justify-center sm:mb-6">
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
</div>
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Create account
</h1>
<p className="text-sm leading-relaxed text-gray-500">
Register to access EDR Freight services.
</Text>
</Box>
</Stack>
</p>
</div>
{error && (
<Alert color="red" variant="light" radius="md" mb="md">
{error}
</Alert>
)}
<div className="flex w-full flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<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)}>
<Stack gap="md">
<SimpleGrid cols={2} spacing="md">
<TextInput
label="First Name"
placeholder="John"
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Email <span className="text-red-500">*</span>
</label>
<input
type="email"
placeholder="john@example.com"
disabled={loading}
error={errors.firstName?.en?.message}
{...register("firstName.en")}
className={fieldClass}
{...register("email")}
/>
<TextInput
label="Last Name"
placeholder="Doe"
disabled={loading}
error={errors.lastName?.en?.message}
{...register("lastName.en")}
/>
</SimpleGrid>
{errorText(errors.email?.message)}
</div>
<TextInput
label="Email Address"
placeholder="john@example.com"
type="email"
disabled={loading}
error={errors.email?.message}
{...register("email")}
/>
<div className="space-y-1.5">
<label htmlFor="signup-phone" className="text-sm font-medium text-gray-800">
Phone <span className="text-red-500">*</span>
</label>
<input type="hidden" {...register("countryCode")} />
<div
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
disabled={loading}
countryCode={{ ...register("countryCode") }}
phone={{ ...register("phone") }}
countryCodeError={errors.countryCode}
phoneError={errors.phone}
/>
<Box>
<PasswordInput
label="Password"
placeholder="Create a strong password"
disabled={loading}
error={errors.password?.message}
{...register("password")}
/>
{passwordValue.length > 0 && (
<Stack gap={4} mt={8}>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Password <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
placeholder="Create a strong password"
disabled={loading}
className={`${fieldClass} pr-11`}
{...register("password")}
/>
<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>
{errorText(errors.password?.message)}
{passwordValue.length > 0 ? (
<div className="mt-2 space-y-1">
{passwordRequirements.map((req) => {
const met = req.test(passwordValue);
return (
<Group key={req.label} gap={6} align="center" wrap="nowrap">
<ThemeIcon
size={16}
radius="xl"
variant={met ? "filled" : "light"}
color={met ? "edr-green" : "gray"}
<div key={req.label} className="flex items-center gap-2">
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500"
}`}
>
{met ? <Check size={10} /> : <X size={10} />}
</ThemeIcon>
<Text size="xs" c={met ? "edr-green.7" : "edr-muted"}>
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
</span>
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
{req.label}
</Text>
</Group>
</span>
</div>
);
})}
</Stack>
)}
</Box>
</div>
) : null}
</div>
<PasswordInput
label="Confirm Password"
placeholder="Re-enter your password"
disabled={loading}
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
/>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Confirm password <span className="text-red-500">*</span>
</label>
<div className="relative">
<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"
disabled={loading}
loading={loading}
size="lg"
color="edr-green"
fullWidth
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
className={`${primaryButtonClass} flex items-center justify-center gap-2`}
>
Create Account
</Button>
{loading ? "Creating account..." : "Create Account"}
{!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?{" "}
<Button
variant="transparent"
p={0}
h="auto"
c="edr-green.6"
fw={600}
fz="sm"
<button
type="button"
onClick={() => navigate("/login")}
className="font-semibold text-primary hover:underline"
>
Sign In
</Button>
</Text>
</Stack>
</button>
</p>
</div>
</form>
</AuthLayout>
</AuthShell>
);
}

View File

@@ -23,6 +23,7 @@ import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import type { SubmitBookingResponse } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { REQUIRED_DOC_FIELDS } from "./constants";
@@ -60,6 +61,8 @@ export function DraftBookingView({
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
const [docError, setDocError] = useState("");
const [priceChangeModal, setPriceChangeModal] =
useState<SubmitBookingResponse | null>(null);
const anyFileSelected = Object.values(selectedFiles).some(Boolean);
const uploadedCodes = useMemo(
@@ -72,9 +75,11 @@ export function DraftBookingView({
const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length;
const { data: generatedPricing } = useQuery(
api.bookings.generatePrice.queryOptions({ input: { id: booking.id },
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
api.bookings.generatePrice.queryOptions({
input: { id: booking.id },
enabled:
(booking.status === "DRAFT" || booking.status === "CHANGES_REQUESTED") &&
!booking.pricingBreakdown,
}),
);
const pricing = (booking.pricingBreakdown ??
@@ -82,8 +87,17 @@ export function DraftBookingView({
null) as Freight.PricingBreakdown | null;
const uploadMutation = useMutation({
mutationFn: (files: Record<string, File | File[] | null>) =>
api.bookings.uploadDocuments.call({ id: booking.id, files }),
mutationFn: async (files: Record<string, File | File[] | null>) => {
if (booking.status === "CHANGES_REQUESTED") {
const result = await api.bookings.update.call({
id: booking.id,
dto: {},
documents: files,
});
return result.booking;
}
return api.bookings.uploadDocuments.call({ id: booking.id, files });
},
onSuccess: () => {
setSelectedFiles({});
setDocError("");
@@ -93,7 +107,20 @@ export function DraftBookingView({
const submitMutation = useMutation({
mutationFn: () => api.bookings.submit.call({ id: booking.id }),
onSuccess: (result) => {
if (result.priceChanged) {
setPriceChangeModal(result);
return;
}
onBookingUpdated();
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
});
const confirmSubmitMutation = useMutation({
mutationFn: () => api.bookings.confirmSubmit.call({ id: booking.id }),
onSuccess: () => {
setPriceChangeModal(null);
onBookingUpdated();
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
@@ -155,7 +182,12 @@ export function DraftBookingView({
/>
<MutationErrors
mutations={[uploadMutation, submitMutation, cancelMutation]}
mutations={[
uploadMutation,
submitMutation,
confirmSubmitMutation,
cancelMutation,
]}
/>
<StatusHero booking={booking}>
@@ -163,7 +195,9 @@ export function DraftBookingView({
booking.latestChangeRequestNote ? (
<ActionRequiredBanner
title="Review the requested changes, then resubmit."
onAction={() => navigate(`/bookings/${booking.id}/edit`)}
onAction={() =>
navigate(`/bookings/${booking.id}/edit?section=documents`)
}
>
{booking.latestChangeRequestNote}
</ActionRequiredBanner>
@@ -260,6 +294,8 @@ export function DraftBookingView({
const isUploaded = uploadedCodes.has(doc.key);
const selected = selectedFiles[doc.key];
const file = booking.files?.find((f) => f.code === doc.key);
const allowReplace =
!isUploaded || booking.status === "CHANGES_REQUESTED";
return (
<DocRow
key={doc.key}
@@ -276,13 +312,19 @@ export function DraftBookingView({
isUploaded ? "verified" : selected ? "ready" : "missing"
}
action={
isUploaded ? (
isUploaded && !allowReplace ? (
<IconSquare
href={file?.signedUrl ?? file?.url}
icon={<Download size={16} />}
/>
) : (
<>
{isUploaded && (
<IconSquare
href={file?.signedUrl ?? file?.url}
icon={<Download size={16} />}
/>
)}
<input
ref={(el) => {
fileInputRefs.current[doc.key] = el;
@@ -318,7 +360,7 @@ export function DraftBookingView({
},
}}
>
{selected ? "Change" : "Add"}
{selected ? "Change" : isUploaded ? "Replace" : "Add"}
</Button>
{selected && (
<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
opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,92 @@
import { Box, Group, Text } from "@mantine/core";
import { AlertTriangle, Check, FileText, History } from "lucide-react";
import {
AlertTriangle,
Check,
FileText,
History,
MapPin,
MoveRight,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { PROGRESS_STAGES, STATUS_MAP } from "../constants";
import { fmtDate, isDraftLike, isNegative } from "../utils";
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
import { SectionCard } from "./layout";
const ACCENT = "#F2A516";
/** Origin → destination strip rendered above the progress tracker. */
function RouteStrip({ booking }: { booking: Freight.IBooking }) {
const origin = yardLabel(booking.originYard);
const destination = yardLabel(booking.destinationYard);
return (
<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({
booking,
children,
@@ -91,6 +171,8 @@ export function StatusHero({
<Box my={26} h={1} w="100%" bg="#EEF2F6" />
{!negative && <RouteStrip booking={booking} />}
{children ?? (
<ProgressTracker
current={cfg.stage}

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
import { api } from "@/services/api";
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
import type {
CreateBookingPayload,
GeneratePriceResponse,
SubmitBookingResponse,
} from "@/services/bookings.service";
import { zodResolver } from "@hookform/resolvers/zod";
import {
@@ -35,6 +37,7 @@ import {
getRouteDirection,
initialBookingFormValues,
stepFields,
type BookingDocuments,
type BookingFormValues,
} from "./new-booking-form/schema";
import { StepIndicator } from "./new-booking-form/StepIndicator";
@@ -48,6 +51,8 @@ import {
StepScheduling,
} from "./new-booking-form/steps";
type PriceModalMode = "submit" | "draft";
export default function NewBookingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -91,68 +96,61 @@ export default function NewBookingPage() {
);
}
const createMutation = useMutation({
mutationFn: async (payload: CreateBookingPayload) => {
const booking = await api.bookings.create.call(payload);
const persistAndPriceMutation = useMutation({
mutationFn: async ({
payload,
mode,
existingBookingId,
}: {
payload: CreateBookingPayload;
mode: PriceModalMode;
existingBookingId: string | null;
}) => {
const documents = (form.getValues("documents") ?? {}) as BookingDocuments;
let bookingId = existingBookingId;
// Documents can't ride along with creation — upload them against the
// new booking id once it exists. Optional here; the booking detail page
// remains the catch-all for any docs the user skips.
const documents = form.getValues("documents") ?? {};
const hasDocuments = Object.values(documents).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
);
if (hasDocuments) {
await api.bookings.uploadDocuments.call({
id: booking.id,
files: documents,
});
if (bookingId) {
await api.bookings.update.call({ id: bookingId, dto: payload, documents });
} else {
const booking = await api.bookings.create.call({ payload, documents });
bookingId = booking.id;
}
return booking;
const pricing = await api.bookings.generatePrice.call({ id: bookingId });
return { bookingId, pricing, mode };
},
onSuccess: (booking) => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${booking.id}`);
},
});
const createAndPriceMutation = useMutation({
mutationFn: async (payload: CreateBookingPayload) => {
const booking = await api.bookings.create.call(payload);
const documents = form.getValues("documents") ?? {};
const hasDocs = Object.values(documents).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
);
if (hasDocs) {
await api.bookings.uploadDocuments.call({
id: booking.id,
files: documents,
});
}
const pricing = await api.bookings.generatePrice.call({ id: booking.id });
return { bookingId: booking.id, pricing };
},
onSuccess: ({ bookingId, pricing }) => {
onSuccess: ({ bookingId, pricing, mode }) => {
setPriceBookingId(bookingId);
setPricingData(pricing);
setPricingPhase("ready");
setPriceModalMode(mode);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
onError: () => {
setPricingPhase("idle");
},
});
const confirmMutation = useMutation({
mutationFn: async () => {
if (!priceBookingId) throw new Error("No booking to confirm");
await api.bookings.submit.call({ id: priceBookingId });
return api.bookings.submit.call({ id: priceBookingId });
},
onSuccess: (result) => {
if (result.priceChanged) {
setPriceChangeResult(result);
return;
}
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${priceBookingId}`);
},
});
const confirmSubmitMutation = useMutation({
mutationFn: async () => {
if (!priceBookingId) throw new Error("No booking to confirm");
return api.bookings.confirmSubmit.call({ id: priceBookingId });
},
onSuccess: () => {
setPriceChangeResult(null);
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${priceBookingId}`);
},
@@ -188,22 +186,15 @@ export default function NewBookingPage() {
return route;
}, [originYard, destinationYard]);
const docValues = form.watch("documents") ?? {};
const hasDocuments = useMemo(
() =>
Object.values(docValues).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
),
[docValues],
);
const [pricingPhase, setPricingPhase] = useState<
"idle" | "generating" | "ready"
>("idle");
const [pricingData, setPricingData] = useState<GeneratePriceResponse | 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 [cancelReason, setCancelReason] = useState("");
@@ -211,6 +202,14 @@ export default function NewBookingPage() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
if (!valid) return;
if (step === 6 && !hasAllRequiredDocuments(form.getValues("documents"))) {
form.setError("documents", {
type: "manual",
message: "Upload all four required documents.",
});
return;
}
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
}
@@ -265,7 +264,9 @@ export default function NewBookingPage() {
)!;
return {
scheduledDate: new Date().toISOString(),
scheduledDate: data.scheduledDate
? new Date(data.scheduledDate).toISOString()
: new Date().toISOString(),
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: data.serviceTypeId,
@@ -273,7 +274,7 @@ export default function NewBookingPage() {
data.equipmentReturn === "with_return"
? "WITH_RETURN"
: "WITHOUT_RETURN",
paymentCurrency: "USD",
paymentCurrency: data.paymentCurrency,
originYardId: data.originYard,
destinationYardId: data.destinationYard,
tradeDirection: direction!,
@@ -313,25 +314,55 @@ export default function NewBookingPage() {
};
}
const handleDraftSubmit = form.handleSubmit((data) => {
const handleSaveDraft = form.handleSubmit((data) => {
try {
const apiPayload = buildApiPayload(data);
createMutation.mutate(apiPayload);
persistAndPriceMutation.mutate({
payload: apiPayload,
mode: "draft",
existingBookingId: priceBookingId,
});
} catch {
// validation error already handled
}
});
const handleGeneratePrice = form.handleSubmit((data) => {
const handleSubmitBooking = form.handleSubmit((data) => {
if (!hasAllRequiredDocuments(data.documents)) {
form.setError("documents", {
type: "manual",
message: "Upload all four required documents.",
});
setStep(6);
return;
}
try {
const apiPayload = buildApiPayload(data);
setPricingPhase("generating");
createAndPriceMutation.mutate(apiPayload);
persistAndPriceMutation.mutate({
payload: apiPayload,
mode: "submit",
existingBookingId: priceBookingId,
});
} catch {
// validation error already handled
}
});
const isPricing =
persistAndPriceMutation.isPending || confirmMutation.isPending;
function closePriceModal() {
setPriceModalMode(null);
if (priceModalMode === "draft" && priceBookingId) {
navigate(`/bookings/${priceBookingId}`);
}
}
function handleDraftModalOk() {
setPriceModalMode(null);
if (priceBookingId) navigate(`/bookings/${priceBookingId}`);
}
return (
<Box
style={{
@@ -376,14 +407,14 @@ export default function NewBookingPage() {
id="new-booking-form"
className="flex flex-col"
style={{ flex: 1 }}
onSubmit={handleDraftSubmit}
onSubmit={(e) => e.preventDefault()}
>
<Box flex={1} p="24px">
<Box mb="lg">
<StepIndicator step={step} />
</Box>
{createMutation.isError && (
{persistAndPriceMutation.isError && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
@@ -391,29 +422,11 @@ export default function NewBookingPage() {
mb="lg"
>
<Text size="sm" fw={600}>
Failed to save draft
Failed to save booking or generate price
</Text>
<Text size="sm" mt={4} c="red.7">
{createMutation.error instanceof Error
? createMutation.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
{persistAndPriceMutation.error instanceof Error
? persistAndPriceMutation.error.message
: "An unexpected error occurred. Please try again."}
</Text>
</Alert>
@@ -450,17 +463,16 @@ export default function NewBookingPage() {
setStep={setStep}
direction={direction!}
referenceData={referenceData}
pricingPhase={pricingPhase}
pricingData={pricingData}
onConfirm={() => confirmMutation.mutate()}
onContinueLater={
priceBookingId
? () => navigate(`/bookings/${priceBookingId}`)
: undefined
onSaveDraft={handleSaveDraft}
onSubmit={handleSubmitBooking}
saveDraftPending={
persistAndPriceMutation.isPending &&
persistAndPriceMutation.variables?.mode === "draft"
}
submitPending={
persistAndPriceMutation.isPending &&
persistAndPriceMutation.variables?.mode === "submit"
}
onAbort={() => setCancelDialogOpen(true)}
confirmPending={confirmMutation.isPending}
abortPending={abortMutation.isPending}
/>
)}
</Box>
@@ -501,51 +513,151 @@ export default function NewBookingPage() {
>
Continue
</Button>
) : pricingPhase === "idle" ? (
<Group>
<Button
type="submit"
form="new-booking-form"
variant={hasDocuments ? "outline" : "filled"}
color="edr-green"
radius="md"
loading={createMutation.isPending}
leftSection={
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
type="button"
color="edr-green"
radius="md"
leftSection={<Send size={16} />}
onClick={handleSubmitBooking}
loading={isPricing}
>
Submit
</Button>
) : null}
)}
</Group>
</Box>
</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
opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}

View File

@@ -2,84 +2,88 @@ import { Check } from "lucide-react";
import { Fragment } from "react";
import { STEPS } from "./schema";
const GREEN = "var(--mantine-color-edr-green-5)";
const GREEN_DEEP = "var(--mantine-color-edr-green-7)";
const BORDER = "var(--mantine-color-edr-border-0)";
const MUTED = "var(--mantine-color-edr-muted-0)";
const INK = "var(--mantine-color-edr-text-0)";
export function StepIndicator({ step }: { step: number }) {
return (
<div className="flex items-center">
{STEPS.map((item, index) => (
<Fragment key={item.id}>
<div className="flex shrink-0 flex-col items-center gap-1">
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
fontWeight: 600,
flexShrink: 0,
transition: "all 0.2s",
...(step > item.id
? {
backgroundColor: "var(--mantine-color-edr-green-5)",
color: "#fff",
boxShadow: "0 2px 8px rgba(14,163,113,0.4)",
}
: step === item.id
<div className="flex items-start">
{STEPS.map((item, index) => {
const done = step > item.id;
const active = step === item.id;
return (
<Fragment key={item.id}>
<div className="flex shrink-0 flex-col items-center gap-2" style={{ minWidth: 34 }}>
<div
style={{
width: 34,
height: 34,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 13,
fontWeight: 700,
flexShrink: 0,
transition: "all 0.2s",
...(done
? {
border: "2.5px solid var(--mantine-color-edr-green-5)",
color: "var(--mantine-color-edr-green-7)",
backgroundColor: "#fff",
boxShadow: "0 0 0 3px rgba(14,163,113,0.12)",
background: "linear-gradient(135deg, #12B981, #0A8A5F)",
color: "#fff",
boxShadow: "0 4px 10px rgba(14,163,113,0.35)",
}
: {
backgroundColor: "#fff",
color: "var(--mantine-color-edr-muted-0)",
border: "2px solid var(--mantine-color-edr-border-0)",
}),
}}
>
{step > item.id ? (
<Check style={{ width: 13, height: 13 }} />
) : (
item.id
)}
: active
? {
border: `2.5px solid ${GREEN}`,
color: GREEN_DEEP,
backgroundColor: "#fff",
boxShadow: "0 0 0 4px rgba(14,163,113,0.12)",
}
: {
backgroundColor: "#fff",
color: MUTED,
border: `2px solid ${BORDER}`,
}),
}}
>
{done ? <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>
<span
style={{
fontSize: 10,
fontWeight: 500,
display: "none",
transition: "color 0.2s",
color:
step >= item.id
? "var(--mantine-color-edr-text-0)"
: "var(--mantine-color-edr-muted-0)",
}}
className="lg:!block"
>
{item.short}
</span>
</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>
))}
{index < STEPS.length - 1 && (
<div
style={{
flex: 1,
height: 3,
borderRadius: 999,
margin: "16px 8px 0",
transition: "background 0.3s",
background: done
? "linear-gradient(90deg, #0A8A5F, #12B981)"
: BORDER,
}}
/>
)}
</Fragment>
);
})}
</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
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts so anything attached
* here shows up as "Uploaded" on the booking detail page. All optional in this
* flow — the detail page remains the catch-all for uploading them later.
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts.
*/
const DOC_SETTING_TS = "2024-01-01T00:00:00.000Z";
@@ -34,7 +32,7 @@ function docField(
fileKey,
fileLabel,
helpText: null,
isRequired: false,
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
@@ -51,7 +49,7 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
code: "booking_documents",
label: "Booking Documents",
description:
"Attach your shipment documents now, or skip and upload them later from the booking page.",
"Attach all four required shipment documents before submitting your booking.",
entity: "booking",
fields: [
docField("commercial_invoice", "Commercial Invoice", 1),
@@ -63,11 +61,32 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
export type BookingDocuments = Record<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
.object({
contractType: z.enum(["new", "renewal"], "Select a contract type."),
previousContractRef: z.string(),
serviceTypeId: z.string("Select a service type."),
paymentCurrency: z.enum(PAYMENT_CURRENCIES, "Select a payment currency."),
firstMile: z
.object({
@@ -202,6 +221,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
previousContractRef: "",
serviceTypeId: "",
paymentCurrency: "USD",
firstMile: {
enabled: false,
pickUpAddress: "",
@@ -232,6 +252,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
1: ["contractType", "previousContractRef"],
2: [
"serviceTypeId",
"paymentCurrency",
"firstMile",
"lastMile",
"equipmentReturn",

View File

@@ -1,48 +1,167 @@
import { Alert, Combobox, Input, InputBase, Select, Text, Title, useCombobox } from "@mantine/core";
import { AlertTriangle, Check, CheckCircle2, Info, Loader, XCircle } from "lucide-react";
import {
Alert,
Box,
Combobox,
Group,
Input,
InputBase,
Paper,
Select,
Text,
Title,
useCombobox,
} from "@mantine/core";
import {
AlertTriangle,
Check,
CheckCircle2,
Info,
Loader,
XCircle,
} from "lucide-react";
import type { ReactNode } from "react";
import { useMemo } from "react";
import type { ControllerRenderProps, FieldError as RhfFieldError } from "react-hook-form";
import type {
ControllerRenderProps,
FieldError as RhfFieldError,
} from "react-hook-form";
import type { BookingFormInputValues } from "./schema";
// Brand tokens (kept local so the form reads consistently with the booking
// detail page and the scheduling step).
const INK = "#10202F";
const MUTED = "#6B7C8E";
const GREEN = "#0EA371";
const GREEN_DARK = "#0A6F4D";
const BORDER = "#E6ECF2";
export function OptionFieldError({ error }: { error?: { message?: string } }) {
if (!error?.message) return null;
return (
<Text size="xs" c="red" mt={4}>
<Text size="xs" c="red" mt={6}>
{error.message}
</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({
selected,
onClick,
disabled,
icon,
iconBg = "#ECF6F1",
iconColor = GREEN_DARK,
title,
description,
children,
}: {
selected: boolean;
onClick?: () => void;
disabled?: boolean;
children: ReactNode;
icon?: ReactNode;
iconBg?: string;
iconColor?: string;
title?: ReactNode;
description?: ReactNode;
children?: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`relative w-full rounded-xl border-2 p-4 text-left transition-all duration-150 ${
disabled
? "cursor-not-allowed border-gray-200 bg-gray-100 opacity-60"
style={{
position: "relative",
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
? "border-emerald-500 bg-emerald-50 shadow-sm shadow-emerald-500/20"
: "border-gray-200 bg-white hover:border-emerald-300 hover:shadow-sm"
}`}
? "linear-gradient(135deg, #F4FBF7 0%, #FFFFFF 70%)"
: "#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 && (
<span className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500">
<Check className="h-3 w-3 text-white" />
<span
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>
)}
{/* 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}
</button>
);
@@ -63,7 +182,7 @@ export function AlertBox({
};
const { color, icon } = map[tone];
return (
<Alert color={color} icon={icon} radius="md" fz="sm">
<Alert color={color} icon={icon} radius="lg" fz="sm">
{children}
</Alert>
);
@@ -71,31 +190,89 @@ export function AlertBox({
export function StepLabel({ children }: { children: ReactNode }) {
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}
</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({
title,
description,
icon,
}: {
title: string;
description: string;
icon?: ReactNode;
}) {
return (
<div>
<Title order={3} className="tracking-tight">
{title}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{description}
</Text>
</div>
<Group gap={14} align="flex-start" wrap="nowrap" mb={22}>
{icon && (
<Box
style={{
flexShrink: 0,
width: 44,
height: 44,
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({
field,
error,
@@ -103,6 +280,7 @@ export function SelectField({
placeholder,
disabled,
data,
leftSection,
}: {
field: ControllerRenderProps<BookingFormInputValues>;
error?: RhfFieldError;
@@ -110,6 +288,7 @@ export function SelectField({
placeholder: string;
disabled?: boolean;
data: string[] | { value: string; label: string }[];
leftSection?: ReactNode;
}) {
return (
<Select
@@ -122,6 +301,11 @@ export function SelectField({
onBlur={field.onBlur}
error={error?.message}
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 (
<Input.Wrapper label={label} error={error?.message}>
<Combobox store={combobox} disabled={disabled}>
<Input.Wrapper label={label} error={error?.message} styles={fieldStyles}>
<Combobox store={combobox} disabled={disabled} shadow="md" radius="md" withinPortal>
<Combobox.Target>
<InputBase
placeholder={placeholder}
disabled={disabled}
radius={10}
styles={fieldStyles}
value={searchQuery || selectedLabel}
onChange={(e) => {
onSearchChange(e.currentTarget.value);

View File

@@ -1,6 +1,6 @@
import { Box, Group, Text } from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common";
import { CheckCircle2 } from "lucide-react";
import { CheckCircle2, FileUp } from "lucide-react";
import { Controller, type UseFormReturn } from "react-hook-form";
import {
@@ -9,7 +9,7 @@ import {
type BookingDocuments,
type BookingFormValues,
} from "./schema";
import { StepHeader } from "./shared";
import { StepCard, StepHeader } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -30,10 +30,11 @@ export function StepDocuments({ form }: { form: BookingForm }) {
const total = BOOKING_DOCS_SETTING.fields.length;
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<FileUp size={22} />}
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
@@ -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
opened={!!selectedDayForModal}
onClose={() => setSelectedDayForModal(null)}
title={selectedDayForModal ? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEE, MMM d yyyy") : ""}
centered
size="sm"
styles={{
header: { borderBottom: `1px solid ${theme.colors["edr-border"][0]}` },
body: { padding: 24 },
}}
size={520}
radius={18}
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.5, blur: 3 }}
>
<Stack gap={12}>
<Text fz={13} c="edr-muted" fw={500}>
Choose a departure time
{/* Header */}
<Box
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>
{selectedDayForModal?.schedules.map((schedule) => (
<Button
key={schedule.id}
variant="outline"
fullWidth
onClick={() => handleSelectScheduleFromModal(schedule.id)}
style={{ height: 64, justifyContent: "flex-start" }}
styles={{
inner: { justifyContent: "flex-start" },
root: {
borderColor: theme.colors["edr-border"][0],
<Text fz={12.5} c="#A9BBCB" mt={2}>
{selectedDayForModal?.schedules.length ?? 0} train
{(selectedDayForModal?.schedules.length ?? 0) !== 1 ? "s" : ""} on{" "}
{originName} {destinationName}
</Text>
</Box>
{/* Schedule list */}
<Stack gap={12} p={24}>
{selectedDayForModal?.schedules.map((schedule) => {
const remaining = schedule.remainingWagons;
const max = schedule.maxWagons || 1;
const pct = Math.max(0, Math.min(100, Math.round((remaining / max) * 100)));
const isSelected = schedule.id === selectedScheduleId;
return (
<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",
"&:hover": {
borderColor: theme.colors["edr-green"][5],
backgroundColor: theme.colors["edr-soft"][0],
},
},
}}
>
<Group gap={16} w="100%">
<Box
style={{
width: 48,
height: 48,
borderRadius: theme.radius.md,
backgroundColor: theme.colors["edr-soft"][0],
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Train size={24} color={theme.colors["edr-green"][5]} />
</Box>
<Stack gap={3} style={{ flex: 1, alignItems: "flex-start" }}>
<Text fw={700} fz={18} c="edr-text.0">
{format(new Date(schedule.scheduleDate), "HH:mm")}
</Text>
{schedule.trainNumber && (
<Text fz={12} c="edr-muted">
Train {schedule.trainNumber}
</Text>
)}
</Stack>
</Group>
</Button>
))}
}}
>
<Group gap={14} wrap="nowrap" align="center">
<Box
style={{
width: 50,
height: 50,
flexShrink: 0,
borderRadius: 13,
background: "linear-gradient(135deg, #ECF6F1, #E0F1E9)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Train size={24} color={theme.colors["edr-green"][6]} />
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group gap={8} align="baseline">
<Text fw={800} fz={18} c="edr-text.0">
{format(new Date(schedule.scheduleDate), "HH:mm")}
</Text>
<Text fz={12.5} c="edr-muted">
{schedule.trainNumber
? `Train ${schedule.trainNumber}`
: `#${schedule.id.slice(0, 6)}`}
</Text>
</Group>
{/* capacity bar */}
<Box mt={8}>
<Group justify="space-between" mb={4}>
<Text fz={11} fw={600} c="edr-muted">
{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>
</Modal>
</Group>
@@ -561,36 +644,42 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
)}
</Group>
{/* Schedule times */}
{/* Availability marker — a dot + count, never the schedule list itself. */}
{d.hasSchedule && (
<Stack gap={3} style={{ flex: 1, overflow: "hidden", minWidth: 0 }}>
{d.schedules.slice(0, 2).map((s) => (
<Group key={s.id} gap={6} align="center" style={{ minWidth: 0 }}>
<Box
style={{
width: 4,
height: 4,
borderRadius: "50%",
backgroundColor: theme.colors["edr-green"][5],
flexShrink: 0,
}}
/>
<Text
fz={12}
fw={700}
c="edr-text.0"
style={{ flex: 1, minWidth: 0 }}
>
{format(new Date(s.scheduleDate), "HH:mm")}
</Text>
</Group>
))}
{d.schedules.length > 2 && (
<Text fz={11} fw={600} c="edr-green.7" style={{ paddingTop: 2 }}>
+{d.schedules.length - 2} more
<Box style={{ flex: 1, display: "flex", alignItems: "flex-end" }}>
<Group
gap={6}
align="center"
wrap="nowrap"
px={9}
py={4}
style={{
borderRadius: 999,
backgroundColor: d.isSelectedDate
? "#fff"
: theme.colors["edr-soft"][0],
border: `1px solid ${
d.isSelectedDate
? theme.colors["edr-green"][2]
: "transparent"
}`,
}}
>
<Box
style={{
width: 7,
height: 7,
borderRadius: "50%",
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>
)}
</Stack>
</Group>
</Box>
)}
</Box>
);

View File

@@ -10,8 +10,11 @@ import {
AsyncComboboxField,
OptionCard,
OptionFieldError,
StepCard,
StepHeader,
} from "./shared";
import { FileSignature } from "lucide-react";
import { Stack } from "@mantine/core";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -52,7 +55,6 @@ export function Step1ContractType({
);
const contractOptions = useMemo<PreviousContractOption[]>(() => {
console.log("Bookings data:", bookings);
if (!bookings) return [];
return bookings?.items
@@ -182,10 +184,11 @@ export function Step1ContractType({
};
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<FileSignature size={22} />}
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
@@ -193,40 +196,33 @@ export function Step1ContractType({
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2">
<OptionCard
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={() => {
field.onChange("new");
form.clearErrors(["contractType", "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
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={() => {
field.onChange("renewal");
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>
<OptionFieldError error={fieldState.error} />
</div>
@@ -234,7 +230,7 @@ export function Step1ContractType({
/>
{contractType === "renewal" && (
<div className="space-y-3 pt-1">
<Stack gap={12} mt={22}>
{error && (
<AlertBox tone="error">
Failed to load previous contracts. Please try again later.
@@ -263,8 +259,8 @@ export function Step1ContractType({
details will be pre-filled.
</AlertBox>
)}
</div>
</Stack>
)}
</div>
</StepCard>
);
}

View File

@@ -1,9 +1,18 @@
import { Switch, TextInput } from "@mantine/core";
import { FileText, Train, Truck } from "lucide-react";
import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
import type { ReactNode } from "react";
import { FileText, Layers, Train, Truck } from "lucide-react";
import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { BookingFormInputValues, type BookingFormValues } from "./schema";
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
import {
fieldStyles,
OptionCard,
OptionFieldError,
StepCard,
StepHeader,
StepLabel,
} from "./shared";
import { PaymentCurrencyField } from "./payment-currency-field";
import type { Freight } from "@edr/types";
@@ -61,10 +70,11 @@ export function Step2ServiceType({
const showServiceSections =
includesCustoms || includesFirstMile || includesLastMile;
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<Layers size={22} />}
title="Service Type"
description="Select the service combination and configure trucking options."
description="Choose the service combination, then configure your trucking options."
/>
<Controller
@@ -72,211 +82,225 @@ export function Step2ServiceType({
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2">
{referenceData?.service
.filter((s) => s.canBeBookedAlone)
.map((s) => {
return (
<OptionCard
selected={field.value === s.id}
onClick={() => field.onChange(s.id)}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-100">
<Train className="h-4 w-4 text-indigo-600" />
</div>
<p className="font-semibold">{s.serviceName}</p>
<p className="mt-0.5 text-xs text-gray-500">
{s.description}
</p>
</OptionCard>
);
})}
.map((s) => (
<OptionCard
key={s.id}
selected={field.value === s.id}
onClick={() => field.onChange(s.id)}
icon={<Train className="h-5 w-5" />}
iconBg="#EEF0FB"
iconColor="#4F46E5"
title={s.serviceName}
description={s.description}
/>
))}
</div>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
<PaymentCurrencyField control={form.control} />
{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 */}
{includesFirstMile && (
<div className="p-4">
<Controller
name="firstMile.enabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">
First Mile Pick-up
</p>
<p className="mt-0.5 text-xs text-gray-500">
Truck pick-up from your premises (Door to Port) to the
origin rail yard.
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => {
const value = e.currentTarget.checked;
field.onChange(value);
if (!value) {
form.setValue("firstMile.pickUpAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
color="edr-green"
/>
</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"
<Controller
name="firstMile.enabled"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<Truck size={18} />}
title="First Mile — Pick-up"
description="Truck pick-up from your premises (Door to Port) to the origin rail yard."
checked={field.value ?? false}
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("firstMile.pickUpAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
>
{firstMileEnabled && (
<Controller
name="firstMile.pickUpAddress"
control={form.control}
render={({ field: af, fieldState }) => (
<TextInput
{...af}
mt="sm"
placeholder="Pick-up address *"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
/>
</ServiceToggle>
)}
</div>
/>
)}
{/* Last Mile */}
{includesLastMile && (
<div className="p-4">
<Controller
name="lastMile.enabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">
Last Mile Delivery
</p>
<p className="mt-0.5 text-xs text-gray-500">
Truck delivery from the destination rail yard to the
final address (Port to Door).
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => {
const value = e.currentTarget.checked;
field.onChange(value);
if (!value) {
form.setValue("lastMile.deliveryAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
form.setValue("equipmentReturn", "with_return", {
shouldDirty: true,
});
}
}}
color="edr-green"
/>
</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"
<Controller
name="lastMile.enabled"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<Truck size={18} />}
title="Last Mile — Delivery"
description="Truck delivery from the destination rail yard to the final address (Port to Door)."
checked={field.value ?? false}
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("lastMile.deliveryAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
form.setValue("equipmentReturn", "with_return", {
shouldDirty: true,
});
}
}}
>
{lastMileEnabled && (
<Controller
name="lastMile.deliveryAddress"
control={form.control}
render={({ field: af, fieldState }) => (
<TextInput
{...af}
mt="sm"
placeholder="Delivery address *"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
/>
</ServiceToggle>
)}
</div>
/>
)}
{/* Equipment Return */}
{includesLastMile && lastMileEnabled && (
<div className="p-4">
<Controller
name="equipmentReturn"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium">Equipment Return</p>
<p className="mt-0.5 text-xs text-gray-500">
{field.value === "with_return"
? "Container returned to EDR after unloading."
: "Container retained by the customer after delivery."}
</p>
</div>
<Switch
checked={field.value === "with_return"}
onChange={(e) => {
field.onChange(
e.currentTarget.checked
? "with_return"
: "without_return",
);
}}
color="edr-green"
/>
</div>
)}
/>
</div>
<Controller
name="equipmentReturn"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<Truck size={18} />}
title="Equipment Return"
description={
field.value === "with_return"
? "Container returned to EDR after unloading."
: "Container retained by the customer after delivery."
}
checked={field.value === "with_return"}
onChange={(v) =>
field.onChange(v ? "with_return" : "without_return")
}
/>
)}
/>
)}
{/* Customs Clearing */}
{includesCustoms && (
<div className="p-4">
<Controller
name="customsClearingEnabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">
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>
<Controller
name="customsClearingEnabled"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<FileText size={18} />}
title="Customs Clearing Service"
description="EDR handles customs documentation and clearance on your behalf."
checked={field.value ?? false}
onChange={(v) => field.onChange(v)}
/>
)}
/>
)}
</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 { Divider, Skeleton, Stack, Switch } from "@mantine/core";
import { Flame, MapPin, Snowflake } from "lucide-react";
import { Box, Divider, Group, Skeleton, Stack, Switch, Text } from "@mantine/core";
import { Flame, MapPin, Route as RouteIcon, Snowflake } from "lucide-react";
import { useEffect, useMemo } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import {
@@ -8,7 +8,7 @@ import {
type BookingFormValues,
getRouteDirection,
} from "./schema";
import { SelectField, StepHeader, StepLabel } from "./shared";
import { SelectField, StepCard, StepHeader, StepLabel } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -63,7 +63,6 @@ export function Step4Route({
const origin = referenceData?.yard.find((y) => y.id === originYard);
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
const direction = getRouteDirection(origin, dest);
console.log({ yardOptions, originYard, destinationYard, direction, origin, dest });
const directionStyle: Record<string, string> = {
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
@@ -85,10 +84,11 @@ export function Step4Route({
const stationSelectDisabled = yardOptions.length === 0;
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<RouteIcon size={22} />}
title="Route"
description="Select the origin and destination yards."
description="Choose the origin and destination yards for your shipment."
/>
{isLoading ? (
@@ -96,7 +96,7 @@ export function Step4Route({
) : (
<div className="space-y-3">
<StepLabel>Route</StepLabel>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2">
<Controller
name="originYard"
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
name="isHazardous"
control={form.control}
render={({ field }) => (
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<Flame className="h-4 w-4 shrink-0 text-red-500" />
<div>
<p className="text-sm font-medium">Hazardous Material</p>
<p className="text-xs text-gray-500">
Applies a Hazard Surcharge to the final bill.
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
<ToggleRow
icon={<Flame size={18} />}
iconBg="#FBEAE7"
iconColor="#C0392B"
title="Hazardous Material"
description="Applies a hazard surcharge to the final bill."
checked={field.value}
onChange={(v) => field.onChange(v)}
/>
)}
/>
<Controller
name="isRefrigerated"
control={form.control}
render={({ field }) => (
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<Snowflake className="h-4 w-4 shrink-0 text-sky-500" />
<div>
<p className="text-sm font-medium">Refrigerated Cargo</p>
<p className="text-xs text-gray-500">
Temperature-controlled transport applies a Refrigerator
Surcharge.
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
<ToggleRow
icon={<Snowflake size={18} />}
iconBg="#E9F0F8"
iconColor="#2E5B96"
title="Refrigerated Cargo"
description="Temperature-controlled transport applies a refrigeration surcharge."
checked={field.value}
onChange={(v) => field.onChange(v)}
/>
)}
/>
</div>
</div>
</Stack>
</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,
Button,
Skeleton,
InputLabel,
Text,
TextInput,
} from "@mantine/core";
@@ -17,9 +16,11 @@ import {
} from "./schema";
import {
AlertBox,
fieldStyles,
OptionCard,
OptionFieldError,
SelectField,
StepCard,
StepHeader,
StepLabel,
} from "./shared";
@@ -116,70 +117,66 @@ export function Step5CargoDetails({
if (isLoading) {
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
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" />
<div className="grid gap-3 sm:grid-cols-2">
<Skeleton height={96} radius="xl" />
<Skeleton height={96} radius="xl" />
<div className="grid gap-4 sm:grid-cols-2">
<Skeleton height={96} radius="lg" />
<Skeleton height={96} radius="lg" />
</div>
<Skeleton height={40} radius="md" />
<Skeleton height={40} w="33%" radius="md" />
<Skeleton height={44} radius="md" />
<Skeleton height={44} w="33%" radius="md" />
</div>
</div>
</StepCard>
);
}
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
/>
{/* Cargo Type */}
<div className="space-y-3">
<InputLabel>Cargo Type *</InputLabel>
<StepLabel>Cargo Type *</StepLabel>
<Controller
name="cargoType"
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2">
<OptionCard
selected={cargoType === "container"}
icon={<Package className="h-5 w-5" />}
iconBg="#ECF6F1"
iconColor="#0A6F4D"
title="Containerized"
description="Pre-packed containerized cargo (20ft / 40ft)."
onClick={() => {
field.onChange("container");
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
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={() => {
field.onChange("bulk");
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>
<OptionFieldError error={fieldState.error} />
</div>
@@ -201,7 +198,8 @@ export function Step5CargoDetails({
placeholder="0.00"
leftSection={<Weight className="h-4 w-4" />}
error={fieldState.error?.message}
radius="md"
radius={10}
styles={fieldStyles}
min={0}
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 {
Badge,
Box,
Button,
Card,
Divider,
Group,
Loader,
SimpleGrid,
Paper,
Stack,
Table,
Text,
Textarea,
} from "@mantine/core";
import { Check, Send, XCircle, FileText, Route, Package, Truck } from "lucide-react";
import { format } from "date-fns";
import {
Calendar,
CheckCircle2,
Circle,
ClipboardCheck,
FileText,
Package,
Pencil,
Route,
Send,
Truck,
} from "lucide-react";
import type { Freight } from "@/types";
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
import {
BookingFormInputValues,
BOOKING_DOCS_SETTING,
type BookingDocuments,
type BookingFormInputValues,
type BookingFormValues,
} from "./schema";
import { StepHeader } from "./shared";
import type { Freight } from "@/types";
import type { GeneratePriceResponse } from "@/services/bookings.service";
export const REVIEW_STEP_TARGETS = {
contract: 1,
service: 2,
route: 3,
cargo: 4,
schedule: 5,
documents: 6,
} as const;
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -28,113 +48,135 @@ type BookingForm = UseFormReturn<
BookingFormValues
>;
function OverviewSection({
icon,
title,
onEdit,
children,
}: {
icon: React.ReactNode;
title: string;
onEdit: () => void;
children: React.ReactNode;
}) {
return (
<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({
form,
setStep,
direction,
referenceData,
pricingPhase = "idle",
pricingData,
onConfirm,
onContinueLater,
onAbort,
confirmPending = false,
abortPending = false,
onSaveDraft,
onSubmit,
saveDraftPending = false,
submitPending = false,
}: {
form: BookingForm;
setStep: (step: number) => void;
direction: Freight.ScheduleTradeDirection;
referenceData?: Freight.BookingReferenceData;
pricingPhase?: "idle" | "generating" | "ready";
pricingData?: GeneratePriceResponse | null;
onConfirm?: () => void;
onContinueLater?: () => void;
onAbort?: () => void;
confirmPending?: boolean;
abortPending?: boolean;
onSaveDraft?: () => void;
onSubmit?: () => void;
saveDraftPending?: boolean;
submitPending?: boolean;
}) {
const values = form.watch();
const serviceType = referenceData?.service.find(
(s) => s.id === values.serviceTypeId,
);
function CompactRow({
label,
value,
target,
}: {
label: string;
value: string;
target: number;
}) {
return (
<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 =
values.cargoType === "container" && values.containers.length > 0
? values.containers
.filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`)
.join(", ")
.filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.containerType || c.type}`)
.join(", ")
: "";
const totalVgm =
values.cargoType === "container"
? values.containers.reduce(
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
0,
)
: 0;
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
0,
)
: Number(values.cargoWeight || 0);
const documents = (values.documents ?? {}) as BookingDocuments;
const docsAttached = BOOKING_DOCS_SETTING.fields.filter((f) => {
const value = documents[f.fileKey];
return Array.isArray(value) ? value.length > 0 : Boolean(value);
}).length;
const docsTotal = BOOKING_DOCS_SETTING.fields.length;
const allDocsReady = hasAllRequiredDocuments(documents);
const cargoValue = (() => {
if (values.cargoType === "container") return containerSummary;
if (values.cargoType === "container") return "Container freight";
if (!referenceData) return "";
const path = values.cargoTypePath ?? [];
const group = referenceData.cargo_type.find((g) => g.id === path[0]);
@@ -143,226 +185,320 @@ export function Step8Review({
return child ? `${group.name}${child.name}` : group.name;
})();
const originYardName = referenceData?.yard.find(
(y) => y.id === values.originYard,
)?.name ?? values.originYard;
const originYardName =
referenceData?.yard.find((y) => y.id === values.originYard)?.name ??
values.originYard;
const destinationYardName = referenceData?.yard.find(
(y) => y.id === values.destinationYard,
)?.name ?? values.destinationYard;
const destinationYardName =
referenceData?.yard.find((y) => y.id === values.destinationYard)?.name ??
values.destinationYard;
const scheduleLabel = values.scheduledDate
? format(new Date(values.scheduledDate), "EEEE, MMM d, yyyy")
: "—";
const directionLabel = direction
? direction.charAt(0) + direction.slice(1).toLowerCase()
: "—";
return (
<Stack gap="md">
<Stack gap="lg">
<StepHeader
icon={<ClipboardCheck size={22} />}
title="Review & Submit"
description="Confirm your contract request before sending it for EDR staff review."
description="Review your booking overview before sending it for EDR staff review."
/>
{/* Pricing Card - Prominent at top */}
{pricingPhase === "generating" && (
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2">
<Group justify="center" py="lg">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Generating price estimate
</Text>
</Group>
</Card>
)}
{pricingPhase === "ready" && pricingData && (
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2 bg-gradient-to-br from-white to-emerald-50/30">
<Stack gap="sm">
<Text size="sm" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
💳 Price Breakdown
</Text>
<Stack gap="xs">
{pricingData.lineItems.map((item) => (
<Group key={item.code} justify="space-between" py={2}>
<Text size="sm" c="dimmed">
{item.description}
</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>
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
{/* Left — booking summary */}
<Stack gap="md" className="min-w-0 flex-1">
<Paper
radius={20}
p="lg"
className="border border-emerald-100 bg-gradient-to-br from-white to-emerald-50/40"
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={4}>
<Text size="xs" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
Booking overview
</Text>
<Text fw={800} size="xl" c="#10202F">
{values.contractType === "new" ? "New Contract" : "Contract Renewal"}
</Text>
<Text size="sm" c="dimmed">
{serviceType?.name ?? "—"} · {originYardName} {destinationYardName}
</Text>
</Stack>
<Badge size="lg" variant="light" color="edr-green" radius="md">
{directionLabel}
</Badge>
</Group>
{pricingData.warnings.length > 0 && (
<Text size="xs" c="orange.7" mt="xs" p="xs" className="bg-orange-50 rounded">
{pricingData.warnings.join(", ")}
</Text>
</Paper>
<OverviewSection
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">
<Button
color="edr-green"
radius="md"
leftSection={<Check size={16} />}
onClick={onConfirm}
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
<DetailRow label="Service" value={serviceType?.name ?? ""} />
<DetailRow
label="Payment currency"
value={values.paymentCurrency ?? "USD"}
/>
<Button
type="button"
onClick={() => setStep(5)}
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
variant="subtle"
size="compact-xs"
color="gray"
mt={4}
onClick={() => setStep(REVIEW_STEP_TARGETS.service)}
>
Edit
</button>
</div>
</CompactCard>
</SimpleGrid>
Edit service options
</Button>
</OverviewSection>
{/* Notes */}
<Controller
name="notes"
control={form.control}
render={({ field }) => (
<Textarea
{...field}
id="notes"
label="Additional Notes"
placeholder="Any special instructions or notes for EDR operations…"
rows={2}
radius="md"
size="sm"
<OverviewSection
icon={<Route size={18} />}
title="Route"
onEdit={() => setStep(REVIEW_STEP_TARGETS.route)}
>
<DetailRow
label="Corridor"
value={`${originYardName}${destinationYardName}`}
/>
<DetailRow label="Trade direction" value={directionLabel} />
<DetailRow label="Shipping line" value={values.shippingLine || "—"} />
<DetailRow
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>
);
}

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

View File

@@ -130,6 +130,13 @@ FAYDA_SESSION_TTL_MINUTES=10
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) -------------------------------------------------------
# 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

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")
}
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 {
ETHIOPIA
DJIBOUTI
@@ -364,7 +374,8 @@ model TrainSchedule {
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id])
coachAssignments CoachAssignment[]
bookings Booking[]
bookings Booking[] @relation("OutboundSchedule")
returnBookings Booking[] @relation("ReturnSchedule")
stopTimes TripStopTime[]
liveStatus TripLiveStatus?
menuItems MenuItem[]
@@ -511,6 +522,19 @@ model Booking {
returnDestinationStationId String?
returnHoldId 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?
contactPhone String?
userAgent String?
@@ -520,7 +544,8 @@ model Booking {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
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[]
paymentIntent PaymentIntent?
ticket Ticket?
@@ -539,6 +564,8 @@ model BookingSeat {
id String @id @default(uuid())
bookingId 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
dateOfBirth DateTime?
passengerCategory PassengerCategory @default(ADULT)
@@ -1159,6 +1186,7 @@ model GateValidationLog {
ticketId String
validatorId String
gateId String?
leg String? // 'OUTBOUND' | 'RETURN' — for round-trip tickets
status String
reason String?
validatedAt DateTime @default(now())

View File

@@ -34,19 +34,25 @@ async function bootstrap() {
## Overview
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
## 🆕 Latest Updates
- **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
- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories
- **Multi-Currency Display:** Bookings track display currency and converted amounts
- **Ticket Lifecycle:** Tickets now include validatedAt and boardedAt timestamps for complete audit trail
## Latest Updates
- **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).
- **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.
- **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).
- **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).
- **Auto No-Show Detection:** Cron marks OUTBOUND_ONLY 30 min after return departure when return leg was never scanned.
- **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
### 🎫 Booking Lifecycle
### Booking Lifecycle
- 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)
- Passenger information collection with verification
- 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)
- Cancel bookings with automatic refunds
- 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
- 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
- **NEW:** Booking type tracking (ONE_WAY vs ROUND_TRIP)
- **NEW:** Display currency and converted pricing per booking
- Booking type field: ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT
- 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
1. **Ethiopian Nationals:**
- Automatic Fayda verification for adults (5 years)
### Passenger Verification
1. Ethiopian Nationals:
- Automatic Fayda verification for adults (5+ years)
- Real-time national ID verification via government database
- Retrieves verified passenger data (name, DOB, gender)
- National IDs not stored (policy compliant)
2. **International Passengers:**
2. International Passengers:
- Passport information collection
- Manual verification for Djiboutian and other nationals
- No government database verification required
### 💰 Age-Based Pricing
- **ADULT** (≥5 years): Pay 100% of base fare
- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100%
### Age-Based Pricing
- ADULT (5+ years): Pay 100% of base fare
- CHILD (under 5): First child travels FREE, subsequent children pay 100%
- Automatic age calculation from date of birth
- Example: 2 adults + 3 children = 4× base fare (first child free)
- **NEW:** Premium charges and insurance fees per seat class
- **NEW:** Transparent fee breakdown in pricing calculations
- Example: 2 adults + 3 children = 4x base fare (first child free)
- NEW: Premium charges and insurance fees per seat class
- NEW: Transparent fee breakdown in pricing calculations
### 💳 Payment Integration
1. **Ethiopian Payment Methods:**
- **Telebirr** - Ethiopia's leading mobile money
- **CBE Birr** - Commercial Bank of Ethiopia
### Payment Integration
1. Ethiopian Payment Methods: Telebirr, CBE Birr
2. Djiboutian Payment Methods: Waafi
3. International Payment Methods: Card, Wallet
2. **Djiboutian Payment Methods:**
- **Waafi** - Djibouti's mobile money service
3. **International Payment Methods:**
- **Card** - International card payments (Visa, Mastercard)
- **Wallet** - Internal wallet system
### 🪑 Seat Management
### Seat Management
- Real-time seat availability by coach and class
- Seat holds with 15-minute expiry
- Auto-assign seats with contiguous algorithm
- Seat blocking for maintenance
- Coach-level seat maps (ordered by sequence)
- 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
- PDF ticket generation
- Gate validation with audit logs
- Offline validation support
- Multi-passenger tickets
- **NEW:** Ticket lifecycle tracking (validatedAt, boardedAt timestamps)
- **NEW:** Complete audit trail for compliance and reporting
- NEW: Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps)
- 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
- Points accumulation on trips
- Reward redemption
- Tier-based benefits
### 💰 Wallet System
### Wallet System
- Top-up via payment methods
- Pay with wallet balance
- Transaction ledger
- Refund to wallet
### 📍 Live Tracking
### Live Tracking
- Real-time trip status
- Location updates
- Delay notifications
- Station crowd signals
### 🔒 Fraud Detection
### Fraud Detection
- Velocity checks (multiple bookings)
- High-value transaction monitoring
- Failed payment pattern detection
- Automatic user blocking
### 👤 Passenger Profiles
### Passenger Profiles
- Comprehensive profile data: gender, date of birth, nationality
- National ID for Ethiopian citizens (Fayda verified)
- Passport information for international passengers
- **NEW:** Complete demographic data for personalized services
- **NEW:** Improved user targeting and communications
- NEW: Complete demographic data for personalized services
### 🌍 Internationalization
### Internationalization
- Multi-language support (English, Amharic, French, Oromo)
- Locale-based responses
- 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
- Automatic detection of cross-border journeys (Ethiopia Djibouti)
### Transit Stop Management
- Automatic detection of cross-border journeys (Ethiopia to Djibouti)
- Dire Dawa as mandatory transit hub for international journeys
- Dual-leg fare calculation (domestic + international)
- Age-based pricing applied independently per leg
- Seamless multi-segment booking workflow
- Transit stop optimization and route planning
### 🔄 Round-Trip Booking
- One-way and round-trip journey options
- 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)
### Coach Type & Class Selection
- Browse available coach types per route
- View seat classes per coach (Economy Regular, Economy Bed, VIP Bed)
- Compare base prices by coach type and class
- Real-time availability per coach configuration
- Deferred pricing at seat selection stage
- Coach amenities and features display
- **NEW:** Sequence-based coach ordering for consistent UI
- **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
- **Stations:** Ordered by sequence (1-15) for consistent route display
- **Coaches:** Ordered by sequence (1+) per type for predictable configuration
- **Booking History:** Sorted chronologically with filtering options
### Data Organization
- Stations ordered by sequence (1-15) for consistent route display
- Coaches ordered by sequence (1+) per type for predictable configuration
- Booking history sorted chronologically with filtering options
## Authentication
@@ -195,26 +214,37 @@ Used for agent, fraud, and reporting endpoints. Requires corporate IAM token.
## Passenger Booking Flow
### 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
\`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
**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:**
\`POST /passengers/register-international\` - Passport information collection
\`POST /passengers/register-international\` Passport information collection
### 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
\`POST /auth/login\` then \`POST /seats/hold\` to reserve seats for 15 minutes
### Step 5: Hold Seats
\`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
\`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
\`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("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
.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("Currencies", "Multi-currency support, exchange rates, and currency conversion")
.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("Promotions", "Promo codes, campaigns, discounts, and redemption tracking")
.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("Schedule", "Trip schedules, availability windows, status tracking, and timing")
.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("Stations", "Station directory, location data, baggage facilities, and amenities")
.addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution")
.addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation, and audit trails")
.addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, multi-leg routing (NEW)")
.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, TRANSIT and ROUND_TRIP_TRANSIT bookings")
.addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger")
//.addServer('http://localhost:4000', 'Development')
// .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 { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger';
import { BookingsService } from './bookings.service';
import { GuestBookingService } from './guest-booking.service';
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
@@ -75,21 +75,24 @@ export class BookingsController {
@Get()
@ApiOperation({
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: '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: 'pageSize', required: false, description: 'Items per page' })
findAll(
@Query('search') search?: string,
@Query('status') status?: string,
@Query('returnLegStatus') returnLegStatus?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.findAll({
search,
status,
status,
returnLegStatus,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20
});
@@ -97,35 +100,153 @@ export class BookingsController {
@Post('guest')
@ApiOperation({
summary: 'Create guest booking without login (optional account creation)',
description: `Creates a booking without requiring login. Features:
**Guest Checkout:**
- No login required
- Contact details from first passenger
- Booking confirmation sent to email/phone
summary: 'Create guest booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT (no login required)',
description: `Creates a booking without requiring login. Supports all four booking types.
**Optional Account Creation:**
- Set createAccount=true with password
- Account created using first passenger details
- Automatic login after booking
- Loyalty points and wallet created
**bookingType: ONE_WAY (default)**
- scheduleId, holdId, originStationId, destinationStationId, seatClassId
- passengers[]: { seatId, passengerName, dateOfBirth, idDocumentType, … }
**Passenger Details Storage:**
- savePassengerDetails=true: Save for future bookings
- Stored by userId (if account created) or deviceId
- Retrieve saved passengers for quick booking
**bookingType: ROUND_TRIP**
- Above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
- passengers[]: each must include returnSeatId (seat on the return leg)
**Verifayda Verification:**
- Ethiopian nationals: National ID verified via Verifayda
- Other nationals: Passport details (no verification)
**bookingType: TRANSIT**
- scheduleId/holdId (leg-1) + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId
- passengers[]: each must include leg2SeatId
**Age-Based Pricing:**
- ADULT (≥5 years): Full fare
- CHILD (<5 years): First child FREE, subsequent children full fare`
**bookingType: ROUND_TRIP_TRANSIT**
- All TRANSIT outbound fields + returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId/returnLeg2ScheduleId/returnLeg2HoldId/returnTransitStationId/returnLeg2DestinationStationId
- passengers[]: each must include leg2SeatId, returnSeatId, returnLeg2SeatId
**Optional account creation:** set createAccount=true with password — creates account from first passenger details, loyalty + wallet initialised.
**Verifayda:** Ethiopian nationals verified; international passengers require passportNumber + passportCountry.`
})
@ApiResponse({ status: 201, description: 'Booking created successfully' })
@ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid data' })
@ApiBody({
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) {
return this.guestService.createGuestBooking(dto);
}
@@ -144,24 +265,149 @@ export class BookingsController {
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Create booking (one-way or round-trip)',
description: `Creates a one-way or round-trip booking for logged-in users.
ONE_WAY booking:
- scheduleId, holdId, originStationId, destinationStationId
- passengers: array of PassengerInputDto with seatId
- Single PNR, single payment
summary: 'Create booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT',
description: `Creates a booking for a logged-in passenger. bookingType controls which fields are required.
ROUND_TRIP booking:
- Outbound: scheduleId, holdId, originStationId, destinationStationId, seatClassId
- Return: returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
- passengers: array of RoundTripPassengerDto with outboundSeatId and returnSeatId
- Combined PNR, single payment for both legs
- Fare = outbound_fare + return_fare, single total, single promo, single loyalty deduction`
**ONE_WAY**
- scheduleId, holdId, originStationId, destinationStationId, seatClassId
- passengers[]: { seatId, passengerName, dateOfBirth, idDocumentType, … }
**ROUND_TRIP**
- Above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
- passengers[]: { seatId (outbound), returnSeatId (return), passengerName, … }
- Combined fare = outbound fare + return fare; single promo/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: 400, description: 'Verifayda verification failed or invalid passenger data' })
@ApiResponse({ status: 404, description: 'Trip or seat hold not found' })
@ApiResponse({ status: 400, description: 'Missing required fields for bookingType, or Verifayda verification failed' })
@ApiResponse({ status: 404, description: 'Schedule or seat hold not found' })
create(@Body() dto: CreateBookingDto) {
return this.service.create(dto);
}

View File

@@ -4,7 +4,10 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
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: '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;
@@ -15,17 +18,17 @@ export class PassengerInputDto {
}
export class RoundTripPassengerDto {
@ApiProperty({
description: 'Outbound journey seat ID',
example: 'seat-uuid-outbound'
})
@ApiProperty({ description: 'Outbound journey seat ID', example: 'seat-uuid-outbound' })
@IsString() outboundSeatId: string;
@ApiProperty({
description: 'Return journey seat ID',
example: 'seat-uuid-return'
})
@ApiProperty({ description: 'Return journey seat ID', example: 'seat-uuid-return' })
@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({
example: 'Abebe Kebede',
@@ -75,16 +78,16 @@ export class CreateBookingDto {
@ApiProperty({ description: 'Passenger ID' })
@IsString() passengerId: string;
@ApiProperty({ description: 'Outbound schedule ID' })
@ApiProperty({ description: 'Outbound / leg-1 schedule ID' })
@IsString() scheduleId: string;
@ApiProperty({ description: 'Outbound seat hold ID' })
@ApiProperty({ description: 'Outbound / leg-1 seat hold ID' })
@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;
@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;
@ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID (Economy Regular, Economy Bed, VIP Bed)' })
@@ -92,15 +95,33 @@ export class CreateBookingDto {
@ApiProperty({
example: 'ONE_WAY',
enum: ['ONE_WAY', 'ROUND_TRIP'],
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`,
enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'],
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'
})
@IsOptional() @IsString() bookingType?: string;
@ApiProperty({
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)
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.' })
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
// Round-trip specific fields
@ApiPropertyOptional({
description: '**ROUND_TRIP ONLY:** Return schedule ID (required when bookingType=ROUND_TRIP)'
})
// Transit-specific fields
@ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 schedule ID' })
@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;
@ApiPropertyOptional({
description: '**ROUND_TRIP ONLY:** Return origin station ID (usually same as outbound destination)'
})
@ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return origin station ID' })
@IsOptional() @IsString() returnOriginStationId?: string;
@ApiPropertyOptional({
description: '**ROUND_TRIP ONLY:** Return destination station ID (usually same as outbound origin)'
})
@ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return destination station ID' })
@IsOptional() @IsString() returnDestinationStationId?: string;
@ApiPropertyOptional({
description: '**ROUND_TRIP ONLY:** Return seat hold ID (required when bookingType=ROUND_TRIP)'
})
@ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return seat hold ID' })
@IsOptional() @IsString() returnHoldId?: string;
@ApiPropertyOptional({
description: '**ROUND_TRIP ONLY:** Return seat class ID (optional, defaults to outbound seatClassId if not provided)'
})
@ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return seat class ID' })
@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 {

View File

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

View File

@@ -6,6 +6,7 @@ import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
function generateRef(): string {
@@ -24,6 +25,7 @@ function calculateAge(dateOfBirth: Date): number {
interface BookingFilters {
search?: string;
status?: string;
returnLegStatus?: string;
page?: number;
pageSize?: number;
}
@@ -36,6 +38,7 @@ export class BookingsService {
private eventEmitter: EventEmitter2,
private verifaydaService: VerifaydaService,
private currencyService: CurrencyService,
private fareEngine: FareEngineService,
) {}
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
@@ -82,6 +85,8 @@ export class BookingsService {
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
@@ -159,6 +164,8 @@ export class BookingsService {
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
@@ -180,7 +187,7 @@ export class BookingsService {
}
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 where: any = {};
@@ -194,9 +201,8 @@ export class BookingsService {
];
}
if (status) {
where.status = status;
}
if (status) where.status = status;
if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
@@ -225,6 +231,10 @@ export class BookingsService {
displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
adultCount: booking.adultCount,
childCount: booking.childCount,
createdAt: booking.createdAt,
passenger: booking.passenger?.user,
schedule: {
@@ -246,9 +256,9 @@ export class BookingsService {
}
async create(dto: CreateBookingDto) {
if (dto.bookingType === 'ROUND_TRIP') {
return this.createRoundTripBooking(dto);
}
if (dto.bookingType === 'ROUND_TRIP') 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);
}
@@ -391,22 +401,42 @@ export class BookingsService {
returnDestinationStationId: dto.returnDestinationStationId,
returnHoldId: dto.returnHoldId,
returnSeatClassId: dto.returnSeatClassId,
returnLegStatus: 'NEITHER_USED',
seats: {
create: passengersData.map(p => ({
seat: { connect: { id: p.outboundSeatId } },
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 ? (outboundFare.baseFareMinor + returnFare.baseFareMinor) : 0,
displayCurrency
}))
}
},
create: [
...passengersData.map(p => ({
seat: { connect: { id: p.outboundSeatId } },
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 ? 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 } } }
});
@@ -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[]) {
const processedPassengers = [];
for (const passenger of passengers) {
@@ -560,76 +886,69 @@ export class BookingsService {
destStopSeq?: number,
): Promise<number> {
const now = new Date();
// Get schedule with route info
// 1. SegmentFareRule — most specific explicit price
const schedule = await this.prisma.trainSchedule.findUnique({
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) {
// Try with nationality first
const segmentFare = await this.prisma.segmentFareRule.findFirst({
where: {
routeId: schedule.routeId,
originStopSequence: originStopSeq,
destinationStopSequence: destStopSeq,
seatClassId,
nationality: nationality || null,
nationality: nationality ?? null,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
});
if (segmentFare) {
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;
}
}) ?? (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) return segmentFare.baseFareMinor;
}
// Fall back to fare rules if no segment fare found
// 2. FareRule table — explicit override rules
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
});
const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
if (bestMatch) return bestMatch.baseFareMinor;
const bestMatch = this.selectBestFareRule(
candidates,
scheduleId,
segmentRoute,
fullRoute,
nationality,
// 3. FareEngine — distance × rate-per-km from the schedule's route
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.`,
);
return bestMatch?.baseFareMinor ?? 35000;
}
async getByRef(bookingRef: string) {
@@ -646,7 +965,11 @@ export class BookingsService {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount,
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: {
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 },
@@ -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(
candidates: any[],
scheduleId: string,

View File

@@ -4,9 +4,18 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
export class GuestPassengerDto {
@ApiProperty({ example: 'seat-id-uuid' })
@ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' })
@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' })
@IsString() passengerName: string;
@@ -36,24 +45,88 @@ export class GuestPassengerDto {
}
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;
@ApiProperty({ example: 'hold-uuid' })
@ApiProperty({ example: 'hold-uuid', description: 'Outbound seat hold UUID' })
@IsString() holdId: string;
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' })
@ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID' })
@IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' })
@ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID' })
@IsString() destinationStationId: string;
@ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. First passenger details used for contact.' })
@IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[];
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' })
@ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID' })
@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' })
@IsOptional() @IsString() promoCode?: string;
@@ -66,7 +139,7 @@ export class CreateGuestBookingDto {
@ApiPropertyOptional({ example: 'password123', description: 'Password if createAccount is true' })
@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;
@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 { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
@@ -28,10 +29,18 @@ export class GuestBookingService {
private seatsService: SeatsService,
private verifaydaService: VerifaydaService,
private currencyService: CurrencyService,
private fareEngine: FareEngineService,
private eventEmitter: EventEmitter2,
) {}
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
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) {
@@ -157,76 +166,7 @@ export class GuestBookingService {
// Create or get guest passenger
const firstPassenger = passengersData[0];
let guestPassenger = null;
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 } });
}
const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger);
// Save passenger details for future use (if requested)
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[]> {
if (!userId && !deviceId) {
throw new BadRequestException('Either userId or deviceId is required');
@@ -343,6 +926,8 @@ export class GuestBookingService {
nationality?: string,
): Promise<number> {
const now = new Date();
// 1. FareRule table — explicit override rules
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId,
@@ -368,14 +953,34 @@ export class GuestBookingService {
for (const priority of priorities) {
const match = candidates.find(
(c) =>
c.tripId === priority.tripId &&
c.route === priority.route &&
c.nationality === priority.nationality,
(c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality,
);
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 {
@ApiProperty()
@IsEmail()
@IsNotEmpty()
to: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
sourceId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
sourceName?: string;
@ApiProperty()
@IsNotEmpty()
@IsString()
subject: string;
body: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
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 {
@ApiProperty()
@IsNotEmpty()
@IsString()
to: string;
@ApiProperty()
@IsNotEmpty()
@IsString()
message: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
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 {
@ApiProperty({ type: [SendMessage] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => SendMessage)
messages: SendMessage[];
}

View File

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

View File

@@ -7,7 +7,7 @@ import { TestNotificationDto } from './notifications.dto';
import { EmailClientService } from './email-client.service';
import { SmsClientService } from './sms-client.service';
import { SendEmail } from './dtos/email.dto';
import { SendMessage } from './dtos/sms.dto';
import { BulkMessagesDto, SingleMessageDto } from './dtos/sms.dto';
@ApiTags('Notifications')
@Controller('notifications')
@@ -51,11 +51,20 @@ export class NotificationsController {
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')
@ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' })
@ApiBody({ type: SendMessage })
sendSms(@Body() dto: SendMessage) {
@ApiBody({ type: SingleMessageDto })
sendSms(@Body() dto: SingleMessageDto) {
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')
@UseGuards(IamGuard)
@IamRoles('ADMIN', 'STAFF')

View File

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

View File

@@ -20,8 +20,8 @@ export class NotificationsService {
private pushAdapter: PushAdapter,
) {
this.channels = new Map<NotificationChannelType, NotificationChannel>([
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, body }).then(() => true) }],
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: 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, sms: body }).then(() => true) }],
['PUSH', this.pushAdapter as NotificationChannel],
]);
}
@@ -107,7 +107,7 @@ export class NotificationsService {
await this.emailClient.sendEmail({
to: passenger.user.email,
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 { ClientProxy } from '@nestjs/microservices';
import { BulkMessagesDto, SendMessage } from './dtos/sms.dto';
import {
Inject,
Injectable,
Logger,
OnApplicationBootstrap,
} from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { BulkMessagesDto, SingleMessageDto } from "./dtos/sms.dto";
@Injectable()
export class SmsClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(SmsClientService.name);
constructor(
@Inject('SMS_SERVICE')
private readonly smsClient: ClientProxy,
@Inject("SMS_SERVICE")
private smsClient: ClientProxy,
) {}
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
async onApplicationBootstrap() {
if (!this.enabled) return;
this.smsClient
.connect()
.then(() => this.logger.log('Connected to SMS service'))
.catch((err) => this.logger.error('Error connecting to SMS service', err));
.then(() => {
this.logger.log("connected to SMS service");
})
.catch((err) => {
console.error("Error happened at SMS service", err);
});
}
async sendSms(dto: SendMessage) {
this.smsClient.emit('send-sms', {
async sendSms(dto: SingleMessageDto) {
if (!this.enabled) return {};
this.smsClient.emit("send-sms", {
...dto,
appKey: 'EDR-PASSENGER-API',
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
return {};
}
async sendBulkMessages(dto: BulkMessagesDto) {
this.smsClient.emit('ozeking-bulk-sms', {
if (!this.enabled) return {};
this.smsClient.emit("ozeking-bulk-sms", {
...dto,
appKey: 'EDR-PASSENGER-API',
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
return {};
}

View File

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

View File

@@ -79,6 +79,28 @@ export class PaymentsController {
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")
@UseGuards(JwtGuard, RolesGuard)
@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 walletDemoAutoSucceed = true;
private readonly waafiDemoTrustReturn = true;
constructor(
private prisma: PrismaService,
private seatsService: SeatsService,
@@ -192,6 +194,41 @@ export class PaymentsService {
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(
bookingId: string,
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: {
intentId: string;
providerTxnId?: string;
@@ -477,7 +538,7 @@ export class PaymentsService {
});
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 tx.paymentIntent.update({
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.' })
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => 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 {

View File

@@ -44,6 +44,15 @@ export class SchedulesService {
const schedule = await this.createSchedule(createDto);
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++;
} catch (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 {
@ApiProperty({ example: 'Economy Regular', description: 'Seat class name' })
name: string;
@ApiProperty({ example: 35000, description: 'Base fare in ETB minor units per passenger' })
baseFareMinor: number;
@ApiProperty({ example: 'Economy Regular' }) name: string;
@ApiProperty({ example: 35000 }) baseFareMinor: number;
}
export class CoachTypeOption {
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach type unique identifier' })
coachTypeId: string;
@ApiProperty({ example: 'Economy', description: 'Coach type display name' })
coachTypeName: string;
@ApiProperty({ example: 'ECO', description: 'Coach type code' })
coachTypeCode: string;
@ApiProperty({
type: 'array',
items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' },
description: 'Available seat classes within this coach type with base fares. User selects specific class at seat selection page.',
})
classes: CoachTypeOptionClass[];
@ApiProperty({ example: 'coach-type-uuid' }) coachTypeId: string;
@ApiProperty({ example: 'Economy' }) coachTypeName: string;
@ApiProperty({ example: 'ECO' }) coachTypeCode: string;
@ApiProperty({ type: 'array', items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' } }) classes: CoachTypeOptionClass[];
}
export class TransitLegDto {
@ApiProperty({ example: 'schedule-uuid' }) scheduleId: string;
@ApiProperty() trainNumber: string;
@ApiProperty() trainName: string;
@ApiProperty() origin: object;
@ApiProperty() destination: object;
@ApiProperty() departureAt: Date;
@ApiProperty() arrivalAt: Date;
@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) {
const outbound = await this.searchSchedules(
dto.originStationId,
dto.destinationStationId,
dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
);
if (dto.journeyType === 'ROUND_TRIP') {
const allInbound = await this.searchSchedules(
dto.destinationStationId,
const [direct, transit] = await Promise.all([
this.searchSchedules(
dto.originStationId,
dto.returnDate ?? dto.date,
dto.destinationStationId,
dto.date,
dto.adultCount,
dto.childCount,
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
? 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();
const inbound = allInbound.filter((schedule) =>
new Date(schedule.departureAt).getTime() > latestOutboundArrival
const inbound = allReturn.filter((s: any) =>
new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival
);
return { journeyType: 'ROUND_TRIP', outbound, inbound };
@@ -60,7 +83,7 @@ export class SearchService {
nationality?: string,
) {
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 totalPassengers = adultCount + (childCount ?? 0);
@@ -81,126 +104,211 @@ export class SearchService {
},
});
const results = [];
const results: any[] = [];
for (const schedule of schedules) {
const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality);
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) {
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);
// Load all schedules on this date that pass through origin
const leg1Schedules = await this.prisma.trainSchedule.findMany({
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 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 results: any[] = [];
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) count++;
}
for (const leg1 of leg1Schedules) {
const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId);
if (!originStop) continue;
if (count > 0) {
const matchingClass = seatClassNames.find((className: string) => {
const classNameLower = className.toLowerCase();
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,
// Every stop after origin on leg1 is a candidate transit station
const candidateTransitStops = leg1.stopTimes.filter(
(s: any) => s.sequence > originStop.sequence,
);
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({
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,
});
const transitStationId = transitStop.stationId;
const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt;
// Find leg2 schedules departing from the transit station within the connection window,
// and reaching the final destination. Search up to the next calendar day to handle
// overnight connections.
const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000);
const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000);
const leg2Schedules = await this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
departureAt: { gte: connWindowStart, lte: connWindowEnd },
stopTimes: { some: { stationId: transitStationId } },
},
include: {
train: true,
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
},
},
});
for (const leg2 of leg2Schedules) {
const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId);
const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId);
if (!leg2TransitStop || !leg2DestStop) continue;
if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue;
// Build individual leg result objects (reuse existing per-schedule logic)
const [leg1Result, leg2Result] = await Promise.all([
this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality),
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;
}
// 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) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
@@ -244,7 +352,8 @@ export class SearchService {
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 childCount = dto.childCount ?? 0;
@@ -379,11 +488,8 @@ export class SearchService {
}
}
console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`);
return seatClasses.map(sc => ({
seatClassName: sc.name,
baseFareMinor: this.getDefaultFareForClass(sc.name),
}));
console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`);
return [];
}
private async buildCoachTypeDetails(
@@ -420,11 +526,10 @@ export class SearchService {
const classes = Array.from(classNames)
.map((className) => {
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
return {
name: className,
baseFareMinor: fareInfo?.baseFareMinor ?? this.getDefaultFareForClass(className),
};
if (!fareInfo) return null;
return { name: className, baseFareMinor: fareInfo.baseFareMinor };
})
.filter((c): c is { name: string; baseFareMinor: number } => c !== null)
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
result.push({
@@ -442,22 +547,28 @@ export class SearchService {
});
}
private getDefaultFareForClass(className: string): number {
const defaults: Record<string, number> = {
'Economy Regular': 35000,
'Economy Bed': 49000,
'VIP Bed': 63000,
};
return defaults[className] ?? 35000;
private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise<number> {
if (!seatClassId) throw new NotFoundException(`Seat class '${seatClassName}' not found`);
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
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 {
const fares: Record<string, number> = {
'Economy Regular': 45000,
'Economy Bed': 65000,
'VIP Bed': 95000,
};
return fares[seatClassName] ?? 45000;
private getDefaultFareForClass(_className: string): never {
throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead');
}
private defaultFare(_seatClassName: string): never {
throw new Error('defaultFare should not be called — use resolveScheduleFare instead');
}
private selectBestFareRule(

View File

@@ -62,7 +62,7 @@ describe('SeatsService - Auto Assign', () => {
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);
});

View File

@@ -547,16 +547,14 @@ export class SeatsService {
@Cron(CronExpression.EVERY_MINUTE)
async expireHolds() {
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
for (const hold of expired) {
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) {
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 { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
@@ -30,15 +30,28 @@ export class TicketsController {
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@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(
@Query('search') search?: string,
@Query('status') status?: string,
@Query('originStationId') originStationId?: string,
@Query('destinationStationId') destinationStationId?: string,
@Query('arrivalDate') arrivalDate?: string,
@Query('skip') skip?: string,
@Query('take') take?: string,
) {
return this.service.listTickets({
search,
status,
originStationId,
destinationStationId,
arrivalDate,
skip: skip ? parseInt(skip) : 0,
take: take ? parseInt(take) : 50,
});
@@ -56,17 +69,8 @@ export class TicketsController {
}
@Get(':bookingRef')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Get ticket with QR code and passenger details',
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`
summary: 'Get ticket with QR code and passenger details (public)',
})
getByRef(@Param('bookingRef') ref: string) {
return this.service.getByRef(ref);
@@ -77,14 +81,30 @@ export class TicketsController {
@ApiBearerAuth('JWT-auth')
@ApiOperation({
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(
@Param('bookingRef') ref: string,
@Param('bookingRef') ref: string,
@Body('validatorId') validatorId: string,
@Body('gateId') gateId?: string
) {
return this.service.validate(ref, validatorId, gateId);
@Body('gateId') gateId?: string,
@Body('leg') leg?: string,
) {
return this.service.validate(ref, validatorId, gateId, leg);
}
@Get(':ticketId/validation-logs')
@@ -106,7 +126,31 @@ export class TicketsController {
@Post('validate/offline')
@UseGuards(JwtGuard)
@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[] }) {
return this.service.validateOfflineBatch(body.validations);
}

View File

@@ -7,13 +7,14 @@ interface OfflineValidation {
validatorId: string;
gateId?: string;
validatedAt: string;
leg?: string;
}
@Injectable()
export class TicketsService {
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 = {};
if (filters.search) {
where.OR = [
@@ -23,7 +24,19 @@ export class TicketsService {
];
}
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({
where,
@@ -31,7 +44,7 @@ export class TicketsService {
booking: {
include: {
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 } },
},
},
@@ -49,6 +62,10 @@ export class TicketsService {
booking: {
bookingRef: t.booking.bookingRef,
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,
currency: t.booking.currency,
displayCurrency: t.booking.displayCurrency,
@@ -69,48 +86,65 @@ export class TicketsService {
}
async generate(bookingId: string) {
if (!bookingId) {
throw new BadRequestException('Booking ID is required');
}
if (!bookingId) throw new BadRequestException('Booking ID is required');
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } }
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } },
},
});
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 ticket = await this.prisma.ticket.upsert({
where: { bookingId },
where: { bookingId },
update: { 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);
for (const seatId of seatIds) {
// Update seat status to BOOKED
await this.prisma.seat.update({
where: { id: seatId },
data: { status: 'BOOKED' },
});
// Create permanent seat blocks for all booked seats
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BOOKED' } });
await this.prisma.seatBlock.create({
data: {
seatId,
reason: `Permanently booked in ticket ${ticket.id}`,
blockedBy: 'SYSTEM',
approvedBy: 'SYSTEM',
}
}).catch(() => null); // Ignore if already exists
data: { seatId, reason: `Booked in ticket ${ticket.id}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' },
}).catch(() => null);
}
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[]) {
@@ -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 } });
if (!booking) throw new NotFoundException('Booking not found');
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
if (!ticket) throw new NotFoundException('Ticket not found');
if (ticket.validatedAt) {
await this.prisma.gateValidationLog.create({
data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' }
});
throw new BadRequestException('Ticket already validated');
const type = booking.bookingType;
const now = new Date();
// ── 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({
data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' }
});
return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
// ── TRANSIT — leg=LEG1 or leg=LEG2 ──────────────────────────────────
if (type === 'TRANSIT') {
const resolvedLeg = (leg ?? 'LEG1').toUpperCase();
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) {
@@ -243,7 +377,7 @@ export class TicketsService {
where: { scheduleId: tripId, status: 'CONFIRMED' },
include: {
ticket: true,
seats: { include: { seat: { include: { coach: true } } } },
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } },
},
});
@@ -256,6 +390,8 @@ export class TicketsService {
coachLabel: b.seats[0]?.seat.coach.number,
qrPayload: b.ticket?.qrPayload,
status: b.status,
bookingType: b.bookingType,
returnLegStatus: (b as any).returnLegStatus ?? null,
validatedAt: b.ticket?.validatedAt,
}));
}
@@ -265,11 +401,13 @@ export class TicketsService {
const processedRefs = new Set<string>();
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++;
continue;
}
processedRefs.add(v.bookingRef);
processedRefs.add(dedupKey);
try {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } });
@@ -286,11 +424,24 @@ export class TicketsService {
continue;
}
if (ticket.validatedAt) {
if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP' &&
booking.bookingType !== 'TRANSIT' && booking.bookingType !== 'ROUND_TRIP_TRANSIT') {
results.duplicate++;
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({
where: { id: ticket.id },
data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId },
@@ -301,11 +452,27 @@ export class TicketsService {
ticketId: ticket.id,
validatorId: v.validatorId,
gateId: v.gateId,
leg: v.leg ?? null,
status: 'APPROVED',
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++;
} catch (err) {
results.failed++;

View File

@@ -24,6 +24,19 @@ export default function BookingsPage() {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
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();
@@ -80,22 +93,23 @@ export default function BookingsPage() {
}
};
const handleExportBookings = async () => {
const selectedColumns = prompt(
'Select columns to export (comma-separated):\n\n' +
'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 cols = selectedColumns.split(',').map(c => c.trim());
const confirmExport = () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (cols.length === 0) { alert('Please select at least one column'); 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 csv = [
cols.join(','),
...data?.items?.map((booking: any) => {
...exportItems.map((booking: any) => {
const values = cols.map(col => {
switch(col) {
switch (col) {
case 'bookingRef': return booking.bookingRef;
case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest';
case 'status': return booking.status;
@@ -108,28 +122,29 @@ export default function BookingsPage() {
}
});
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 = `bookings-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
setExportModalOpen(false);
};
const columns = [
{
key: 'bookingRef',
{
key: 'bookingRef',
label: 'Reference',
sortable: true,
render: (booking: any) => (
<span className="font-mono font-semibold">{booking.bookingRef}</span>
),
},
{
key: 'passenger',
{
key: 'passenger',
label: 'Passenger',
render: (booking: any) => (
<div>
@@ -138,32 +153,39 @@ export default function BookingsPage() {
</div>
),
},
{
key: 'bookingType',
label: 'Class',
{
key: 'bookingType',
label: 'Type',
sortable: true,
render: (booking: any) => booking.bookingType || 'ONE_WAY',
},
{
{
key: 'passengerCount',
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',
label: 'Status',
render: (booking: any) => (
<Badge variant="status" status={booking.status}>{booking.status}</Badge>
),
},
{
key: 'totalMinor',
{
key: 'totalMinor',
label: 'Amount',
sortable: true,
render: (booking: any) => formatCurrency(booking.totalMinor, booking.currency),
},
{
key: 'paymentStatus',
{
key: 'paymentStatus',
label: 'Payment',
render: (booking: any) => (
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>
@@ -171,8 +193,8 @@ export default function BookingsPage() {
</Badge>
),
},
{
key: 'createdAt',
{
key: 'createdAt',
label: 'Created',
sortable: true,
render: (booking: any) => formatDateTime(booking.createdAt),
@@ -208,7 +230,7 @@ export default function BookingsPage() {
<h1 className="text-2xl font-bold">Bookings</h1>
<p className="text-muted-foreground">Manage all passenger bookings</p>
</div>
<ActionButton variant="export" icon={Download} onClick={handleExportBookings}>Export</ActionButton>
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
<div className="card">
@@ -253,7 +275,7 @@ export default function BookingsPage() {
loading={isLoading}
emptyMessage="No bookings found"
/>
{data?.meta && (
<Pagination
currentPage={data.meta.page}
@@ -264,15 +286,9 @@ export default function BookingsPage() {
</div>
{/* Booking Details Modal */}
<Modal
isOpen={!!selectedBooking}
onClose={() => setSelectedBooking(null)}
title="Booking Details"
size="xl"
>
<Modal isOpen={!!selectedBooking} onClose={() => setSelectedBooking(null)} title="Booking Details" size="xl">
{selectedBooking && (
<div className="space-y-6">
{/* Booking Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Booking Reference</label>
@@ -281,9 +297,7 @@ export default function BookingsPage() {
<div>
<label className="text-sm font-medium text-muted-foreground">Status</label>
<div className="mt-1">
<Badge variant="status" status={selectedBooking.status}>
{selectedBooking.status}
</Badge>
<Badge variant="status" status={selectedBooking.status}>{selectedBooking.status}</Badge>
</div>
</div>
<div>
@@ -298,7 +312,6 @@ export default function BookingsPage() {
<hr className="border-muted" />
{/* Passenger Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Passenger Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -323,7 +336,6 @@ export default function BookingsPage() {
<hr className="border-muted" />
{/* Booking Details */}
<div>
<h3 className="text-lg font-semibold mb-3">Journey Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -348,7 +360,6 @@ export default function BookingsPage() {
<hr className="border-muted" />
{/* Payment Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Payment Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -377,7 +388,6 @@ export default function BookingsPage() {
<hr className="border-muted" />
{/* Additional Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Additional Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -393,12 +403,7 @@ export default function BookingsPage() {
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
variant="secondary"
onClick={() => setSelectedBooking(null)}
>
Close
</ActionButton>
<ActionButton variant="secondary" onClick={() => setSelectedBooking(null)}>Close</ActionButton>
</div>
</div>
)}
@@ -407,10 +412,7 @@ export default function BookingsPage() {
{/* Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={deleteConfirmOpen}
onClose={() => {
setDeleteConfirmOpen(false);
setBookingToDelete(null);
}}
onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); }}
onConfirm={handleConfirmDelete}
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.`}
@@ -419,6 +421,53 @@ export default function BookingsPage() {
isLoading={deleteMutation.isPending}
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>
);
}

View File

@@ -145,7 +145,7 @@ export default function CoachesPage() {
const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false);
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();
// Coach Types Queries
@@ -252,12 +252,17 @@ export default function CoachesPage() {
};
const confirmDelete = async () => {
if (deleteConfirm.item?.isCoachType) {
await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
} else {
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
try {
if (deleteConfirm.item?.isCoachType) {
await deleteCoachTypeMutation.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 || [];
@@ -536,6 +541,8 @@ export default function CoachesPage() {
message={`Are you sure you want to delete ${deleteConfirm.item?.name || deleteConfirm.item?.number}?`}
confirmText="Delete"
isDanger={true}
isLoading={deleteCoachTypeMutation.isPending || deleteCoachMutation.isPending}
error={deleteConfirm.error}
warning={
deleteConfirm.item?.isCoachType
? '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 [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();
@@ -52,22 +58,23 @@ export default function PassengersPage() {
console.error('Passengers API Error:', error);
}
const handleExportPassengers = async () => {
const selectedColumns = prompt(
'Select columns to export (comma-separated):\n\n' +
'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 cols = selectedColumns.split(',').map(c => c.trim());
const confirmExportPassengers = () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (cols.length === 0) { alert('Please select at least one column'); 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 csv = [
cols.join(','),
...data?.items?.map((passenger: any) => {
...exportItems.map((passenger: any) => {
const values = cols.map(col => {
switch(col) {
switch (col) {
case 'fullName': return passenger.fullName;
case 'email': return passenger.email || '';
case 'phone': return passenger.phone || '';
@@ -79,15 +86,16 @@ export default function PassengersPage() {
}
});
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 = `passengers-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
setExportModalOpen(false);
};
const columns = [
@@ -160,7 +168,7 @@ export default function PassengersPage() {
<p className="text-muted-foreground">Manage passenger profiles and verification</p>
</div>
<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>
@@ -381,6 +389,51 @@ export default function PassengersPage() {
</div>
)}
</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>
);
}

View File

@@ -6,27 +6,76 @@ import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { paymentsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function PaymentsPage() {
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({
queryKey: ['payments', filters],
queryFn: () => paymentsApi.getAll(filters),
queryFn: () => paymentsApi.getAll({
search: filters.search || undefined,
status: filters.status || undefined,
method: filters.method || undefined,
}),
});
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) },
];
const confirmExport = () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (cols.length === 0) { alert('Please select at least one column'); return; }
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 (
<div className="space-y-6">
@@ -35,36 +84,91 @@ export default function PaymentsPage() {
<h1 className="text-2xl font-bold text-foreground">Payments</h1>
<p className="text-muted-foreground">Manage payment transactions and refunds</p>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="PENDING">Pending</option>
<option value="COMPLETED">Completed</option>
<option value="FAILED">Failed</option>
</select>
</div>
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="PENDING">Pending</option>
<option value="COMPLETED">Completed</option>
<option value="FAILED">Failed</option>
</select>
</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>
<DataTable
data={(data as any)?.items || (Array.isArray(data) ? data : [])}
columns={columns}
actions={actions}
actions={[]}
loading={isLoading}
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>
);
}

View File

@@ -362,12 +362,16 @@ export default function RoutesPage() {
type="text"
name="code"
className="input"
value={generateRouteCode(originStationId, destinationStationId)}
readOnly
defaultValue={editingRoute ? editingRoute.code : undefined}
key={editingRoute ? `code-edit-${editingRoute.id}` : `code-new-${originStationId}-${destinationStationId}`}
placeholder={generateRouteCode(originStationId, destinationStationId) || 'e.g. ADD-DJI'}
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>
<label className="label">Route Name *</label>
@@ -375,11 +379,16 @@ export default function RoutesPage() {
type="text"
name="name"
className="input"
value={generateRouteName(originStationId, destinationStationId)}
readOnly
defaultValue={editingRoute ? editingRoute.name : undefined}
key={editingRoute ? `name-edit-${editingRoute.id}` : `name-new-${originStationId}-${destinationStationId}`}
placeholder={generateRouteName(originStationId, destinationStationId) || 'e.g. Addis Ababa - Djibouti'}
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>

View File

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

View File

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

View File

@@ -9,11 +9,11 @@ import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
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';
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 [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
@@ -22,6 +22,8 @@ export default function TicketsPage() {
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [selectedColumns, setSelectedColumns] = useState<Record<string, boolean>>({
ticketNumber: true,
booking: true,
@@ -35,7 +37,15 @@ export default function TicketsPage() {
const { data, isLoading, error } = useQuery({
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({
@@ -94,25 +104,31 @@ export default function TicketsPage() {
}
};
const handleExportTickets = async () => {
setExportModalOpen(true);
};
const confirmExport = () => {
const cols = Object.entries(selectedColumns)
.filter(([, selected]) => selected)
.map(([col]) => col);
if (cols.length === 0) {
alert('Please select at least one column');
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 = [
cols.join(','),
...data?.items?.map((ticket: any) => {
...exportItems.map((ticket: any) => {
const values = cols.map(col => {
switch(col) {
switch (col) {
case 'ticketNumber': return ticket.ticketNumber || '';
case 'booking': return ticket.booking?.bookingRef || '';
case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`;
@@ -126,9 +142,9 @@ export default function TicketsPage() {
}
});
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');
@@ -175,12 +191,12 @@ export default function TicketsPage() {
},
{
key: 'seat',
label: 'Seat',
label: 'Seat/Bed',
sortable: true,
render: (ticket: any) => (
<div>
<div className="font-mono font-semibold">Coach {ticket.seat?.coach?.number || 'N/A'} - Seat {ticket.seat?.seatNumber || 'N/A'}</div>
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.name || '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?.type || 'N/A'}</div>
</div>
),
},
@@ -204,15 +220,31 @@ export default function TicketsPage() {
key: 'boarded',
label: 'Boarded',
render: (ticket: any) => (
ticket.boardedAt ? (
ticket.validatedAt ? (
<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>
) : (
<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 = [
@@ -248,7 +280,7 @@ export default function TicketsPage() {
<h1 className="text-2xl font-bold text-foreground">Tickets</h1>
<p className="text-muted-foreground">Manage tickets and validations</p>
</div>
<ActionButton icon={Download} variant="secondary" onClick={handleExportTickets}>Export</ActionButton>
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
{/* Filters */}
@@ -301,12 +333,12 @@ export default function TicketsPage() {
</select>
</div>
<div>
<label className="label">Trip Date</label>
<label className="label">Arrival Date</label>
<input
type="date"
className="input"
value={filters.tripDate}
onChange={(e) => setFilters({ ...filters, tripDate: e.target.value })}
value={filters.arrivalDate}
onChange={(e) => setFilters({ ...filters, arrivalDate: e.target.value })}
/>
</div>
<div>
@@ -337,10 +369,7 @@ export default function TicketsPage() {
{/* Board Confirmation Dialog */}
<ConfirmDialog
isOpen={boardConfirmOpen}
onClose={() => {
setBoardConfirmOpen(false);
setTicketToBoard(null);
}}
onClose={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}
onConfirm={handleConfirmBoard}
title="Board Ticket"
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 */}
<ConfirmDialog
isOpen={deleteConfirmOpen}
onClose={() => {
setDeleteConfirmOpen(false);
setTicketToDelete(null);
}}
onClose={() => { setDeleteConfirmOpen(false); setTicketToDelete(null); }}
onConfirm={handleConfirmDelete}
title="Delete Ticket"
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 */}
<Modal
isOpen={detailsModalOpen}
onClose={() => {
setDetailsModalOpen(false);
setSelectedTicket(null);
}}
onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}
title="Ticket Details"
size="lg"
>
@@ -444,21 +467,35 @@ export default function TicketsPage() {
</div>
</div>
{selectedTicket.boardedAt && (
{selectedTicket.validatedAt && (
<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="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.boardedAt)}</p>
<p className="text-sm text-muted-foreground">Validated At</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 className="flex justify-end gap-2 pt-4">
<ActionButton
variant="secondary"
onClick={() => {
setDetailsModalOpen(false);
setSelectedTicket(null);
}}
>
<ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}>
Close
</ActionButton>
</div>
@@ -466,49 +503,55 @@ export default function TicketsPage() {
)}
</Modal>
{/* Export Columns Modal */}
{/* Export Modal */}
<Modal
isOpen={exportModalOpen}
onClose={() => setExportModalOpen(false)}
title="Export Tickets - Select Columns"
title="Export Tickets"
size="md"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Select which columns to include in the export</p>
<div className="space-y-3 max-h-96 overflow-y-auto">
{[
{ key: 'ticketNumber', label: 'Ticket Number' },
{ key: 'booking', label: 'Booking Reference & Passenger' },
{ key: 'trip', label: 'Trip (Origin → Destination)' },
{ key: 'coach', label: 'Coach Number' },
{ key: 'seat', label: 'Seat Number' },
{ key: 'seatClass', label: 'Seat Class' },
{ key: 'amount', label: 'Amount' },
{ key: 'status', label: 'Status' },
{ key: 'boarded', label: 'Boarded Status' },
].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={selectedColumns[col.key] || false}
onChange={(e) =>
setSelectedColumns({ ...selectedColumns, [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 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>
<p className="text-sm font-medium mb-2">Select Columns</p>
<div className="space-y-2 max-h-56 overflow-y-auto">
{[
{ key: 'ticketNumber', label: 'Ticket Number' },
{ key: 'booking', label: 'Booking Reference & Passenger' },
{ key: 'trip', label: 'Trip (Origin → Destination)' },
{ key: 'coach', label: 'Coach Number' },
{ key: 'seat', label: 'Seat Number' },
{ key: 'seatClass', label: 'Seat Class' },
{ key: 'amount', label: 'Amount' },
{ key: 'status', label: 'Status' },
{ key: 'boarded', label: 'Boarded Status' },
].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={selectedColumns[col.key] || false}
onChange={(e) => setSelectedColumns({ ...selectedColumns, [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>
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
</div>
</div>
</Modal>

View File

@@ -15,6 +15,7 @@ interface ConfirmDialogProps {
isLoading?: boolean;
isDanger?: boolean;
warning?: string;
error?: string;
}
export default function ConfirmDialog({
@@ -28,6 +29,7 @@ export default function ConfirmDialog({
isLoading = false,
isDanger = false,
warning,
error,
}: ConfirmDialogProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
@@ -47,6 +49,12 @@ export default function ConfirmDialog({
</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">
<ActionButton variant="secondary" onClick={onClose} disabled={isLoading}>
{cancelText}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -7,6 +7,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
import { useState, useEffect } from "react";
import { PaymentMethod } from "@/types";
import { format } from "date-fns";
import {
CreditCard,
Smartphone,
@@ -23,12 +24,14 @@ const getIconForMethod = (methodId: string) => {
export default function PaymentPage() {
const router = useRouter();
const { bookingId, pnr, selectedSchedule, passengers } = useBookingStore();
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore();
const { selectedCurrency, setPaymentIntent, updateStatus } =
usePaymentStore();
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery<PaymentMethod[]>({
queryKey: ['paymentMethods'],
queryFn: async () => {
@@ -38,10 +41,21 @@ export default function PaymentPage() {
});
// Calculate total amount
const baseFare = passengers.reduce(
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce(
(sum) => sum + (outboundSchedule.baseFareAdult || 0),
0,
) : 0;
const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce(
(sum) => sum + (inboundSchedule.baseFareAdult || 0),
0,
) : 0;
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce(
(sum) => sum + (selectedSchedule?.baseFareAdult || 0),
0,
);
const totalAmount = baseFare;
const paymentMutation = useMutation({
@@ -207,47 +221,258 @@ export default function PaymentPage() {
{/* Order Summary */}
<div className="card mb-6">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">
<h2 className="text-xl font-semibold mb-6 text-gray-900 dark:text-gray-100">
Order summary
</h2>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Route</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{selectedSchedule?.origin} {selectedSchedule?.destination}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Train</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{selectedSchedule?.trainNumber}
</span>
</div>
{selectedSchedule?.selectedSeatClassName && (
<div className="flex justify-between">
<div className="space-y-6">
{isRoundTrip ? (
<>
{/* Outbound Journey */}
<div>
<div className="flex items-center gap-2 mb-4">
<div className="w-2 h-2 bg-primary rounded-full" />
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Outbound Journey</span>
<span className="ml-auto text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium">
{outboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
</span>
</div>
{/* Flight-style timeline */}
<div className="flex">
{/* Left column: Timeline with dots and line */}
<div className="flex flex-col items-center w-8 flex-shrink-0">
{/* Origin dot */}
<div className="w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900 z-10" />
{/* Vertical line */}
<div className="w-0.5 flex-1 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
{/* Destination dot */}
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
</div>
{/* Right column: Content */}
<div className="flex-1 flex flex-col">
{/* Origin */}
<div className="pb-8">
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'HH:mm') : '--:--'}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{outboundSchedule?.departureTime ? format(new Date(outboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{outboundSchedule?.origin}
</div>
</div>
{/* Journey Info */}
<div className="pb-8">
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="font-medium">{outboundSchedule?.duration}</span>
</div>
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="font-medium">Train {outboundSchedule?.trainNumber}</span>
</div>
</div>
</div>
{/* Destination */}
<div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{outboundSchedule?.arrivalTime ? format(new Date(outboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{outboundSchedule?.destination}
</div>
</div>
</div>
</div>
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-800">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Outbound fare</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">ETB {(outboundBaseFare / 100).toFixed(2)}</span>
</div>
</div>
</div>
{/* Return Journey */}
<div className="pt-4 border-t-2 border-dashed border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 mb-4">
<div className="w-2 h-2 bg-blue-500 rounded-full" />
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Return Journey</span>
<span className="ml-auto text-xs px-2 py-0.5 bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full font-medium">
{inboundSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
</span>
</div>
{/* Flight-style timeline */}
<div className="flex">
{/* Left column: Timeline with dots and line */}
<div className="flex flex-col items-center w-8 flex-shrink-0">
{/* Origin dot */}
<div className="w-4 h-4 rounded-full border-4 border-blue-500 bg-white dark:bg-gray-900 z-10" />
{/* Vertical line */}
<div className="w-0.5 flex-1 bg-gradient-to-b from-blue-500 via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
{/* Destination dot */}
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
</div>
{/* Right column: Content */}
<div className="flex-1 flex flex-col">
{/* Origin */}
<div className="pb-8">
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'HH:mm') : '--:--'}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{inboundSchedule?.departureTime ? format(new Date(inboundSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{inboundSchedule?.origin}
</div>
</div>
{/* Journey Info */}
<div className="pb-8">
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="font-medium">{inboundSchedule?.duration}</span>
</div>
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="font-medium">Train {inboundSchedule?.trainNumber}</span>
</div>
</div>
</div>
{/* Destination */}
<div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'HH:mm') : '--:--'}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{inboundSchedule?.arrivalTime ? format(new Date(inboundSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{inboundSchedule?.destination}
</div>
</div>
</div>
</div>
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-800">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Return fare</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">ETB {(inboundBaseFare / 100).toFixed(2)}</span>
</div>
</div>
</div>
</>
) : (
<>
{/* One-Way Journey */}
<div>
<div className="flex items-center gap-2 mb-4">
<div className="w-2 h-2 bg-primary rounded-full" />
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">Your Journey</span>
<span className="ml-auto text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full font-medium">
{selectedSchedule?.selectedSeatClassName?.replace(/_/g, ' ') || 'Standard'}
</span>
</div>
{/* Flight-style timeline */}
<div className="flex">
{/* Left column: Timeline with dots and line */}
<div className="flex flex-col items-center w-8 flex-shrink-0">
{/* Origin dot */}
<div className="w-4 h-4 rounded-full border-4 border-primary bg-white dark:bg-gray-900 z-10" />
{/* Vertical line */}
<div className="w-0.5 flex-1 bg-gradient-to-b from-primary via-gray-300 dark:via-gray-700 to-gray-300 dark:to-gray-700 my-2" />
{/* Destination dot */}
<div className="w-4 h-4 rounded-full border-4 border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-900 z-10" />
</div>
{/* Right column: Content */}
<div className="flex-1 flex flex-col">
{/* Origin */}
<div className="pb-8">
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'HH:mm') : '--:--'}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{selectedSchedule?.departureTime ? format(new Date(selectedSchedule.departureTime), 'EEE, MMM d') : 'N/A'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{selectedSchedule?.origin}
</div>
</div>
{/* Journey Info */}
<div className="pb-8">
<div className="flex items-center gap-6 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="font-medium">{selectedSchedule?.duration}</span>
</div>
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="font-medium">Train {selectedSchedule?.trainNumber}</span>
</div>
</div>
</div>
{/* Destination */}
<div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">
{selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'HH:mm') : '--:--'}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{selectedSchedule?.arrivalTime ? format(new Date(selectedSchedule.arrivalTime), 'EEE, MMM d') : 'N/A'}
</div>
<div className="text-base font-semibold text-gray-900 dark:text-white mt-2">
{selectedSchedule?.destination}
</div>
</div>
</div>
</div>
</div>
</>
)}
{/* Passengers and Total */}
<div className="pt-4 border-t-2 border-gray-200 dark:border-gray-700">
<div className="flex justify-between text-sm mb-3">
<span className="text-gray-600 dark:text-gray-400">
Class
Passengers
</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{selectedSchedule.selectedSeatClassName.replace(/_/g, " ")}
{passengers.length} passenger{passengers.length !== 1 ? "s" : ""}
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">
Passengers
</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{passengers.length} passenger
{passengers.length !== 1 ? "s" : ""}
</span>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
<div className="flex justify-between text-lg font-bold">
<span className="text-gray-900 dark:text-gray-100">
<div className="flex justify-between items-center pt-3 border-t border-gray-200 dark:border-gray-700">
<span className="text-base font-bold text-gray-900 dark:text-gray-100">
Total amount
</span>
<span className="text-primary dark:text-gray-100">
<span className="text-2xl font-bold text-primary dark:text-gray-100">
ETB {(totalAmount / 100).toFixed(2)}
</span>
</div>

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