mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 09:35:44 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/priority
This commit is contained in:
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateVehicleDto } from './create-vehicle.dto';
|
||||||
|
|
||||||
|
export class UpdateVehicleDto extends PartialType(CreateVehicleDto) {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts
Normal file
13
apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts
Normal 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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts
Normal file
109
apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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:**
|
**bookingType: ONE_WAY (default)**
|
||||||
- No login required
|
- scheduleId, holdId, originStationId, destinationStationId, seatClassId
|
||||||
- Contact details from first passenger
|
- passengers[]: { seatId, passengerName, dateOfBirth, idDocumentType, … }
|
||||||
- Booking confirmation sent to email/phone
|
|
||||||
|
|
||||||
**Optional Account Creation:**
|
**bookingType: ROUND_TRIP**
|
||||||
- Set createAccount=true with password
|
- Above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
|
||||||
- Account created using first passenger details
|
- passengers[]: each must include returnSeatId (seat on the return leg)
|
||||||
- Automatic login after booking
|
|
||||||
- Loyalty points and wallet created
|
|
||||||
|
|
||||||
**Passenger Details Storage:**
|
**bookingType: TRANSIT**
|
||||||
- savePassengerDetails=true: Save for future bookings
|
- scheduleId/holdId (leg-1) + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId
|
||||||
- Stored by userId (if account created) or deviceId
|
- passengers[]: each must include leg2SeatId
|
||||||
- Retrieve saved passengers for quick booking
|
|
||||||
|
|
||||||
**Verifayda Verification:**
|
**bookingType: ROUND_TRIP_TRANSIT**
|
||||||
- Ethiopian nationals: National ID verified via Verifayda
|
- All TRANSIT outbound fields + returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId/returnLeg2ScheduleId/returnLeg2HoldId/returnTransitStationId/returnLeg2DestinationStationId
|
||||||
- Other nationals: Passport details (no verification)
|
- passengers[]: each must include leg2SeatId, returnSeatId, returnLeg2SeatId
|
||||||
|
|
||||||
**Age-Based Pricing:**
|
**Optional account creation:** set createAccount=true with password — creates account from first passenger details, loyalty + wallet initialised.
|
||||||
- ADULT (≥5 years): Full fare
|
|
||||||
- CHILD (<5 years): First child FREE, subsequent children full fare`
|
**Verifayda:** Ethiopian nationals verified; international passengers require passportNumber + passportCountry.`
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 201, description: 'Booking created successfully' })
|
@ApiBody({
|
||||||
@ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid data' })
|
type: CreateGuestBookingDto,
|
||||||
|
examples: {
|
||||||
|
ONE_WAY: {
|
||||||
|
summary: 'ONE_WAY — single direct journey (guest)',
|
||||||
|
value: {
|
||||||
|
scheduleId: 'schedule-uuid',
|
||||||
|
holdId: 'hold-uuid',
|
||||||
|
originStationId: 'station-uuid',
|
||||||
|
destinationStationId: 'station-uuid',
|
||||||
|
seatClassId: 'seat-class-uuid',
|
||||||
|
bookingType: 'ONE_WAY',
|
||||||
|
displayCurrency: 'ETB',
|
||||||
|
passengers: [{
|
||||||
|
seatId: 'seat-uuid',
|
||||||
|
passengerName: 'Abebe Kebede',
|
||||||
|
dateOfBirth: '1990-05-15',
|
||||||
|
idDocumentType: 'NATIONAL_ID',
|
||||||
|
idDocumentNumber: 'ET123456789',
|
||||||
|
nationality: 'Ethiopian',
|
||||||
|
phone: '+251911234567',
|
||||||
|
email: 'abebe@email.com',
|
||||||
|
}],
|
||||||
|
savePassengerDetails: true,
|
||||||
|
deviceId: 'device-uuid-123',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ROUND_TRIP: {
|
||||||
|
summary: 'ROUND_TRIP — outbound + return, single PNR (guest)',
|
||||||
|
value: {
|
||||||
|
scheduleId: 'outbound-schedule-uuid',
|
||||||
|
holdId: 'outbound-hold-uuid',
|
||||||
|
originStationId: 'addis-station-uuid',
|
||||||
|
destinationStationId: 'djibouti-station-uuid',
|
||||||
|
seatClassId: 'seat-class-uuid',
|
||||||
|
bookingType: 'ROUND_TRIP',
|
||||||
|
returnScheduleId: 'return-schedule-uuid',
|
||||||
|
returnHoldId: 'return-hold-uuid',
|
||||||
|
returnOriginStationId: 'djibouti-station-uuid',
|
||||||
|
returnDestinationStationId: 'addis-station-uuid',
|
||||||
|
returnSeatClassId: 'seat-class-uuid',
|
||||||
|
displayCurrency: 'ETB',
|
||||||
|
passengers: [{
|
||||||
|
seatId: 'outbound-seat-uuid',
|
||||||
|
returnSeatId: 'return-seat-uuid',
|
||||||
|
passengerName: 'Abebe Kebede',
|
||||||
|
dateOfBirth: '1990-05-15',
|
||||||
|
idDocumentType: 'NATIONAL_ID',
|
||||||
|
idDocumentNumber: 'ET123456789',
|
||||||
|
nationality: 'Ethiopian',
|
||||||
|
phone: '+251911234567',
|
||||||
|
}],
|
||||||
|
savePassengerDetails: true,
|
||||||
|
deviceId: 'device-uuid-123',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TRANSIT: {
|
||||||
|
summary: 'TRANSIT — connecting train, single PNR (guest)',
|
||||||
|
value: {
|
||||||
|
scheduleId: 'leg1-schedule-uuid',
|
||||||
|
holdId: 'leg1-hold-uuid',
|
||||||
|
originStationId: 'addis-station-uuid',
|
||||||
|
destinationStationId: 'diredawa-station-uuid',
|
||||||
|
seatClassId: 'seat-class-uuid',
|
||||||
|
bookingType: 'TRANSIT',
|
||||||
|
leg2ScheduleId: 'leg2-schedule-uuid',
|
||||||
|
leg2HoldId: 'leg2-hold-uuid',
|
||||||
|
transitStationId: 'diredawa-station-uuid',
|
||||||
|
leg2DestinationStationId: 'djibouti-station-uuid',
|
||||||
|
displayCurrency: 'ETB',
|
||||||
|
passengers: [{
|
||||||
|
seatId: 'leg1-seat-uuid',
|
||||||
|
leg2SeatId: 'leg2-seat-uuid',
|
||||||
|
passengerName: 'Abebe Kebede',
|
||||||
|
dateOfBirth: '1990-05-15',
|
||||||
|
idDocumentType: 'NATIONAL_ID',
|
||||||
|
idDocumentNumber: 'ET123456789',
|
||||||
|
nationality: 'Ethiopian',
|
||||||
|
phone: '+251911234567',
|
||||||
|
}],
|
||||||
|
deviceId: 'device-uuid-123',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ROUND_TRIP_TRANSIT: {
|
||||||
|
summary: 'ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds (guest)',
|
||||||
|
value: {
|
||||||
|
scheduleId: 'ob-leg1-schedule-uuid',
|
||||||
|
holdId: 'ob-leg1-hold-uuid',
|
||||||
|
originStationId: 'addis-station-uuid',
|
||||||
|
destinationStationId: 'diredawa-station-uuid',
|
||||||
|
seatClassId: 'seat-class-uuid',
|
||||||
|
bookingType: 'ROUND_TRIP_TRANSIT',
|
||||||
|
leg2ScheduleId: 'ob-leg2-schedule-uuid',
|
||||||
|
leg2HoldId: 'ob-leg2-hold-uuid',
|
||||||
|
transitStationId: 'diredawa-station-uuid',
|
||||||
|
leg2DestinationStationId: 'djibouti-station-uuid',
|
||||||
|
returnScheduleId: 'ret-leg1-schedule-uuid',
|
||||||
|
returnHoldId: 'ret-leg1-hold-uuid',
|
||||||
|
returnOriginStationId: 'djibouti-station-uuid',
|
||||||
|
returnDestinationStationId: 'diredawa-station-uuid',
|
||||||
|
returnLeg2ScheduleId: 'ret-leg2-schedule-uuid',
|
||||||
|
returnLeg2HoldId: 'ret-leg2-hold-uuid',
|
||||||
|
returnTransitStationId: 'diredawa-station-uuid',
|
||||||
|
returnLeg2DestinationStationId: 'addis-station-uuid',
|
||||||
|
displayCurrency: 'ETB',
|
||||||
|
passengers: [{
|
||||||
|
seatId: 'ob-leg1-seat-uuid',
|
||||||
|
leg2SeatId: 'ob-leg2-seat-uuid',
|
||||||
|
returnSeatId: 'ret-leg1-seat-uuid',
|
||||||
|
returnLeg2SeatId: 'ret-leg2-seat-uuid',
|
||||||
|
passengerName: 'Abebe Kebede',
|
||||||
|
dateOfBirth: '1990-05-15',
|
||||||
|
idDocumentType: 'NATIONAL_ID',
|
||||||
|
idDocumentNumber: 'ET123456789',
|
||||||
|
nationality: 'Ethiopian',
|
||||||
|
phone: '+251911234567',
|
||||||
|
}],
|
||||||
|
deviceId: 'device-uuid-123',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 201, description: 'Booking created successfully with fareBreakdown' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Missing required seat IDs for bookingType, or Verifayda verification failed' })
|
||||||
createGuest(@Body() dto: CreateGuestBookingDto) {
|
createGuest(@Body() dto: CreateGuestBookingDto) {
|
||||||
return this.guestService.createGuestBooking(dto);
|
return this.guestService.createGuestBooking(dto);
|
||||||
}
|
}
|
||||||
@@ -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:
|
**ONE_WAY**
|
||||||
- scheduleId, holdId, originStationId, destinationStationId
|
- scheduleId, holdId, originStationId, destinationStationId, seatClassId
|
||||||
- passengers: array of PassengerInputDto with seatId
|
- passengers[]: { seatId, passengerName, dateOfBirth, idDocumentType, … }
|
||||||
- Single PNR, single payment
|
|
||||||
|
|
||||||
ROUND_TRIP booking:
|
**ROUND_TRIP**
|
||||||
- Outbound: scheduleId, holdId, originStationId, destinationStationId, seatClassId
|
- Above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
|
||||||
- Return: returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
|
- passengers[]: { seatId (outbound), returnSeatId (return), passengerName, … }
|
||||||
- passengers: array of RoundTripPassengerDto with outboundSeatId and returnSeatId
|
- Combined fare = outbound fare + return fare; single promo/loyalty deduction
|
||||||
- Combined PNR, single payment for both legs
|
|
||||||
- Fare = outbound_fare + return_fare, single total, single promo, single loyalty deduction`
|
**TRANSIT** (connecting train, single PNR)
|
||||||
|
- scheduleId/holdId for leg-1 + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId
|
||||||
|
- passengers[]: { seatId (leg-1), leg2SeatId (leg-2), passengerName, … }
|
||||||
|
|
||||||
|
**ROUND_TRIP_TRANSIT** (round trip, each direction via connecting train)
|
||||||
|
- All TRANSIT outbound fields + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnLeg2ScheduleId, returnLeg2HoldId, returnTransitStationId, returnLeg2DestinationStationId
|
||||||
|
- passengers[]: { seatId, leg2SeatId, returnSeatId, returnLeg2SeatId, passengerName, … }
|
||||||
|
- 4 holds required, 4 seat sets per passenger, single PNR, single payment
|
||||||
|
|
||||||
|
**Age-Based Pricing (all types)**
|
||||||
|
- ADULT (≥5 years): full fare per leg
|
||||||
|
- CHILD (<5 years): first child FREE per booking, subsequent children full fare`
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
type: CreateBookingDto,
|
||||||
|
examples: {
|
||||||
|
ONE_WAY: {
|
||||||
|
summary: 'ONE_WAY — single direct journey',
|
||||||
|
value: {
|
||||||
|
passengerId: 'passenger-uuid',
|
||||||
|
scheduleId: 'schedule-uuid',
|
||||||
|
holdId: 'hold-uuid',
|
||||||
|
originStationId: 'station-uuid',
|
||||||
|
destinationStationId: 'station-uuid',
|
||||||
|
seatClassId: 'seat-class-uuid',
|
||||||
|
bookingType: 'ONE_WAY',
|
||||||
|
displayCurrency: 'ETB',
|
||||||
|
passengers: [{
|
||||||
|
seatId: 'seat-uuid',
|
||||||
|
passengerName: 'Abebe Kebede',
|
||||||
|
dateOfBirth: '1990-05-15',
|
||||||
|
idDocumentType: 'NATIONAL_ID',
|
||||||
|
idDocumentNumber: 'ET123456789',
|
||||||
|
nationality: 'Ethiopian',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ROUND_TRIP: {
|
||||||
|
summary: 'ROUND_TRIP — outbound + return, single PNR',
|
||||||
|
value: {
|
||||||
|
passengerId: 'passenger-uuid',
|
||||||
|
scheduleId: 'outbound-schedule-uuid',
|
||||||
|
holdId: 'outbound-hold-uuid',
|
||||||
|
originStationId: 'addis-station-uuid',
|
||||||
|
destinationStationId: 'djibouti-station-uuid',
|
||||||
|
seatClassId: 'seat-class-uuid',
|
||||||
|
bookingType: 'ROUND_TRIP',
|
||||||
|
returnScheduleId: 'return-schedule-uuid',
|
||||||
|
returnHoldId: 'return-hold-uuid',
|
||||||
|
returnOriginStationId: 'djibouti-station-uuid',
|
||||||
|
returnDestinationStationId: 'addis-station-uuid',
|
||||||
|
returnSeatClassId: 'seat-class-uuid',
|
||||||
|
displayCurrency: 'ETB',
|
||||||
|
passengers: [{
|
||||||
|
seatId: 'outbound-seat-uuid',
|
||||||
|
returnSeatId: 'return-seat-uuid',
|
||||||
|
passengerName: 'Abebe Kebede',
|
||||||
|
dateOfBirth: '1990-05-15',
|
||||||
|
idDocumentType: 'NATIONAL_ID',
|
||||||
|
idDocumentNumber: 'ET123456789',
|
||||||
|
nationality: 'Ethiopian',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TRANSIT: {
|
||||||
|
summary: 'TRANSIT — connecting train, single PNR',
|
||||||
|
value: {
|
||||||
|
passengerId: 'passenger-uuid',
|
||||||
|
scheduleId: 'leg1-schedule-uuid',
|
||||||
|
holdId: 'leg1-hold-uuid',
|
||||||
|
originStationId: 'addis-station-uuid',
|
||||||
|
destinationStationId: 'diredawa-station-uuid',
|
||||||
|
seatClassId: 'seat-class-uuid',
|
||||||
|
bookingType: 'TRANSIT',
|
||||||
|
leg2ScheduleId: 'leg2-schedule-uuid',
|
||||||
|
leg2HoldId: 'leg2-hold-uuid',
|
||||||
|
transitStationId: 'diredawa-station-uuid',
|
||||||
|
leg2DestinationStationId: 'djibouti-station-uuid',
|
||||||
|
displayCurrency: 'ETB',
|
||||||
|
passengers: [{
|
||||||
|
seatId: 'leg1-seat-uuid',
|
||||||
|
leg2SeatId: 'leg2-seat-uuid',
|
||||||
|
passengerName: 'Abebe Kebede',
|
||||||
|
dateOfBirth: '1990-05-15',
|
||||||
|
idDocumentType: 'NATIONAL_ID',
|
||||||
|
idDocumentNumber: 'ET123456789',
|
||||||
|
nationality: 'Ethiopian',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ROUND_TRIP_TRANSIT: {
|
||||||
|
summary: 'ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds',
|
||||||
|
value: {
|
||||||
|
passengerId: 'passenger-uuid',
|
||||||
|
scheduleId: 'ob-leg1-schedule-uuid',
|
||||||
|
holdId: 'ob-leg1-hold-uuid',
|
||||||
|
originStationId: 'addis-station-uuid',
|
||||||
|
destinationStationId: 'diredawa-station-uuid',
|
||||||
|
seatClassId: 'seat-class-uuid',
|
||||||
|
bookingType: 'ROUND_TRIP_TRANSIT',
|
||||||
|
leg2ScheduleId: 'ob-leg2-schedule-uuid',
|
||||||
|
leg2HoldId: 'ob-leg2-hold-uuid',
|
||||||
|
transitStationId: 'diredawa-station-uuid',
|
||||||
|
leg2DestinationStationId: 'djibouti-station-uuid',
|
||||||
|
returnScheduleId: 'ret-leg1-schedule-uuid',
|
||||||
|
returnHoldId: 'ret-leg1-hold-uuid',
|
||||||
|
returnOriginStationId: 'djibouti-station-uuid',
|
||||||
|
returnDestinationStationId: 'diredawa-station-uuid',
|
||||||
|
returnLeg2ScheduleId: 'ret-leg2-schedule-uuid',
|
||||||
|
returnLeg2HoldId: 'ret-leg2-hold-uuid',
|
||||||
|
returnTransitStationId: 'diredawa-station-uuid',
|
||||||
|
returnLeg2DestinationStationId: 'addis-station-uuid',
|
||||||
|
displayCurrency: 'ETB',
|
||||||
|
passengers: [{
|
||||||
|
seatId: 'ob-leg1-seat-uuid',
|
||||||
|
leg2SeatId: 'ob-leg2-seat-uuid',
|
||||||
|
returnSeatId: 'ret-leg1-seat-uuid',
|
||||||
|
returnLeg2SeatId: 'ret-leg2-seat-uuid',
|
||||||
|
passengerName: 'Abebe Kebede',
|
||||||
|
dateOfBirth: '1990-05-15',
|
||||||
|
idDocumentType: 'NATIONAL_ID',
|
||||||
|
idDocumentNumber: 'ET123456789',
|
||||||
|
nationality: 'Ethiopian',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 201, description: 'Booking created with fare breakdown' })
|
@ApiResponse({ status: 201, description: 'Booking created with fare breakdown' })
|
||||||
@ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' })
|
@ApiResponse({ status: 400, description: 'Missing required fields for bookingType, or Verifayda verification failed' })
|
||||||
@ApiResponse({ status: 404, description: 'Trip or seat hold not found' })
|
@ApiResponse({ status: 404, description: 'Schedule or seat hold not found' })
|
||||||
create(@Body() dto: CreateBookingDto) {
|
create(@Body() dto: CreateBookingDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -547,16 +547,14 @@ export class SeatsService {
|
|||||||
|
|
||||||
@Cron(CronExpression.EVERY_MINUTE)
|
@Cron(CronExpression.EVERY_MINUTE)
|
||||||
async expireHolds() {
|
async expireHolds() {
|
||||||
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
|
const now = new Date();
|
||||||
|
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: now } } });
|
||||||
|
if (expired.length === 0) return;
|
||||||
|
|
||||||
|
const expiredIds = expired.map(h => h.id);
|
||||||
for (const hold of expired) {
|
for (const hold of expired) {
|
||||||
await this.releaseSeats(hold.seatIds);
|
await this.releaseSeats(hold.seatIds);
|
||||||
try {
|
|
||||||
await this.prisma.seatHold.delete({ where: { id: hold.id } });
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof Error && !err.message.includes('P2025')) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
await this.prisma.seatHold.deleteMany({ where: { id: { in: expiredIds } } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ export default function BookingsPage() {
|
|||||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
|
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
|
||||||
const [successMessage, setSuccessMessage] = useState('');
|
const [successMessage, setSuccessMessage] = useState('');
|
||||||
|
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||||
|
const [exportDateFrom, setExportDateFrom] = useState('');
|
||||||
|
const [exportDateTo, setExportDateTo] = useState('');
|
||||||
|
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
|
||||||
|
bookingRef: true,
|
||||||
|
passenger: true,
|
||||||
|
status: true,
|
||||||
|
bookingType: false,
|
||||||
|
passengerCount: false,
|
||||||
|
totalMinor: true,
|
||||||
|
paymentStatus: true,
|
||||||
|
createdAt: true,
|
||||||
|
});
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -80,22 +93,23 @@ export default function BookingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportBookings = async () => {
|
const confirmExport = () => {
|
||||||
const selectedColumns = prompt(
|
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
|
||||||
'Select columns to export (comma-separated):\n\n' +
|
if (cols.length === 0) { alert('Please select at least one column'); return; }
|
||||||
'Available: bookingRef, passenger, status, bookingType, passengerCount, totalMinor, paymentStatus, createdAt\n\n' +
|
|
||||||
'Default: bookingRef, passenger, status, totalMinor, paymentStatus, createdAt',
|
|
||||||
'bookingRef, passenger, status, totalMinor, paymentStatus, createdAt'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!selectedColumns) return;
|
const exportItems = (data?.items || []).filter((b: any) => {
|
||||||
|
if (!exportDateFrom && !exportDateTo) return true;
|
||||||
|
const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null;
|
||||||
|
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
|
||||||
|
if (exportDateTo && (!d || d > exportDateTo)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
const cols = selectedColumns.split(',').map(c => c.trim());
|
|
||||||
const csv = [
|
const csv = [
|
||||||
cols.join(','),
|
cols.join(','),
|
||||||
...data?.items?.map((booking: any) => {
|
...exportItems.map((booking: any) => {
|
||||||
const values = cols.map(col => {
|
const values = cols.map(col => {
|
||||||
switch(col) {
|
switch (col) {
|
||||||
case 'bookingRef': return booking.bookingRef;
|
case 'bookingRef': return booking.bookingRef;
|
||||||
case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest';
|
case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest';
|
||||||
case 'status': return booking.status;
|
case 'status': return booking.status;
|
||||||
@@ -108,7 +122,7 @@ export default function BookingsPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return values.map(v => `"${v}"`).join(',');
|
return values.map(v => `"${v}"`).join(',');
|
||||||
}) || []
|
}),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv' });
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
@@ -117,6 +131,7 @@ export default function BookingsPage() {
|
|||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`;
|
a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
a.click();
|
a.click();
|
||||||
|
setExportModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -147,7 +162,14 @@ export default function BookingsPage() {
|
|||||||
{
|
{
|
||||||
key: 'passengerCount',
|
key: 'passengerCount',
|
||||||
label: 'Passengers',
|
label: 'Passengers',
|
||||||
render: (booking: any) => `${(booking.adultCount || 0) + (booking.childCount || 0)}`,
|
render: (booking: any) => {
|
||||||
|
const adults = booking.adultCount || 0;
|
||||||
|
const children = booking.childCount || 0;
|
||||||
|
if (adults === 0 && children === 0) return '—';
|
||||||
|
const parts = [`Adult: ${adults}`];
|
||||||
|
if (children > 0) parts.push(`Child: ${children}`);
|
||||||
|
return parts.join(' / ');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
@@ -208,7 +230,7 @@ export default function BookingsPage() {
|
|||||||
<h1 className="text-2xl font-bold">Bookings</h1>
|
<h1 className="text-2xl font-bold">Bookings</h1>
|
||||||
<p className="text-muted-foreground">Manage all passenger bookings</p>
|
<p className="text-muted-foreground">Manage all passenger bookings</p>
|
||||||
</div>
|
</div>
|
||||||
<ActionButton variant="export" icon={Download} onClick={handleExportBookings}>Export</ActionButton>
|
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -264,15 +286,9 @@ export default function BookingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Booking Details Modal */}
|
{/* Booking Details Modal */}
|
||||||
<Modal
|
<Modal isOpen={!!selectedBooking} onClose={() => setSelectedBooking(null)} title="Booking Details" size="xl">
|
||||||
isOpen={!!selectedBooking}
|
|
||||||
onClose={() => setSelectedBooking(null)}
|
|
||||||
title="Booking Details"
|
|
||||||
size="xl"
|
|
||||||
>
|
|
||||||
{selectedBooking && (
|
{selectedBooking && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Booking Information */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-muted-foreground">Booking Reference</label>
|
<label className="text-sm font-medium text-muted-foreground">Booking Reference</label>
|
||||||
@@ -281,9 +297,7 @@ export default function BookingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-muted-foreground">Status</label>
|
<label className="text-sm font-medium text-muted-foreground">Status</label>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<Badge variant="status" status={selectedBooking.status}>
|
<Badge variant="status" status={selectedBooking.status}>{selectedBooking.status}</Badge>
|
||||||
{selectedBooking.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -298,7 +312,6 @@ export default function BookingsPage() {
|
|||||||
|
|
||||||
<hr className="border-muted" />
|
<hr className="border-muted" />
|
||||||
|
|
||||||
{/* Passenger Information */}
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-3">Passenger Information</h3>
|
<h3 className="text-lg font-semibold mb-3">Passenger Information</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@@ -323,7 +336,6 @@ export default function BookingsPage() {
|
|||||||
|
|
||||||
<hr className="border-muted" />
|
<hr className="border-muted" />
|
||||||
|
|
||||||
{/* Booking Details */}
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-3">Journey Details</h3>
|
<h3 className="text-lg font-semibold mb-3">Journey Details</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@@ -348,7 +360,6 @@ export default function BookingsPage() {
|
|||||||
|
|
||||||
<hr className="border-muted" />
|
<hr className="border-muted" />
|
||||||
|
|
||||||
{/* Payment Information */}
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-3">Payment Information</h3>
|
<h3 className="text-lg font-semibold mb-3">Payment Information</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@@ -377,7 +388,6 @@ export default function BookingsPage() {
|
|||||||
|
|
||||||
<hr className="border-muted" />
|
<hr className="border-muted" />
|
||||||
|
|
||||||
{/* Additional Information */}
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-3">Additional Information</h3>
|
<h3 className="text-lg font-semibold mb-3">Additional Information</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@@ -393,12 +403,7 @@ export default function BookingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
<ActionButton
|
<ActionButton variant="secondary" onClick={() => setSelectedBooking(null)}>Close</ActionButton>
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setSelectedBooking(null)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</ActionButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -407,10 +412,7 @@ export default function BookingsPage() {
|
|||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirmOpen}
|
isOpen={deleteConfirmOpen}
|
||||||
onClose={() => {
|
onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); }}
|
||||||
setDeleteConfirmOpen(false);
|
|
||||||
setBookingToDelete(null);
|
|
||||||
}}
|
|
||||||
onConfirm={handleConfirmDelete}
|
onConfirm={handleConfirmDelete}
|
||||||
title="Delete Booking"
|
title="Delete Booking"
|
||||||
message={`Are you sure you want to permanently delete booking ${bookingToDelete?.bookingRef}? This action cannot be undone and will release all associated seats.`}
|
message={`Are you sure you want to permanently delete booking ${bookingToDelete?.bookingRef}? This action cannot be undone and will release all associated seats.`}
|
||||||
@@ -419,6 +421,53 @@ export default function BookingsPage() {
|
|||||||
isLoading={deleteMutation.isPending}
|
isLoading={deleteMutation.isPending}
|
||||||
isDanger={true}
|
isDanger={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Export Modal */}
|
||||||
|
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Date From (Created)</label>
|
||||||
|
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Date To (Created)</label>
|
||||||
|
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2">Select Columns</p>
|
||||||
|
<div className="space-y-2 max-h-56 overflow-y-auto">
|
||||||
|
{[
|
||||||
|
{ key: 'bookingRef', label: 'Booking Reference' },
|
||||||
|
{ key: 'passenger', label: 'Passenger' },
|
||||||
|
{ key: 'status', label: 'Status' },
|
||||||
|
{ key: 'bookingType', label: 'Booking Type' },
|
||||||
|
{ key: 'passengerCount', label: 'Passenger Count' },
|
||||||
|
{ key: 'totalMinor', label: 'Amount' },
|
||||||
|
{ key: 'paymentStatus', label: 'Payment Status' },
|
||||||
|
{ key: 'createdAt', label: 'Created At' },
|
||||||
|
].map((col) => (
|
||||||
|
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportColumns[col.key] || false}
|
||||||
|
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{col.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||||
|
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
|
||||||
|
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export default function PassengersPage() {
|
|||||||
});
|
});
|
||||||
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
|
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
|
||||||
|
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||||
|
const [exportDateFrom, setExportDateFrom] = useState('');
|
||||||
|
const [exportDateTo, setExportDateTo] = useState('');
|
||||||
|
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
|
||||||
|
fullName: true, email: true, phone: true, gender: true, nationality: true, verified: true,
|
||||||
|
});
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -52,22 +58,23 @@ export default function PassengersPage() {
|
|||||||
console.error('Passengers API Error:', error);
|
console.error('Passengers API Error:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExportPassengers = async () => {
|
const confirmExportPassengers = () => {
|
||||||
const selectedColumns = prompt(
|
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
|
||||||
'Select columns to export (comma-separated):\n\n' +
|
if (cols.length === 0) { alert('Please select at least one column'); return; }
|
||||||
'Available: fullName, email, phone, dateOfBirth, gender, nationality, verified\n\n' +
|
|
||||||
'Default: fullName, email, phone, gender, nationality, verified',
|
|
||||||
'fullName, email, phone, gender, nationality, verified'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!selectedColumns) return;
|
const exportItems = (data?.items || []).filter((p: any) => {
|
||||||
|
if (!exportDateFrom && !exportDateTo) return true;
|
||||||
|
const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
|
||||||
|
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
|
||||||
|
if (exportDateTo && (!d || d > exportDateTo)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
const cols = selectedColumns.split(',').map(c => c.trim());
|
|
||||||
const csv = [
|
const csv = [
|
||||||
cols.join(','),
|
cols.join(','),
|
||||||
...data?.items?.map((passenger: any) => {
|
...exportItems.map((passenger: any) => {
|
||||||
const values = cols.map(col => {
|
const values = cols.map(col => {
|
||||||
switch(col) {
|
switch (col) {
|
||||||
case 'fullName': return passenger.fullName;
|
case 'fullName': return passenger.fullName;
|
||||||
case 'email': return passenger.email || '';
|
case 'email': return passenger.email || '';
|
||||||
case 'phone': return passenger.phone || '';
|
case 'phone': return passenger.phone || '';
|
||||||
@@ -79,7 +86,7 @@ export default function PassengersPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return values.map(v => `"${v}"`).join(',');
|
return values.map(v => `"${v}"`).join(',');
|
||||||
}) || []
|
}),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv' });
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
@@ -88,6 +95,7 @@ export default function PassengersPage() {
|
|||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `passengers-${new Date().toISOString().split('T')[0]}.csv`;
|
a.download = `passengers-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
a.click();
|
a.click();
|
||||||
|
setExportModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -160,7 +168,7 @@ export default function PassengersPage() {
|
|||||||
<p className="text-muted-foreground">Manage passenger profiles and verification</p>
|
<p className="text-muted-foreground">Manage passenger profiles and verification</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<ActionButton variant="export" icon={Download} onClick={handleExportPassengers}>Export</ActionButton>
|
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -381,6 +389,51 @@ export default function PassengersPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
{/* Export Modal */}
|
||||||
|
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Passengers" size="md">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Date From (Registered)</label>
|
||||||
|
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Date To (Registered)</label>
|
||||||
|
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2">Select Columns</p>
|
||||||
|
<div className="space-y-2 max-h-56 overflow-y-auto">
|
||||||
|
{[
|
||||||
|
{ key: 'fullName', label: 'Full Name' },
|
||||||
|
{ key: 'email', label: 'Email' },
|
||||||
|
{ key: 'phone', label: 'Phone' },
|
||||||
|
{ key: 'dateOfBirth', label: 'Date of Birth' },
|
||||||
|
{ key: 'gender', label: 'Gender' },
|
||||||
|
{ key: 'nationality', label: 'Nationality' },
|
||||||
|
{ key: 'verified', label: 'Verified' },
|
||||||
|
].map((col) => (
|
||||||
|
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportColumns[col.key] || false}
|
||||||
|
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{col.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||||
|
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
|
||||||
|
<ActionButton onClick={confirmExportPassengers}>Export CSV</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import Badge from '@/components/ui/Badge';
|
|||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import { ticketsApi, apiClient, schedulesApi, stationsApi } from '@/lib/api';
|
import { ticketsApi, apiClient, stationsApi } from '@/lib/api';
|
||||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||||
|
|
||||||
export default function TicketsPage() {
|
export default function TicketsPage() {
|
||||||
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', tripDate: '' });
|
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
|
||||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
|
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
|
||||||
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
|
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
|
||||||
@@ -22,6 +22,8 @@ export default function TicketsPage() {
|
|||||||
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
||||||
const [selectedTicket, setSelectedTicket] = useState<any>(null);
|
const [selectedTicket, setSelectedTicket] = useState<any>(null);
|
||||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||||
|
const [exportDateFrom, setExportDateFrom] = useState('');
|
||||||
|
const [exportDateTo, setExportDateTo] = useState('');
|
||||||
const [selectedColumns, setSelectedColumns] = useState<Record<string, boolean>>({
|
const [selectedColumns, setSelectedColumns] = useState<Record<string, boolean>>({
|
||||||
ticketNumber: true,
|
ticketNumber: true,
|
||||||
booking: true,
|
booking: true,
|
||||||
@@ -35,7 +37,15 @@ export default function TicketsPage() {
|
|||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ['tickets', filters],
|
queryKey: ['tickets', filters],
|
||||||
queryFn: () => ticketsApi.getAll({ ...filters, skip: 0, take: 50 }),
|
queryFn: () => ticketsApi.getAll({
|
||||||
|
search: filters.search || undefined,
|
||||||
|
status: filters.status || undefined,
|
||||||
|
originStationId: filters.originStationId || undefined,
|
||||||
|
destinationStationId: filters.destinationStationId || undefined,
|
||||||
|
arrivalDate: filters.arrivalDate || undefined,
|
||||||
|
skip: 0,
|
||||||
|
take: 50,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: stationsData } = useQuery({
|
const { data: stationsData } = useQuery({
|
||||||
@@ -94,10 +104,6 @@ export default function TicketsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportTickets = async () => {
|
|
||||||
setExportModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmExport = () => {
|
const confirmExport = () => {
|
||||||
const cols = Object.entries(selectedColumns)
|
const cols = Object.entries(selectedColumns)
|
||||||
.filter(([, selected]) => selected)
|
.filter(([, selected]) => selected)
|
||||||
@@ -108,11 +114,21 @@ export default function TicketsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const exportItems = (data?.items || []).filter((ticket: any) => {
|
||||||
|
if (!exportDateFrom && !exportDateTo) return true;
|
||||||
|
const d = ticket.schedule?.arrivalAt
|
||||||
|
? new Date(ticket.schedule.arrivalAt).toISOString().split('T')[0]
|
||||||
|
: null;
|
||||||
|
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
|
||||||
|
if (exportDateTo && (!d || d > exportDateTo)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
const csv = [
|
const csv = [
|
||||||
cols.join(','),
|
cols.join(','),
|
||||||
...data?.items?.map((ticket: any) => {
|
...exportItems.map((ticket: any) => {
|
||||||
const values = cols.map(col => {
|
const values = cols.map(col => {
|
||||||
switch(col) {
|
switch (col) {
|
||||||
case 'ticketNumber': return ticket.ticketNumber || '';
|
case 'ticketNumber': return ticket.ticketNumber || '';
|
||||||
case 'booking': return ticket.booking?.bookingRef || '';
|
case 'booking': return ticket.booking?.bookingRef || '';
|
||||||
case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`;
|
case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`;
|
||||||
@@ -126,7 +142,7 @@ export default function TicketsPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return values.map(v => `"${v}"`).join(',');
|
return values.map(v => `"${v}"`).join(',');
|
||||||
}) || []
|
}),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv' });
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
@@ -175,12 +191,12 @@ export default function TicketsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'seat',
|
key: 'seat',
|
||||||
label: 'Seat',
|
label: 'Seat/Bed',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (ticket: any) => (
|
render: (ticket: any) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-mono font-semibold">Coach {ticket.seat?.coach?.number || 'N/A'} - Seat {ticket.seat?.seatNumber || 'N/A'}</div>
|
<div className="font-mono font-semibold">{ticket.seat?.coach?.number || 'N/A'} - {ticket.seat?.seatNumber || 'N/A'}</div>
|
||||||
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.name || 'N/A'}</div>
|
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.type || 'N/A'}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -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>
|
||||||
|
<label className="label">Date From (Arrival)</label>
|
||||||
|
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Date To (Arrival)</label>
|
||||||
|
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
<div>
|
||||||
{[
|
<p className="text-sm font-medium mb-2">Select Columns</p>
|
||||||
{ key: 'ticketNumber', label: 'Ticket Number' },
|
<div className="space-y-2 max-h-56 overflow-y-auto">
|
||||||
{ key: 'booking', label: 'Booking Reference & Passenger' },
|
{[
|
||||||
{ key: 'trip', label: 'Trip (Origin → Destination)' },
|
{ key: 'ticketNumber', label: 'Ticket Number' },
|
||||||
{ key: 'coach', label: 'Coach Number' },
|
{ key: 'booking', label: 'Booking Reference & Passenger' },
|
||||||
{ key: 'seat', label: 'Seat Number' },
|
{ key: 'trip', label: 'Trip (Origin → Destination)' },
|
||||||
{ key: 'seatClass', label: 'Seat Class' },
|
{ key: 'coach', label: 'Coach Number' },
|
||||||
{ key: 'amount', label: 'Amount' },
|
{ key: 'seat', label: 'Seat Number' },
|
||||||
{ key: 'status', label: 'Status' },
|
{ key: 'seatClass', label: 'Seat Class' },
|
||||||
{ key: 'boarded', label: 'Boarded Status' },
|
{ key: 'amount', label: 'Amount' },
|
||||||
].map((col) => (
|
{ key: 'status', label: 'Status' },
|
||||||
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
|
{ key: 'boarded', label: 'Boarded Status' },
|
||||||
<input
|
].map((col) => (
|
||||||
type="checkbox"
|
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
|
||||||
checked={selectedColumns[col.key] || false}
|
<input
|
||||||
onChange={(e) =>
|
type="checkbox"
|
||||||
setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked })
|
checked={selectedColumns[col.key] || false}
|
||||||
}
|
onChange={(e) => setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked })}
|
||||||
className="w-4 h-4 rounded border-gray-300"
|
className="w-4 h-4 rounded border-gray-300"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium">{col.label}</span>
|
<span className="text-sm font-medium">{col.label}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||||
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>
|
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
|
||||||
Cancel
|
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
|
||||||
</ActionButton>
|
|
||||||
<ActionButton onClick={confirmExport}>
|
|
||||||
Export CSV
|
|
||||||
</ActionButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 ?? "",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user