Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/priority

This commit is contained in:
marshal
2026-06-18 10:29:42 +03:00
34 changed files with 1303 additions and 334 deletions

View File

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

View File

@@ -44,7 +44,6 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder"; import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module"; import { PaymentModule } from "./modules/payment/payment.module";
import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
@@ -56,6 +55,7 @@ import { ContainersModule } from './modules/container-management/containers.modu
import { CargoesModule } from './modules/cargoes/cargoes.module'; import { CargoesModule } from './modules/cargoes/cargoes.module';
import { RoutesModule } from './modules/routes/routes.module'; import { RoutesModule } from './modules/routes/routes.module';
import { OverviewModule } from './modules/overview/overview.module'; import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
@Module({ @Module({
imports: [ imports: [
@@ -113,12 +113,12 @@ import { OverviewModule } from './modules/overview/overview.module';
CargoesModule, CargoesModule,
RoutesModule, RoutesModule,
OverviewModule, OverviewModule,
VehiclesModule,
], ],
providers: [ providers: [
EdrOrgSeeder, EdrOrgSeeder,
DemoUsersSeeder, DemoUsersSeeder,
FreightStaffUsersSeeder, FreightStaffUsersSeeder,
DemoBookingsSeeder,
PricingDataSeeder, PricingDataSeeder,
FileUploadSettingsSeeder, FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder, FreightPermissionKeyMigrationSeeder,

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

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

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

@@ -35,14 +35,16 @@ async function bootstrap() {
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM. Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
## Latest Updates ## Latest Updates
- **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 or RETURN). - **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. - **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. - **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. - **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. - **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. - **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. - **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. - **Multi-Currency Display:** Bookings track display currency and converted amounts.
- **Ticket Lifecycle:** Tickets now include validatedAt, outboundBoardedAt, returnBoardedAt for complete audit trail. - **Ticket Lifecycle:** Tickets now include validatedAt, outboundBoardedAt, returnBoardedAt for complete audit trail.
@@ -61,11 +63,13 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- Multi-segment journey support - Multi-segment journey support
- Cross-border journeys via Dire Dawa transit (Ethiopia to Djibouti) - Cross-border journeys via Dire Dawa transit (Ethiopia to Djibouti)
- Round-trip booking with return journey scheduling - Round-trip booking with return journey scheduling
- Transit booking (single journey via connecting train, single PNR, single ticket)
- Round-trip transit booking (round trip where one or both directions use a connecting train)
- Coach type selection with seat class and pricing options - Coach type selection with seat class and pricing options
- NEW: Booking type tracking (ONE_WAY vs ROUND_TRIP) - Booking type field: ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT
- NEW: Display currency and converted pricing per booking - Display currency and converted pricing per booking
- NEW: returnLegStatus field tracks which legs of a round-trip were used - returnLegStatus field tracks which legs of a round-trip were used
- NEW: GET /bookings?returnLegStatus=OUTBOUND_ONLY filters no-show returns in back-office - GET /bookings?returnLegStatus=OUTBOUND_ONLY filters no-show returns in back-office
### Passenger Verification ### Passenger Verification
1. Ethiopian Nationals: 1. Ethiopian Nationals:
@@ -111,21 +115,33 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
- NEW: Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets - NEW: Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
- NEW: Complete audit trail per leg for compliance and reporting - NEW: Complete audit trail per leg for compliance and reporting
### Round-Trip Leg Tracking (NEW) ### 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 - returnLegStatus on Booking: NOT_APPLICABLE, NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED
- Gate validation POST /tickets/:ref/validate accepts optional leg field (OUTBOUND or RETURN) - 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 - 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 - Back-office filter: GET /bookings?returnLegStatus=OUTBOUND_ONLY surfaces no-shows
- Offline batch: validateOfflineBatch accepts leg per entry, handles both legs of same booking - Offline batch: validateOfflineBatch accepts leg per entry, handles both legs of same booking
### Round-Trip Booking ### Round-Trip & Transit Bookings
- One-way and round-trip journey options - ONE_WAY and ROUND_TRIP for direct routes
- Flexible return date selection - TRANSIT for single connecting journey (Dire Dawa hub), single PNR
- Combined pricing for outbound + return legs - ROUND_TRIP_TRANSIT for round trips via connecting trains
- Separate seat management per leg - Combined pricing: total = sum of all leg base fares, single promo/loyalty deduction
- Independent modification/cancellation per leg - Separate seat management per leg; each leg stored with its scheduleId and leg number
- Return journey tracking and notifications - returnLegStatus tracks which legs have been boarded for no-show management
- NEW: Booking type stored for analytics and reporting
### Loyalty Program ### Loyalty Program
- 4 tiers: Bronze, Silver, Gold, Platinum - 4 tiers: Bronze, Silver, Gold, Platinum
@@ -198,26 +214,37 @@ Used for agent, fraud, and reporting endpoints. Requires corporate IAM token.
## Passenger Booking Flow ## Passenger Booking Flow
### Step 1: Search Trips ### Step 1: Search Trips
\`POST /search\` with origin, destination, date, passenger counts, and nationality \`POST /search\` with origin, destination, date, passenger counts, and nationality.
For round-trips also pass \`journeyType=ROUND_TRIP\` and \`returnDate\`.
### Step 2: Get Fare Quote ### Step 2: Get Fare Quote
\`POST /search/fare-quote\` with passenger counts and display currency \`POST /search/fare-quote\` with passenger counts and display currency.
For round-trips also pass \`returnScheduleId\`, \`returnOriginStationId\`, \`returnDestinationStationId\`.
### Step 3: Passenger Information & Verification ### Step 3: Passenger Information & Verification
**For Ethiopian Passengers:** **For Ethiopian Passengers:**
\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (5+ years) \`POST /passengers/verify-fayda\` Automatic Fayda verification for adults (5+ years)
**For International Passengers:** **For International Passengers:**
\`POST /passengers/register-international\` - Passport information collection \`POST /passengers/register-international\` Passport information collection
### Step 4: View Seat Map ### Step 4: View Seat Map
\`GET /seats/seatmap/{scheduleId}\` - Show available coaches and seats \`GET /seats/seatmap/{scheduleId}\` Show available coaches and seats.
For round-trips, call this twice: once for outbound scheduleId, once for return scheduleId.
### Step 5: Login & Hold Seats ### Step 5: Hold Seats
\`POST /auth/login\` then \`POST /seats/hold\` to reserve seats for 15 minutes \`POST /seats/hold\` to reserve seats for 15 minutes.
- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\`
- TRANSIT leg-2: second hold call → \`leg2HoldId\`
- ROUND_TRIP return: second hold call → \`returnHoldId\`
- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\`
### Step 6: Create Booking ### Step 6: Create Booking
\`POST /bookings/guest\` with verified passenger details and held seats Choose the right endpoint and bookingType:
- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\`
- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\`
- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\`
- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\`
### Step 7: Process Payment ### Step 7: Process Payment
\`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian) \`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian)
@@ -268,7 +295,7 @@ Payment providers send notifications to:
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation") .addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails") .addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
.addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management") .addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management")
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports returnLegStatus filter for round-trip no-show management") .addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT booking types. returnLegStatus filter for round-trip no-show management")
.addTag("Config", "System settings, feature flags, and configuration management") .addTag("Config", "System settings, feature flags, and configuration management")
.addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion") .addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion")
.addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications") .addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications")
@@ -285,7 +312,6 @@ Payment providers send notifications to:
.addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation") .addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation")
.addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking") .addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking")
.addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards") .addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards")
.addTag("Round Trip", "Round-trip bookings, return scheduling, combined pricing, and management (NEW)")
.addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance") .addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance")
.addTag("Schedule", "Trip schedules, availability windows, status tracking, and timing") .addTag("Schedule", "Trip schedules, availability windows, status tracking, and timing")
.addTag("Search", "Trip search, fare quotes, coach types, and real-time availability") .addTag("Search", "Trip search, fare quotes, coach types, and real-time availability")
@@ -294,8 +320,8 @@ Payment providers send notifications to:
.addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability") .addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability")
.addTag("Stations", "Station directory, location data, baggage facilities, and amenities") .addTag("Stations", "Station directory, location data, baggage facilities, and amenities")
.addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution") .addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution")
.addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation with per-leg tracking (OUTBOUND/RETURN), and audit trails") .addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation with per-leg tracking (OUTBOUND/RETURN/LEG1/LEG2/OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2), and audit trails")
.addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, multi-leg routing (NEW)") .addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, TRANSIT and ROUND_TRIP_TRANSIT bookings")
.addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger") .addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger")
//.addServer('http://localhost:4000', 'Development') //.addServer('http://localhost:4000', 'Development')
// .addServer("https://api.edr-platform.com", "Production") // .addServer("https://api.edr-platform.com", "Production")

View File

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

View File

@@ -4,8 +4,10 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client'; import { Currency, IdDocumentType } from '@prisma/client';
export class PassengerInputDto { export class PassengerInputDto {
@ApiProperty() @IsString() seatId: string; @ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' }) @IsString() seatId: string;
@ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Seat ID on leg-2 schedule' }) @IsOptional() @IsString() leg2SeatId?: string; @ApiPropertyOptional({ example: 'leg2-seat-uuid', description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID' }) @IsOptional() @IsString() leg2SeatId?: string;
@ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID' }) @IsOptional() @IsString() returnSeatId?: string;
@ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) @IsOptional() @IsString() returnLeg2SeatId?: string;
@ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string; @ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string;
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first free), Age ≥5 = ADULT (full fare)' }) @IsDateString() dateOfBirth: string; @ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first free), Age ≥5 = ADULT (full fare)' }) @IsDateString() dateOfBirth: string;
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
@@ -76,16 +78,16 @@ export class CreateBookingDto {
@ApiProperty({ description: 'Passenger ID' }) @ApiProperty({ description: 'Passenger ID' })
@IsString() passengerId: string; @IsString() passengerId: string;
@ApiProperty({ description: 'Outbound schedule ID' }) @ApiProperty({ description: 'Outbound / leg-1 schedule ID' })
@IsString() scheduleId: string; @IsString() scheduleId: string;
@ApiProperty({ description: 'Outbound seat hold ID' }) @ApiProperty({ description: 'Outbound / leg-1 seat hold ID' })
@IsString() holdId: string; @IsString() holdId: string;
@ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID (must match the hold)' }) @ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID' })
@IsString() originStationId: string; @IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID (must match the hold)' }) @ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID' })
@IsString() destinationStationId: string; @IsString() destinationStationId: string;
@ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID (Economy Regular, Economy Bed, VIP Bed)' }) @ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID (Economy Regular, Economy Bed, VIP Bed)' })
@@ -94,14 +96,32 @@ export class CreateBookingDto {
@ApiProperty({ @ApiProperty({
example: 'ONE_WAY', example: 'ONE_WAY',
enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'], enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'],
description: `Booking type:\n\n**ONE_WAY:** Single journey\n\n**ROUND_TRIP:** Outbound + return, single PNR\n\n**TRANSIT:** Single journey via connecting train, single PNR, single ticket\n\n**ROUND_TRIP_TRANSIT:** Round trip where one or both directions use a connecting train`, description: `Booking type:
**ONE_WAY:** Single direct journey — needs: scheduleId, holdId. Passenger: seatId.
**ROUND_TRIP:** Outbound + return, single PNR — needs above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId. Passenger: seatId + returnSeatId.
**TRANSIT:** Single journey via connecting train, single PNR — needs above + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId. Passenger: seatId + leg2SeatId.
**ROUND_TRIP_TRANSIT:** Round trip via connecting trains — needs all 4 hold sets + all station fields. Passenger: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId.`,
default: 'ONE_WAY' default: 'ONE_WAY'
}) })
@IsOptional() @IsString() bookingType?: string; @IsOptional() @IsString() bookingType?: string;
@ApiProperty({ @ApiProperty({
type: [PassengerInputDto], type: [PassengerInputDto],
description: `Passenger array - type depends on bookingType:\n\n**For ONE_WAY:** PassengerInputDto[]\n- Each passenger has: seatId, passengerName, dateOfBirth, etc.\n\n**For ROUND_TRIP:** RoundTripPassengerDto[]\n- Each passenger has: outboundSeatId, returnSeatId, passengerName, dateOfBirth, etc.\n\n**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare` description: `Passenger array — required seat fields vary by bookingType:
**ONE_WAY:** { seatId, passengerName, dateOfBirth, idDocumentType, … }
**ROUND_TRIP:** { seatId (outbound leg-1), returnSeatId (return leg-1), passengerName, … }
**TRANSIT:** { seatId (leg-1), leg2SeatId (leg-2), passengerName, … }
**ROUND_TRIP_TRANSIT:** { seatId, leg2SeatId, returnSeatId, returnLeg2SeatId, passengerName, … }
**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare.`
}) })
@IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto)
passengers: PassengerInputDto[]; passengers: PassengerInputDto[];

View File

@@ -202,7 +202,7 @@ export class BookingsService {
} }
if (status) where.status = status; if (status) where.status = status;
if (returnLegStatus) where.returnLegStatus = returnLegStatus; if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.booking.findMany({ this.prisma.booking.findMany({
@@ -233,6 +233,8 @@ export class BookingsService {
contactPhone: booking.contactPhone, contactPhone: booking.contactPhone,
bookingType: booking.bookingType, bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null, returnLegStatus: (booking as any).returnLegStatus ?? null,
adultCount: booking.adultCount,
childCount: booking.childCount,
createdAt: booking.createdAt, createdAt: booking.createdAt,
passenger: booking.passenger?.user, passenger: booking.passenger?.user,
schedule: { schedule: {

View File

@@ -4,16 +4,16 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client'; import { Currency, IdDocumentType } from '@prisma/client';
export class GuestPassengerDto { export class GuestPassengerDto {
@ApiProperty({ example: 'seat-id-uuid', description: 'Outbound seat ID (or only seat for ONE_WAY)' }) @ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' })
@IsString() seatId: string; @IsString() seatId: string;
@ApiPropertyOptional({ example: 'seat-id-uuid', description: 'Return seat ID (ROUND_TRIP / ROUND_TRIP_TRANSIT outbound leg-1)' }) @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; @IsOptional() @IsString() returnSeatId?: string;
@ApiPropertyOptional({ example: 'seat-id-uuid', description: 'Leg-2 seat ID (TRANSIT / ROUND_TRIP_TRANSIT outbound leg-2)' }) @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; @IsOptional() @IsString() leg2SeatId?: string;
@ApiPropertyOptional({ example: 'seat-id-uuid', description: 'ROUND_TRIP_TRANSIT: return journey leg-2 seat ID' }) @ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID. Required for ROUND_TRIP_TRANSIT.' })
@IsOptional() @IsString() returnLeg2SeatId?: string; @IsOptional() @IsString() returnLeg2SeatId?: string;
@ApiProperty({ example: 'Abebe Kebede' }) @ApiProperty({ example: 'Abebe Kebede' })
@@ -45,7 +45,16 @@ export class GuestPassengerDto {
} }
export class CreateGuestBookingDto { export class CreateGuestBookingDto {
@ApiPropertyOptional({ example: 'ONE_WAY', enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'], default: 'ONE_WAY' }) @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'; @IsOptional() @IsString() bookingType?: 'ONE_WAY' | 'ROUND_TRIP' | 'TRANSIT' | 'ROUND_TRIP_TRANSIT';
@ApiProperty({ example: 'schedule-uuid', description: 'Outbound / leg-1 schedule UUID' }) @ApiProperty({ example: 'schedule-uuid', description: 'Outbound / leg-1 schedule UUID' })
@@ -108,7 +117,14 @@ export class CreateGuestBookingDto {
@ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 seat class UUID' }) @ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 seat class UUID' })
@IsOptional() @IsString() returnLeg2SeatClassId?: string; @IsOptional() @IsString() returnLeg2SeatClassId?: string;
@ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. For ROUND_TRIP each passenger must include returnSeatId.' }) @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[]; @IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[];
@ApiPropertyOptional({ example: 'WEEKEND15' }) @ApiPropertyOptional({ example: 'WEEKEND15' })

View File

@@ -19,6 +19,24 @@ export class SendMessage {
from?: string; from?: string;
} }
export class SingleMessageDto {
@ApiProperty({
description: 'Recipient phone number',
example: '+1234567890',
})
@IsString()
@IsNotEmpty()
to: string;
@ApiProperty({
description: 'Message content',
example: 'Test Single SMS from',
})
@IsString()
@IsNotEmpty()
sms: string;
}
export class BulkMessagesDto { export class BulkMessagesDto {
@ApiProperty({ type: [SendMessage] }) @ApiProperty({ type: [SendMessage] })
@IsArray() @IsArray()

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ export class NotificationsService {
) { ) {
this.channels = new Map<NotificationChannelType, NotificationChannel>([ this.channels = new Map<NotificationChannelType, NotificationChannel>([
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then(() => true) }], ['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then(() => true) }],
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }], ['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, sms: body }).then(() => true) }],
['PUSH', this.pushAdapter as NotificationChannel], ['PUSH', this.pushAdapter as NotificationChannel],
]); ]);
} }

View File

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

View File

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

View File

@@ -547,16 +547,14 @@ export class SeatsService {
@Cron(CronExpression.EVERY_MINUTE) @Cron(CronExpression.EVERY_MINUTE)
async expireHolds() { async expireHolds() {
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } }); const now = new Date();
for (const hold of expired) { 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); 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

@@ -31,18 +31,27 @@ export class TicketsController {
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all tickets with optional filters' }) @ApiOperation({ summary: 'List all tickets with optional filters' })
@ApiQuery({ name: 'search', required: false }) @ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'status', required: false, description: 'ACTIVE | USED | CANCELLED' }) @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: 'skip', required: false })
@ApiQuery({ name: 'take', required: false }) @ApiQuery({ name: 'take', required: false })
listTickets( listTickets(
@Query('search') search?: string, @Query('search') search?: string,
@Query('status') status?: string, @Query('status') status?: string,
@Query('originStationId') originStationId?: string,
@Query('destinationStationId') destinationStationId?: string,
@Query('arrivalDate') arrivalDate?: string,
@Query('skip') skip?: string, @Query('skip') skip?: string,
@Query('take') take?: string, @Query('take') take?: string,
) { ) {
return this.service.listTickets({ return this.service.listTickets({
search, search,
status, status,
originStationId,
destinationStationId,
arrivalDate,
skip: skip ? parseInt(skip) : 0, skip: skip ? parseInt(skip) : 0,
take: take ? parseInt(take) : 50, take: take ? parseInt(take) : 50,
}); });
@@ -60,17 +69,8 @@ export class TicketsController {
} }
@Get(':bookingRef') @Get(':bookingRef')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ @ApiOperation({
summary: 'Get ticket with QR code and passenger details', summary: 'Get ticket with QR code and passenger details (public)',
description: `Returns ticket information including:
- QR code for gate scanning
- Barcode for offline validation
- Passenger details (name, age category, nationality)
- Journey details (origin, destination, seat, coach)
- Fare breakdown with currency
- PDF download link`
}) })
getByRef(@Param('bookingRef') ref: string) { getByRef(@Param('bookingRef') ref: string) {
return this.service.getByRef(ref); return this.service.getByRef(ref);

View File

@@ -14,7 +14,7 @@ interface OfflineValidation {
export class TicketsService { export class TicketsService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
async listTickets(filters: { search?: string; status?: string; skip: number; take: number }) { async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) {
const where: any = {}; const where: any = {};
if (filters.search) { if (filters.search) {
where.OR = [ where.OR = [
@@ -24,7 +24,19 @@ export class TicketsService {
]; ];
} }
if (filters.status) { if (filters.status) {
where.booking = { status: filters.status }; where.booking = { ...where.booking, status: filters.status };
}
if (filters.originStationId) {
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } };
}
if (filters.destinationStationId) {
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } };
}
if (filters.arrivalDate) {
const start = new Date(filters.arrivalDate);
const end = new Date(filters.arrivalDate);
end.setDate(end.getDate() + 1);
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } };
} }
const tickets = await this.prisma.ticket.findMany({ const tickets = await this.prisma.ticket.findMany({
where, where,
@@ -32,7 +44,7 @@ export class TicketsService {
booking: { booking: {
include: { include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } }, seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } }, passenger: { include: { user: true } },
}, },
}, },
@@ -235,7 +247,16 @@ export class TicketsService {
}; };
} }
async validate(bookingRef: string, validatorId: string, gateId?: string, leg?: string) { async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) {
// Accept either a ticket UUID or a bookingRef
let bookingRef = ticketIdOrRef;
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ticketIdOrRef);
if (isUuid) {
const ticket = await this.prisma.ticket.findUnique({ where: { id: ticketIdOrRef }, select: { bookingRef: true } });
if (!ticket) throw new NotFoundException('Ticket not found');
bookingRef = ticket.bookingRef;
}
const resolvedValidatorId = validatorId || 'BACKOFFICE';
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } }); const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
if (!booking) throw new NotFoundException('Booking not found'); if (!booking) throw new NotFoundException('Booking not found');
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } }); const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
@@ -247,11 +268,10 @@ export class TicketsService {
// ── ONE_WAY / TRANSIT (single scan) ─────────────────────────────────── // ── ONE_WAY / TRANSIT (single scan) ───────────────────────────────────
if (type === 'ONE_WAY') { if (type === 'ONE_WAY') {
if (ticket.validatedAt) { if (ticket.validatedAt) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } }); return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
throw new BadRequestException('Ticket already validated');
} }
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
return { validated: true, ticketId: ticket.id, validatedAt: now }; return { validated: true, ticketId: ticket.id, validatedAt: now };
} }
@@ -264,28 +284,32 @@ export class TicketsService {
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
const alreadyValidated = logs.some(l => l.leg === resolvedLeg); const alreadyValidated = logs.some(l => l.leg === resolvedLeg);
if (alreadyValidated) { if (alreadyValidated) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); 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`); throw new BadRequestException(`${resolvedLeg} already validated`);
} }
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); 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, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); 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 }; return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
} }
// ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ──────────────────────── // ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ────────────────────────
if (type === 'ROUND_TRIP') { if (type === 'ROUND_TRIP') {
const resolvedLeg = (leg ?? 'OUTBOUND').toUpperCase(); 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> = {}; const bookingData: Record<string, any> = {};
if (resolvedLeg === 'OUTBOUND') { if (resolvedLeg === 'OUTBOUND') {
if ((booking as any).outboundBoardedAt) { if ((booking as any).outboundBoardedAt) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any }); 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'); throw new BadRequestException('Outbound leg already used');
} }
bookingData.outboundBoardedAt = now; bookingData.outboundBoardedAt = now;
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
} else if (resolvedLeg === 'RETURN') { } else if (resolvedLeg === 'RETURN') {
if ((booking as any).returnBoardedAt) { if ((booking as any).returnBoardedAt) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any }); 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'); throw new BadRequestException('Return leg already used');
} }
bookingData.returnBoardedAt = now; bookingData.returnBoardedAt = now;
@@ -298,7 +322,7 @@ export class TicketsService {
else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY'; else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); 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 }; return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
} }
@@ -311,7 +335,7 @@ export class TicketsService {
} }
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
if (logs.some(l => l.leg === resolvedLeg)) { if (logs.some(l => l.leg === resolvedLeg)) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); 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`); throw new BadRequestException(`${resolvedLeg} already validated`);
} }
const bookingData: Record<string, any> = {}; const bookingData: Record<string, any> = {};
@@ -327,18 +351,17 @@ export class TicketsService {
else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_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 (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 } }); 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, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); 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 }; return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
} }
// Fallback for unknown booking types — single scan // Fallback for unknown booking types — single scan
if (ticket.validatedAt) { if (ticket.validatedAt) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } }); return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
throw new BadRequestException('Ticket already validated');
} }
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
return { validated: true, ticketId: ticket.id, validatedAt: now }; return { validated: true, ticketId: ticket.id, validatedAt: now };
} }
@@ -354,7 +377,7 @@ export class TicketsService {
where: { scheduleId: tripId, status: 'CONFIRMED' }, where: { scheduleId: tripId, status: 'CONFIRMED' },
include: { include: {
ticket: true, ticket: true,
seats: { include: { seat: { include: { coach: true } } } }, seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } }, passenger: { include: { user: true } },
}, },
}); });

View File

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

View File

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

View File

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

View File

@@ -228,7 +228,7 @@ export default function SeatsPage() {
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
if (isBedCoach && hasBedPositionData) { if (isBedCoach) {
const arrangement = parseSeatArrangement(coach.seatArrangement); const arrangement = parseSeatArrangement(coach.seatArrangement);
const seatsPerRow = arrangement[0] + (arrangement[1] || 0); const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
const allSeatsForLayout = [...validSeats, ...removedSeats]; const allSeatsForLayout = [...validSeats, ...removedSeats];

View File

@@ -9,11 +9,11 @@ import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import { ticketsApi, apiClient, schedulesApi, stationsApi } from '@/lib/api'; import { ticketsApi, apiClient, stationsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function TicketsPage() { export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', tripDate: '' }); const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [ticketToDelete, setTicketToDelete] = useState<any>(null); const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false); const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
@@ -22,6 +22,8 @@ export default function TicketsPage() {
const [detailsModalOpen, setDetailsModalOpen] = useState(false); const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null); const [selectedTicket, setSelectedTicket] = useState<any>(null);
const [exportModalOpen, setExportModalOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [selectedColumns, setSelectedColumns] = useState<Record<string, boolean>>({ const [selectedColumns, setSelectedColumns] = useState<Record<string, boolean>>({
ticketNumber: true, ticketNumber: true,
booking: true, booking: true,
@@ -35,7 +37,15 @@ export default function TicketsPage() {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ['tickets', filters], queryKey: ['tickets', filters],
queryFn: () => ticketsApi.getAll({ ...filters, skip: 0, take: 50 }), queryFn: () => ticketsApi.getAll({
search: filters.search || undefined,
status: filters.status || undefined,
originStationId: filters.originStationId || undefined,
destinationStationId: filters.destinationStationId || undefined,
arrivalDate: filters.arrivalDate || undefined,
skip: 0,
take: 50,
}),
}); });
const { data: stationsData } = useQuery({ const { data: stationsData } = useQuery({
@@ -94,25 +104,31 @@ export default function TicketsPage() {
} }
}; };
const handleExportTickets = async () => {
setExportModalOpen(true);
};
const confirmExport = () => { const confirmExport = () => {
const cols = Object.entries(selectedColumns) const cols = Object.entries(selectedColumns)
.filter(([, selected]) => selected) .filter(([, selected]) => selected)
.map(([col]) => col); .map(([col]) => col);
if (cols.length === 0) { if (cols.length === 0) {
alert('Please select at least one column'); alert('Please select at least one column');
return; return;
} }
const exportItems = (data?.items || []).filter((ticket: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = ticket.schedule?.arrivalAt
? new Date(ticket.schedule.arrivalAt).toISOString().split('T')[0]
: null;
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [ const csv = [
cols.join(','), cols.join(','),
...data?.items?.map((ticket: any) => { ...exportItems.map((ticket: any) => {
const values = cols.map(col => { const values = cols.map(col => {
switch(col) { switch (col) {
case 'ticketNumber': return ticket.ticketNumber || ''; case 'ticketNumber': return ticket.ticketNumber || '';
case 'booking': return ticket.booking?.bookingRef || ''; case 'booking': return ticket.booking?.bookingRef || '';
case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`; case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`;
@@ -126,9 +142,9 @@ export default function TicketsPage() {
} }
}); });
return values.map(v => `"${v}"`).join(','); return values.map(v => `"${v}"`).join(',');
}) || [] }),
].join('\n'); ].join('\n');
const blob = new Blob([csv], { type: 'text/csv' }); const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -175,12 +191,12 @@ export default function TicketsPage() {
}, },
{ {
key: 'seat', key: 'seat',
label: 'Seat', label: 'Seat/Bed',
sortable: true, sortable: true,
render: (ticket: any) => ( render: (ticket: any) => (
<div> <div>
<div className="font-mono font-semibold">Coach {ticket.seat?.coach?.number || 'N/A'} - Seat {ticket.seat?.seatNumber || 'N/A'}</div> <div className="font-mono font-semibold">{ticket.seat?.coach?.number || 'N/A'} - {ticket.seat?.seatNumber || 'N/A'}</div>
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.name || 'N/A'}</div> <div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.type || 'N/A'}</div>
</div> </div>
), ),
}, },
@@ -264,7 +280,7 @@ export default function TicketsPage() {
<h1 className="text-2xl font-bold text-foreground">Tickets</h1> <h1 className="text-2xl font-bold text-foreground">Tickets</h1>
<p className="text-muted-foreground">Manage tickets and validations</p> <p className="text-muted-foreground">Manage tickets and validations</p>
</div> </div>
<ActionButton icon={Download} variant="secondary" onClick={handleExportTickets}>Export</ActionButton> <ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div> </div>
{/* Filters */} {/* Filters */}
@@ -317,12 +333,12 @@ export default function TicketsPage() {
</select> </select>
</div> </div>
<div> <div>
<label className="label">Trip Date</label> <label className="label">Arrival Date</label>
<input <input
type="date" type="date"
className="input" className="input"
value={filters.tripDate} value={filters.arrivalDate}
onChange={(e) => setFilters({ ...filters, tripDate: e.target.value })} onChange={(e) => setFilters({ ...filters, arrivalDate: e.target.value })}
/> />
</div> </div>
<div> <div>
@@ -353,10 +369,7 @@ export default function TicketsPage() {
{/* Board Confirmation Dialog */} {/* Board Confirmation Dialog */}
<ConfirmDialog <ConfirmDialog
isOpen={boardConfirmOpen} isOpen={boardConfirmOpen}
onClose={() => { onClose={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}
setBoardConfirmOpen(false);
setTicketToBoard(null);
}}
onConfirm={handleConfirmBoard} onConfirm={handleConfirmBoard}
title="Board Ticket" title="Board Ticket"
message={`Are you sure you want to board ticket ${ticketToBoard?.ticketNumber}? This will mark the ticket as USED.`} message={`Are you sure you want to board ticket ${ticketToBoard?.ticketNumber}? This will mark the ticket as USED.`}
@@ -368,10 +381,7 @@ export default function TicketsPage() {
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirmOpen} isOpen={deleteConfirmOpen}
onClose={() => { onClose={() => { setDeleteConfirmOpen(false); setTicketToDelete(null); }}
setDeleteConfirmOpen(false);
setTicketToDelete(null);
}}
onConfirm={handleConfirmDelete} onConfirm={handleConfirmDelete}
title="Delete Ticket" title="Delete Ticket"
message={`Are you sure you want to permanently delete ticket ${ticketToDelete?.ticketNumber}? This action cannot be undone.`} message={`Are you sure you want to permanently delete ticket ${ticketToDelete?.ticketNumber}? This action cannot be undone.`}
@@ -384,10 +394,7 @@ export default function TicketsPage() {
{/* Ticket Details Modal */} {/* Ticket Details Modal */}
<Modal <Modal
isOpen={detailsModalOpen} isOpen={detailsModalOpen}
onClose={() => { onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}
setDetailsModalOpen(false);
setSelectedTicket(null);
}}
title="Ticket Details" title="Ticket Details"
size="lg" size="lg"
> >
@@ -488,13 +495,7 @@ export default function TicketsPage() {
)} )}
<div className="flex justify-end gap-2 pt-4"> <div className="flex justify-end gap-2 pt-4">
<ActionButton <ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}>
variant="secondary"
onClick={() => {
setDetailsModalOpen(false);
setSelectedTicket(null);
}}
>
Close Close
</ActionButton> </ActionButton>
</div> </div>
@@ -502,49 +503,55 @@ export default function TicketsPage() {
)} )}
</Modal> </Modal>
{/* Export Columns Modal */} {/* Export Modal */}
<Modal <Modal
isOpen={exportModalOpen} isOpen={exportModalOpen}
onClose={() => setExportModalOpen(false)} onClose={() => setExportModalOpen(false)}
title="Export Tickets - Select Columns" title="Export Tickets"
size="md" size="md"
> >
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-muted-foreground">Select which columns to include in the export</p> <div className="grid grid-cols-2 gap-4">
<div>
<div className="space-y-3 max-h-96 overflow-y-auto"> <label className="label">Date From (Arrival)</label>
{[ <input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
{ key: 'ticketNumber', label: 'Ticket Number' }, </div>
{ key: 'booking', label: 'Booking Reference & Passenger' }, <div>
{ key: 'trip', label: 'Trip (Origin → Destination)' }, <label className="label">Date To (Arrival)</label>
{ key: 'coach', label: 'Coach Number' }, <input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
{ key: 'seat', label: 'Seat Number' }, </div>
{ key: 'seatClass', label: 'Seat Class' }, </div>
{ key: 'amount', label: 'Amount' },
{ key: 'status', label: 'Status' }, <div>
{ key: 'boarded', label: 'Boarded Status' }, <p className="text-sm font-medium mb-2">Select Columns</p>
].map((col) => ( <div className="space-y-2 max-h-56 overflow-y-auto">
<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 { key: 'ticketNumber', label: 'Ticket Number' },
type="checkbox" { key: 'booking', label: 'Booking Reference & Passenger' },
checked={selectedColumns[col.key] || false} { key: 'trip', label: 'Trip (Origin → Destination)' },
onChange={(e) => { key: 'coach', label: 'Coach Number' },
setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked }) { key: 'seat', label: 'Seat Number' },
} { key: 'seatClass', label: 'Seat Class' },
className="w-4 h-4 rounded border-gray-300" { key: 'amount', label: 'Amount' },
/> { key: 'status', label: 'Status' },
<span className="text-sm font-medium">{col.label}</span> { key: 'boarded', label: 'Boarded Status' },
</label> ].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>
<div className="flex justify-end gap-2 pt-4 border-t"> <div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}> <ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
Cancel <ActionButton onClick={confirmExport}>Export CSV</ActionButton>
</ActionButton>
<ActionButton onClick={confirmExport}>
Export CSV
</ActionButton>
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@@ -285,6 +285,7 @@ export default function ReviewPage() {
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
return { return {
seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''),
...(isRoundTrip && { returnSeatId: (p as any).inboundSeatId || '' }),
passengerName: p.name, passengerName: p.name,
dateOfBirth: p.dateOfBirth, dateOfBirth: p.dateOfBirth,
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
@@ -323,6 +324,7 @@ export default function ReviewPage() {
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
return { return {
seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''),
...(isRoundTrip && { returnSeatId: (p as any).inboundSeatId || '' }),
passengerName: p.name, passengerName: p.name,
dateOfBirth: p.dateOfBirth, dateOfBirth: p.dateOfBirth,
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',

View File

@@ -10,10 +10,10 @@ export default registerAs("app", () => ({
serviceAuthToken: process.env.SERVICE_AUTH_TOKEN ?? "", serviceAuthToken: process.env.SERVICE_AUTH_TOKEN ?? "",
reconciliation: { reconciliation: {
/** How often the stale-intent sweep runs. */ /** How often the stale-intent sweep runs. */
sweepIntervalMs: parseInt( // TODO(demo): revert to the env-driven line below after the demo. Temporarily FORCED to 30s
process.env.RECONCILE_SWEEP_INTERVAL_MS ?? "60000", // here so the .env (RECONCILE_SWEEP_INTERVAL_MS) cannot override it.
10, // sweepIntervalMs: parseInt(process.env.RECONCILE_SWEEP_INTERVAL_MS ?? "60000", 10),
), sweepIntervalMs: 30_000,
/** An intent is "stale" when non-terminal and untouched for this long. */ /** An intent is "stale" when non-terminal and untouched for this long. */
staleAfterMs: parseInt(process.env.RECONCILE_STALE_AFTER_MS ?? "60000", 10), staleAfterMs: parseInt(process.env.RECONCILE_STALE_AFTER_MS ?? "60000", 10),
batchSize: parseInt(process.env.RECONCILE_BATCH_SIZE ?? "20", 10), batchSize: parseInt(process.env.RECONCILE_BATCH_SIZE ?? "20", 10),

View File

@@ -13,7 +13,10 @@ export default registerAs("waafi", () => ({
// Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front. // Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front.
paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? "MWALLET_ACCOUNT", paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? "MWALLET_ACCOUNT",
// Waafi has no ETB; when set this overrides the asserted currency (USD/DJF/SLSH). // Waafi has no ETB; when set this overrides the asserted currency (USD/DJF/SLSH).
currency: process.env.WAAFI_CURRENCY ?? "DJF", // TODO(demo): revert to the env-driven line below after the demo. Temporarily FORCED to USD
// here so the .env (WAAFI_CURRENCY) cannot override it.
// currency: process.env.WAAFI_CURRENCY ?? "DJF",
currency: "USD",
// Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth). // Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth).
successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? "", successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? "",
failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? "", failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? "",

View File

@@ -42,7 +42,7 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy {
private readonly cacBankProvider: CacBankProvider, private readonly cacBankProvider: CacBankProvider,
) { ) {
this.intervalMs = this.intervalMs =
config.get<number>("app.reconciliation.sweepIntervalMs") ?? 60_000; config.get<number>("app.reconciliation.sweepIntervalMs") ?? 30_000;
this.staleAfterMs = this.staleAfterMs =
config.get<number>("app.reconciliation.staleAfterMs") ?? 60_000; config.get<number>("app.reconciliation.staleAfterMs") ?? 60_000;
this.batchSize = config.get<number>("app.reconciliation.batchSize") ?? 20; this.batchSize = config.get<number>("app.reconciliation.batchSize") ?? 20;