Tour package booking, app release, new endpoints, more updates and fixes

This commit is contained in:
Stephanos A
2026-07-05 00:28:06 +03:00
parent 868639084c
commit 595be6e123
68 changed files with 2773 additions and 787 deletions

View File

@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "packageId" TEXT,
ADD COLUMN "priceTierId" TEXT;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_priceTierId_fkey" FOREIGN KEY ("priceTierId") REFERENCES "PackagePriceTier"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "FraudAlert" ADD COLUMN "acknowledgedAt" TIMESTAMP(3);

View File

@@ -0,0 +1,13 @@
CREATE TABLE "passenger"."AppRelease" (
"id" TEXT NOT NULL,
"os" TEXT NOT NULL,
"version" TEXT NOT NULL,
"forceUpdate" BOOLEAN NOT NULL DEFAULT false,
"storeLink" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AppRelease_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "AppRelease_os_version_key" ON "passenger"."AppRelease"("os", "version");

View File

@@ -506,6 +506,8 @@ model Booking {
bookingRef String @unique
passengerId String
scheduleId String
packageId String?
priceTierId String?
bookingType String @default("ONE_WAY")
status BookingStatus @default(DRAFT)
currency String @default("ETB")
@@ -544,6 +546,8 @@ model Booking {
passenger Passenger @relation(fields: [passengerId], references: [id])
schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], references: [id])
returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id])
package TravelPackage? @relation(fields: [packageId], references: [id])
priceTier PackagePriceTier? @relation(fields: [priceTierId], references: [id])
seats BookingSeat[]
paymentIntent PaymentIntent?
tickets Ticket[]
@@ -1301,6 +1305,7 @@ model FraudAlert {
context Json
severity String @default("MEDIUM")
acknowledged Boolean @default(false)
acknowledgedAt DateTime?
createdAt DateTime @default(now())
@@index([iamUserId, createdAt])
@@index([acknowledged])
@@ -1428,7 +1433,8 @@ model TravelPackage {
outboundSchedule TrainSchedule @relation("PackageOutbound", fields: [outboundScheduleId], references: [id])
returnSchedule TrainSchedule @relation("PackageReturn", fields: [returnScheduleId], references: [id])
priceTiers PackagePriceTier[]
bookings PackageBooking[]
bookings Booking[]
packageBookings PackageBooking[]
inquiries PackageInquiry[]
@@index([status, validFrom])
@@ -1446,7 +1452,8 @@ model PackagePriceTier {
bookedSeats Int @default(0)
package TravelPackage @relation(fields: [packageId], references: [id])
bookings PackageBooking[]
bookings Booking[]
packageBookings PackageBooking[]
inquiries PackageInquiry[]
@@unique([packageId, seatType])
@@ -1536,3 +1543,17 @@ model PackageInquiry {
@@index([packageId])
@@schema("passenger")
}
model AppRelease {
id String @id @default(uuid())
os String // "android" | "ios"
version String
forceUpdate Boolean @default(false)
storeLink String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([os, version])
@@schema("passenger")
}

View File

@@ -854,26 +854,64 @@ async function runStep(name: string, step: () => Promise<unknown>): Promise<bool
}
}
async function seedPackageBookings() {
const pkg = await prisma.travelPackage.findFirst({ where: { code: 'KULUBBI-2025' }, include: { priceTiers: true } });
if (!pkg || !pkg.priceTiers.length) {
console.log(' ⚠️ Kulubbi package not found, skipping package booking seed');
return;
}
const tier = pkg.priceTiers[0];
const passenger = await prisma.passenger.findFirst();
const existing = await prisma.packageBooking.findFirst({ where: { bookingRef: 'PKG-SEED01' } });
if (existing) { console.log(' Package booking seed already exists'); return; }
await prisma.packageBooking.create({
data: {
bookingRef: 'PKG-SEED01',
packageId: pkg.id,
priceTierId: tier.id,
passengerId: passenger?.id ?? null,
contactEmail: 'kelemu@email.com',
contactPhone: '+251911234567',
passengerCount: 2,
totalMinor: tier.priceMinor * 2,
currency: 'ETB',
displayCurrency: 'ETB',
displayTotalMinor: tier.priceMinor * 2,
status: 'PENDING_PAYMENT',
passengers: {
create: [
{ passengerName: 'Abebe Kebede', idDocumentType: 'NATIONAL_ID' },
{ passengerName: 'Tigist Alemu', idDocumentType: 'NATIONAL_ID' },
],
},
},
});
console.log(' ✅ Sample package booking created (PKG-SEED01)');
}
async function main() {
console.log('🌱 Comprehensive EDR Seed Starting...\n');
const steps: Array<[string, () => Promise<unknown>]> = [
['System Users', seedSystemUsers],
['Stations', seedStations],
['Coach Types & Classes', seedCoachTypesAndClasses],
['Route', seedRoute],
['Coaches', seedCoaches],
['Trips', seedTrips],
['Fare Rules', seedFareRules],
['Currency', seedCurrency],
['Payment Methods', seedPaymentMethods],
['Segment Fares', seedSegmentFares],
['Notification Templates', seedNotificationTemplates],
['Menu & Food', seedMenuAndFood],
['Promotions', seedPromotions],
['FAQ', seedFAQ],
['Fraud Rules', seedFraudRules],
['Kulubbi Package', seedKulubbiPackage],
// ['System Users', seedSystemUsers],
// ['Stations', seedStations],
// ['Coach Types & Classes', seedCoachTypesAndClasses],
// ['Route', seedRoute],
// ['Coaches', seedCoaches],
// ['Trips', seedTrips],
// ['Fare Rules', seedFareRules],
// ['Currency', seedCurrency],
// ['Payment Methods', seedPaymentMethods],
// ['Segment Fares', seedSegmentFares],
// ['Notification Templates', seedNotificationTemplates],
// ['Menu & Food', seedMenuAndFood],
// ['Promotions', seedPromotions],
// ['FAQ', seedFAQ],
// ['Fraud Rules', seedFraudRules],
// ['Kulubbi Package', seedKulubbiPackage],
// ['Package Bookings', seedPackageBookings],
];
let failed = 0;

View File

@@ -61,6 +61,7 @@ import { PackagesModule } from './modules/packages/packages.module';
import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module';
import { HealthModule } from './modules/health/health.module';
import { TasksModule } from './modules/tasks/tasks.module';
import { AppReleasesModule } from './modules/app-releases/app-releases.module';
@Module({
imports: [
@@ -130,6 +131,7 @@ import { TasksModule } from './modules/tasks/tasks.module';
ExcessBaggageModule,
HealthModule,
TasksModule,
AppReleasesModule,
],
providers: [
{ provide: APP_GUARD, useClass: DynamicThrottlerGuard },

View File

@@ -6,6 +6,7 @@ import {
HttpStatus,
Logger,
} from '@nestjs/common';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
@@ -22,15 +23,27 @@ export class HttpExceptionFilter implements ExceptionFilter {
const response = ctx.getResponse();
const request = ctx.getRequest();
let prismaMessage: string | null = null;
if (exception instanceof PrismaClientKnownRequestError) {
if (exception.code === 'P2003') {
const field = (exception.meta?.field_name as string | undefined) ?? 'a related record';
prismaMessage = `Cannot delete this record because it is still referenced by ${field}. Remove the related records first.`;
} else if (exception.code === 'P2025') {
prismaMessage = 'Record not found.';
}
}
const status =
exception instanceof HttpException
? exception.getStatus()
: prismaMessage
? HttpStatus.BAD_REQUEST
: HttpStatus.INTERNAL_SERVER_ERROR;
const messageRaw =
exception instanceof HttpException
? exception.getResponse()
: 'Internal server error';
: prismaMessage ?? 'Internal server error';
const message =
typeof messageRaw === 'string'

View File

@@ -0,0 +1,50 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, SetMetadata } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiParam } from '@nestjs/swagger';
import { AppReleasesService, AppReleaseDto } from './app-releases.service';
import { PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('App Releases')
@Controller('app-releases')
export class AppReleasesController {
constructor(private service: AppReleasesService) {}
@Get()
@SetMetadata('isPublic', true)
@ApiOperation({ summary: 'List all app releases (public)' })
getAll() {
return this.service.getAll();
}
@Get('latest/:os')
@SetMetadata('isPublic', true)
@ApiOperation({ summary: 'Get latest release for a given OS (public)' })
@ApiParam({ name: 'os', enum: ['android', 'ios'] })
getLatest(@Param('os') os: string) {
return this.service.getLatest(os);
}
@Post()
@PassengerStaff(PASSENGER_PERMS.admin)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create an app release (admin)' })
create(@Body() dto: AppReleaseDto) {
return this.service.create(dto);
}
@Patch(':id')
@PassengerStaff(PASSENGER_PERMS.admin)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update an app release (admin)' })
update(@Param('id') id: string, @Body() dto: Partial<AppReleaseDto>) {
return this.service.update(id, dto);
}
@Delete(':id')
@PassengerStaff(PASSENGER_PERMS.admin)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete an app release (admin)' })
remove(@Param('id') id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AppReleasesController } from './app-releases.controller';
import { AppReleasesService } from './app-releases.service';
import { PrismaModule } from '../../common/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [AppReleasesController],
providers: [AppReleasesService],
})
export class AppReleasesModule {}

View File

@@ -0,0 +1,71 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PrismaService } from '../../common/prisma.service';
export class AppReleaseDto {
@ApiProperty({ enum: ['android', 'ios'] })
@IsIn(['android', 'ios'])
os: string;
@ApiProperty({ example: '1.2.3' })
@IsString()
version: string;
@ApiProperty({ default: false })
@IsBoolean()
forceUpdate: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
storeLink?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
@Injectable()
export class AppReleasesService {
constructor(private prisma: PrismaService) {}
private get db() {
return (this.prisma as any);
}
getAll() {
return this.db.appRelease.findMany({ orderBy: [{ os: 'asc' }, { createdAt: 'desc' }] });
}
async getLatest(os: string) {
const release = await this.db.appRelease.findFirst({
where: { os },
orderBy: { createdAt: 'desc' },
});
if (!release) throw new NotFoundException(`No release found for ${os}`);
return release;
}
async create(dto: AppReleaseDto) {
const existing = await this.db.appRelease.findUnique({
where: { os_version: { os: dto.os, version: dto.version } },
});
if (existing) throw new ConflictException(`Release ${dto.os} ${dto.version} already exists`);
return this.db.appRelease.create({ data: dto });
}
async update(id: string, dto: Partial<AppReleaseDto>) {
const release = await this.db.appRelease.findUnique({ where: { id } });
if (!release) throw new NotFoundException('App release not found');
return this.db.appRelease.update({ where: { id }, data: dto });
}
async remove(id: string) {
const release = await this.db.appRelease.findUnique({ where: { id } });
if (!release) throw new NotFoundException('App release not found');
await this.db.appRelease.delete({ where: { id } });
return { deleted: true, id };
}
}

View File

@@ -30,8 +30,8 @@ export class AuditController {
entityType: entityType || undefined,
};
const items = await this.auditService.getLogs(filters);
return { items };
const result = await this.auditService.getLogs(filters);
return { items: result.data, total: result.total, limit: result.limit, offset: result.offset };
}
@Get('logs/:id')

View File

@@ -133,6 +133,12 @@ export class CreateBookingDto {
@IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto)
passengers: PassengerInputDto[];
@ApiPropertyOptional({ description: 'Package ID — when set, fare is taken from the package price tier instead of the fare engine' })
@IsOptional() @IsString() packageId?: string;
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
@IsOptional() @IsString() priceTierId?: string;
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
@IsOptional() @IsString() promoCode?: string;

View File

@@ -203,6 +203,9 @@ export class BookingsService {
const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const onlyPackages = bookingType === 'PACKAGE';
const includePackageBookings = !returnLegStatus && bookingType !== 'ONE_WAY' && bookingType !== 'ROUND_TRIP' && bookingType !== 'TRANSIT' && bookingType !== 'ROUND_TRIP_TRANSIT';
const where: any = {};
if (search) {
@@ -232,7 +235,7 @@ export class BookingsService {
if (status) where.status = status;
if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
if (bookingType) where.bookingType = bookingType;
if (bookingType && !onlyPackages) where.bookingType = bookingType;
if (dateFrom || dateTo) {
where.createdAt = {
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
@@ -240,17 +243,125 @@ export class BookingsService {
};
}
if (paymentStatus) {
const statusMap: Record<string, string> = {
PAID: 'SUCCEEDED',
PENDING: 'REQUIRES_ACTION',
FAILED: 'FAILED',
REFUNDED: 'REFUNDED',
};
const statusMap: Record<string, string> = { PAID: 'SUCCEEDED', PENDING: 'REQUIRES_ACTION', FAILED: 'FAILED', REFUNDED: 'REFUNDED' };
const mapped = statusMap[paymentStatus] ?? paymentStatus;
where.paymentIntent = { is: { status: mapped } };
}
const [items, total] = await Promise.all([
const pkgWhere: any = {};
if (search) {
pkgWhere.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ contactEmail: { contains: search, mode: 'insensitive' } },
{ contactPhone: { contains: search, mode: 'insensitive' } },
{ passengers: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
];
}
if (status) pkgWhere.status = status;
if (dateFrom || dateTo) pkgWhere.createdAt = where.createdAt;
if (paymentStatus) pkgWhere.paymentIntent = { is: { status: (where.paymentIntent as any)?.is?.status } };
if (onlyPackages) {
// Package bookings live in two places:
// 1. PackageBooking table (dedicated package bookings)
// 2. Booking table with packageId != null (round-trip bookings linked to a package)
const bookingPkgWhere: any = { packageId: { not: null } };
if (status) bookingPkgWhere.status = status;
if (dateFrom || dateTo) bookingPkgWhere.createdAt = where.createdAt;
if (paymentStatus) bookingPkgWhere.paymentIntent = where.paymentIntent;
if (search) bookingPkgWhere.OR = where.OR;
const [pkgItems, pkgTotal, regPkgItems, regPkgTotal] = await Promise.all([
this.prisma.packageBooking.findMany({
where: pkgWhere,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
package: { select: { id: true, name: true, code: true } },
priceTier: { select: { id: true, label: true, seatType: true } },
passengers: true,
paymentIntent: true,
},
}),
this.prisma.packageBooking.count({ where: pkgWhere }),
this.prisma.booking.findMany({
where: bookingPkgWhere,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
passenger: { select: { id: true, iamUserId: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: true,
seats: { include: { seat: true } },
},
}),
this.prisma.booking.count({ where: bookingPkgWhere }),
]);
const iamUserIds = regPkgItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[];
const iamRows = iamUserIds.length > 0
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
[iamUserIds],
)
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
const mappedRegPkg = regPkgItems.map((booking: any) => {
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory }));
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalMinor: booking.totalMinor, currency: 'ETB',
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
bookingType: booking.bookingType, packageId: booking.packageId, isPackageBooking: true,
returnLegStatus: (booking as any).returnLegStatus ?? null,
adultCount: booking.adultCount, childCount: booking.childCount,
createdAt: booking.createdAt,
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
passengers: uniquePassengers,
schedule: booking.schedule ? {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
} : null,
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
};
});
const mappedPkg = pkgItems.map((b: any) => ({
id: b.id, bookingRef: b.bookingRef, status: b.status,
totalMinor: b.totalMinor, currency: b.currency || 'ETB',
displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor,
contactEmail: b.contactEmail, contactPhone: b.contactPhone,
bookingType: 'PACKAGE', packageId: b.packageId, isPackageBooking: true,
packageName: b.package?.name, packageCode: b.package?.code,
returnLegStatus: null, adultCount: b.passengerCount, childCount: 0,
createdAt: b.createdAt, passenger: null,
passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [],
passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [],
schedule: null, paymentIntent: b.paymentIntent, seatCount: b.passengerCount,
}));
const total = pkgTotal + regPkgTotal;
const allItems = [...mappedPkg, ...mappedRegPkg]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, pageSize);
return {
items: allItems,
meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
};
}
const [regularItems, regularTotal, pkgItems, pkgTotal] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
@@ -264,9 +375,22 @@ export class BookingsService {
},
}),
this.prisma.booking.count({ where }),
includePackageBookings
? this.prisma.packageBooking.findMany({
where: pkgWhere,
orderBy: { createdAt: 'desc' },
include: {
package: { select: { id: true, name: true, code: true } },
priceTier: { select: { id: true, label: true, seatType: true } },
passengers: true,
paymentIntent: true,
},
})
: Promise.resolve([] as any[]),
includePackageBookings ? this.prisma.packageBooking.count({ where: pkgWhere }) : Promise.resolve(0),
]);
const iamUserIds = items.map(b => b.passenger?.iamUserId).filter(Boolean) as string[];
const iamUserIds = regularItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[];
const iamRows = iamUserIds.length > 0
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
@@ -275,19 +399,10 @@ export class BookingsService {
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
return {
items: items.map(booking => {
const mappedRegular = regularItems.map((booking: any) => {
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
// Build passenger list with categories
const passengerDetails = booking.seats.map((s: any) => ({
name: s.passengerName,
category: s.passengerCategory // 'ADULT' or 'CHILD'
}));
// Get unique names with their categories
const uniquePassengers = Array.from(
new Map(passengerDetails.map(p => [p.name, p])).values()
);
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory }));
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
return {
id: booking.id,
bookingRef: booking.bookingRef,
@@ -299,15 +414,15 @@ export class BookingsService {
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone,
bookingType: booking.bookingType,
packageId: booking.packageId ?? null,
isPackageBooking: !!booking.packageId,
returnLegStatus: (booking as any).returnLegStatus ?? null,
adultCount: booking.adultCount,
childCount: booking.childCount,
createdAt: booking.createdAt,
passenger: iam
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
: null,
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
passengers: uniquePassengers, // Include category info
passengers: uniquePassengers,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
@@ -317,13 +432,43 @@ export class BookingsService {
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
};
}),
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
});
const mappedPkg = pkgItems.map((b: any) => ({
id: b.id,
bookingRef: b.bookingRef,
status: b.status,
totalMinor: b.totalMinor,
currency: b.currency || 'ETB',
displayCurrency: b.displayCurrency,
displayTotalMinor: b.displayTotalMinor,
contactEmail: b.contactEmail,
contactPhone: b.contactPhone,
bookingType: 'PACKAGE',
packageId: b.packageId,
isPackageBooking: true,
packageName: b.package?.name,
packageCode: b.package?.code,
returnLegStatus: null,
adultCount: b.passengerCount,
childCount: 0,
createdAt: b.createdAt,
passenger: null,
passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [],
passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [],
schedule: null,
paymentIntent: b.paymentIntent,
seatCount: b.passengerCount,
}));
const total = regularTotal + pkgTotal;
const allItems = [...mappedRegular, ...mappedPkg]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, pageSize);
return {
items: allItems,
meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
};
}
@@ -363,7 +508,9 @@ export class BookingsService {
const passengersData = await this.processPassengers(dto.passengers as any[]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const fareCalculation = await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
const fareCalculation = dto.packageId && dto.priceTierId
? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount)
: await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = fareCalculation.totalMinor;
@@ -401,6 +548,7 @@ export class BookingsService {
childCount,
displayCurrency,
displayTotalMinor,
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
seats: {
create: passengersWithFares.map(p => ({
seat: { connect: { id: p.seatId } },
@@ -421,6 +569,12 @@ export class BookingsService {
});
await this.seatsService.confirmSeats(passengersData.map(p => p.seatId));
if (dto.packageId && dto.priceTierId) {
await this.prisma.packagePriceTier.update({
where: { id: dto.priceTierId },
data: { bookedSeats: { increment: passengersData.length } },
});
}
this.eventEmitter.emit('booking.created', { booking });
return { ...booking, fareBreakdown: fareCalculation };
}
@@ -468,23 +622,38 @@ export class BookingsService {
const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const [outboundFare, returnFare] = await Promise.all([
// Package bookings use fixed tier price split equally across both legs
let outboundFare: Awaited<ReturnType<typeof this.calculateFare>>;
let returnFare: Awaited<ReturnType<typeof this.calculateFare>>;
let combinedBaseFareMinor: number;
let discountMinor = 0;
let loyaltyMinor = 0;
let totalMinor: number;
if (dto.packageId && dto.priceTierId) {
const pkgFare = await this.calculatePackageFare(dto.priceTierId, adultCount, childCount);
// Split evenly across both legs for per-seat fare recording
const halfMinor = Math.round(pkgFare.baseFareMinor / 2);
outboundFare = { ...pkgFare, baseFareMinor: halfMinor, totalBaseFareMinor: Math.round(pkgFare.totalBaseFareMinor / 2) };
returnFare = { ...pkgFare, baseFareMinor: pkgFare.baseFareMinor - halfMinor, totalBaseFareMinor: pkgFare.totalBaseFareMinor - Math.round(pkgFare.totalBaseFareMinor / 2) };
combinedBaseFareMinor = pkgFare.totalBaseFareMinor;
totalMinor = pkgFare.totalMinor;
} else {
[outboundFare, returnFare] = await Promise.all([
this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount),
this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount)
]);
const combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor;
let discountMinor = 0;
combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
}
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
@@ -541,6 +710,7 @@ export class BookingsService {
returnHoldId: dto.returnHoldId,
returnSeatClassId: dto.returnSeatClassId,
returnLegStatus: 'NEITHER_USED',
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
seats: {
create: [
...passengersWithFares.map(p => ({
@@ -586,6 +756,13 @@ export class BookingsService {
this.seatsService.confirmSeats(returnSeatIds)
]);
if (dto.packageId && dto.priceTierId) {
await this.prisma.packagePriceTier.update({
where: { id: dto.priceTierId },
data: { bookedSeats: { increment: passengersData.length } },
});
}
this.eventEmitter.emit('booking.created', { booking });
return {
@@ -1048,6 +1225,30 @@ export class BookingsService {
return { adultCount, childCount };
}
private async calculatePackageFare(
priceTierId: string,
adultCount: number,
childCount: number,
) {
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } });
const passengerCount = adultCount + childCount;
const totalBaseFareMinor = tier.priceMinor * passengerCount;
return {
baseFareMinor: tier.priceMinor,
adultCount,
adultFareMinor: tier.priceMinor * adultCount,
childCount,
freeChildrenCount: 0,
paidChildrenCount: childCount,
childFareMinor: tier.priceMinor * childCount,
totalBaseFareMinor,
discountMinor: 0,
loyaltyRedemptionMinor: 0,
taxesFeesMinor: 0,
totalMinor: totalBaseFareMinor,
};
}
private async calculateFare(
scheduleId: string,
seatClassId: string,
@@ -1182,7 +1383,64 @@ export class BookingsService {
paymentIntent: true, tickets: { take: 1 },
},
});
if (!booking) throw new NotFoundException('Booking not found');
if (!booking) {
// Fall back to PackageBooking
const pkgBooking = await this.prisma.packageBooking.findUnique({
where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId },
include: {
package: { include: { outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } }, returnSchedule: { include: { originStation: true, destinationStation: true } } } },
priceTier: true,
passengers: true,
paymentIntent: true,
},
});
if (!pkgBooking) throw new NotFoundException('Booking not found');
return {
id: pkgBooking.id,
bookingRef: pkgBooking.bookingRef,
status: pkgBooking.status,
totalMinor: pkgBooking.totalMinor,
currency: pkgBooking.currency || 'ETB',
adultCount: pkgBooking.passengerCount,
childCount: 0,
displayCurrency: pkgBooking.displayCurrency,
displayTotalMinor: pkgBooking.displayTotalMinor ?? undefined,
bookingType: 'PACKAGE',
packageId: pkgBooking.packageId,
priceTierId: pkgBooking.priceTierId,
packageName: (pkgBooking as any).package?.name,
packageCode: (pkgBooking as any).package?.code,
tierLabel: (pkgBooking as any).priceTier?.label,
isPackageBooking: true,
returnLegStatus: null,
contactEmail: pkgBooking.contactEmail,
contactPhone: pkgBooking.contactPhone,
createdAt: pkgBooking.createdAt,
schedule: (pkgBooking as any).package?.outboundSchedule ? {
id: (pkgBooking as any).package.outboundSchedule.id,
trainNumber: (pkgBooking as any).package.outboundSchedule.train?.number,
trainName: (pkgBooking as any).package.outboundSchedule.train?.name,
origin: (pkgBooking as any).package.outboundSchedule.originStation,
destination: (pkgBooking as any).package.outboundSchedule.destinationStation,
departureAt: (pkgBooking as any).package.outboundSchedule.departureAt,
arrivalAt: (pkgBooking as any).package.outboundSchedule.arrivalAt,
} : null,
passengers: (pkgBooking as any).passengers?.map((p: any) => ({
fullName: p.passengerName,
category: 'ADULT',
leg: 1,
fareMinor: Math.round(pkgBooking.totalMinor / pkgBooking.passengerCount),
verifaydaVerified: false,
seat: null,
})),
payment: (pkgBooking as any).paymentIntent
? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status }
: undefined,
ticket: undefined,
};
}
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalMinor: booking.totalMinor, currency: 'ETB',

View File

@@ -81,8 +81,10 @@ export class GuestBookingService {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId)
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId)
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined);
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
@@ -306,10 +308,16 @@ export class GuestBookingService {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId);
const synth = (sched: any, stationId: string, seq: number) => {
const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation;
return { stationId, sequence: seq, station };
};
const obStops = outboundSchedule.stopTimes.length > 0 ? outboundSchedule.stopTimes : [synth(outboundSchedule, outboundSchedule.originStationId, 0), synth(outboundSchedule, outboundSchedule.destinationStationId, 1)];
const retStops = returnSchedule.stopTimes.length > 0 ? returnSchedule.stopTimes : [synth(returnSchedule, returnSchedule.originStationId, 0), synth(returnSchedule, returnSchedule.destinationStationId, 1)];
const outboundOriginStop = obStops.find((s: any) => s.stationId === dto.originStationId) ?? obStops[0];
const outboundDestStop = obStops.find((s: any) => s.stationId === dto.destinationStationId) ?? obStops[obStops.length - 1];
const returnOriginStop = retStops.find((s: any) => s.stationId === dto.returnOriginStationId) ?? retStops[0];
const returnDestStop = retStops.find((s: any) => s.stationId === dto.returnDestinationStationId) ?? retStops[retStops.length - 1];
if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule');
if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');

View File

@@ -130,7 +130,7 @@ export class FareEngineService {
const adultCount = dto.adultCount ?? 1;
const childCount = dto.childCount ?? 0;
const freeChildrenCount = Math.min(childCount, 1);
const freeChildrenCount = Math.min(childCount, adultCount);
const paidChildrenCount = Math.max(0, childCount - 1);
// Subtotal includes: (distance-based fare + premium + insurance) × passengers
@@ -169,7 +169,7 @@ export class FareEngineService {
`Total fare/pax: ${farePerPassengerMinor} ETB minor`,
``,
`Adults: ${adultCount} × ${farePerPassengerMinor} = ${adultSubtotal} ETB minor`,
`Children: ${childCount} (${freeChildrenCount} free + ${paidChildrenCount} paid)`,
`Children: ${childCount} (${freeChildrenCount} free [1 per adult] + ${paidChildrenCount} paid)`,
` Free child: ${freeChildrenCount} × ${premiumPerPassenger + insurancePerPassenger} = ${freeChildSubtotal} ETB minor`,
` Paid child: ${paidChildrenCount} × ${farePerPassengerMinor} = ${paidChildSubtotal} ETB minor`,
``,

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common';
import { Controller, Get, Post, Patch, Param, Body, Query, Logger } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { FraudService, FraudRuleConfig } from './fraud.service';
import { PassengerStaff } from '../../common/passenger-guards';
@@ -48,6 +48,31 @@ export class FraudController {
return { data: rule, message: 'Rule updated successfully' };
}
/**
* Acknowledge a fraud alert
*/
@Patch('alerts/:id/acknowledge')
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Acknowledge a fraud alert' })
async acknowledgeAlert(@Param('id') id: string) {
const alert = await this.fraudService.acknowledgeAlert(id);
return { data: alert, message: 'Alert acknowledged' };
}
/**
* Block user via userId
*/
@Post('users/:userId/block')
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Block user by userId' })
async blockUserById(
@Param('userId') userId: string,
@Body() body: { reason?: string; durationMinutes?: number },
) {
await this.fraudService.blockUserTemporarily(userId, body.durationMinutes ?? 60);
return { message: `User blocked for ${body.durationMinutes ?? 60} minutes` };
}
/**
* Block user temporarily
*/

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FraudService } from './fraud.service';
import { FraudController } from './fraud.controller';
@Module({
imports: [HttpModule],
imports: [HttpModule, TypeOrmModule],
providers: [FraudService],
controllers: [FraudController],
exports: [FraudService],

View File

@@ -164,6 +164,16 @@ export class FraudService {
this.logger.log(`Passenger (iamUserId=${iamUserId}) unblocked`);
}
/**
* Acknowledge a fraud alert
*/
async acknowledgeAlert(id: string) {
return this.prisma.fraudAlert.update({
where: { id },
data: { acknowledged: true, acknowledgedAt: new Date() },
});
}
/**
* Get all fraud alerts
*/

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Controller, Get, Param, Post, Delete, UseGuards, SetMetadata, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { LoyaltyService } from './loyalty.service';
import { JwtGuard } from '../../common/jwt.guard';
@@ -9,7 +9,9 @@ import { JwtGuard } from '../../common/jwt.guard';
@ApiBearerAuth('JWT-auth')
export class LoyaltyController {
constructor(private service: LoyaltyService) {}
@Get('accounts') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all loyalty accounts' }) getAccounts(@Query() q: any) { return this.service.getAccounts(q); }
@Get(':passengerId') @ApiOperation({ summary: 'Get loyalty account with tier progress' }) getAccount(@Param('passengerId') id: string) { return this.service.getAccount(id); }
@Get(':passengerId/rewards') @ApiOperation({ summary: 'Get available rewards' }) getRewards(@Param('passengerId') id: string) { return this.service.getRewards(id); }
@Post(':passengerId/rewards/:rewardId/redeem') @ApiOperation({ summary: 'Redeem a loyalty reward' }) redeemReward(@Param('passengerId') pid: string, @Param('rewardId') rid: string) { return this.service.redeemReward(pid, rid); }
@Delete('accounts/:id') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete loyalty account' }) deleteAccount(@Param('id') id: string) { return this.service.deleteAccount(id); }
}

View File

@@ -5,6 +5,42 @@ import { PrismaService } from '../../common/prisma.service';
export class LoyaltyService {
constructor(private prisma: PrismaService) {}
async getAccounts(params: { search?: string; tier?: string; page?: string; pageSize?: string } = {}) {
const { search, tier, page = '1', pageSize = '20' } = params;
const skip = (parseInt(page) - 1) * parseInt(pageSize);
const where: any = {};
if (tier) where.tier = tier;
if (search) {
where.passenger = {
OR: [
{ user: { fullName: { contains: search, mode: 'insensitive' } } },
{ user: { email: { contains: search, mode: 'insensitive' } } },
],
};
}
const [items, total] = await Promise.all([
this.prisma.loyaltyAccount.findMany({
where,
skip,
take: parseInt(pageSize),
orderBy: { pointsBalance: 'desc' },
include: { passenger: { include: { user: true } } },
}),
this.prisma.loyaltyAccount.count({ where }),
]);
return {
items: items.map(a => ({
...a,
passenger: a.passenger ? {
id: a.passenger.id,
fullName: (a.passenger as any).user?.fullName ?? null,
email: (a.passenger as any).user?.email ?? null,
phone: (a.passenger as any).user?.phone ?? null,
} : null,
})),
meta: { page: parseInt(page), pageSize: parseInt(pageSize), total, totalPages: Math.ceil(total / parseInt(pageSize)) },
};
}
async getAccount(passengerId: string) {
const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } });
if (!account) throw new NotFoundException('Loyalty account not found');
@@ -40,4 +76,15 @@ export class LoyaltyService {
await this.prisma.loyaltyReward.update({ where: { id: rewardId }, data: { available: false } });
return { redeemed: true, pointsUsed: reward.costPoints, balanceAfter: newBalance };
}
async deleteAccount(id: string) {
const account = await this.prisma.loyaltyAccount.findUnique({ where: { id } });
if (!account) throw new NotFoundException('Loyalty account not found');
await this.prisma.$transaction([
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { accountId: id } }),
this.prisma.loyaltyReward.deleteMany({ where: { accountId: id } }),
this.prisma.loyaltyAccount.delete({ where: { id } }),
]);
return { deleted: true, accountId: id };
}
}

View File

@@ -1,8 +1,8 @@
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { PackagesService } from './packages.service';
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto } from './packages.dto';
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto, PackageBookingContextDto } from './packages.dto';
import { IamGuard } from '../../common/iam-adapter';
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
@@ -63,6 +63,19 @@ export class PackagesController {
return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20);
}
@Get('bookings')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all package bookings (backoffice)' })
listBookings(
@Query('packageId') packageId?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.listBookings({ packageId, status, page: page ? +page : 1, pageSize: pageSize ? +pageSize : 20 });
}
@Get('my-bookings')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@@ -78,6 +91,21 @@ export class PackagesController {
return this.service.getBookingByRef(ref);
}
@Get(':id/booking-context')
@IsPublic()
@ApiOperation({ summary: 'Get booking context for self-service package booking' })
@ApiQuery({ name: 'tierId', required: true })
@ApiQuery({ name: 'adultCount', required: true })
@ApiQuery({ name: 'childCount', required: false })
getBookingContext(
@Param('id') id: string,
@Query('tierId') tierId: string,
@Query('adultCount') adultCount: string,
@Query('childCount') childCount?: string,
) {
return this.service.getBookingContext(id, tierId, parseInt(adultCount), childCount ? parseInt(childCount) : 0);
}
@Get(':id')
@IsPublic()
@ApiOperation({ summary: 'Get package details' })

View File

@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID } from 'class-validator';
import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID, IsPositive } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@@ -93,6 +93,12 @@ export class BookPackagePassengerDto {
@ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string;
}
export class PackageBookingContextDto {
@ApiProperty() @IsUUID() tierId: string;
@ApiProperty({ example: 1 }) @IsInt() @IsPositive() adultCount: number;
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() @Min(0) childCount?: number;
}
export class BookPackageDto {
@ApiProperty() @IsUUID() packageId: string;
@ApiProperty() @IsUUID() priceTierId: string;

View File

@@ -3,9 +3,10 @@ import { PrismaModule } from '../../common/prisma.module';
import { PackagesController } from './packages.controller';
import { PackagesService } from './packages.service';
import { CurrencyModule } from '../currency/currency.module';
import { BookingsModule } from '../bookings/bookings.module';
@Module({
imports: [PrismaModule, CurrencyModule],
imports: [PrismaModule, CurrencyModule, BookingsModule],
controllers: [PackagesController],
providers: [PackagesService],
exports: [PackagesService],

View File

@@ -3,6 +3,8 @@ import { PrismaService } from '../../common/prisma.service';
import { CurrencyService } from '../currency/currency.service';
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto';
import { Currency } from '@prisma/client';
import { BookingsService } from '../bookings/bookings.service';
import { GuestBookingService } from '../bookings/guest-booking.service';
function generateRef(): string {
return 'PKG-' + Array.from({ length: 6 }, () =>
@@ -15,8 +17,94 @@ export class PackagesService {
constructor(
private readonly prisma: PrismaService,
private readonly currencyService: CurrencyService,
private readonly bookingsService: BookingsService,
private readonly guestBookingService: GuestBookingService,
) {}
async getBookingContext(packageId: string, tierId: string, adultCount: number, childCount = 0) {
const pkg = await this.prisma.travelPackage.findUnique({
where: { id: packageId },
include: {
priceTiers: true,
outboundSchedule: {
include: {
originStation: true,
destinationStation: true,
coachAssignments: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } },
},
},
returnSchedule: { include: { originStation: true, destinationStation: true } },
},
});
if (!pkg || pkg.status !== 'ACTIVE') throw new NotFoundException('Package not available');
const tier = pkg.priceTiers.find(t => t.id === tierId);
if (!tier) throw new NotFoundException('Price tier not found');
const passengerCount = adultCount + childCount;
if (passengerCount < 1) throw new BadRequestException('At least one passenger required');
const remaining = tier.availableSeats - tier.bookedSeats;
if (passengerCount > remaining)
throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`);
const totalMinor = tier.priceMinor * passengerCount;
// Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches
let seatClassId: string | null = null;
let coachTypeId: string | null = null;
for (const a of pkg.outboundSchedule.coachAssignments) {
const sc = a.coach.coachType?.seatClasses?.find(
(s: any) => s.name.toLowerCase().includes(tier.seatType.toLowerCase()) ||
tier.seatType.toLowerCase().includes(s.name.toLowerCase()),
);
if (sc) { seatClassId = sc.id; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; }
}
// Fallback: use the first coach assignment's coachTypeId if no match found
if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) {
const first = pkg.outboundSchedule.coachAssignments[0];
coachTypeId = first.coach.coachTypeId ?? first.coach.coachType?.id ?? null;
}
return {
packageId: pkg.id,
packageName: pkg.name,
priceTierId: tier.id,
tierLabel: tier.label,
seatType: tier.seatType,
seatClassId,
coachTypeId,
adultCount,
childCount,
passengerCount,
pricePerPassengerMinor: tier.priceMinor,
totalMinor,
currency: tier.currency,
remainingSeats: remaining,
outboundSchedule: {
scheduleId: pkg.outboundScheduleId,
originStationId: pkg.originStationId,
destinationStationId: pkg.destinationStationId,
departureAt: pkg.outboundSchedule.departureAt,
arrivalAt: pkg.outboundSchedule.arrivalAt,
originStation: pkg.outboundSchedule.originStation,
destinationStation: pkg.outboundSchedule.destinationStation,
},
returnSchedule: pkg.returnSchedule ? {
scheduleId: pkg.returnScheduleId,
originStationId: pkg.destinationStationId,
destinationStationId: pkg.originStationId,
departureAt: pkg.returnSchedule.departureAt,
arrivalAt: pkg.returnSchedule.arrivalAt,
originStation: pkg.returnSchedule.destinationStation,
destinationStation: pkg.returnSchedule.originStation,
} : null,
includedServices: pkg.includedServices,
busTransferIncluded: pkg.busTransferIncluded,
busTransferRoute: pkg.busTransferRoute,
};
}
async createInquiry(dto: CreateInquiryDto) {
return this.prisma.packageInquiry.create({
data: {
@@ -74,7 +162,7 @@ export class PackagesService {
returnSchedule: { include: { originStation: true, destinationStation: true } },
},
orderBy: { validFrom: 'asc' },
});
}).then(pkgs => pkgs.map(p => ({ ...p, journeyType: p.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' })));
}
async getById(id: string) {
@@ -87,7 +175,7 @@ export class PackagesService {
},
});
if (!pkg) throw new NotFoundException('Package not found');
return pkg;
return { ...pkg, journeyType: pkg.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' };
}
create(dto: CreatePackageDto) {
@@ -296,6 +384,29 @@ export class PackagesService {
return booking;
}
async listBookings({ packageId, status, page = 1, pageSize = 20 }: { packageId?: string; status?: string; page?: number; pageSize?: number }) {
const where: any = {};
if (packageId) where.packageId = packageId;
if (status) where.status = status;
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([
this.prisma.packageBooking.findMany({
where,
include: {
package: { select: { id: true, name: true, code: true } },
priceTier: { select: { id: true, label: true, seatType: true } },
passengers: true,
paymentIntent: true,
},
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.packageBooking.count({ where }),
]);
return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) };
}
async listAll(page = 1, pageSize = 20) {
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([

View File

@@ -433,39 +433,49 @@ export class PassengersService {
}
async deletePassenger(id: string) {
const passenger = await this.prisma.passenger.findUnique({
// id may be a TravelerProfile.id (from the list endpoint) or a Passenger.id
let passenger = await this.prisma.passenger.findUnique({
where: { id },
include: {
user: true
}
include: { user: true },
});
if (!passenger) {
const profile = await this.prisma.travelerProfile.findUnique({ where: { id } });
if (!profile?.passengerId) throw new NotFoundException('Passenger not found');
passenger = await this.prisma.passenger.findUnique({
where: { id: profile.passengerId },
include: { user: true },
});
if (!passenger) throw new NotFoundException('Passenger not found');
}
const passengerId = passenger.id;
// Check usage before allowing deletion
const usage = await this.checkPassengerUsage(id);
const usage = await this.checkPassengerUsage(passengerId);
if (usage.isInUse && usage.constraints) {
const passengerName = (passenger as any).user?.fullName || `Passenger ${id.slice(-8)}`;
const passengerName = (passenger as any).user?.fullName || `Passenger ${passengerId.slice(-8)}`;
throw new DeleteOperationException('Passenger', passengerName, usage.constraints);
}
await this.prisma.$transaction([
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId: id } } }),
this.prisma.loyaltyAccount.deleteMany({ where: { passengerId: id } }),
this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId: id } } }),
this.prisma.walletAccount.deleteMany({ where: { passengerId: id } }),
this.prisma.notification.deleteMany({ where: { passengerId: id } }),
this.prisma.travelerProfile.deleteMany({ where: { passengerId: id } }),
this.prisma.savedRoute.deleteMany({ where: { passengerId: id } }),
this.prisma.packageBooking.deleteMany({ where: { passengerId: id } }),
this.prisma.ticket.deleteMany({ where: { booking: { passengerId: id } } }),
this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }),
this.prisma.booking.deleteMany({ where: { passengerId: id } }),
this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId: id } } }),
this.prisma.journey.deleteMany({ where: { passengerId: id } }),
this.prisma.passenger.delete({ where: { id } }),
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId } } }),
this.prisma.loyaltyAccount.deleteMany({ where: { passengerId } }),
this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId } } }),
this.prisma.walletAccount.deleteMany({ where: { passengerId } }),
this.prisma.notification.deleteMany({ where: { passengerId } }),
this.prisma.travelerProfile.deleteMany({ where: { passengerId } }),
this.prisma.savedRoute.deleteMany({ where: { passengerId } }),
this.prisma.packageBooking.deleteMany({ where: { passengerId } }),
this.prisma.ticket.deleteMany({ where: { booking: { passengerId } } }),
this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId } } }),
this.prisma.booking.deleteMany({ where: { passengerId } }),
this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId } } }),
this.prisma.journey.deleteMany({ where: { passengerId } }),
this.prisma.passenger.delete({ where: { id: passengerId } }),
]);
return { deleted: true, passengerId: id };
return { deleted: true, passengerId };
}
async checkPassengerUsage(id: string) {

View File

@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
HttpStatus,
Param,
@@ -42,6 +43,14 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
export class PaymentsController {
constructor(private service: PaymentsService) {}
@Delete(":id")
@PassengerStaff([PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Delete a payment intent record (admin only)" })
deletePayment(@Param("id") id: string) {
return this.service.deletePayment(id);
}
@Get("all")
@PassengerStaff([PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")

View File

@@ -55,6 +55,13 @@ export class PaymentsService {
private currencyService: CurrencyService,
) {}
async deletePayment(id: string) {
const intent = await this.prisma.paymentIntent.findUnique({ where: { id } });
if (!intent) throw new NotFoundException('Payment intent not found');
await this.prisma.paymentIntent.delete({ where: { id } });
return { deleted: true, id };
}
async getAll(filters: {
search?: string;
status?: string;

View File

@@ -1,8 +1,8 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Body, Controller, Post, Get, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SearchService } from './search.service';
import { SearchTripsDto, FareQuoteDto } from './search.dto';
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto';
@ApiTags('Search')
@Controller('search')
@@ -66,4 +66,29 @@ Nationality-Based:
getFareQuote(@Body() dto: FareQuoteDto) {
return this.service.getFareQuote(dto);
}
@Get('fare-breakdown')
@ApiOperation({
summary: 'Per-passenger fare breakdown for booking review page',
description: `Calculates a line-item fare for each individual passenger based on their date of birth, nationality, and chosen seat class.
- Age is derived from dateOfBirth at request time (ADULT ≥5 yrs, CHILD <5 yrs)
- First CHILD in the list travels free (pays only premium + insurance fees)
- Each passenger can have a different seat class and nationality
- Returns per-passenger lines plus subtotal, discount, and grand total
**passengers** must be a URL-encoded JSON array, e.g.:
\`[{"passengerName":"Abebe","dateOfBirth":"1985-03-15","seatClassId":"uuid","nationality":"Ethiopian"}]\``,
})
@ApiQuery({ name: 'scheduleId', description: 'TrainSchedule UUID' })
@ApiQuery({ name: 'originStationId', description: 'Origin station UUID' })
@ApiQuery({ name: 'destinationStationId', description: 'Destination station UUID' })
@ApiQuery({ name: 'passengers', description: 'URL-encoded JSON array of passengers: [{passengerName, dateOfBirth, seatClassId, nationality?}]' })
@ApiQuery({ name: 'promoCode', required: false })
@ApiQuery({ name: 'displayCurrency', required: false, enum: ['ETB', 'DJF', 'USD'] })
@ApiResponse({ status: 200, description: 'Per-passenger fare lines with grand total' })
@ApiResponse({ status: 404, description: 'Schedule not found' })
getFareBreakdown(@Query() dto: FareBreakdownRequestDto) {
return this.service.getFareBreakdown(dto);
}
}

View File

@@ -75,6 +75,43 @@ export class CoachTypeOptionClass {
@ApiProperty({ example: 35000 }) baseFareMinor: number;
}
export class FareBreakdownPassengerDto {
@ApiProperty({ example: 'Abebe Kebede', description: 'Passenger name (for display only)' })
@IsString() passengerName: string;
@ApiProperty({ example: '1985-03-15', description: 'Date of birth — determines ADULT (≥5 yrs) or CHILD (<5 yrs)' })
@IsDateString() dateOfBirth: string;
@ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID for this passenger' })
@IsString() seatClassId: string;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality — affects billing currency and seat class variant' })
@IsOptional() @IsString() nationality?: string;
}
export class FareBreakdownRequestDto {
@ApiProperty({ example: 'schedule-uuid' })
@IsString() scheduleId: string;
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID (must be a stop on the schedule)' })
@IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' })
@IsString() destinationStationId: string;
@ApiProperty({
example: '[{"passengerName":"Abebe","dateOfBirth":"1985-03-15","seatClassId":"uuid","nationality":"Ethiopian"}]',
description: 'URL-encoded JSON array of passengers. Each entry: { passengerName, dateOfBirth (YYYY-MM-DD), seatClassId, nationality? }',
})
@IsString() passengers: string;
@ApiPropertyOptional({ example: 'WEEKEND15' })
@IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional({ example: 'USD', enum: Currency })
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
}
export class CoachTypeOption {
@ApiProperty({ example: 'coach-type-uuid' }) coachTypeId: string;
@ApiProperty({ example: 'Economy' }) coachTypeName: string;

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SearchTripsDto, FareQuoteDto } from './search.dto';
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, FareBreakdownPassengerDto } from './search.dto';
import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { SegmentsService } from '../segments/segments.service';
@@ -477,6 +477,124 @@ export class SearchService {
};
}
async getFareBreakdown(dto: FareBreakdownRequestDto) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
const now = new Date();
const displayCurrency = dto.displayCurrency ?? Currency.ETB;
let parsedPassengers: FareBreakdownPassengerDto[];
try {
parsedPassengers = JSON.parse(dto.passengers as unknown as string);
} catch {
throw new NotFoundException('passengers must be a valid JSON array');
}
// Categorise passengers by age
const categorised = parsedPassengers.map(p => {
const ageMs = now.getTime() - new Date(p.dateOfBirth).getTime();
const ageYears = ageMs / (1000 * 60 * 60 * 24 * 365.25);
return { ...p, category: (ageYears >= 5 ? 'ADULT' : 'CHILD') as 'ADULT' | 'CHILD', ageYears };
});
const adultCount = categorised.filter(p => p.category === 'ADULT').length;
const childCount = categorised.filter(p => p.category === 'CHILD').length;
// Ask the fare engine for the authoritative free-child count using the full group
// Use the first passenger's seatClassId as a representative — freeChildrenCount
// depends only on adultCount/childCount, not on seat class.
const groupFare = await this.fareEngine.calculate({
routeId: schedule.routeId!,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
seatClassId: categorised[0].seatClassId,
nationality: categorised[0].nationality,
scheduleId: dto.scheduleId,
adultCount,
childCount,
});
const freeChildrenAllowed = groupFare.freeChildrenCount;
// Calculate per-passenger fare rate (engine called with 1 adult, 0 children — pure rate lookup)
let freeChildrenUsed = 0;
const passengerLines = await Promise.all(
categorised.map(async (p) => {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId!,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
seatClassId: p.seatClassId,
nationality: p.nationality,
scheduleId: dto.scheduleId,
adultCount: 1,
childCount: 0,
});
const isFree = p.category === 'CHILD' && freeChildrenUsed < freeChildrenAllowed;
if (isFree) freeChildrenUsed++;
const fareMinor = isFree
? fare.premiumPerPassenger + fare.insurancePerPassenger
: fare.farePerPassengerMinor;
const displayFareMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(fareMinor, Currency.ETB, displayCurrency)
: fareMinor;
return {
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
category: p.category,
ageYears: Math.floor(p.ageYears),
seatClassId: fare.seatClassId,
seatClassName: fare.seatClassName,
nationality: p.nationality ?? null,
baseFareMinor: fare.baseFarePerPassengerMinor,
premiumMinor: fare.premiumPerPassenger,
insuranceFeeMinor: fare.insurancePerPassenger,
fareMinor,
isFree,
displayCurrency,
displayFareMinor,
};
}),
);
let subtotalMinor = passengerLines.reduce((sum, l) => sum + l.fareMinor, 0);
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > now) {
discountMinor = promo.percentOff
? Math.round(subtotalMinor * promo.percentOff / 100)
: (promo.amountOffMinor ?? 0);
}
}
const totalMinor = subtotalMinor - discountMinor;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
return {
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
passengers: passengerLines,
subtotalMinor,
discountMinor,
totalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor,
};
}
private async calculateFaresForSegment(
schedule: ScheduleWithIncludes,
originStationId: string,

View File

@@ -1,5 +1,6 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
@Injectable()
export class SeatClassesService {
@@ -45,8 +46,24 @@ export class SeatClassesService {
}
async deleteSeatClass(id: string) {
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
const sc = await this.prisma.seatClass.findUnique({
where: { id },
include: {
_count: { select: { fareRules: true, routeFareRules: true, segmentFares: true } },
},
});
if (!sc) throw new NotFoundException('SeatClass not found');
const totalFareRules =
(sc as any)._count.fareRules +
(sc as any)._count.routeFareRules +
(sc as any)._count.segmentFares;
if (totalFareRules > 0)
throw new DeleteOperationException('Seat Class', sc.name, [
{ entityName: 'fare rule', count: totalFareRules, action: 'delete' },
]);
return this.prisma.seatClass.delete({ where: { id } });
}
}

View File

@@ -28,6 +28,25 @@ import { IamGuard } from "../../common/iam-adapter";
export class SeatsController {
constructor(private service: SeatsService) {}
// ── Coach Availability ────────────────────────────────────────────────────
@Get('coaches/:scheduleId')
@SetMetadata('isPublic', true)
@ApiOperation({
summary: 'List coaches with remaining seat counts for a schedule',
description: 'Returns each coach assigned to the schedule with total, available, held, and booked seat counts. Optionally scoped to a specific origin→destination leg.',
})
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
@ApiQuery({ name: 'originStationId', required: false, description: 'Scope availability to this origin station' })
@ApiQuery({ name: 'destinationStationId', required: false, description: 'Scope availability to this destination station' })
@ApiResponse({ status: 200, description: 'Coaches with seat availability counts' })
getCoachesWithAvailability(
@Param('scheduleId') scheduleId: string,
@Query('originStationId') originStationId?: string,
@Query('destinationStationId') destinationStationId?: string,
) {
return this.service.getCoachesWithAvailability(scheduleId, originStationId, destinationStationId);
}
// ── Seat Map ──────────────────────────────────────────────────────────────
@Get("seatmap/:scheduleId")
@SetMetadata('isPublic', true)

View File

@@ -367,7 +367,24 @@ export class SeatsService {
where: { scheduleId: dto.scheduleId },
select: { stationId: true, sequence: true },
});
const seqOf = (stationId: string) => stopTimes.find(s => s.stationId === stationId)?.sequence;
// When no stop times exist, fall back to the schedule's own origin/destination
// with synthetic sequences so the hold can still be created.
let effectiveStopTimes = stopTimes;
if (stopTimes.length === 0) {
const sched = await tx.trainSchedule.findUnique({
where: { id: dto.scheduleId },
select: { originStationId: true, destinationStationId: true },
});
if (sched) {
effectiveStopTimes = [
{ stationId: sched.originStationId, sequence: 0 },
{ stationId: sched.destinationStationId, sequence: 1 },
];
}
}
const seqOf = (stationId: string) => effectiveStopTimes.find(s => s.stationId === stationId)?.sequence;
const reqFrom = seqOf(dto.originStationId);
const reqTo = seqOf(dto.destinationStationId);
@@ -604,6 +621,56 @@ export class SeatsService {
await this.prisma.journey.deleteMany({ where: { bookingId } as any });
}
async getCoachesWithAvailability(scheduleId: string, originStationId?: string, destinationStationId?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { originStationId: true, destinationStationId: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
const assignments = await this.prisma.coachAssignment.findMany({
where: { scheduleId },
include: {
coach: {
include: {
seats: { select: { id: true, status: true, seatNumber: true } },
coachType: { include: { seatClasses: { select: { name: true } } } },
},
},
},
orderBy: { positionNumber: 'asc' },
});
const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(
scheduleId,
allSeatIds,
originStationId ?? schedule.originStationId,
destinationStationId ?? schedule.destinationStationId,
);
return assignments.map(a => {
const seats = a.coach.seats.filter(s => s.seatNumber && !s.seatNumber.startsWith('-'));
const totalSeats = seats.length;
const unavailable = seats.filter(s => {
const status = effectiveStatuses.get(s.id) ?? s.status;
return status === 'HELD' || status === 'BOOKED' || status === 'BLOCKED';
}).length;
return {
coachId: a.coach.id,
coachNumber: a.coach.number,
positionNumber: a.positionNumber,
coachTypeName: a.coach.coachType?.name ?? '',
seatClasses: a.coach.coachType?.seatClasses.map(sc => sc.name) ?? [],
totalSeats,
availableSeats: totalSeats - unavailable,
heldSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'HELD').length,
bookedSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'BOOKED').length,
};
});
}
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
const seats = await this.prisma.seat.findMany({
where: {

View File

@@ -3,6 +3,7 @@ import { REQUEST } from '@nestjs/core';
import { PrismaService } from '../../common/prisma.service';
import { AuditService } from '../../common/audit.service';
import { CreateStationDto } from './stations.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
interface StationFilters {
search?: string;
@@ -96,7 +97,35 @@ export class StationsService {
}
async remove(id: string) {
const station = await this.findOne(id);
const station = await this.prisma.station.findUnique({
where: { id },
include: {
_count: { select: { stopTimes: true } },
originSchedules: { take: 1, select: { id: true } },
destinationSchedules: { take: 1, select: { id: true } },
},
});
if (!station) throw new NotFoundException('Station not found');
const [routeStopCount, originCount, destCount, stopTimeCount] = await Promise.all([
this.prisma.routeStop.count({ where: { stationId: id } }),
this.prisma.trainSchedule.count({ where: { originStationId: id } }),
this.prisma.trainSchedule.count({ where: { destinationStationId: id } }),
(station as any)._count.stopTimes as number,
]);
const constraints = [];
if (routeStopCount > 0)
constraints.push({ entityName: 'route', count: routeStopCount, action: 'delete' as const });
const scheduleCount = originCount + destCount;
if (scheduleCount > 0)
constraints.push({ entityName: 'schedule', count: scheduleCount, action: 'delete' as const });
if (stopTimeCount > 0)
constraints.push({ entityName: 'stop time', count: stopTimeCount, action: 'delete' as const });
if (constraints.length > 0)
throw new DeleteOperationException('Station', `${station.name} (${station.code})`, constraints);
const deleted = await this.prisma.station.delete({ where: { id } });
await this.auditService.log({

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Delete, UseGuards, SetMetadata, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { WalletService } from './wallet.service';
@@ -11,6 +11,8 @@ import { JwtGuard } from '../../common/jwt.guard';
@Throttle({ strict: { limit: 20, ttl: 60_000 } })
export class WalletController {
constructor(private service: WalletService) {}
@Get('accounts') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all wallet accounts' }) getAccounts(@Query() q: any) { return this.service.getAccounts(q); }
@Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); }
@Post(':passengerId/topup') @ApiOperation({ summary: 'Top up wallet' }) topUp(@Param('passengerId') id: string, @Body('amountMinor') amount: number) { return this.service.topUp(id, amount); }
@Delete('accounts/:id') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete wallet account' }) deleteAccount(@Param('id') id: string) { return this.service.deleteAccount(id); }
}

View File

@@ -5,6 +5,42 @@ import { PrismaService } from '../../common/prisma.service';
export class WalletService {
constructor(private prisma: PrismaService) {}
async getAccounts(params: { search?: string; page?: string; pageSize?: string } = {}) {
const { search, page = '1', pageSize = '20' } = params;
const skip = (parseInt(page) - 1) * parseInt(pageSize);
const where: any = {};
if (search) {
where.passenger = {
OR: [
{ user: { fullName: { contains: search, mode: 'insensitive' } } },
{ user: { email: { contains: search, mode: 'insensitive' } } },
],
};
}
const [items, total] = await Promise.all([
this.prisma.walletAccount.findMany({
where,
skip,
take: parseInt(pageSize),
orderBy: { balanceMinor: 'desc' },
include: { passenger: { include: { user: true } } },
}),
this.prisma.walletAccount.count({ where }),
]);
return {
items: items.map(w => ({
...w,
passenger: w.passenger ? {
id: w.passenger.id,
fullName: (w.passenger as any).user?.fullName ?? null,
email: (w.passenger as any).user?.email ?? null,
phone: (w.passenger as any).user?.phone ?? null,
} : null,
})),
meta: { page: parseInt(page), pageSize: parseInt(pageSize), total, totalPages: Math.ceil(total / parseInt(pageSize)) },
};
}
async getWallet(passengerId: string) {
const wallet = await this.prisma.walletAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } });
if (!wallet) throw new NotFoundException('Wallet not found');
@@ -18,4 +54,14 @@ export class WalletService {
await this.prisma.walletAccount.update({ where: { passengerId }, data: { balanceMinor: newBalance } });
return this.prisma.walletLedgerEntry.create({ data: { walletId: wallet.id, type: 'CREDIT', amountMinor, balanceAfterMinor: newBalance, description } });
}
async deleteAccount(id: string) {
const wallet = await this.prisma.walletAccount.findUnique({ where: { id } });
if (!wallet) throw new NotFoundException('Wallet account not found');
await this.prisma.$transaction([
this.prisma.walletLedgerEntry.deleteMany({ where: { walletId: id } }),
this.prisma.walletAccount.delete({ where: { id } }),
]);
return { deleted: true, accountId: id };
}
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,186 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { appReleasesApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
const EMPTY_FORM = { os: 'android', version: '', forceUpdate: false, storeLink: '', notes: '' };
export default function AppReleasesPage() {
const queryClient = useQueryClient();
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [form, setForm] = useState({ ...EMPTY_FORM });
const [formError, setFormError] = useState('');
const [deleteTarget, setDeleteTarget] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['app-releases'],
queryFn: () => appReleasesApi.getAll(),
});
const flash = (msg: string) => { setSuccessMessage(msg); setTimeout(() => setSuccessMessage(''), 3000); };
const saveMutation = useMutation({
mutationFn: (payload: any) =>
editing ? appReleasesApi.update(editing.id, payload) : appReleasesApi.create(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['app-releases'] });
setFormOpen(false);
setEditing(null);
setForm({ ...EMPTY_FORM });
setFormError('');
flash(editing ? 'Release updated.' : 'Release created.');
},
onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to save.'),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => appReleasesApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['app-releases'] });
setDeleteTarget(null);
setDeleteError(null);
flash('Release deleted.');
},
onError: (e: any) => setDeleteError(e?.response?.data?.message || e?.message || 'Failed to delete.'),
});
const openCreate = () => { setEditing(null); setForm({ ...EMPTY_FORM }); setFormError(''); setFormOpen(true); };
const openEdit = (r: any) => {
setEditing(r);
setForm({ os: r.os, version: r.version, forceUpdate: r.forceUpdate, storeLink: r.storeLink || '', notes: r.notes || '' });
setFormError('');
setFormOpen(true);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!form.version.trim()) { setFormError('Version is required.'); return; }
saveMutation.mutate({ ...form, version: form.version.trim(), storeLink: form.storeLink || undefined, notes: form.notes || undefined });
};
const releases: any[] = Array.isArray(data) ? data : [];
const columns = [
{
key: 'os', label: 'OS',
render: (r: any) => (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold ${r.os === 'ios' ? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300' : 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'}`}>
{r.os === 'ios' ? '🍎 iOS' : '🤖 Android'}
</span>
),
},
{ key: 'version', label: 'Version', render: (r: any) => <span className="font-mono font-semibold">{r.version}</span> },
{
key: 'forceUpdate', label: 'Force Update',
render: (r: any) => <Badge variant="status" status={r.forceUpdate ? 'ACTIVE' : 'INACTIVE'}>{r.forceUpdate ? 'Yes' : 'No'}</Badge>,
},
{
key: 'storeLink', label: 'Store Link',
render: (r: any) => r.storeLink
? <a href={r.storeLink} target="_blank" rel="noreferrer" className="text-primary text-sm underline truncate max-w-[180px] block">{r.storeLink}</a>
: <span className="text-muted-foreground"></span>,
},
{ key: 'notes', label: 'Notes', render: (r: any) => <span className="text-sm text-muted-foreground truncate max-w-[200px] block">{r.notes || '—'}</span> },
{ key: 'createdAt', label: 'Created', render: (r: any) => <span className="text-sm">{formatDateTime(r.createdAt)}</span> },
];
const actions = [
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Pencil },
{ label: 'Delete', onClick: (r: any) => { setDeleteError(null); setDeleteTarget(r); }, variant: 'danger' as const, icon: Trash2 },
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">App Releases</h1>
<p className="text-muted-foreground">Manage mobile app version release control</p>
</div>
<ActionButton icon={Plus} onClick={openCreate}>New Release</ActionButton>
</div>
{successMessage && (
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200"> {successMessage}</div>
)}
<div className="card">
<DataTable data={releases} columns={columns} actions={actions} loading={isLoading} emptyMessage="No releases found" />
</div>
{/* Create / Edit Modal */}
<Modal isOpen={formOpen} onClose={() => setFormOpen(false)} title={editing ? 'Edit Release' : 'New Release'} size="md">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">OS</label>
<select className="input" value={form.os} onChange={(e) => setForm({ ...form, os: e.target.value })}>
<option value="android">Android</option>
<option value="ios">iOS</option>
</select>
</div>
<div>
<label className="label">Version Number</label>
<input type="text" className="input" placeholder="e.g. 1.2.3" value={form.version}
onChange={(e) => setForm({ ...form, version: e.target.value })} />
</div>
</div>
<div>
<label className="label">Force Update</label>
<div className="flex items-center gap-3 mt-1">
{(['true', 'false'] as const).map((val) => (
<label key={val} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="forceUpdate" checked={form.forceUpdate === (val === 'true')}
onChange={() => setForm({ ...form, forceUpdate: val === 'true' })} className="w-4 h-4" />
<span className="text-sm font-medium">{val === 'true' ? 'Yes — force update' : 'No — optional'}</span>
</label>
))}
</div>
</div>
<div>
<label className="label">Store Link</label>
<input type="url" className="input" placeholder="https://play.google.com/..." value={form.storeLink}
onChange={(e) => setForm({ ...form, storeLink: e.target.value })} />
</div>
<div>
<label className="label">Notes</label>
<textarea className="input min-h-[80px] resize-y" placeholder="Release notes or changelog..." value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</div>
{formError && <p className="text-sm text-red-600 dark:text-red-400">{formError}</p>}
<div className="flex justify-end gap-2 pt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setFormOpen(false)}>Cancel</ActionButton>
<ActionButton type="submit" isLoading={saveMutation.isPending}>
{editing ? 'Save Changes' : 'Create Release'}
</ActionButton>
</div>
</form>
</Modal>
<ConfirmDialog
isOpen={!!deleteTarget}
onClose={() => { setDeleteTarget(null); setDeleteError(null); }}
onConfirm={async () => { if (deleteTarget) await deleteMutation.mutateAsync(deleteTarget.id); }}
title="Delete Release"
message={`Delete ${deleteTarget?.os} v${deleteTarget?.version}? This cannot be undone.`}
confirmText="Delete" cancelText="Cancel" isLoading={deleteMutation.isPending} isDanger
error={deleteError ?? undefined}
/>
</div>
);
}

View File

@@ -121,7 +121,7 @@ export default function AuditLogsPage() {
},
];
const logs = data?.items || [];
const logs: any[] = Array.isArray(data?.items) ? data.items : [];
const stats = {
total: logs.length,
creates: logs.filter((l: any) => l.action === 'CREATE').length,
@@ -140,7 +140,7 @@ export default function AuditLogsPage() {
icon={Download}
variant="secondary"
onClick={() => {
const items = data?.items || [];
const items: any[] = Array.isArray(data?.items) ? data!.items : [];
if (!items.length) return;
const headers = ['Timestamp', 'Action', 'Entity Type', 'Entity ID', 'User ID', 'IP Address'];
const rows = items.map((l: any) => [

View File

@@ -142,7 +142,14 @@ function BookingsPageContent() {
key: 'bookingRef', label: 'Reference', sortable: true,
render: (booking: any) => (
<div>
<div className="font-mono font-semibold">{booking.bookingRef}</div>
<div className="font-mono font-semibold flex items-center gap-1.5">
{booking.bookingRef}
{booking.isPackageBooking && (
<span className="inline-flex items-center gap-1 bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400 text-xs font-semibold px-1.5 py-0.5 rounded" title="Package booking">
PKG
</span>
)}
</div>
<div className="text-xs text-muted-foreground">{booking.bookingType || 'ONE_WAY'}</div>
</div>
),
@@ -254,6 +261,14 @@ function BookingsPageContent() {
<option value="CANCELLED">Cancelled</option>
<option value="BOARDED">Boarded</option>
</select>
<select className="input w-44" value={extraFilters.bookingType}
onChange={(e) => setExtraFilters({ ...extraFilters, bookingType: e.target.value })}>
<option value="">All Types</option>
<option value="ONE_WAY">One Way</option>
<option value="ROUND_TRIP">Round Trip</option>
<option value="ROUND_TRIP_TRANSIT">Round Trip Transit</option>
<option value="PACKAGE">Package</option>
</select>
<button type="button" className="input w-auto px-4 text-sm font-medium text-primary border-primary/40"
onClick={() => setShowExtraFilters(v => !v)}>
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
@@ -261,16 +276,6 @@ function BookingsPageContent() {
</div>
{showExtraFilters && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 pt-1">
<div>
<label className="label">Booking Type</label>
<select className="input" value={extraFilters.bookingType}
onChange={(e) => setExtraFilters({ ...extraFilters, bookingType: e.target.value })}>
<option value="">All Types</option>
<option value="ONE_WAY">One Way</option>
<option value="ROUND_TRIP">Round Trip</option>
<option value="ROUND_TRIP_TRANSIT">Round Trip Transit</option>
</select>
</div>
<div>
<label className="label">Payment Status</label>
<select className="input" value={extraFilters.paymentStatus}
@@ -324,9 +329,10 @@ function BookingsPageContent() {
<div className="mt-4 flex flex-wrap gap-2">
{[
(b.bookingType || 'ONE_WAY').replace(/_/g, ' '),
b.isPackageBooking && b.packageName ? `PKG: ${b.packageName}` : null,
`${b.adultCount ?? 0} Adult${(b.adultCount ?? 0) !== 1 ? 's' : ''}${(b.childCount ?? 0) > 0 ? ` · ${b.childCount} Child${b.childCount !== 1 ? 'ren' : ''}` : ''}`,
b.displayCurrency || b.currency || 'ETB',
].map((tag) => (
].filter(Boolean).map((tag) => (
<span key={tag} className="inline-flex items-center gap-1.5 bg-white/20 text-white text-xs font-medium px-3 py-1 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-200" />{tag}
</span>
@@ -346,18 +352,31 @@ function BookingsPageContent() {
</div>
</section>
{/* Package info */}
{b.isPackageBooking && (
<section>
<SectionHeader title="Package" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Package Name" value={b.packageName || '—'} />
<Field label="Package Code" value={b.packageCode || '—'} mono />
<Field label="Tier" value={b.tierLabel || '—'} />
<Field label="Package ID" value={b.packageId || '—'} mono truncate />
</div>
</section>
)}
{/* Journey */}
<section>
<SectionHeader title="Journey" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Origin" value={b.schedule?.originStation?.name} />
<Field label="Destination" value={b.schedule?.destinationStation?.name} />
<Field label="Origin" value={b.schedule?.originStation?.name || b.schedule?.origin?.name} />
<Field label="Destination" value={b.schedule?.destinationStation?.name || b.schedule?.destination?.name} />
<Field label="Departure" value={b.schedule?.departureAt ? formatDateTime(b.schedule.departureAt) : ''} />
<Field label="Arrival" value={b.schedule?.arrivalAt ? formatDateTime(b.schedule.arrivalAt) : ''} />
<Field label="Adults" value={String(b.adultCount ?? 0)} />
<Field label="Children" value={String(b.childCount ?? 0)} />
<Field label="Promo Code" value={b.promoCode || 'None'} />
<Field label="Schedule ID" value={b.scheduleId} mono truncate />
{!b.isPackageBooking && <Field label="Schedule ID" value={b.scheduleId} mono truncate />}
</div>
</section>
@@ -398,32 +417,39 @@ function BookingsPageContent() {
</div>
</section>
{/* Seats */}
{b.seats && b.seats.length > 0 && (
{/* Seats / Passengers */}
{(() => {
const items: any[] = b.seats?.length ? b.seats : (b.passengers?.length ? b.passengers : []);
if (!items.length) return null;
const isSeats = !!b.seats?.length;
return (
<section>
<SectionHeader title={`Seats (${b.seats.length})`} />
<SectionHeader title={`${isSeats ? 'Seats' : 'Passengers'} (${items.length})`} />
<div className="divide-y divide-muted rounded-lg border border-muted overflow-hidden">
{b.seats.map((bs: any, i: number) => (
{items.map((p: any, i: number) => (
<div key={i} className="flex items-center justify-between px-4 py-3 bg-muted/20 hover:bg-muted/40 transition-colors">
<div className="flex items-center gap-3">
<span className="w-6 h-6 rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400 text-xs font-bold flex items-center justify-center shrink-0">{i + 1}</span>
<div>
<p className="text-sm font-semibold">{bs.passengerName || '—'}</p>
<p className="text-sm font-semibold">{p.passengerName || p.fullName || p.name || '—'}</p>
<p className="text-xs text-muted-foreground">
{bs.passengerCategory || '—'}{bs.leg ? ` · Leg ${bs.leg}` : ''}{bs.idDocumentType ? ` · ${bs.idDocumentType}` : ''}
{bs.verifaydaVerified ? ' · ✓ Verified' : ''}
{p.passengerCategory || p.category || '—'}{p.leg ? ` · Leg ${p.leg}` : ''}{p.idDocumentType ? ` · ${p.idDocumentType}` : ''}
{p.verifaydaVerified ? ' · ✓ Verified' : ''}
</p>
</div>
</div>
{isSeats && (
<div className="text-right">
<p className="text-sm font-mono font-semibold">{bs.seat?.seatNumber || bs.seatId || '—'}</p>
<p className="text-xs text-muted-foreground">{formatCurrency(bs.fareMinor ?? 0, b.currency || 'ETB')}</p>
<p className="text-sm font-mono font-semibold">{p.seat?.seatNumber || p.seatId || '—'}</p>
<p className="text-xs text-muted-foreground">{formatCurrency(p.fareMinor ?? 0, b.currency || 'ETB')}</p>
</div>
)}
</div>
))}
</div>
</section>
)}
);
})()}
{/* Timestamps */}
<section>

View File

@@ -15,7 +15,7 @@ export default function ClassesPage() {
const [filters, setFilters] = useState({ search: '' });
const [showModal, setShowModal] = useState(false);
const [editingClass, setEditingClass] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null }>({ isOpen: false, class: null });
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null; error?: string }>({ isOpen: false, class: null });
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
const queryClient = useQueryClient();
@@ -60,6 +60,10 @@ export default function ClassesPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['classes'] });
},
onError: (e: any) => {
const msg = e?.response?.data?.message || e?.message || 'Failed to delete class';
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg }));
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
@@ -89,13 +93,16 @@ export default function ClassesPage() {
};
const handleDelete = (cls: any) => {
setDeleteConfirm({ isOpen: true, class: cls });
setDeleteConfirm({ isOpen: true, class: cls, error: undefined });
};
const confirmDelete = async () => {
if (deleteConfirm.class) {
if (!deleteConfirm.class) return;
try {
await deleteMutation.mutateAsync(deleteConfirm.class.id);
setDeleteConfirm({ isOpen: false, class: null });
} catch {
// error is set by onError handler
}
};
@@ -230,6 +237,8 @@ export default function ClassesPage() {
message={`Are you sure you want to delete ${deleteConfirm.class?.name}?`}
confirmText="Delete"
isDanger={true}
isLoading={deleteMutation.isPending}
error={deleteConfirm.error}
warning="This class may be used by coaches and fare rules. Deleting it may impact seat assignments and pricing."
/>

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair } from 'lucide-react';
import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair, Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
@@ -147,6 +147,8 @@ export default function CoachesPage() {
const [editingItem, setEditingItem] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
const [exportUtilModalOpen, setExportUtilModalOpen] = useState(false);
const [exportUtilFormat, setExportUtilFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
const queryClient = useQueryClient();
@@ -245,7 +247,6 @@ export default function CoachesPage() {
coachTypeId: formData.get('coachTypeId') as string,
arrangement: formData.get('arrangement') as string,
capacity: parseInt(formData.get('capacity') as string),
sequence: parseInt(formData.get('sequence') as string),
status: formData.get('status') as string,
};
@@ -361,14 +362,6 @@ export default function CoachesPage() {
// Coaches Columns
const coachColumns = [
{
key: 'sequence',
label: 'Sequence',
sortable: true,
render: (coach: any) => (
<span className="font-mono font-semibold text-sm">{coach.sequence}</span>
),
},
{
key: 'number',
label: 'Number',
@@ -584,11 +577,53 @@ export default function CoachesPage() {
{/* Utilization Tab */}
{activeTab === 'utilization' && (() => {
const rows = Array.isArray(utilizationData) ? utilizationData : (utilizationData as any)?.data || [];
const UTIL_COLS = [
{ key: 'number', label: 'Coach' },
{ key: 'coachType', label: 'Type' },
{ key: 'totalSeats', label: 'Total Seats' },
{ key: 'availableSeats', label: 'Available' },
{ key: 'bookedSeats', label: 'Booked' },
{ key: 'blockedSeats', label: 'Blocked' },
{ key: 'maintenanceSeats', label: 'Maintenance' },
{ key: 'utilizationRate', label: 'Utilization %' },
{ key: 'totalAssignments', label: 'Assignments' },
{ key: 'totalBookings', label: 'Total Bookings' },
];
const doExport = () => {
if (!rows.length) { alert('No data to export'); return; }
const headers = UTIL_COLS.map(c => c.label);
const exportRows = rows.map((r: any) => UTIL_COLS.map(({ key }) => String(r[key] ?? '')));
const dateStr = new Date().toISOString().split('T')[0];
if (exportUtilFormat === 'pdf') {
const w = window.open('', '_blank')!;
w.document.write(`<!DOCTYPE html><html><head><title>Coach Utilization Report</title><style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>`);
w.document.write(`<h2>Coach Utilization Report — ${dateStr}</h2><table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
exportRows.forEach((r: string[]) => { w.document.write(`<tr>${r.map((v: string) => `<td>${v}</td>`).join('')}</tr>`); });
w.document.write('</tbody></table></body></html>');
w.document.close(); w.print();
} else if (exportUtilFormat === 'excel') {
const tsv = [headers.join('\t'), ...exportRows.map((r: string[]) => r.join('\t'))].join('\n');
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `coach-utilization-${dateStr}.xls`; a.click(); URL.revokeObjectURL(url);
} else {
const csv = [headers.map(h => `"${h}"`).join(','), ...exportRows.map((r: string[]) => r.map((v: string) => `"${v.replace(/"/g, '""')}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `coach-utilization-${dateStr}.csv`; a.click(); URL.revokeObjectURL(url);
}
setExportUtilModalOpen(false);
};
return (
<div className="pt-6 space-y-4">
<div className="flex justify-end">
<ActionButton icon={Download} variant="secondary" onClick={() => setExportUtilModalOpen(true)}>Export</ActionButton>
</div>
<DataTable
columns={[
{ key: 'sequence', label: 'Seq', render: (r: any) => <span className="font-mono">{r.sequence}</span> },
{ key: 'number', label: 'Coach', render: (r: any) => <span className="font-medium">{r.number}</span> },
{ key: 'coachType', label: 'Type', render: (r: any) => <span className="text-sm">{r.coachType || 'N/A'}</span> },
{ key: 'totalSeats', label: 'Total Seats', render: (r: any) => <span className="font-mono">{r.totalSeats}</span> },
@@ -615,6 +650,26 @@ export default function CoachesPage() {
loading={utilizationLoading}
emptyMessage="No coach utilization data available"
/>
<Modal isOpen={exportUtilModalOpen} onClose={() => setExportUtilModalOpen(false)} title="Export Utilization Report" size="sm">
<div className="space-y-4">
<div>
<p className="text-sm font-medium mb-2">Export Format</p>
<div className="flex gap-3">
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="utilExportFormat" value={fmt} checked={exportUtilFormat === fmt} onChange={() => setExportUtilFormat(fmt)} className="w-4 h-4" />
<span className="text-sm font-medium capitalize">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportUtilModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={doExport}>Export</ActionButton>
</div>
</div>
</Modal>
</div>
);
})()}
@@ -826,22 +881,6 @@ export default function CoachesPage() {
/>
</div>
<div>
<label className="label">Sequence Number *</label>
<input
type="number"
name="sequence"
className="input"
defaultValue={editingItem?.sequence }
min="1"
required
placeholder="e.g., 1"
/>
<p className="text-xs text-muted-foreground mt-1">
Position in train consist
</p>
</div>
<div>
<label className="label">Status *</label>
<select

View File

@@ -1,12 +1,12 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download, Eye, Star } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Eye, Star, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { loyaltyApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
@@ -40,12 +40,25 @@ const TIER_GRAD: Record<string, string> = {
export default function LoyaltyPage() {
const [filters, setFilters] = useState({ search: '', tier: '' });
const [selected, setSelected] = useState<any>(null);
const [toDelete, setToDelete] = useState<any>(null);
const [deleteError, setDeleteError] = useState('');
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['loyalty', filters],
queryFn: () => loyaltyApi.getAccounts(filters),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => loyaltyApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['loyalty'] });
setToDelete(null);
setDeleteError('');
},
onError: (err: any) => setDeleteError(err?.message || 'Failed to delete loyalty account'),
});
const columns = [
{ key: 'passenger', label: 'Passenger', render: (account: any) => (
<div>
@@ -64,6 +77,7 @@ export default function LoyaltyPage() {
const actions = [
{ label: 'View Details', onClick: (a: any) => setSelected(a), variant: 'secondary' as const, icon: Eye },
{ label: 'Delete', onClick: (a: any) => { setDeleteError(''); setToDelete(a); }, variant: 'danger' as const, icon: Trash2 },
];
return (
@@ -103,6 +117,19 @@ export default function LoyaltyPage() {
emptyMessage="No loyalty accounts found"
/>
{/* Delete Confirm Dialog */}
<ConfirmDialog
isOpen={!!toDelete}
onClose={() => { setToDelete(null); setDeleteError(''); }}
onConfirm={() => deleteMutation.mutate(toDelete.id)}
title="Delete Loyalty Account"
message={`Are you sure you want to delete the loyalty account for ${toDelete?.passenger?.fullName || 'this passenger'}? This will permanently remove all points, ledger entries, and rewards.`}
confirmText="Delete"
isDanger
isLoading={deleteMutation.isPending}
error={deleteError}
/>
{/* Loyalty Details Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Loyalty Account Details" size="xl">
{selected && (() => {

View File

@@ -0,0 +1,7 @@
'use client';
import DashboardLayout from '../dashboard/layout';
export default function PackageBookingsLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,255 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Eye } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import Pagination from '@/components/ui/Pagination';
import { packagesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
</div>
);
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
</h3>
);
export default function PackageBookingsPage() {
const [filters, setFilters] = useState({ packageId: '', status: '', page: 1, pageSize: 20 });
const [selected, setSelected] = useState<any>(null);
const { data: rawData, isLoading, error } = useQuery({
queryKey: ['package-bookings', filters],
queryFn: () => packagesApi.getBookings(filters),
});
// Unwrap in case the interceptor double-wraps: { data: { items, ... } } or { items, ... }
const data: any = (rawData as any)?.items ? rawData : (rawData as any)?.data ?? rawData;
const { data: packagesData } = useQuery({
queryKey: ['packages-all-simple'],
queryFn: () => packagesApi.getAll({ pageSize: 100 }),
});
const packages: any[] = packagesData?.items || [];
const columns = [
{
key: 'bookingRef',
label: 'Reference',
sortable: true,
render: (b: any) => (
<div>
<div className="font-mono font-semibold flex items-center gap-1.5">
{b.bookingRef}
<span className="inline-flex items-center bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400 text-xs font-semibold px-1.5 py-0.5 rounded">PKG</span>
</div>
<div className="text-xs text-muted-foreground">{b.package?.name}</div>
</div>
),
},
{
key: 'tier',
label: 'Tier',
render: (b: any) => b.priceTier ? (
<div>
<div className="text-sm font-medium">{b.priceTier.label}</div>
<div className="text-xs text-muted-foreground">{b.priceTier.seatType}</div>
</div>
) : <span className="text-muted-foreground"></span>,
},
{
key: 'passengers',
label: 'Passengers',
render: (b: any) => (
<div>
<div className="font-semibold">{b.passengerCount}</div>
<div className="text-xs text-muted-foreground">{b.contactPhone || b.contactEmail || '—'}</div>
</div>
),
},
{
key: 'payment',
label: 'Payment',
render: (b: any) => (
<div>
<Badge variant="status" status={b.paymentIntent?.status || 'PENDING'}>
{b.paymentIntent?.status || 'PENDING'}
</Badge>
<div className="text-xs text-muted-foreground mt-1">{formatCurrency(b.totalMinor, b.currency || 'ETB')}</div>
</div>
),
},
{
key: 'status',
label: 'Status',
render: (b: any) => <Badge variant="status" status={b.status}>{b.status}</Badge>,
},
{
key: 'createdAt',
label: 'Booked At',
render: (b: any) => <span className="text-sm text-muted-foreground">{formatDateTime(b.createdAt)}</span>,
},
];
const actions = [
{ label: 'View', onClick: (b: any) => setSelected(b), variant: 'secondary' as const, icon: Eye },
];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Package Bookings</h1>
<p className="text-muted-foreground">All bookings made through travel packages</p>
</div>
<div className="card">
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
Error: {(error as any)?.response?.data?.message || (error as any)?.message || String(error)}
</div>
)}
<div className="flex flex-wrap gap-3 mb-4">
<div className="flex-1 min-w-48">
<select className="input" value={filters.packageId}
onChange={(e) => setFilters({ ...filters, packageId: e.target.value, page: 1 })}>
<option value="">All Packages</option>
{packages.map((p: any) => (
<option key={p.id} value={p.id}>{p.name} ({p.code})</option>
))}
</select>
</div>
<select className="input w-48" value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value, page: 1 })}>
<option value="">All Statuses</option>
<option value="PENDING_PAYMENT">Pending Payment</option>
<option value="CONFIRMED">Confirmed</option>
<option value="CANCELLED">Cancelled</option>
</select>
</div>
<DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No package bookings found" />
{data?.totalPages > 1 && (
<Pagination currentPage={filters.page} totalPages={data.totalPages}
onPageChange={(page) => setFilters({ ...filters, page })} />
)}
</div>
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Package Booking Details" size="xl">
{selected && (() => {
const b = selected;
return (
<div>
<div className="-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r from-emerald-600 to-emerald-700 rounded-t-lg">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-emerald-100 text-xs font-semibold uppercase tracking-widest mb-1">Booking Reference</p>
<p className="text-white text-3xl font-mono font-bold tracking-wider">{b.bookingRef}</p>
</div>
<div className="text-right shrink-0">
<Badge variant="status" status={b.status}>{b.status}</Badge>
<p className="text-emerald-200 text-xs mt-2">{formatDateTime(b.createdAt)}</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{[b.package?.name, b.priceTier?.label, `${b.passengerCount} passenger${b.passengerCount !== 1 ? 's' : ''}`].filter(Boolean).map((tag) => (
<span key={tag} className="inline-flex items-center gap-1.5 bg-white/20 text-white text-xs font-medium px-3 py-1 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-200" />{tag}
</span>
))}
</div>
</div>
<div className="space-y-6">
<section>
<SectionHeader title="Package" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Package Name" value={b.package?.name} />
<Field label="Package Code" value={b.package?.code} mono />
<Field label="Tier" value={b.priceTier?.label} />
<Field label="Seat Type" value={b.priceTier?.seatType} />
</div>
</section>
<section>
<SectionHeader title="Contact" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Email" value={b.contactEmail} truncate />
<Field label="Phone" value={b.contactPhone} />
<Field label="Promo Code" value={b.promoCode || 'None'} />
<Field label="Passenger ID" value={b.passengerId || 'Guest'} mono truncate />
</div>
</section>
<section>
<SectionHeader title="Payment" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-100 dark:border-emerald-800 rounded-lg p-3 col-span-2">
<p className="text-xs text-emerald-700 dark:text-emerald-400 mb-1">Total Amount</p>
<p className="text-xl font-bold text-emerald-800 dark:text-emerald-300">{formatCurrency(b.totalMinor, b.currency || 'ETB')}</p>
{b.displayCurrency && b.displayCurrency !== (b.currency || 'ETB') && (
<p className="text-xs text-emerald-600 dark:text-emerald-500 mt-0.5">
{formatCurrency(b.displayTotalMinor ?? b.totalMinor, b.displayCurrency)}
</p>
)}
</div>
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-2">Payment Status</p>
<Badge variant="status" status={b.paymentIntent?.status || 'PENDING'}>{b.paymentIntent?.status || 'PENDING'}</Badge>
</div>
<Field label="Method" value={b.paymentIntent?.method || '—'} />
</div>
</section>
{b.passengers?.length > 0 && (
<section>
<SectionHeader title={`Passengers (${b.passengers.length})`} />
<div className="divide-y divide-muted rounded-lg border border-muted overflow-hidden">
{b.passengers.map((p: any, i: number) => (
<div key={i} className="flex items-center justify-between px-4 py-3 bg-muted/20 hover:bg-muted/40 transition-colors">
<div className="flex items-center gap-3">
<span className="w-6 h-6 rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400 text-xs font-bold flex items-center justify-center shrink-0">{i + 1}</span>
<div>
<p className="text-sm font-semibold">{p.passengerName}</p>
<p className="text-xs text-muted-foreground">
{p.dateOfBirth ? new Date(p.dateOfBirth).toLocaleDateString() : ''}
{p.idDocumentType ? ` · ${p.idDocumentType}` : ''}
{p.passportNumber ? ` · ${p.passportNumber}` : ''}
</p>
</div>
</div>
</div>
))}
</div>
</section>
)}
<section>
<SectionHeader title="Timestamps" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Created" value={formatDateTime(b.createdAt)} />
<Field label="Last Updated" value={formatDateTime(b.updatedAt)} />
<Field label="Booking ID" value={b.id} mono truncate />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
</div>
</div>
);
})()}
</Modal>
</div>
);
}

View File

@@ -1,13 +1,14 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download, Eye } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Eye, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { paymentsApi } from '@/lib/api';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { paymentsApi, apiClient } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
@@ -26,6 +27,10 @@ const SectionHeader = ({ title }: { title: string }) => (
export default function PaymentsPage() {
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
const [selectedPayment, setSelectedPayment] = useState<any>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [paymentToDelete, setPaymentToDelete] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState('');
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
@@ -33,6 +38,23 @@ export default function PaymentsPage() {
reference: true, booking: true, amount: true, method: true, status: true, createdAt: true,
});
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/payments/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payments'] });
setDeleteConfirmOpen(false);
setPaymentToDelete(null);
setDeleteError(null);
setSuccessMessage('Payment deleted successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
setDeleteError(error?.response?.data?.message || error?.message || 'Failed to delete payment');
},
});
const { data, isLoading } = useQuery({
queryKey: ['payments', filters],
queryFn: () => paymentsApi.getAll({
@@ -102,6 +124,7 @@ export default function PaymentsPage() {
const paymentActions = [
{ label: 'View Details', onClick: (p: any) => setSelectedPayment(p), variant: 'secondary' as const, icon: Eye },
{ label: 'Delete', onClick: (p: any) => { setDeleteError(null); setPaymentToDelete(p); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 },
];
return (
@@ -115,6 +138,9 @@ export default function PaymentsPage() {
</div>
<div className="card">
{successMessage && (
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200"> {successMessage}</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
@@ -244,6 +270,16 @@ export default function PaymentsPage() {
})()}
</Modal>
<ConfirmDialog
isOpen={deleteConfirmOpen}
onClose={() => { setDeleteConfirmOpen(false); setPaymentToDelete(null); setDeleteError(null); }}
onConfirm={async () => { if (paymentToDelete) await deleteMutation.mutateAsync(paymentToDelete.id); }}
title="Delete Payment"
message={`Permanently delete payment ${paymentToDelete?.reference || paymentToDelete?.id?.substring(0, 8)}? This cannot be undone.`}
confirmText="Delete" cancelText="Cancel" isLoading={deleteMutation.isPending} isDanger
error={deleteError ?? undefined}
/>
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
<div className="space-y-4">

View File

@@ -6,6 +6,7 @@ import { Download, TrendingUp, Users, DollarSign, AlertCircle } from 'lucide-rea
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import { bookingsApi } from '@/lib/api';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
const COLORS = ['#3b82f6', '#10b981', '#f59e0b'];
@@ -13,6 +14,8 @@ export default function ReportsPage() {
const [dateRange, setDateRange] = useState('30');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
const getDateRange = () => {
const end = new Date();
@@ -49,7 +52,7 @@ export default function ReportsPage() {
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
});
// Filter bookings by date range
// Filter bookings by date range — exclude CANCELLED from revenue calculations
const bookings = Array.isArray(bookingsData?.items)
? bookingsData.items.filter((b: any) => {
const bookingDate = new Date(b.createdAt).toISOString().split('T')[0];
@@ -57,13 +60,15 @@ export default function ReportsPage() {
})
: [];
// Calculate metrics
const totalRevenue = bookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0);
const totalBookings = bookings.length;
const avgTicketPrice = totalBookings > 0 ? Math.round(totalRevenue / totalBookings) : 0;
const revenueBookings = bookings.filter((b: any) => b.status !== 'CANCELLED' && b.status !== 'REFUNDED');
// Group by date for revenue chart
const byDate = bookings.reduce((acc: Record<string, any>, b: any) => {
// Calculate metrics — revenue excludes cancelled/refunded bookings
const totalRevenue = revenueBookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0);
const totalBookings = bookings.length;
const avgTicketPrice = revenueBookings.length > 0 ? Math.round(totalRevenue / revenueBookings.length) : 0;
// Group by date for revenue chart — exclude cancelled/refunded
const byDate = revenueBookings.reduce((acc: Record<string, any>, b: any) => {
const date = new Date(b.createdAt).toISOString().split('T')[0];
if (!acc[date]) {
acc[date] = { totalMinor: 0, count: 0 };
@@ -81,6 +86,40 @@ export default function ReportsPage() {
bookings: d.count || 0,
}));
const REPORT_COLS = [
{ key: 'date', label: 'Date' },
{ key: 'revenue', label: 'Revenue (ETB)' },
{ key: 'bookings', label: 'Bookings' },
];
const doExport = () => {
if (!chartData.length) { alert('No data to export'); return; }
const headers = REPORT_COLS.map(c => c.label);
const rows = chartData.map(r => [r.date, String(Math.round(r.revenue)), String(r.bookings)]);
const dateStr = new Date().toISOString().split('T')[0];
if (exportFormat === 'pdf') {
const w = window.open('', '_blank')!;
w.document.write(`<!DOCTYPE html><html><head><title>Revenue Report</title><style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>`);
w.document.write(`<h2>Revenue Report — ${dates.startDate} to ${dates.endDate}</h2>`);
w.document.write(`<p>Total Revenue: ETB ${Math.round(totalRevenue / 100).toLocaleString()} | Total Bookings: ${totalBookings} | Cancelled: ${bookings.filter((b: any) => b.status === 'CANCELLED').length}</p>`);
w.document.write(`<table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
rows.forEach(r => { w.document.write(`<tr>${r.map(v => `<td>${v}</td>`).join('')}</tr>`); });
w.document.write('</tbody></table></body></html>');
w.document.close(); w.print();
} else if (exportFormat === 'excel') {
const tsv = [headers.join('\t'), ...rows.map(r => r.join('\t'))].join('\n');
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `revenue-report-${dateStr}.xls`; a.click(); URL.revokeObjectURL(url);
} else {
const csv = [headers.map(h => `"${h}"`).join(','), ...rows.map(r => r.map(v => `"${v}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `revenue-report-${dateStr}.csv`; a.click(); URL.revokeObjectURL(url);
}
setExportModalOpen(false);
};
return (
<div className="space-y-6">
<div>
@@ -131,7 +170,7 @@ export default function ReportsPage() {
</>
)}
<ActionButton icon={Download} variant="secondary" disabled={isLoading}>
<ActionButton icon={Download} variant="secondary" disabled={isLoading} onClick={() => setExportModalOpen(true)}>
Export
</ActionButton>
</div>
@@ -147,7 +186,7 @@ export default function ReportsPage() {
<div>
<p className="text-muted-foreground text-sm font-medium">Total Revenue</p>
<p className="text-2xl font-bold mt-2">ETB {Math.round(totalRevenue / 100).toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-1">Last {dateRange} days</p>
<p className="text-xs text-muted-foreground mt-1">Excl. cancelled &amp; refunded</p>
</div>
<DollarSign className="h-8 w-8 text-blue-500 opacity-20" />
</div>
@@ -169,7 +208,7 @@ export default function ReportsPage() {
<div>
<p className="text-muted-foreground text-sm font-medium">Avg. Ticket Price</p>
<p className="text-2xl font-bold mt-2">ETB {(avgTicketPrice / 100).toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-1">Per booking</p>
<p className="text-xs text-muted-foreground mt-1">Non-cancelled bookings</p>
</div>
<TrendingUp className="h-8 w-8 text-purple-500 opacity-20" />
</div>
@@ -314,6 +353,28 @@ export default function ReportsPage() {
</div>
</div>
</div>
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Revenue Report" size="sm">
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Exports daily revenue and booking counts for the selected date range. Cancelled and refunded bookings are excluded from revenue figures.</p>
<div>
<p className="text-sm font-medium mb-2">Export Format</p>
<div className="flex gap-3">
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="reportExportFormat" value={fmt} checked={exportFormat === fmt} onChange={() => setExportFormat(fmt)} className="w-4 h-4" />
<span className="text-sm font-medium capitalize">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</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={doExport}>Export</ActionButton>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -171,7 +171,7 @@ export default function RoutesPage() {
const [originStationId, setOriginStationId] = useState('');
const [destinationStationId, setDestinationStationId] = useState('');
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null }>({ isOpen: false, route: null });
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string }>({ isOpen: false, route: null });
const [search, setSearch] = useState('');
const queryClient = useQueryClient();
@@ -211,6 +211,10 @@ export default function RoutesPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['routes'] });
},
onError: (e: any) => {
const msg = e?.response?.data?.message || e?.message || 'Failed to delete route';
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg }));
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
@@ -315,13 +319,16 @@ export default function RoutesPage() {
};
const handleDelete = (route: any) => {
setDeleteConfirm({ isOpen: true, route });
setDeleteConfirm({ isOpen: true, route, error: undefined });
};
const confirmDelete = async () => {
if (deleteConfirm.route) {
if (!deleteConfirm.route) return;
try {
await deleteMutation.mutateAsync(deleteConfirm.route.id);
setDeleteConfirm({ isOpen: false, route: null });
} catch {
// error is set by onError handler
}
};
@@ -462,6 +469,8 @@ export default function RoutesPage() {
message={`Are you sure you want to delete ${deleteConfirm.route?.name}?`}
confirmText="Delete"
isDanger={true}
isLoading={deleteMutation.isPending}
error={deleteConfirm.error}
warning="This route may be referenced by schedules and bookings. Deleting it may impact these systems."
/>

View File

@@ -14,7 +14,7 @@ export default function StationsPage() {
const [filters, setFilters] = useState({ search: '', country: '', operational: '' });
const [showModal, setShowModal] = useState(false);
const [editingStation, setEditingStation] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; station: any | null }>({ isOpen: false, station: null });
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; station: any | null; error?: string }>({ isOpen: false, station: null });
const [formError, setFormError] = useState<string | null>(null);
const queryClient = useQueryClient();
@@ -50,6 +50,10 @@ export default function StationsPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stations'] });
},
onError: (e: any) => {
const msg = e?.response?.data?.message || e?.message || 'Failed to delete station';
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg }));
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
@@ -82,13 +86,16 @@ export default function StationsPage() {
};
const handleDelete = (station: any) => {
setDeleteConfirm({ isOpen: true, station });
setDeleteConfirm({ isOpen: true, station, error: undefined });
};
const confirmDelete = async () => {
if (deleteConfirm.station) {
if (!deleteConfirm.station) return;
try {
await deleteMutation.mutateAsync(deleteConfirm.station.id);
setDeleteConfirm({ isOpen: false, station: null });
} catch {
// error is set by onError handler
}
};
@@ -235,6 +242,8 @@ export default function StationsPage() {
message={`Are you sure you want to delete ${deleteConfirm.station?.name}?`}
confirmText="Delete"
isDanger={true}
isLoading={deleteMutation.isPending}
error={deleteConfirm.error}
warning="This station may be referenced by routes, schedules, and bookings. Deleting it may impact these systems."
/>

View File

@@ -16,7 +16,7 @@ export default function TrainsPage() {
const [showModal, setShowModal] = useState(false);
const [editingTrain, setEditingTrain] = useState<TrainType | null>(null);
const [search, setSearch] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null }>({ isOpen: false, train: null });
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null; error?: string }>({ isOpen: false, train: null });
const queryClient = useQueryClient();
@@ -54,6 +54,10 @@ export default function TrainsPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trains'] });
},
onError: (error: any) => {
const msg = error?.response?.data?.message || error?.message || 'Failed to delete train';
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg }));
},
});
const restoreTrainMutation = useMutation({
@@ -67,13 +71,16 @@ export default function TrainsPage() {
});
const handleDelete = (train: TrainType) => {
setDeleteConfirm({ isOpen: true, train });
setDeleteConfirm({ isOpen: true, train, error: undefined });
};
const confirmDelete = async () => {
if (deleteConfirm.train) {
if (!deleteConfirm.train) return;
try {
await deleteTrainMutation.mutateAsync(deleteConfirm.train.id);
setDeleteConfirm({ isOpen: false, train: null });
} catch {
// error is set by onError handler
}
};
@@ -228,6 +235,8 @@ export default function TrainsPage() {
message={`Are you sure you want to delete train ${deleteConfirm.train?.number}?`}
confirmText="Delete"
isDanger={true}
isLoading={deleteTrainMutation.isPending}
error={deleteConfirm.error}
warning="This train may be assigned to schedules and trips. Deleting it may impact these systems and associated bookings."
/>

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,206 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Eye, Wallet, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { walletApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
</div>
);
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
</h3>
);
export default function WalletAccountsPage() {
const [filters, setFilters] = useState({ search: '' });
const [selected, setSelected] = useState<any>(null);
const [toDelete, setToDelete] = useState<any>(null);
const [deleteError, setDeleteError] = useState('');
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['wallet-accounts', filters],
queryFn: () => walletApi.getAccounts(filters),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => walletApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['wallet-accounts'] });
setToDelete(null);
setDeleteError('');
},
onError: (err: any) => setDeleteError(err?.message || 'Failed to delete wallet account'),
});
const columns = [
{ key: 'passenger', label: 'Passenger', render: (w: any) => (
<div>
<div className="font-medium">{w.passenger?.fullName || 'N/A'}</div>
<div className="text-xs text-muted-foreground">{w.passenger?.email || ''}</div>
</div>
)},
{ key: 'balanceMinor', label: 'Balance', render: (w: any) => (
<span className="font-semibold">{formatCurrency(w.balanceMinor ?? 0, w.currency || 'ETB')}</span>
)},
{ key: 'currency', label: 'Currency', render: (w: any) => w.currency || 'ETB' },
{ key: 'status', label: 'Status', render: (w: any) => (
<Badge variant="status" status={w.isActive !== false ? 'CONFIRMED' : 'CANCELLED'}>
{w.isActive !== false ? 'Active' : 'Inactive'}
</Badge>
)},
];
const actions = [
{ label: 'View Details', onClick: (w: any) => setSelected(w), variant: 'secondary' as const, icon: Eye },
{ label: 'Delete', onClick: (w: any) => { setDeleteError(''); setToDelete(w); }, variant: 'danger' as const, icon: Trash2 },
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Wallet Accounts</h1>
<p className="text-muted-foreground">Manage passenger wallet accounts</p>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search by name or email..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
</div>
</div>
<DataTable
data={Array.isArray(data) ? data : (data?.items || [])}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No wallet accounts found"
/>
{/* Delete Confirm Dialog */}
<ConfirmDialog
isOpen={!!toDelete}
onClose={() => { setToDelete(null); setDeleteError(''); }}
onConfirm={() => deleteMutation.mutate(toDelete.id)}
title="Delete Wallet Account"
message={`Are you sure you want to delete the wallet account for ${toDelete?.passenger?.fullName || 'this passenger'}? This will permanently remove the account and all ledger entries.`}
confirmText="Delete"
isDanger
isLoading={deleteMutation.isPending}
error={deleteError}
/>
{/* Wallet Details Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Wallet Account Details" size="xl">
{selected && (() => {
const w = selected;
const balance = w.balanceMinor ?? 0;
const passengerName = w.passenger?.fullName || 'N/A';
return (
<div>
<div className="from-blue-600 to-blue-700 -mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r rounded-t-lg">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
<Wallet className="w-7 h-7 text-white" />
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-xl font-bold truncate">{passengerName}</p>
<p className="text-blue-200 text-sm">{w.passenger?.email || ''}</p>
</div>
<div className="text-right shrink-0">
<Badge variant="status" status={w.isActive !== false ? 'CONFIRMED' : 'CANCELLED'}>
{w.isActive !== false ? 'Active' : 'Inactive'}
</Badge>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'Current Balance', value: formatCurrency(balance, w.currency || 'ETB') },
{ label: 'Currency', value: w.currency || 'ETB' },
{ label: 'Total Topped Up', value: formatCurrency(w.totalTopUp ?? 0, w.currency || 'ETB') },
].map(({ label, value }) => (
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-blue-200 text-xs">{label}</p>
<p className="text-white text-sm font-bold truncate">{value}</p>
</div>
))}
</div>
</div>
<div className="space-y-6">
<section>
<SectionHeader title="Balance" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-3 col-span-2">
<p className="text-xs text-blue-700 dark:text-blue-400 mb-1">Current Balance</p>
<p className="text-xl font-bold text-blue-800 dark:text-blue-300">{formatCurrency(balance, w.currency || 'ETB')}</p>
</div>
<Field label="Total Topped Up" value={formatCurrency(w.totalTopUp ?? 0, w.currency || 'ETB')} />
<Field label="Total Spent" value={formatCurrency(w.totalSpent ?? 0, w.currency || 'ETB')} />
</div>
</section>
<section>
<SectionHeader title="Account Details" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Currency" value={w.currency || 'ETB'} />
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-2">Status</p>
<Badge variant="status" status={w.isActive !== false ? 'CONFIRMED' : 'CANCELLED'}>
{w.isActive !== false ? 'Active' : 'Inactive'}
</Badge>
</div>
<Field label="Locked" value={w.isLocked ? 'Yes' : 'No'} />
<Field label="Lock Reason" value={w.lockReason || 'N/A'} truncate />
</div>
</section>
<section>
<SectionHeader title="Passenger" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Full Name" value={w.passenger?.fullName} />
<Field label="Email" value={w.passenger?.email} truncate />
<Field label="Phone" value={w.passenger?.phone} />
<Field label="Passenger ID" value={w.passengerId || w.passenger?.id} mono truncate />
</div>
</section>
<section>
<SectionHeader title="Timestamps & IDs" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Created" value={formatDateTime(w.createdAt)} />
<Field label="Last Updated" value={formatDateTime(w.updatedAt)} />
<Field label="Account ID" value={w.id} mono truncate />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
</div>
</div>
);
})()}
</Modal>
</div>
);
}

View File

@@ -36,6 +36,7 @@ import {
Grid3x3,
Banknote,
Activity,
Smartphone,
} from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store';
import { cn } from '@/lib/utils';
@@ -70,6 +71,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
title: 'Tourism',
items: [
{ name: 'Packages', href: '/packages', icon: Package, permission: PERMS.admin },
// { name: 'Pkg Bookings', href: '/package-bookings', icon: Ticket, permission: PERMS.admin },
{ name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare, permission: PERMS.admin },
]
},
@@ -91,49 +93,44 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin },
{ name: 'Tariff Rates', href: '/tariff-rates', icon: Banknote, permission: PERMS.admin },
{ name: 'Fare Rules', href: '/fare-management', icon: Settings, permission: PERMS.admin },
{ name: 'Payments', href: '/payments', icon: CreditCard, permission: PERMS.payments.view },
{ name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage },
{ name: 'Payment Methods', href: '/payment-methods', icon: CreditCard, permission: PERMS.payments.view },
{ name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin },
{ name: 'Payment Methods', href: '/payment-methods', icon: CreditCard, permission: PERMS.payments.view },
{ name: 'Wallet Accounts', href: '/wallet-accounts', icon: Wallet, permission: PERMS.payments.view },
]
},
{
title: 'Customer Services',
items: [
{ name: 'Loyalty Program', href: '/loyalty', icon: Gift, permission: PERMS.passengers.view },
{ name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view },
// { name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view },
{ name: 'Notifications', href: '/notifications', icon: Bell, permission: PERMS.notifications.send },
]
},
// {
// title: 'Customer Services',
// items: [
// { name: 'Loyalty Program', href: '/loyalty', icon: Gift },
// { name: 'Support Center', href: '/support', icon: MessageSquare },
// { name: 'Notifications', href: '/notifications', icon: Bell },
// ]
// },
{
title: 'Security & Compliance',
items: [
{ name: 'Audit Logs', href: '/audit', icon: AlertTriangle, permission: PERMS.audit.view },
{ name: 'Fraud Detection', href: '/fraud', icon: Shield, permission: PERMS.fraud.view },
{ name: 'Verifayda Integration', href: '/verifayda', icon: UserCheck, permission: PERMS.admin },
// { name: 'Verifayda Integration', href: '/verifayda', icon: UserCheck, permission: PERMS.admin },
]
},
{
title: 'Analytics & Reports',
items: [
{ name: 'Reports', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
]
},
{
title: 'System',
items: [
{ name: 'Agent Operations', href: '/agents', icon: Briefcase, permission: PERMS.agents.view },
{ name: 'User Management', href: '/settings/users', icon: Users, permission: PERMS.admin },
{ name: 'Agents', href: '/agents', icon: Briefcase, permission: PERMS.agents.view },
{ name: 'Users', href: '/settings/users', icon: Users, permission: PERMS.admin },
{ name: 'Settings', href: '/settings', icon: Settings, permission: PERMS.admin },
{ name: 'Health', href: '/health', icon: Activity, permission: PERMS.admin },
{ name: 'App Releases', href: '/app-releases', icon: Smartphone, permission: PERMS.admin },
]
}
];

View File

@@ -103,12 +103,32 @@ export default function ConfirmDialog({
</div>
)}
{error && (
<div className="flex gap-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-4 py-3">
{error && (() => {
const lines = error.split('\n').map(l => l.trim()).filter(Boolean);
const bullets = lines.filter(l => l.startsWith('•'));
const intro = lines.find(l => l.startsWith('Cannot') || (!l.startsWith('•') && !l.startsWith('Once') && !l.startsWith('The following')));
const outro = lines.find(l => l.startsWith('Once'));
return (
<div className="rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-4 py-3 space-y-2">
<div className="flex gap-2 items-start">
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-xs text-red-800 dark:text-red-300 leading-relaxed">{error}</p>
<p className="text-xs font-medium text-red-800 dark:text-red-300 leading-relaxed">{intro ?? error}</p>
</div>
{bullets.length > 0 && (
<ul className="list-disc list-inside space-y-1 pl-6">
{bullets.map((b, i) => (
<li key={i} className="text-xs text-red-800 dark:text-red-300 leading-relaxed">
{b.replace(/^•\s*/, '')}
</li>
))}
</ul>
)}
{outro && (
<p className="text-xs text-red-700 dark:text-red-400 pl-6">{outro}</p>
)}
</div>
);
})()}
</div>
{/* Footer */}

View File

@@ -6,6 +6,8 @@ export const bookingsApi = {
getAll: (filters?: BookingFilters) => {
const params = new URLSearchParams();
if (filters?.status) params.append('status', filters.status);
if (filters?.bookingType) params.append('bookingType', filters.bookingType);
if (filters?.paymentStatus) params.append('paymentStatus', filters.paymentStatus);
if (filters?.dateFrom) params.append('dateFrom', filters.dateFrom);
if (filters?.dateTo) params.append('dateTo', filters.dateTo);
if (filters?.search) params.append('search', filters.search);

View File

@@ -158,8 +158,11 @@ export const seatsApi = {
// Payments API
export const paymentsApi = {
getAll: async (params?: any) => {
const query = new URLSearchParams(params as Record<string, string>).toString();
const response = await apiClient.get<any>(`/payments${query ? `?${query}` : ''}`);
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([, v]) => v !== '' && v !== undefined && v !== null)
) as Record<string, string>;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/payments/all${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
@@ -229,6 +232,7 @@ export const loyaltyApi = {
adjustPoints: (accountId: string, data: any) => apiClient.post<any>(`/loyalty/accounts/${accountId}/adjust`, data),
getRewards: () => apiClient.get<any[]>('/loyalty/rewards'),
createReward: (data: any) => apiClient.post<any>('/loyalty/rewards', data),
delete: (id: string) => apiClient.delete(`/loyalty/accounts/${id}`),
};
// Wallet API
@@ -244,6 +248,7 @@ export const walletApi = {
getAccount: (passengerId: string) => apiClient.get<any>(`/wallet/accounts/${passengerId}`),
adjustBalance: (accountId: string, data: any) => apiClient.post<any>(`/wallet/accounts/${accountId}/adjust`, data),
getLedger: (accountId: string) => apiClient.get<any[]>(`/wallet/accounts/${accountId}/ledger`),
delete: (id: string) => apiClient.delete(`/wallet/accounts/${id}`),
};
// Promotions API
@@ -301,12 +306,14 @@ export const notificationsApi = {
// Fraud API
export const fraudApi = {
getAlerts: async (params?: any) => {
const query = new URLSearchParams(params as Record<string, string>).toString();
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([, v]) => v !== '' && v !== undefined && v !== null)
) as Record<string, string>;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/fraud/alerts${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
// Backend returns { data: [...], total } or { items: [...] }
const arr = (response as any)?.data ?? (response as any)?.items ?? response;
return { items: Array.isArray(arr) ? arr : [] };
},
acknowledgeAlert: (id: string) => apiClient.patch<any>(`/fraud/alerts/${id}/acknowledge`),
getRules: () => apiClient.get<any[]>('/fraud/rules'),
@@ -403,6 +410,15 @@ export const packagesApi = {
activate: (id: string) => apiClient.patch<any>(`/packages/${id}/activate`, {}),
deactivate: (id: string) => apiClient.patch<any>(`/packages/${id}/deactivate`, {}),
remove: (id: string) => apiClient.delete(`/packages/${id}`),
getBookings: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
) as Record<string, string>;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/packages/bookings${query ? `?${query}` : ''}`);
if ((response as any)?.data) return Array.isArray((response as any).data) ? { items: (response as any).data } : response;
return Array.isArray(response) ? { items: response } : response;
},
addTier: (packageId: string, data: any) => apiClient.post<any>(`/packages/${packageId}/tiers`, data),
updateTier: (tierId: string, data: any) => apiClient.patch<any>(`/packages/tiers/${tierId}`, data),
deleteTier: (tierId: string) => apiClient.delete(`/packages/tiers/${tierId}`),
@@ -455,3 +471,14 @@ export const systemConfigApi = {
getAll: () => apiClient.get<Record<string, string>>('/config'),
update: (data: Record<string, string>) => apiClient.patch<Record<string, string>>('/config', data),
};
// App Releases API
export const appReleasesApi = {
getAll: async () => {
const response = await apiClient.get<any>('/app-releases');
return Array.isArray(response) ? response : (response as any)?.items || (response as any)?.data || [];
},
create: (data: any) => apiClient.post<any>('/app-releases', data),
update: (id: string, data: any) => apiClient.patch<any>(`/app-releases/${id}`, data),
remove: (id: string) => apiClient.delete(`/app-releases/${id}`),
};

View File

@@ -46,6 +46,8 @@ export interface RevenueData {
export interface BookingFilters {
status?: string;
bookingType?: string;
paymentStatus?: string;
dateFrom?: string;
dateTo?: string;
search?: string;

View File

@@ -25,7 +25,7 @@ type BookingWithTicket = {
export default function ConfirmationPage() {
const router = useRouter();
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking } = useBookingStore();
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking, packageName } = useBookingStore();
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
const [copied, setCopied] = useState(false);
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
@@ -181,7 +181,9 @@ export default function ConfirmationPage() {
<CheckCircle className="w-12 h-12 text-green-600 dark:text-green-400" />
</div>
</div>
<h1 className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">Booking confirmed!</h1>
<h1 className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">
{packageName ? `${packageName} booking confirmed!` : 'Booking confirmed!'}
</h1>
<p className="text-gray-600 dark:text-gray-400 text-lg">Your train tickets are ready</p>
</div>
@@ -375,7 +377,20 @@ export default function ConfirmationPage() {
</div>
<div>
<p className="text-gray-600 dark:text-gray-400">Seat</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">{passenger.seatNumber || 'Will be assigned'}</p>
{isRoundTrip ? (
<div className="space-y-0.5">
<p className="font-semibold text-gray-900 dark:text-gray-100">
Outbound: {(passenger as any).outboundSeatNumber || 'Auto-assigned at boarding'}{(passenger as any).outboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>(Coach {(passenger as any).outboundCoachNumber})</span>}
</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
Return: {(passenger as any).inboundSeatNumber || 'Auto-assigned at boarding'}{(passenger as any).inboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>(Coach {(passenger as any).inboundCoachNumber})</span>}
</p>
</div>
) : (
<p className="font-semibold text-gray-900 dark:text-gray-100">
{passenger.seatNumber || 'Auto-assigned at boarding'}{passenger.coachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>(Coach {passenger.coachNumber})</span>}
</p>
)}
</div>
</div>
</div>

View File

@@ -28,7 +28,7 @@ const getIconForMethod = (methodId: string) => {
export default function PaymentPage() {
const router = useRouter();
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore();
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName } = useBookingStore();
const { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore();
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
@@ -368,7 +368,7 @@ export default function PaymentPage() {
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-green-800 dark:text-green-300">
Your booking is successfully reserved
{packageName ? `Your ${packageName} booking is successfully reserved` : 'Your booking is successfully reserved'}
</p>
<p className="text-sm text-green-700 dark:text-green-400 mt-0.5">
Booking Reference: <span className="font-bold">{pnr}</span>

View File

@@ -181,6 +181,8 @@ export default function ResultsPage() {
trainNumber: schedule.trainNumber,
origin: schedule.origin?.name || 'Origin',
destination: schedule.destination?.name || 'Destination',
originStationId: schedule.origin?.id || schedule.originStationId || '',
destinationStationId: schedule.destination?.id || schedule.destinationStationId || '',
departureTime: schedule.departureAt || schedule.departureTime || '',
arrivalTime: schedule.arrivalAt || schedule.arrivalTime || '',
duration: durationStr,
@@ -616,7 +618,9 @@ export default function ResultsPage() {
</span>
</div>
<div className="space-y-2.5">
{coachType.classes.map((cls: any, idx: number) => (
{coachType.classes.map((cls: any, idx: number) => {
const seatsAvailable = classModal.availabilityByClass?.[cls.name];
return (
<div
key={idx}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-gray-50/80 dark:bg-gray-800/40 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 transition-colors"
@@ -626,6 +630,17 @@ export default function ResultsPage() {
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{cls.name}
</span>
{seatsAvailable !== undefined && (
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded-full ${
seatsAvailable === 0
? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400'
: seatsAvailable <= 5
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400'
: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
}`}>
{seatsAvailable === 0 ? 'Full' : `${seatsAvailable} left`}
</span>
)}
</div>
<div className="flex items-baseline gap-1">
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
@@ -636,7 +651,8 @@ export default function ResultsPage() {
</span>
</div>
</div>
))}
);
})}
</div>
</div>
)}

View File

@@ -7,9 +7,9 @@ import { useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { format } from 'date-fns';
import { formatTime, getTimePeriod } from '@/utils/format';
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { ChevronLeft } from 'lucide-react';
import { calculatePassengerFare, isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
// Helper function to decode JWT token and extract passengerId
function getPassengerIdFromToken(token: string): string | null {
@@ -54,10 +54,11 @@ function getPassengerIdFromToken(token: string): string | null {
export default function ReviewPage() {
const router = useRouter();
const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, seatHold, setBookingId, setPNR, createAccount, passengerId: storedPassengerId, searchCriteria } = useBookingStore();
const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, seatHold, setBookingId, setPNR, createAccount, passengerId: storedPassengerId, searchCriteria, packageId, priceTierId, packageTierPriceMinor, packageName } = useBookingStore();
const { user, isAuthenticated } = useAuthStore();
const [timeLeft, setTimeLeft] = useState<string>('');
const [seatDetails, setSeatDetails] = useState<Record<string, string>>({});
const [fareBreakdown, setFareBreakdown] = useState<any>(null);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
@@ -97,8 +98,8 @@ export default function ReviewPage() {
// The seat class/category is chosen once per leg (coach type selected on /booking/seats),
// so every seat on that leg shares it — no need to look it up per-seat.
const formatSeatClass = (schedule: any): string => {
const raw = schedule?.selectedSeatClassName || schedule?.seatClassName || schedule?.selectedSeatClass;
if (!raw) return 'Standard';
const raw = schedule?.seatClassName;
if (!raw) return '';
return String(raw).replace(/_/g, ' ');
};
@@ -308,6 +309,10 @@ export default function ReviewPage() {
if (searchCriteria.promoCode) {
bookingData.promoCode = searchCriteria.promoCode;
}
// Add package fields if this is a package booking
if (packageId) bookingData.packageId = packageId;
if (priceTierId) bookingData.priceTierId = priceTierId;
} else {
// For guests: send full passenger details array
bookingData = {
@@ -352,6 +357,10 @@ export default function ReviewPage() {
if (searchCriteria.promoCode) {
bookingData.promoCode = searchCriteria.promoCode;
}
// Add package fields if this is a package booking
if (packageId) bookingData.packageId = packageId;
if (priceTierId) bookingData.priceTierId = priceTierId;
}
if (typeof window !== 'undefined' && !isAuthenticated && bookingData.deviceId && !localStorage.getItem('deviceId')) {
@@ -394,19 +403,48 @@ export default function ReviewPage() {
}
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum, _, i) => {
return sum + calculatePassengerFare(passengers, i, outboundSchedule.baseFareAdult || 0);
}, 0) : 0;
const fetchFareBreakdown = useCallback(async (scheduleId: string, originStationId: string, destinationStationId: string) => {
try {
const seatClasses: any[] = await apiClient.get('/seat-classes');
const scheduleSeatClassName = isRoundTrip
? (outboundSchedule as any)?.seatClassName
: (selectedSchedule as any)?.seatClassName;
const seatClassId = seatClasses.find((sc: any) => sc.name === scheduleSeatClassName)?.id || seatClasses[0]?.id;
if (!seatClassId) return;
const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum, _, i) => {
return sum + calculatePassengerFare(passengers, i, inboundSchedule.baseFareAdult || 0);
}, 0) : 0;
const passengersParam = JSON.stringify(
passengers.map(p => ({
passengerName: p.name,
dateOfBirth: p.dateOfBirth,
seatClassId,
nationality: p.nationality,
}))
);
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => {
const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0;
return sum + calculatePassengerFare(passengers, i, farePerPassenger);
}, 0);
const total = baseFare;
const params = new URLSearchParams({
scheduleId,
originStationId,
destinationStationId,
passengers: passengersParam,
displayCurrency,
...(searchCriteria?.promoCode ? { promoCode: searchCriteria.promoCode } : {}),
});
const result: any = await apiClient.get(`/search/fare-breakdown?${params}`);
setFareBreakdown(result);
} catch (err) {
console.error('Failed to fetch fare breakdown:', err);
}
}, [passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]);
useEffect(() => {
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return;
const scheduleId = isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id;
if (!scheduleId) return;
fetchFareBreakdown(scheduleId, searchCriteria.originStationId, searchCriteria.destinationStationId);
}, [fetchFareBreakdown, isRoundTrip, outboundSchedule?.id, selectedSchedule?.id, searchCriteria?.originStationId, searchCriteria?.destinationStationId]);
const total = packageTierPriceMinor ?? fareBreakdown?.totalMinor ?? 0;
// Shared fare sidebar — rendered in right column (desktop) and inline (mobile)
const FareSidebar = () => (
@@ -415,18 +453,10 @@ export default function ReviewPage() {
Fare breakdown
</h2>
{passengers.map((p, i) => {
const outFare = outboundSchedule?.baseFareAdult || 0;
const inFare = inboundSchedule?.baseFareAdult || 0;
const onewayFare = selectedSchedule?.baseFareAdult || 0;
// Apply first child free logic using utility functions
const outboundFare = calculatePassengerFare(passengers, i, outFare);
const inboundFare = calculatePassengerFare(passengers, i, inFare);
const oneWayFare = calculatePassengerFare(passengers, i, onewayFare);
const passengerTotal = isRoundTrip ? outboundFare + inboundFare : oneWayFare;
const line = fareBreakdown?.passengers?.[i];
const passengerTotal = line?.fareMinor ?? 0;
const isChildPassenger = isChild(p);
const isFreeChild = isChildPassenger && isFirstChild(passengers, i);
const isFreeChild = line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i));
return (
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
@@ -445,18 +475,6 @@ export default function ReviewPage() {
{formatFare(passengerTotal, displayCurrency)}
</span>
</div>
{isRoundTrip && (
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
<div className="flex justify-between">
<span>Outbound {isFreeChild ? '(Free)' : ''}</span>
<span>{formatFare(outboundFare, displayCurrency)}</span>
</div>
<div className="flex justify-between">
<span>Return {isFreeChild ? '(Free)' : ''}</span>
<span>{formatFare(inboundFare, displayCurrency)}</span>
</div>
</div>
)}
</div>
);
})}
@@ -491,7 +509,9 @@ export default function ReviewPage() {
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6 pb-28 lg:pb-10">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">Review your booking</h1>
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">
{packageName ? `Review your ${packageName} booking` : 'Review your booking'}
</h1>
{seatHold && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 mb-4 flex items-center gap-2">
@@ -755,6 +775,7 @@ export default function ReviewPage() {
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-2">
<p className="text-xs text-gray-500 dark:text-gray-400">Outbound Seat</p>
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
{(p as any).outboundCoachNumber && <span className="text-gray-500 dark:text-gray-400 ml-1">{(p as any).outboundCoachNumber} </span>}
{(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : 'Auto-assign'}
</p>
{(p as any).outboundSeatId && (
@@ -764,6 +785,7 @@ export default function ReviewPage() {
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-2">
<p className="text-xs text-gray-500 dark:text-gray-400">Return Seat</p>
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
{(p as any).inboundCoachNumber && <span className="text-gray-500 dark:text-gray-400 ml-1">{(p as any).inboundCoachNumber} </span>}
{(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : 'Auto-assign'}
</p>
{(p as any).inboundSeatId && (
@@ -774,7 +796,10 @@ export default function ReviewPage() {
) : (
<div className="text-right">
<p className="text-sm text-gray-600 dark:text-gray-400">Seat</p>
<p className="font-medium text-gray-900 dark:text-gray-100">{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}</p>
<p className="font-medium text-gray-900 dark:text-gray-100">
{p.coachNumber && <span className="text-gray-500 dark:text-gray-400 ml-1">{p.coachNumber} </span>}
{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}
</p>
{p.seatId && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(selectedSchedule)}</p>
)}

View File

@@ -136,6 +136,7 @@ export default function SeatsPage() {
setPassengers,
searchCriteria,
bookingId,
packageName,
} = useBookingStore();
// Maps passenger index -> assigned seat id. A passenger can only get a seat while
// they are the "active" passenger, which prevents bulk/batch selection across passengers.
@@ -160,6 +161,9 @@ export default function SeatsPage() {
? outboundSchedule
: selectedSchedule;
const coachTypeId = (currentSchedule as any)?.selectedCoachTypeId;
const journeyDirection = isRoundTrip
? currentJourneyType === "inbound" ? "RETURN" : "OUTBOUND"
: "ONE_WAY";
const {
data: seatMapData,
@@ -168,7 +172,7 @@ export default function SeatsPage() {
} = useQuery({
queryKey: ["seatmap", currentSchedule?.id, coachTypeId, currentJourneyType],
queryFn: async () => {
const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${coachTypeId}`;
const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${coachTypeId}&journeyDirection=${journeyDirection}`;
console.log("🪑 Seatmap Request:", {
endpoint,
});
@@ -198,19 +202,20 @@ export default function SeatsPage() {
seatId: seatIds[i],
}));
// For round trip inbound, swap origin and destination
const isInbound = isRoundTrip && currentJourneyType === "inbound";
const originId = isInbound
? searchCriteria?.destinationStationId
: searchCriteria?.originStationId;
const destinationId = isInbound
? searchCriteria?.originStationId
: searchCriteria?.destinationStationId;
// Always use the schedule's own station IDs (set from schedule.origin.id / schedule.destination.id)
// so they are guaranteed to exist in the trip's TripStopTime records.
const scheduleForHold = isInbound ? inboundSchedule : (isRoundTrip ? outboundSchedule : selectedSchedule);
const originId = (scheduleForHold as any)?.originStationId || searchCriteria?.originStationId;
const destinationId = (scheduleForHold as any)?.destinationStationId || searchCriteria?.destinationStationId;
console.log('🎫 Hold request:', { scheduleId: currentSchedule?.id, originId, destinationId, isInbound });
return apiClient.post(`/seats/hold`, {
scheduleId: currentSchedule?.id,
originStationId: originId,
destinationStationId: destinationId,
journeyDirection: isInbound ? "RETURN" : "OUTBOUND",
passengers: passengersForHold,
});
},
@@ -443,6 +448,7 @@ export default function SeatsPage() {
...p,
outboundSeatId: seatIds[i],
outboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
outboundCoachNumber: selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
};
});
setPassengers(updatedPassengers);
@@ -473,12 +479,14 @@ export default function SeatsPage() {
...p,
inboundSeatId: seatIds[i],
inboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
inboundCoachNumber: selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
};
}
return {
...p,
seatId: seatIds[i],
seatNumber: seatData ? buildSeatLabel(seatData) : '',
coachNumber: selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
};
});
setPassengers(updatedPassengers);
@@ -1291,8 +1299,8 @@ export default function SeatsPage() {
<h1 className="text-base font-bold text-gray-900 dark:text-white">
{isRoundTrip
? currentJourneyType === "outbound"
? "Select Outbound Seats"
: "Select Return Seats"
? packageName ? `Select Outbound Seats for ${packageName}` : "Select Outbound Seats"
: packageName ? `Select Return Seats for ${packageName}` : "Select Return Seats"
: "Select Seats"}
</h1>
{!allSeatsAssigned && (

View File

@@ -5,9 +5,7 @@ import { apiClient } from "@/lib/api-client";
import { useParams, useRouter } from "next/navigation";
import Image from "next/image";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useBookingStore } from "@/lib/booking-store";
import {
ChevronLeft,
Calendar,
@@ -23,11 +21,6 @@ import {
Tag,
Shield,
X,
CheckCircle2,
Phone,
Mail,
User,
FileText,
} from "lucide-react";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -87,6 +80,7 @@ interface PackageDetail {
busTransferRoute: string | null;
validFrom: string;
validUntil: string;
journeyType: 'ONE_WAY' | 'ROUND_TRIP';
priceTiers: PriceTier[];
outboundSchedule: Schedule;
returnSchedule: Schedule | null;
@@ -122,313 +116,6 @@ function formatPrice(minor: number, currency: string): string {
return `${currency} ${(minor / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
// ─── Inquiry form ────────────────────────────────────────────────────────────
const inquirySchema = z.object({
travelerCount: z
.number({ invalid_type_error: "Enter a valid number" })
.int("Must be a whole number")
.min(1, "At least 1 traveler required")
.max(50, "Maximum 50 travelers per booking"),
contactName: z
.string()
.min(2, "Name must be at least 2 characters")
.max(100),
contactEmail: z
.string()
.min(1, "Email is required")
.email("Enter a valid email address"),
contactPhone: z
.string()
.min(7, "Enter a valid phone number")
.max(20, "Phone number too long")
.regex(/^[+\d\s\-()\\.]+$/, "Invalid phone number format"),
notes: z.string().max(500, "Notes must be under 500 characters").optional(),
});
type InquiryForm = z.infer<typeof inquirySchema>;
interface InquiryModalProps {
packageId: string;
packageName: string;
tier: PriceTier;
onClose: () => void;
}
function InquiryModal({ packageId, packageName, tier, onClose }: InquiryModalProps) {
const [submitted, setSubmitted] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<InquiryForm>({
resolver: zodResolver(inquirySchema as any),
defaultValues: { travelerCount: 1 },
});
const onSubmit = async (data: InquiryForm) => {
setSubmitError(null);
try {
await apiClient.post("/packages/inquiries", {
packageId,
priceTierId: tier.id,
travelerCount: data.travelerCount,
contactName: data.contactName,
contactEmail: data.contactEmail,
contactPhone: data.contactPhone,
notes: data.notes ?? "",
});
setSubmitted(true);
} catch (err: any) {
setSubmitError(
err?.response?.data?.message ||
"Something went wrong. Please try again.",
);
}
};
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-[90] bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4">
<div className="w-full sm:max-w-lg bg-white dark:bg-gray-900 rounded-t-3xl sm:rounded-2xl shadow-2xl overflow-hidden max-h-[92vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
<div>
<h2 className="text-base font-bold text-gray-900 dark:text-white">
Book Package
</h2>
<p className="text-xs text-gray-400 mt-0.5 line-clamp-1">
{packageName.trim()}
</p>
</div>
<button
type="button"
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<X className="w-4 h-4 text-gray-500" />
</button>
</div>
{/* Success state */}
{submitted ? (
<div className="flex-1 flex flex-col items-center justify-center px-6 py-12 text-center">
<div className="w-16 h-16 bg-green-50 dark:bg-green-900/20 rounded-full flex items-center justify-center mb-4">
<CheckCircle2 className="w-8 h-8 text-green-500" />
</div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
Inquiry Submitted!
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs">
We&apos;ve received your booking inquiry. Our team will contact you
shortly to confirm your reservation.
</p>
<button
type="button"
onClick={onClose}
className="mt-6 px-6 py-2.5 bg-primary text-white text-sm font-semibold rounded-xl hover:bg-[rgb(16,89,60)] transition-colors"
>
Done
</button>
</div>
) : (
<>
{/* Selected tier summary */}
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10 flex-shrink-0">
<div className="flex items-center justify-between">
<div>
<p className="text-[10px] text-gray-400 uppercase tracking-wide font-semibold">
Selected seat type
</p>
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">
{tier.label.trim()}{" "}
<span className="text-[10px] font-normal text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded ml-1">
{tier.seatType.trim()}
</span>
</p>
</div>
<div className="text-right">
<p className="text-[10px] text-gray-400 uppercase tracking-wide font-semibold">
Per person
</p>
<p className="text-base font-extrabold text-primary mt-0.5">
{formatPrice(tier.priceMinor, tier.currency)}
</p>
</div>
</div>
</div>
{/* Form */}
<form
onSubmit={handleSubmit(onSubmit)}
className="flex-1 overflow-y-auto scrollbar-hide px-6 py-5 space-y-4"
>
{/* Traveler count */}
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
Number of Travelers <span className="text-red-500">*</span>
</label>
<div className="relative">
<Users className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="number"
min={1}
max={50}
{...register("travelerCount", { valueAsNumber: true })}
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary/30 transition-colors ${
errors.travelerCount
? "border-red-400 focus:border-red-400"
: "border-gray-200 dark:border-gray-700 focus:border-primary"
}`}
/>
</div>
{errors.travelerCount && (
<p className="text-xs text-red-500 mt-1">{errors.travelerCount.message}</p>
)}
</div>
{/* Contact name */}
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
Full Name <span className="text-red-500">*</span>
</label>
<div className="relative">
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Your full name"
{...register("contactName")}
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30 transition-colors ${
errors.contactName
? "border-red-400 focus:border-red-400"
: "border-gray-200 dark:border-gray-700 focus:border-primary"
}`}
/>
</div>
{errors.contactName && (
<p className="text-xs text-red-500 mt-1">{errors.contactName.message}</p>
)}
</div>
{/* Email */}
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
Email Address <span className="text-red-500">*</span>
</label>
<div className="relative">
<Mail className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="email"
placeholder="you@example.com"
{...register("contactEmail")}
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30 transition-colors ${
errors.contactEmail
? "border-red-400 focus:border-red-400"
: "border-gray-200 dark:border-gray-700 focus:border-primary"
}`}
/>
</div>
{errors.contactEmail && (
<p className="text-xs text-red-500 mt-1">{errors.contactEmail.message}</p>
)}
</div>
{/* Phone */}
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
Phone Number <span className="text-red-500">*</span>
</label>
<div className="relative">
<Phone className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="tel"
placeholder="+251 912 345 678"
{...register("contactPhone")}
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30 transition-colors ${
errors.contactPhone
? "border-red-400 focus:border-red-400"
: "border-gray-200 dark:border-gray-700 focus:border-primary"
}`}
/>
</div>
{errors.contactPhone && (
<p className="text-xs text-red-500 mt-1">{errors.contactPhone.message}</p>
)}
</div>
{/* Notes */}
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
Additional Notes{" "}
<span className="text-gray-400 font-normal">(optional)</span>
</label>
<div className="relative">
<FileText className="absolute left-3.5 top-3.5 w-4 h-4 text-gray-400" />
<textarea
rows={3}
placeholder="Any special requirements or questions..."
{...register("notes")}
className={`w-full pl-10 pr-4 py-3 rounded-xl border-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30 resize-none transition-colors ${
errors.notes
? "border-red-400 focus:border-red-400"
: "border-gray-200 dark:border-gray-700 focus:border-primary"
}`}
/>
</div>
{errors.notes && (
<p className="text-xs text-red-500 mt-1">{errors.notes.message}</p>
)}
</div>
{/* API error */}
{submitError && (
<div className="flex items-start gap-2.5 p-3.5 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-xs text-red-600 dark:text-red-400">{submitError}</p>
</div>
)}
{/* Submit */}
<div className="pt-1 pb-2">
<button
type="submit"
disabled={isSubmitting}
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-60 text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Submitting...
</>
) : (
<>
Submit Inquiry <ArrowRight className="w-4 h-4" />
</>
)}
</button>
<p className="text-[10px] text-gray-400 text-center mt-2">
Our team will contact you shortly to confirm.
</p>
</div>
</form>
</>
)}
</div>
</div>
</>
);
}
// ─── Journey Card ─────────────────────────────────────────────────────────────
function JourneyCard({ schedule, label }: { schedule: Schedule; label: string }) {
@@ -505,17 +192,17 @@ function PriceTiersPanel({
tiers,
selectedTierId,
onSelect,
selectedTier,
onBookNow,
isRoundTrip,
}: {
tiers: PriceTier[];
selectedTierId: string | null;
onSelect: (id: string) => void;
selectedTier?: PriceTier;
onBookNow: () => void;
isRoundTrip: boolean;
}) {
const priceMultiplier = isRoundTrip ? 2 : 1;
return (
<div className="space-y-4">
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800">
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-4">
Select Seat Type
@@ -531,17 +218,15 @@ function PriceTiersPanel({
const soldOut = tier.availableSeats === 0;
const selected = tier.id === selectedTierId;
return (
<button
<div
key={tier.id}
type="button"
disabled={soldOut}
onClick={() => onSelect(tier.id)}
className={`w-full text-left rounded-xl border-2 p-3.5 transition-all ${
onClick={() => !soldOut && onSelect(tier.id)}
className={`rounded-xl border-2 p-3.5 transition-all ${
soldOut
? "border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed"
: selected
? "border-primary bg-primary/5"
: "border-gray-200 dark:border-gray-700 hover:border-primary/50 hover:shadow-sm"
: "border-gray-200 dark:border-gray-700 hover:border-primary/50 hover:shadow-sm cursor-pointer"
}`}
>
{/* Row 1: radio + full label */}
@@ -577,43 +262,115 @@ function PriceTiersPanel({
)}
</div>
<p className="text-sm font-extrabold text-primary">
{formatPrice(tier.priceMinor, tier.currency)}
{formatPrice(tier.priceMinor * priceMultiplier, tier.currency)}
</p>
</div>
{/* Book Now — shown only when selected */}
{selected && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onBookNow(); }}
className="mt-3.5 w-full py-3 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-md flex items-center justify-center gap-2"
>
Book Now <ArrowRight className="w-4 h-4" />
</button>
)}
</div>
);
})}
</div>
)}
</div>
);
}
{/* Summary + CTA */}
{selectedTier && (
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-primary/30 shadow-sm">
<div className="space-y-2 mb-4">
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500 dark:text-gray-400">Seat type</p>
<p className="text-xs font-semibold text-gray-900 dark:text-white">
{selectedTier.label.trim()}
</p>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500 dark:text-gray-400">Price per person</p>
<p className="text-base font-extrabold text-primary">
{formatPrice(selectedTier.priceMinor, selectedTier.currency)}
</p>
</div>
</div>
<button
type="button"
onClick={onBookNow}
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2"
>
Book Now <ArrowRight className="w-4 h-4" />
// ─── Passenger count picker ──────────────────────────────────────────────────
function PassengerCountModal({
tier,
onClose,
onConfirm,
loading,
error,
priceMultiplier,
}: {
tier: PriceTier;
onClose: () => void;
onConfirm: (adultCount: number, childCount: number) => void;
loading: boolean;
error: string | null;
priceMultiplier: number;
}) {
const [adultCount, setAdultCount] = useState(1);
const [childCount, setChildCount] = useState(0);
const total = adultCount + childCount;
const remaining = tier.availableSeats - tier.bookedSeats;
return (
<>
<div className="fixed inset-0 z-[90] bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4">
<div className="w-full sm:max-w-sm bg-white dark:bg-gray-900 rounded-t-3xl sm:rounded-2xl shadow-2xl overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<h2 className="text-base font-bold text-gray-900 dark:text-white">Number of passengers</h2>
<button type="button" onClick={onClose} className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800">
<X className="w-4 h-4 text-gray-500" />
</button>
</div>
)}
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10">
<p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">Selected tier</p>
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.label.trim()}</p>
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per person</p>
</div>
<div className="px-6 py-5 space-y-4">
{[{ label: "Adults", sub: "Age 5+", value: adultCount, min: 1, set: setAdultCount },
{ label: "Children", sub: "Under 5", value: childCount, min: 0, set: setChildCount }]
.map(({ label, sub, value, min, set }) => (
<div key={label} className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-white">{label}</p>
<p className="text-xs text-gray-400">{sub}</p>
</div>
<div className="flex items-center gap-3">
<button type="button" onClick={() => set(Math.max(min, value - 1))}
disabled={value <= min}
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
</button>
<span className="w-6 text-center text-base font-bold text-gray-900 dark:text-white">{value}</span>
<button type="button" onClick={() => set(value + 1)}
disabled={total >= remaining}
className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center text-lg font-bold text-gray-600 dark:text-gray-300 disabled:opacity-30 hover:border-primary hover:text-primary transition-colors">
+
</button>
</div>
</div>
))}
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
<span className="text-sm text-gray-500">Total</span>
<span className="text-base font-extrabold text-primary">{formatPrice(tier.priceMinor * priceMultiplier * total, tier.currency)}</span>
</div>
{error && (
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-xs text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<button type="button" onClick={() => onConfirm(adultCount, childCount)}
disabled={loading || total < 1}
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-60 text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2">
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> Loading...</> : <>Continue <ArrowRight className="w-4 h-4" /></>}
</button>
</div>
</div>
</div>
</>
);
}
@@ -623,9 +380,12 @@ export default function PackageDetailPage() {
const params = useParams();
const router = useRouter();
const id = params?.id as string;
const { clearBooking, setSearchCriteria, setSelectedSchedule, setOutboundSchedule, setInboundSchedule, setPassengers, setPackageContext } = useBookingStore();
const [selectedTierId, setSelectedTierId] = useState<string | null>(null);
const [inquiryOpen, setInquiryOpen] = useState(false);
const [passengerModalOpen, setPassengerModalOpen] = useState(false);
const [bookingContextLoading, setBookingContextLoading] = useState(false);
const [bookingContextError, setBookingContextError] = useState<string | null>(null);
const { data: pkg, isLoading, isError } = useQuery<PackageDetail>({
queryKey: ["package", id],
@@ -636,6 +396,82 @@ export default function PackageDetailPage() {
const selectedTier = pkg?.priceTiers?.find((t) => t.id === selectedTierId);
const isRoundTripPkg = pkg?.journeyType === 'ROUND_TRIP';
const handleBookNow = async (adultCount: number, childCount: number) => {
if (!selectedTier || !pkg) return;
setBookingContextLoading(true);
setBookingContextError(null);
try {
const ctx: any = await apiClient.get(
`/packages/${id}/booking-context?tierId=${selectedTier.id}&adultCount=${adultCount}&childCount=${childCount}`,
);
clearBooking();
const passengerCount = adultCount + childCount;
const isRoundTrip = !!ctx.returnSchedule;
const toSchedule = (s: any) => ({
id: s.scheduleId,
trainNumber: s.trainNumber ?? "",
origin: s.originStation?.name ?? "",
destination: s.destinationStation?.name ?? "",
originStationId: s.originStationId ?? s.originStation?.id ?? "",
destinationStationId: s.destinationStationId ?? s.destinationStation?.id ?? "",
departureTime: s.departureAt,
arrivalTime: s.arrivalAt,
duration: s.durationMinutes ? `${Math.floor(s.durationMinutes / 60)}h ${s.durationMinutes % 60}m` : "",
baseFareAdult: Math.round(ctx.totalMinor / passengerCount),
baseFareChild: 0,
displayCurrency: selectedTier.currency,
selectedSeatClass: ctx.seatClassId,
selectedSeatClassName: ctx.seatClassName ?? "",
seatClassName: ctx.seatClassName ?? "",
selectedCoachTypeId: ctx.coachTypeId ?? "",
});
const outboundSched = toSchedule(ctx.outboundSchedule);
setSearchCriteria({
originStationId: ctx.outboundSchedule.originStation?.id ?? "",
destinationStationId: ctx.outboundSchedule.destinationStation?.id ?? "",
departureDate: ctx.outboundSchedule.departureAt?.slice(0, 10) ?? "",
returnDate: ctx.returnSchedule?.departureAt?.slice(0, 10),
tripType: isRoundTrip ? "ROUND_TRIP" : "ONE_WAY",
adultCount,
childCount,
nationality: "ETHIOPIAN",
});
if (isRoundTrip) {
setOutboundSchedule(outboundSched);
setInboundSchedule(toSchedule(ctx.returnSchedule));
} else {
setSelectedSchedule(outboundSched);
}
setPassengers(
Array.from({ length: passengerCount }, (_, i) => ({
name: "",
dateOfBirth: "",
nationality: "ETHIOPIAN",
isPrimaryPassenger: i === 0,
})),
);
setPackageContext(id, selectedTier.id, ctx.totalMinor, pkg.name);
router.push("/booking/passengers");
} catch (err: any) {
setBookingContextError(
err?.response?.data?.message ?? "Failed to load booking context. Please try again.",
);
} finally {
setBookingContextLoading(false);
}
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
@@ -670,13 +506,15 @@ export default function PackageDetailPage() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Inquiry modal */}
{inquiryOpen && selectedTier && (
<InquiryModal
packageId={pkg.id}
packageName={pkg.name}
{/* Passenger count modal */}
{passengerModalOpen && selectedTier && (
<PassengerCountModal
tier={selectedTier}
onClose={() => setInquiryOpen(false)}
onClose={() => { setPassengerModalOpen(false); setBookingContextError(null); }}
onConfirm={handleBookNow}
loading={bookingContextLoading}
error={bookingContextError}
priceMultiplier={isRoundTripPkg ? 2 : 1}
/>
)}
@@ -756,7 +594,7 @@ export default function PackageDetailPage() {
<p className="text-xs font-bold text-gray-800 dark:text-white mt-0.5">
{pkg.priceTiers.length
? formatPrice(
Math.min(...pkg.priceTiers.map((t) => t.priceMinor)),
Math.min(...pkg.priceTiers.map((t) => t.priceMinor)) * (isRoundTripPkg ? 2 : 1),
pkg.priceTiers[0].currency,
)
: "—"}
@@ -864,8 +702,8 @@ export default function PackageDetailPage() {
tiers={pkg.priceTiers}
selectedTierId={selectedTierId}
onSelect={setSelectedTierId}
selectedTier={selectedTier}
onBookNow={() => setInquiryOpen(true)}
onBookNow={() => setPassengerModalOpen(true)}
isRoundTrip={isRoundTripPkg}
/>
</div>
</div>
@@ -877,8 +715,8 @@ export default function PackageDetailPage() {
tiers={pkg.priceTiers}
selectedTierId={selectedTierId}
onSelect={setSelectedTierId}
selectedTier={selectedTier}
onBookNow={() => setInquiryOpen(true)}
onBookNow={() => setPassengerModalOpen(true)}
isRoundTrip={isRoundTripPkg}
/>
</div>
</div>

View File

@@ -0,0 +1,5 @@
import PackagesSection from "@/components/PackagesSection";
export default function PackagesPage() {
return <PackagesSection />;
}

View File

@@ -51,6 +51,7 @@ interface HolidayPackage {
includedServices?: string[];
busTransferIncluded?: boolean;
busTransferRoute?: string | null;
journeyType?: 'ONE_WAY' | 'ROUND_TRIP';
returnSchedule?: Schedule | null;
outboundSchedule?: Schedule;
priceTiers: PriceTier[];
@@ -82,10 +83,11 @@ function daysUntil(iso: string): number {
function minPrice(
tiers: PriceTier[],
multiplier = 1,
): { amount: number; currency: string } | null {
if (!tiers?.length) return null;
const min = tiers.reduce((a, b) => (a.priceMinor < b.priceMinor ? a : b));
return { amount: min.priceMinor / 100, currency: min.currency };
return { amount: (min.priceMinor * multiplier) / 100, currency: min.currency };
}
function fmtPrice(minor: number, currency: string): string {
@@ -142,7 +144,8 @@ function AvailBar({ booked, total }: { booked: number; total: number }) {
// ─── Featured Card (first package — full-width, image left) ───────────────────
function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
const price = minPrice(pkg.priceTiers);
const multiplier = pkg.journeyType === 'ROUND_TRIP' ? 2 : 1;
const price = minPrice(pkg.priceTiers, multiplier);
const origin = pkg.outboundSchedule?.originStation;
const dest = pkg.outboundSchedule?.destinationStation;
const days = daysUntil(pkg.validUntil);
@@ -312,7 +315,8 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
// ─── Regular Package Card ─────────────────────────────────────────────────────
function PackageCard({ pkg }: { pkg: HolidayPackage }) {
const price = minPrice(pkg.priceTiers);
const multiplier = pkg.journeyType === 'ROUND_TRIP' ? 2 : 1;
const price = minPrice(pkg.priceTiers, multiplier);
const origin = pkg.outboundSchedule?.originStation;
const dest = pkg.outboundSchedule?.destinationStation;
@@ -485,7 +489,6 @@ export default function PackagesSection() {
queryKey: ["packages"],
queryFn: async () =>
(await apiClient.get<HolidayPackage[]>("/packages")) as HolidayPackage[],
staleTime: 5 * 60 * 1000,
});
if (isError) return null;

View File

@@ -29,14 +29,17 @@ export interface PassengerDetail {
isPrimaryPassenger: boolean;
seatId?: string;
seatNumber?: string;
coachNumber?: string;
phone?: string;
email?: string;
gender?: string;
// Round-trip specific seat assignments
outboundSeatId?: string;
outboundSeatNumber?: string;
outboundCoachNumber?: string;
inboundSeatId?: string;
inboundSeatNumber?: string;
inboundCoachNumber?: string;
returnSeatId?: string;
returnSeatNumber?: string;
}
@@ -46,6 +49,8 @@ export interface SelectedSchedule {
trainNumber: string;
origin: string;
destination: string;
originStationId?: string;
destinationStationId?: string;
departureTime: string;
arrivalTime: string;
duration: string;
@@ -79,6 +84,10 @@ interface BookingState {
selectedPaymentMethod: string | null;
createAccount: boolean;
passengerId: string | null;
packageId: string | null;
packageName: string | null;
priceTierId: string | null;
packageTierPriceMinor: number | null;
setSearchCriteria: (criteria: SearchCriteria) => void;
setSelectedSchedule: (schedule: SelectedSchedule) => void;
@@ -91,6 +100,7 @@ interface BookingState {
setPaymentMethod: (method: string) => void;
setCreateAccount: (create: boolean) => void;
setPassengerId: (id: string | null) => void;
setPackageContext: (packageId: string, priceTierId: string, priceMinor: number, packageName?: string) => void;
clearBooking: () => void;
}
@@ -107,8 +117,13 @@ export const useBookingStore = create<BookingState>()(persist(
selectedPaymentMethod: null,
createAccount: false,
passengerId: null,
packageId: null,
packageName: null,
priceTierId: null,
packageTierPriceMinor: null,
setSearchCriteria: (criteria) => set({ searchCriteria: criteria }),
setPackageContext: (packageId, priceTierId, priceMinor, packageName) => set({ packageId, packageName: packageName ?? null, priceTierId, packageTierPriceMinor: priceMinor }),
setSelectedSchedule: (schedule) => set({ selectedSchedule: schedule }),
setOutboundSchedule: (schedule) => set({ outboundSchedule: schedule }),
setInboundSchedule: (schedule) => set({ inboundSchedule: schedule }),
@@ -131,6 +146,10 @@ export const useBookingStore = create<BookingState>()(persist(
selectedPaymentMethod: null,
createAccount: false,
passengerId: null,
packageId: null,
packageName: null,
priceTierId: null,
packageTierPriceMinor: null,
}),
} as BookingState)),
{

View File

@@ -41,6 +41,7 @@ export interface Schedule {
faresByClass?: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency?: string; displayAmountMinor?: number }>; // API returns this
coachTypes?: Array<{
coachId: string;
coachTypeId: string;
coachTypeName: string;
coachTypeCode: string;
classes: Array<{

View File

@@ -33,15 +33,15 @@ export function isChild(passenger: PassengerWithAge): boolean {
}
/**
* Check if this child gets the free fare (first child in the list)
* Check if this child gets a free fare (1 free child per adult)
*/
export function isFirstChild(passengers: PassengerWithAge[], currentIndex: number): boolean {
const currentPassenger = passengers[currentIndex];
if (!isChild(currentPassenger)) return false;
// Count children before this passenger
const adultCount = passengers.filter(p => !isChild(p)).length;
const childrenBefore = passengers.slice(0, currentIndex).filter(p => isChild(p));
return childrenBefore.length === 0;
return childrenBefore.length < adultCount;
}
/**
@@ -93,8 +93,8 @@ export function formatFare(amountMinor: number, currency: string = 'ETB'): strin
export function getPricingSummary(passengers: PassengerWithAge[], baseFare: number) {
const adults = passengers.filter(p => !isChild(p));
const children = passengers.filter(p => isChild(p));
const freeChildren = Math.min(children.length, 1);
const paidChildren = Math.max(0, children.length - 1);
const freeChildren = Math.min(children.length, adults.length);
const paidChildren = Math.max(0, children.length - adults.length);
return {
adultCount: adults.length,