mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Tour package booking, app release, new endpoints, more updates and fixes
This commit is contained in:
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "FraudAlert" ADD COLUMN "acknowledgedAt" TIMESTAMP(3);
|
||||
@@ -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");
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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`,
|
||||
``,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
186
apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx
Normal file
186
apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (() => {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function PackageBookingsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`),
|
||||
};
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface RevenueData {
|
||||
|
||||
export interface BookingFilters {
|
||||
status?: string;
|
||||
bookingType?: string;
|
||||
paymentStatus?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
search?: string;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
5
apps/edr-passenger-web/portal/src/app/packages/page.tsx
Normal file
5
apps/edr-passenger-web/portal/src/app/packages/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import PackagesSection from "@/components/PackagesSection";
|
||||
|
||||
export default function PackagesPage() {
|
||||
return <PackagesSection />;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
{
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user