Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Abubeker Yasin
2026-07-07 10:23:01 +03:00
43 changed files with 1321 additions and 679 deletions

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "PackageBooking" ADD COLUMN "adultCount" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "childCount" INTEGER NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "PackagePriceTier" ADD COLUMN "seatClassId" TEXT;
-- AddForeignKey
ALTER TABLE "PackagePriceTier" ADD CONSTRAINT "PackagePriceTier_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "TrainSchedule" ADD COLUMN "isPackageOnly" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -100,6 +100,7 @@ model SeatClass {
fareRules FareRule[] fareRules FareRule[]
routeFareRules RouteFareRule[] routeFareRules RouteFareRule[]
segmentFares SegmentFareRule[] segmentFares SegmentFareRule[]
packagePriceTiers PackagePriceTier[]
@@unique([coachTypeId, name]) @@unique([coachTypeId, name])
@@index([coachTypeId]) @@index([coachTypeId])
@@index([coachTypeId, nationalityType, bedPosition]) @@index([coachTypeId, nationalityType, bedPosition])
@@ -367,6 +368,7 @@ model TrainSchedule {
onTimePercent Int @default(100) onTimePercent Int @default(100)
carbonRating String @default("A") carbonRating String @default("A")
notes String? notes String?
isPackageOnly Boolean @default(false)
train Train @relation(fields: [trainId], references: [id]) train Train @relation(fields: [trainId], references: [id])
route Route? @relation(fields: [routeId], references: [id]) route Route? @relation(fields: [routeId], references: [id])
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id]) originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
@@ -1448,6 +1450,7 @@ model TravelPackage {
model PackagePriceTier { model PackagePriceTier {
id String @id @default(uuid()) id String @id @default(uuid())
packageId String packageId String
seatClassId String?
seatType String seatType String
label String label String
priceMinor Int priceMinor Int
@@ -1456,6 +1459,7 @@ model PackagePriceTier {
bookedSeats Int @default(0) bookedSeats Int @default(0)
package TravelPackage @relation(fields: [packageId], references: [id]) package TravelPackage @relation(fields: [packageId], references: [id])
seatClass SeatClass? @relation(fields: [seatClassId], references: [id])
bookings Booking[] bookings Booking[]
packageBookings PackageBooking[] packageBookings PackageBooking[]
inquiries PackageInquiry[] inquiries PackageInquiry[]
@@ -1474,6 +1478,8 @@ model PackageBooking {
contactPhone String? contactPhone String?
status BookingStatus @default(PENDING_PAYMENT) status BookingStatus @default(PENDING_PAYMENT)
passengerCount Int @default(1) passengerCount Int @default(1)
adultCount Int @default(1)
childCount Int @default(0)
totalMinor Int totalMinor Int
currency String @default("ETB") currency String @default("ETB")
displayCurrency Currency? displayCurrency Currency?

View File

@@ -8,7 +8,6 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
import { IamModule as TriaIamModule } from '@tria-plc/iamapi-common/iam.module'; import { IamModule as TriaIamModule } from '@tria-plc/iamapi-common/iam.module';
import { DataSeeder } from '@tria-plc/iamapi-common/db/seed/seeder'; import { DataSeeder } from '@tria-plc/iamapi-common/db/seed/seeder';
import { EOtpType } from '@tria-plc/iamapi-common';
import { SharedAuthModule } from '@tria-plc/api-common/modules/auth/shared-auth.module'; import { SharedAuthModule } from '@tria-plc/api-common/modules/auth/shared-auth.module';
import { import {
EDR_PASSENGER_APPLICATION, EDR_PASSENGER_APPLICATION,
@@ -98,16 +97,6 @@ import { SegmentFareSeeder } from './seed/segment-fare.seeder';
TriaIamModule.forRoot({ TriaIamModule.forRoot({
applications: [EDR_PASSENGER_APPLICATION], applications: [EDR_PASSENGER_APPLICATION],
permissions: EDR_PASSENGER_PERMISSIONS, permissions: EDR_PASSENGER_PERMISSIONS,
otpMessages: {
[EOtpType.MFA_LOGIN]: ({ otp }) =>
`Your EDR Passenger login code is ${otp}. It will expire in 5 minutes.`,
[EOtpType.VERIFY_PHONE_NUMBER]: ({ otp }) =>
`Your EDR Passenger phone verification code is ${otp}. It will expire in 5 minutes.`,
[EOtpType.RESET_PASSWORD]: ({ route }) =>
`Reset your EDR Passenger password using this link: ${route}`,
[EOtpType.SET_PASSWORD]: ({ route }) =>
`Set your EDR Passenger password using this link: ${route}`,
},
}), }),
SharedAuthModule, SharedAuthModule,
PrismaModule, PrismaModule,

View File

@@ -470,10 +470,11 @@ export class BookingsController {
@ApiOperation({ @ApiOperation({
description: 'Permanently deletes a booking record' description: 'Permanently deletes a booking record'
}) })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
@ApiResponse({ status: 200, description: 'Booking deleted successfully' }) @ApiResponse({ status: 200, description: 'Booking deleted successfully' })
@ApiResponse({ status: 404, description: 'Booking not found' }) @ApiResponse({ status: 404, description: 'Booking not found' })
delete(@Param('id') id: string) { delete(@Param('id') id: string, @Query('cascade') cascade?: string) {
return this.service.delete(id); return this.service.delete(id, cascade === 'true');
} }
@Patch(':id') @Patch(':id')

View File

@@ -31,9 +31,10 @@ function resolvePackageRoundTripTotal(
if (!booking.packageId || booking.bookingType !== 'ROUND_TRIP' || !tierPriceMinor) { if (!booking.packageId || booking.bookingType !== 'ROUND_TRIP' || !tierPriceMinor) {
return booking.totalMinor; return booking.totalMinor;
} }
// First child per adult is free; additional children pay full adult fare
const adultFareMinor = tierPriceMinor * 2; const adultFareMinor = tierPriceMinor * 2;
const childFareMinor = Math.round(adultFareMinor * 0.1); const paidChildren = Math.max(0, childCount - adultCount);
return adultCount * adultFareMinor + childCount * childFareMinor; return adultCount * adultFareMinor + paidChildren * adultFareMinor;
} }
function calculateAge(dateOfBirth: Date): number { function calculateAge(dateOfBirth: Date): number {
@@ -557,7 +558,10 @@ export class BookingsService {
if (p.category === PassengerCategory.ADULT) { if (p.category === PassengerCategory.ADULT) {
fareMinor = fareCalculation.baseFareMinor; fareMinor = fareCalculation.baseFareMinor;
} else if (dto.packageId) { } else if (dto.packageId) {
fareMinor = Math.round(fareCalculation.baseFareMinor * 0.1); // Free children (first per adult) get fareMinor=0; paid children pay full adult fare.
// passengersWithFares is built in adult-first order so we track paid children by count.
const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length;
fareMinor = childIdx < adultCount ? 0 : fareCalculation.baseFareMinor;
} else { } else {
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; } if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
else fareMinor = fareCalculation.baseFareMinor; else fareMinor = fareCalculation.baseFareMinor;
@@ -704,8 +708,11 @@ export class BookingsService {
outboundFareMinor = outboundFare.baseFareMinor; outboundFareMinor = outboundFare.baseFareMinor;
returnFareMinor = returnFare.baseFareMinor; returnFareMinor = returnFare.baseFareMinor;
} else if (dto.packageId) { } else if (dto.packageId) {
outboundFareMinor = Math.round(outboundFare.baseFareMinor * 0.1); // Free children (first per adult) get fareMinor=0; paid children pay full adult fare.
returnFareMinor = Math.round(returnFare.baseFareMinor * 0.1); const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length;
const isFreeChild = childIdx < adultCount;
outboundFareMinor = isFreeChild ? 0 : outboundFare.baseFareMinor;
returnFareMinor = isFreeChild ? 0 : returnFare.baseFareMinor;
} else { } else {
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; } if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
else outboundFareMinor = outboundFare.baseFareMinor; else outboundFareMinor = outboundFare.baseFareMinor;
@@ -1255,19 +1262,19 @@ export class BookingsService {
childCount: number, childCount: number,
) { ) {
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } }); const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } });
// For round-trip packages the caller splits the tier price across legs, so // First child per adult travels free (no seat); additional children pay full adult fare.
// priceMinor here is already the per-leg amount. Children pay 10% of adult fare. const freeChildrenCount = Math.min(childCount, adultCount);
const childFareMinor = Math.round(tier.priceMinor * 0.1); const paidChildrenCount = Math.max(0, childCount - adultCount);
const adultFareMinor = tier.priceMinor * adultCount; const adultFareMinor = tier.priceMinor * adultCount;
const childTotalMinor = childFareMinor * childCount; const childTotalMinor = tier.priceMinor * paidChildrenCount;
const totalBaseFareMinor = adultFareMinor + childTotalMinor; const totalBaseFareMinor = adultFareMinor + childTotalMinor;
return { return {
baseFareMinor: tier.priceMinor, baseFareMinor: tier.priceMinor,
adultCount, adultCount,
adultFareMinor, adultFareMinor,
childCount, childCount,
freeChildrenCount: 0, freeChildrenCount,
paidChildrenCount: childCount, paidChildrenCount,
childFareMinor: childTotalMinor, childFareMinor: childTotalMinor,
totalBaseFareMinor, totalBaseFareMinor,
discountMinor: 0, discountMinor: 0,
@@ -1555,18 +1562,48 @@ export class BookingsService {
}); });
} }
async delete(id: string) { async delete(id: string, cascade = false) {
const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } }); const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } });
if (!booking) throw new NotFoundException('Booking not found'); if (!booking) throw new NotFoundException('Booking not found');
// Check usage before allowing deletion if (!cascade) {
const usage = await this.checkBookingUsage(id); const usage = await this.checkBookingUsage(id);
if (usage.isInUse && usage.constraints) { if (usage.isInUse && usage.constraints) {
throw new DeleteOperationException('Booking', booking.bookingRef, usage.constraints); throw new DeleteOperationException('Booking', booking.bookingRef, usage.constraints);
}
} }
await this.seatsService.releaseSeats(booking.id); await this.seatsService.releaseSeats(booking.id);
if (cascade) {
// Delete all child records that reference this booking (no onDelete: Cascade in schema)
const paymentIntent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: id } });
if (paymentIntent) {
await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: paymentIntent.id } });
await this.prisma.paymentIntent.delete({ where: { bookingId: id } });
}
const tickets = await this.prisma.ticket.findMany({ where: { bookingId: id }, select: { id: true } });
for (const t of tickets) {
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: t.id } });
}
await this.prisma.ticket.deleteMany({ where: { bookingId: id } });
await this.prisma.bookingModification.deleteMany({ where: { bookingId: id } });
await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: id } });
await this.prisma.agentBooking.deleteMany({ where: { bookingId: id } });
const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: id }, select: { id: true } });
for (const fo of foodOrders) {
await this.prisma.foodOrderItem.deleteMany({ where: { orderId: fo.id } });
}
await this.prisma.foodOrder.deleteMany({ where: { bookingId: id } });
await this.prisma.baggageBooking.deleteMany({ where: { bookingId: id } });
await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: id } });
const journey = await this.prisma.journey.findUnique({ where: { bookingId: id } });
if (journey) {
await this.prisma.journeySegment.deleteMany({ where: { journeyId: journey.id } });
await this.prisma.journey.delete({ where: { bookingId: id } });
}
}
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } }); await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } });
await this.prisma.booking.delete({ where: { id } }); await this.prisma.booking.delete({ where: { id } });

View File

@@ -32,10 +32,6 @@ export class CurrenciesService {
async createCurrency(dto: CreateCurrencyDto) { async createCurrency(dto: CreateCurrencyDto) {
const { code, name, symbol, baseCurrencyCode = 'ETB', exchangeRate } = dto; const { code, name, symbol, baseCurrencyCode = 'ETB', exchangeRate } = dto;
if (!['ETB', 'USD', 'DJF'].includes(code.toUpperCase())) {
throw new BadRequestException('Unsupported currency code');
}
if (exchangeRate <= 0) { if (exchangeRate <= 0) {
throw new BadRequestException('Exchange rate must be positive'); throw new BadRequestException('Exchange rate must be positive');
} }

View File

@@ -76,10 +76,11 @@ export class FleetController {
@Delete('classes/:id') @Delete('classes/:id')
@ApiOperation({ summary: 'Delete a class' }) @ApiOperation({ summary: 'Delete a class' })
@ApiParam({ name: 'id', description: 'Class UUID' }) @ApiParam({ name: 'id', description: 'Class UUID' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
@ApiResponse({ status: 200, description: 'Class deleted' }) @ApiResponse({ status: 200, description: 'Class deleted' })
@ApiResponse({ status: 404, description: 'Class not found' }) @ApiResponse({ status: 404, description: 'Class not found' })
deleteClass(@Param('id') id: string) { deleteClass(@Param('id') id: string, @Query('cascade') cascade?: string) {
return this.service.deleteClass(id); return this.service.deleteClass(id, cascade === 'true');
} }
// Seat Class Endpoints (DEPRECATED - use Classes endpoints instead) // Seat Class Endpoints (DEPRECATED - use Classes endpoints instead)
@@ -112,10 +113,11 @@ export class FleetController {
@Delete('seat-classes/:id') @Delete('seat-classes/:id')
@ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' }) @ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' })
@ApiParam({ name: 'id', description: 'Class UUID' }) @ApiParam({ name: 'id', description: 'Class UUID' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
@ApiResponse({ status: 200, description: 'Class deleted' }) @ApiResponse({ status: 200, description: 'Class deleted' })
@ApiResponse({ status: 404, description: 'Class not found' }) @ApiResponse({ status: 404, description: 'Class not found' })
deleteSeatClass(@Param('id') id: string) { deleteSeatClass(@Param('id') id: string, @Query('cascade') cascade?: string) {
return this.service.deleteClass(id); return this.service.deleteClass(id, cascade === 'true');
} }
// Train Endpoints // Train Endpoints
@@ -147,10 +149,11 @@ export class FleetController {
@Delete('trains/:id') @Delete('trains/:id')
@ApiOperation({ summary: 'Delete a train service' }) @ApiOperation({ summary: 'Delete a train service' })
@ApiParam({ name: 'id', description: 'Train UUID' }) @ApiParam({ name: 'id', description: 'Train UUID' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
@ApiResponse({ status: 200, description: 'Train deleted' }) @ApiResponse({ status: 200, description: 'Train deleted' })
@ApiResponse({ status: 404, description: 'Train not found' }) @ApiResponse({ status: 404, description: 'Train not found' })
deleteTrain(@Param('id') id: string) { deleteTrain(@Param('id') id: string, @Query('cascade') cascade?: string) {
return this.service.deleteTrain(id); return this.service.deleteTrain(id, cascade === 'true');
} }
@Patch('trains/:id/restore') @Patch('trains/:id/restore')
@@ -309,10 +312,11 @@ export class FleetController {
@Delete('coaches/:id') @Delete('coaches/:id')
@ApiOperation({ summary: 'Delete a coach' }) @ApiOperation({ summary: 'Delete a coach' })
@ApiParam({ name: 'id', description: 'Coach UUID' }) @ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
@ApiResponse({ status: 200, description: 'Coach deleted successfully' }) @ApiResponse({ status: 200, description: 'Coach deleted successfully' })
@ApiResponse({ status: 404, description: 'Coach not found' }) @ApiResponse({ status: 404, description: 'Coach not found' })
deleteCoach(@Param('id') id: string) { deleteCoach(@Param('id') id: string, @Query('cascade') cascade?: string) {
return this.service.deleteCoach(id); return this.service.deleteCoach(id, cascade === 'true');
} }
@Post('assignments') @Post('assignments')

View File

@@ -273,7 +273,7 @@ export class FleetService {
}); });
} }
async deleteClass(id: string) { async deleteClass(id: string, cascade = false) {
const seatClass = await this.prisma.seatClass.findUnique({ const seatClass = await this.prisma.seatClass.findUnique({
where: { id }, where: { id },
include: { include: {
@@ -284,19 +284,27 @@ export class FleetService {
}); });
if (!seatClass) throw new NotFoundException('Seat class not found'); if (!seatClass) throw new NotFoundException('Seat class not found');
const totalFareRules = seatClass.fareRules.length + seatClass.routeFareRules.length + seatClass.segmentFares.length; if (!cascade) {
const constraints = []; const totalFareRules = seatClass.fareRules.length + seatClass.routeFareRules.length + seatClass.segmentFares.length;
const constraints = [];
if (totalFareRules > 0) { if (totalFareRules > 0) {
constraints.push({ constraints.push({
entityName: 'fare rule', entityName: 'fare rule',
count: totalFareRules, count: totalFareRules,
action: 'delete' as const action: 'delete' as const
}); });
}
if (constraints.length > 0) {
throw new DeleteOperationException('Seat Class', seatClass.name, constraints);
}
} }
if (constraints.length > 0) { if (cascade) {
throw new DeleteOperationException('Seat Class', seatClass.name, constraints); await this.prisma.fareRule.deleteMany({ where: { seatClassId: id } });
await this.prisma.routeFareRule.deleteMany({ where: { seatClassId: id } });
await this.prisma.segmentFareRule.deleteMany({ where: { seatClassId: id } });
} }
return this.prisma.seatClass.delete({ where: { id } }); return this.prisma.seatClass.delete({ where: { id } });
@@ -354,24 +362,78 @@ export class FleetService {
}); });
} }
async deleteTrain(id: string) { async deleteTrain(id: string, cascade = false) {
const train = await this.prisma.train.findUnique({ const train = await this.prisma.train.findUnique({
where: { id }, where: { id },
include: { schedules: true }, include: { schedules: true },
}); });
if (!train) throw new NotFoundException('Train not found'); if (!train) throw new NotFoundException('Train not found');
const constraints = []; if (!cascade) {
if (train.schedules.length > 0) { const constraints = [];
constraints.push({ if (train.schedules.length > 0) {
entityName: 'schedule', constraints.push({
count: train.schedules.length, entityName: 'schedule',
action: 'delete' as const count: train.schedules.length,
}); action: 'delete' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Train', `${train.number} (${train.name})`, constraints);
}
} }
if (constraints.length > 0) { if (cascade && train.schedules.length > 0) {
throw new DeleteOperationException('Train', `${train.number} (${train.name})`, constraints); const scheduleIds = train.schedules.map((s: any) => s.id);
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await this.prisma.tripLiveStatus.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await this.prisma.menuItem.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
const bookings = await this.prisma.booking.findMany({
where: { OR: [{ scheduleId: { in: scheduleIds } }, { returnScheduleId: { in: scheduleIds } }] },
select: { id: true },
});
if (bookings.length > 0) {
const bookingIds = bookings.map(b => b.id);
const tickets = await this.prisma.ticket.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (tickets.length > 0) {
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: { in: tickets.map(t => t.id) } } });
}
await this.prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } });
const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (foodOrders.length > 0) {
await this.prisma.foodOrderItem.deleteMany({ where: { orderId: { in: foodOrders.map(o => o.id) } } });
}
await this.prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } });
const paymentIntents = await this.prisma.paymentIntent.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (paymentIntents.length > 0) {
await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: { in: paymentIntents.map(p => p.id) } } });
}
await this.prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.journey.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.booking.deleteMany({ where: { id: { in: bookingIds } } });
}
const packages = await this.prisma.travelPackage.findMany({
where: { OR: [{ outboundScheduleId: { in: scheduleIds } }, { returnScheduleId: { in: scheduleIds } }] },
select: { id: true },
});
if (packages.length > 0) {
const packageIds = packages.map(p => p.id);
await this.prisma.packagePriceTier.deleteMany({ where: { packageId: { in: packageIds } } });
await this.prisma.travelPackage.deleteMany({ where: { id: { in: packageIds } } });
}
await this.prisma.trainSchedule.deleteMany({ where: { id: { in: scheduleIds } } });
} }
return this.prisma.train.delete({ where: { id } }); return this.prisma.train.delete({ where: { id } });
@@ -477,7 +539,7 @@ export class FleetService {
}); });
} }
async deleteCoach(id: string) { async deleteCoach(id: string, cascade = false) {
const coach = await this.prisma.coach.findUnique({ const coach = await this.prisma.coach.findUnique({
where: { id }, where: { id },
include: { include: {
@@ -493,45 +555,58 @@ export class FleetService {
}); });
if (!coach) throw new NotFoundException('Coach not found'); if (!coach) throw new NotFoundException('Coach not found');
const constraints = []; if (!cascade) {
const constraints = [];
if ((coach as any).assignments.length > 0) { if ((coach as any).assignments.length > 0) {
constraints.push({ constraints.push({
entityName: 'schedule assignment', entityName: 'schedule assignment',
count: (coach as any).assignments.length, count: (coach as any).assignments.length,
action: 'reassign' as const action: 'reassign' as const
}); });
}
const bookedSeats = (coach as any).seats.filter((seat: any) => seat.bookingSeats.length > 0);
if (bookedSeats.length > 0) {
constraints.push({
entityName: 'booked seat',
count: bookedSeats.length,
action: 'complete' as const
});
}
const blockedSeats = (coach as any).seats.filter((seat: any) => seat.blocks.length > 0);
if (blockedSeats.length > 0) {
constraints.push({
entityName: 'blocked seat',
count: blockedSeats.length,
action: 'delete' as const
});
}
const seatsWithTickets = (coach as any).seats.filter((seat: any) => seat.tickets.length > 0);
if (seatsWithTickets.length > 0) {
constraints.push({
entityName: 'seat with issued ticket',
count: seatsWithTickets.length,
action: 'complete' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Coach', coach.number, constraints);
}
} }
const bookedSeats = (coach as any).seats.filter((seat: any) => seat.bookingSeats.length > 0); if (cascade) {
if (bookedSeats.length > 0) { const seatIds = (coach as any).seats.map((s: any) => s.id);
constraints.push({ if (seatIds.length > 0) {
entityName: 'booked seat', await this.prisma.bookingSeat.deleteMany({ where: { seatId: { in: seatIds } } });
count: bookedSeats.length, await this.prisma.seatBlock.deleteMany({ where: { seatId: { in: seatIds } } });
action: 'complete' as const await this.prisma.ticket.deleteMany({ where: { seatId: { in: seatIds } } });
}); }
} await this.prisma.coachAssignment.deleteMany({ where: { coachId: id } });
await this.prisma.routeCoachTemplate.deleteMany({ where: { coachId: id } });
const blockedSeats = (coach as any).seats.filter((seat: any) => seat.blocks.length > 0);
if (blockedSeats.length > 0) {
constraints.push({
entityName: 'blocked seat',
count: blockedSeats.length,
action: 'delete' as const
});
}
const seatsWithTickets = (coach as any).seats.filter((seat: any) => seat.tickets.length > 0);
if (seatsWithTickets.length > 0) {
constraints.push({
entityName: 'seat with issued ticket',
count: seatsWithTickets.length,
action: 'complete' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Coach', coach.number, constraints);
} }
await this.prisma.seat.deleteMany({ where: { coachId: id } }); await this.prisma.seat.deleteMany({ where: { coachId: id } });

View File

@@ -142,8 +142,9 @@ export class PackagesController {
@UseGuards(IamGuard) @UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete package (admin)' }) @ApiOperation({ summary: 'Delete package (admin)' })
remove(@Param('id') id: string) { @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete even with active bookings' })
return this.service.remove(id); remove(@Param('id') id: string, @Query('cascade') cascade?: string) {
return this.service.remove(id, cascade === 'true');
} }
@Patch(':id/activate') @Patch(':id/activate')

View File

@@ -3,6 +3,9 @@ import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreatePriceTierDto { export class CreatePriceTierDto {
@ApiPropertyOptional({ description: 'SeatClass ID to link this tier to a specific seat class' })
@IsOptional() @IsUUID() seatClassId?: string;
@ApiProperty({ example: 'HSC' }) @ApiProperty({ example: 'HSC' })
@IsString() seatType: string; @IsString() seatType: string;
@@ -31,6 +34,7 @@ export class UpdateInquiryStatusDto {
} }
export class UpdatePriceTierDto { export class UpdatePriceTierDto {
@ApiPropertyOptional() @IsOptional() @IsUUID() seatClassId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() seatType?: string; @ApiPropertyOptional() @IsOptional() @IsString() seatType?: string;
@ApiPropertyOptional() @IsOptional() @IsString() label?: string; @ApiPropertyOptional() @IsOptional() @IsString() label?: string;
@ApiPropertyOptional() @IsOptional() @IsInt() @Min(0) priceMinor?: number; @ApiPropertyOptional() @IsOptional() @IsInt() @Min(0) priceMinor?: number;

View File

@@ -8,9 +8,12 @@ import { GuestBookingService } from '../bookings/guest-booking.service';
/** Package-specific fare rules */ /** Package-specific fare rules */
const PKG_MAX_ADULTS = 5; const PKG_MAX_ADULTS = 5;
const PKG_MAX_CHILDREN = 2; const PKG_CHILDREN_PER_ADULT = 5; // max 5 children per adult
const PKG_CHILD_FARE_RATIO = 0.1;
/**
* First child per adult travels FREE (no seat).
* Additional children beyond one per adult pay the full adult fare.
*/
function calculatePackageFareBreakdown( function calculatePackageFareBreakdown(
priceMinor: number, priceMinor: number,
isRoundTrip: boolean, isRoundTrip: boolean,
@@ -19,9 +22,10 @@ function calculatePackageFareBreakdown(
) { ) {
const multiplier = isRoundTrip ? 2 : 1; const multiplier = isRoundTrip ? 2 : 1;
const adultFareMinor = priceMinor * multiplier; const adultFareMinor = priceMinor * multiplier;
const childFareMinor = Math.round(adultFareMinor * PKG_CHILD_FARE_RATIO); const freeChildren = Math.min(childCount, adultCount);
const totalMinor = adultCount * adultFareMinor + childCount * childFareMinor; const paidChildren = Math.max(0, childCount - adultCount);
return { adultFareMinor, childFareMinor, totalMinor, multiplier }; const totalMinor = adultCount * adultFareMinor + paidChildren * adultFareMinor;
return { adultFareMinor, freeChildren, paidChildren, totalMinor, multiplier };
} }
function deriveAge(dateOfBirth: string | Date): number { function deriveAge(dateOfBirth: string | Date): number {
@@ -68,27 +72,33 @@ export class PackagesService {
if (adultCount < 1) throw new BadRequestException('At least one adult passenger required'); if (adultCount < 1) throw new BadRequestException('At least one adult passenger required');
if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`); if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`);
if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`); const maxChildren = adultCount * PKG_CHILDREN_PER_ADULT;
if (childCount > maxChildren) throw new BadRequestException(`Maximum ${PKG_CHILDREN_PER_ADULT} children per adult (${maxChildren} for ${adultCount} adult${adultCount !== 1 ? 's' : ''}) allowed per package booking`);
const passengerCount = adultCount + childCount;
const remaining = tier.availableSeats - tier.bookedSeats; const remaining = tier.availableSeats - tier.bookedSeats;
if (passengerCount > remaining)
throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`);
const isRoundTrip = !!pkg.returnScheduleId; const isRoundTrip = !!pkg.returnScheduleId;
const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown( const { adultFareMinor, freeChildren, paidChildren, totalMinor } = calculatePackageFareBreakdown(
tier.priceMinor, isRoundTrip, adultCount, childCount, tier.priceMinor, isRoundTrip, adultCount, childCount,
); );
// Only adults and paid children need seats; free children travel without a seat
const seatsNeeded = adultCount + paidChildren;
const passengerCount = adultCount + childCount;
if (seatsNeeded > remaining)
throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`);
// Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches // Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches
let seatClassId: string | null = null; let seatClassId: string | null = tier.seatClassId ?? null;
let seatClassName: string | null = null;
let coachTypeId: string | null = null; let coachTypeId: string | null = null;
for (const a of pkg.outboundSchedule.coachAssignments) { for (const a of pkg.outboundSchedule.coachAssignments) {
const sc = a.coach.coachType?.seatClasses?.find( const sc = seatClassId
(s: any) => s.name.toLowerCase().includes(tier.seatType.toLowerCase()) || ? a.coach.coachType?.seatClasses?.find((s: any) => s.id === seatClassId)
tier.seatType.toLowerCase().includes(s.name.toLowerCase()), : a.coach.coachType?.seatClasses?.find(
); (s: any) => s.name.toLowerCase().includes(tier.seatType.toLowerCase()) ||
if (sc) { seatClassId = sc.id; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; } tier.seatType.toLowerCase().includes(s.name.toLowerCase()),
);
if (sc) { seatClassId = sc.id; seatClassName = sc.name; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; }
} }
if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) { if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) {
const first = pkg.outboundSchedule.coachAssignments[0]; const first = pkg.outboundSchedule.coachAssignments[0];
@@ -102,16 +112,19 @@ export class PackagesService {
tierLabel: tier.label, tierLabel: tier.label,
seatType: tier.seatType, seatType: tier.seatType,
seatClassId, seatClassId,
seatClassName,
coachTypeId, coachTypeId,
adultCount, adultCount,
childCount, childCount,
passengerCount, passengerCount,
isRoundTrip, isRoundTrip,
pricePerAdultMinor: adultFareMinor, pricePerAdultMinor: adultFareMinor,
pricePerChildMinor: childFareMinor, pricePerChildMinor: adultFareMinor, // paid children pay full adult fare
childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`, freeChildrenCount: freeChildren,
paidChildrenCount: paidChildren,
childFareNote: `First child per adult travels free (no seat); additional children pay full adult fare`,
maxAdults: PKG_MAX_ADULTS, maxAdults: PKG_MAX_ADULTS,
maxChildren: PKG_MAX_CHILDREN, maxChildren: adultCount * PKG_CHILDREN_PER_ADULT,
totalMinor, totalMinor,
currency: tier.currency, currency: tier.currency,
remainingSeats: remaining, remainingSeats: remaining,
@@ -203,7 +216,7 @@ export class PackagesService {
const pkg = await this.prisma.travelPackage.findUnique({ const pkg = await this.prisma.travelPackage.findUnique({
where: { id }, where: { id },
include: { include: {
priceTiers: true, priceTiers: { include: { seatClass: { include: { coachType: true } } } },
outboundSchedule: { outboundSchedule: {
include: { include: {
originStation: true, originStation: true,
@@ -312,25 +325,31 @@ export class PackagesService {
return this.prisma.packagePriceTier.delete({ where: { id: tierId } }); return this.prisma.packagePriceTier.delete({ where: { id: tierId } });
} }
async remove(id: string) { async remove(id: string, cascade = false) {
const pkg = await this.prisma.travelPackage.findUnique({ const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
where: { id },
include: { bookings: { select: { id: true, status: true } } },
});
if (!pkg) throw new NotFoundException('Package not found'); if (!pkg) throw new NotFoundException('Package not found');
const hasActive = pkg.bookings.some((b) => b.status === 'PENDING_PAYMENT' || b.status === 'CONFIRMED');
if (hasActive) throw new BadRequestException('Cannot delete a package with active bookings');
await this.prisma.$transaction(async (tx) => { const packageBookings = await this.prisma.packageBooking.findMany({
const bookingIds = pkg.bookings.map((b) => b.id); where: { packageId: id },
if (bookingIds.length > 0) { select: { id: true, status: true },
await tx.packageBookingPassenger.deleteMany({ where: { bookingId: { in: bookingIds } } });
await tx.packagePaymentIntent.deleteMany({ where: { packageBookingId: { in: bookingIds } } });
await tx.packageBooking.deleteMany({ where: { packageId: id } });
}
await tx.packagePriceTier.deleteMany({ where: { packageId: id } });
await tx.travelPackage.delete({ where: { id } });
}); });
if (!cascade) {
const hasActive = packageBookings.some(b => b.status === 'PENDING_PAYMENT' || b.status === 'CONFIRMED');
if (hasActive) throw new BadRequestException('Cannot delete a package with active bookings. Use cascade=true to force delete.');
}
const pbIds = packageBookings.map(b => b.id);
if (pbIds.length > 0) {
await this.prisma.packageBookingPassenger.deleteMany({ where: { bookingId: { in: pbIds } } });
await this.prisma.packagePaymentIntent.deleteMany({ where: { packageBookingId: { in: pbIds } } });
await this.prisma.packageBooking.deleteMany({ where: { id: { in: pbIds } } });
}
// PackageInquiry references packageId and priceTierId
await this.prisma.packageInquiry.deleteMany({ where: { packageId: id } });
await this.prisma.packagePriceTier.deleteMany({ where: { packageId: id } });
await this.prisma.travelPackage.delete({ where: { id } });
return { deleted: true }; return { deleted: true };
} }
@@ -369,18 +388,21 @@ export class PackagesService {
if (adultCount < 1) throw new BadRequestException('At least one adult passenger required'); if (adultCount < 1) throw new BadRequestException('At least one adult passenger required');
if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`); if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`);
if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`); const maxChildrenBook = adultCount * PKG_CHILDREN_PER_ADULT;
if (childCount > maxChildrenBook) throw new BadRequestException(`Maximum ${PKG_CHILDREN_PER_ADULT} children per adult (${maxChildrenBook} for ${adultCount} adult${adultCount !== 1 ? 's' : ''}) allowed per package booking`);
const passengerCount = adultCount + childCount; const passengerCount = adultCount + childCount;
const remaining = tier.availableSeats - tier.bookedSeats; const remaining = tier.availableSeats - tier.bookedSeats;
if (passengerCount > remaining) {
throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`);
}
const isRoundTrip = !!pkg.returnScheduleId; const isRoundTrip = !!pkg.returnScheduleId;
const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown( const { adultFareMinor, freeChildren, paidChildren, totalMinor } = calculatePackageFareBreakdown(
tier.priceMinor, isRoundTrip, adultCount, childCount, tier.priceMinor, isRoundTrip, adultCount, childCount,
); );
// Only adults and paid children need seats; free children travel without a seat
const seatsNeeded = adultCount + paidChildren;
if (seatsNeeded > remaining) {
throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`);
}
const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB; const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB;
const displayTotalMinor = const displayTotalMinor =
displayCurrency !== Currency.ETB displayCurrency !== Currency.ETB
@@ -398,6 +420,8 @@ export class PackagesService {
contactPhone: dto.contactPhone, contactPhone: dto.contactPhone,
promoCode: dto.promoCode, promoCode: dto.promoCode,
passengerCount, passengerCount,
adultCount,
childCount,
totalMinor, totalMinor,
currency: 'ETB', currency: 'ETB',
displayCurrency, displayCurrency,
@@ -427,7 +451,7 @@ export class PackagesService {
}), }),
this.prisma.packagePriceTier.update({ this.prisma.packagePriceTier.update({
where: { id: dto.priceTierId }, where: { id: dto.priceTierId },
data: { bookedSeats: { increment: passengerCount } }, data: { bookedSeats: { increment: seatsNeeded } },
}), }),
]); ]);
@@ -438,8 +462,10 @@ export class PackagesService {
adultCount, adultCount,
adultFareMinor, adultFareMinor,
childCount, childCount,
childFareMinor, freeChildrenCount: freeChildren,
childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`, paidChildrenCount: paidChildren,
paidChildFareMinor: adultFareMinor,
childFareNote: `First child per adult travels free (no seat); additional children pay full adult fare`,
totalMinor, totalMinor,
currency: 'ETB', currency: 'ETB',
displayCurrency, displayCurrency,

View File

@@ -508,10 +508,11 @@ Returns saved passenger details with generated IDs and confirmation.`,
summary: 'Delete passenger (admin only)', summary: 'Delete passenger (admin only)',
description: 'Permanently deletes a passenger record and associated data' description: 'Permanently deletes a passenger record and associated data'
}) })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related bookings and data' })
@ApiResponse({ status: 200, description: 'Passenger deleted successfully' }) @ApiResponse({ status: 200, description: 'Passenger deleted successfully' })
@ApiResponse({ status: 404, description: 'Passenger not found' }) @ApiResponse({ status: 404, description: 'Passenger not found' })
deletePassenger(@Param('id') id: string) { deletePassenger(@Param('id') id: string, @Query('cascade') cascade?: string) {
return this.service.deletePassenger(id); return this.service.deletePassenger(id, cascade === 'true');
} }
@Get(':id/usage') @Get(':id/usage')

View File

@@ -432,7 +432,7 @@ export class PassengersService {
}; };
} }
async deletePassenger(id: string) { async deletePassenger(id: string, cascade = false) {
// id may be a TravelerProfile.id (from the list endpoint) or a Passenger.id // id may be a TravelerProfile.id (from the list endpoint) or a Passenger.id
let passenger = await this.prisma.passenger.findUnique({ let passenger = await this.prisma.passenger.findUnique({
where: { id }, where: { id },
@@ -451,29 +451,67 @@ export class PassengersService {
const passengerId = passenger.id; const passengerId = passenger.id;
// Check usage before allowing deletion if (!cascade) {
const usage = await this.checkPassengerUsage(passengerId); const usage = await this.checkPassengerUsage(passengerId);
if (usage.isInUse && usage.constraints) { if (usage.isInUse && usage.constraints) {
const passengerName = (passenger as any).user?.fullName || `Passenger ${passengerId.slice(-8)}`; const passengerName = (passenger as any).user?.fullName || `Passenger ${passengerId.slice(-8)}`;
throw new DeleteOperationException('Passenger', passengerName, usage.constraints); throw new DeleteOperationException('Passenger', passengerName, usage.constraints);
}
} }
await this.prisma.$transaction([ // Resolve booking IDs first (needed for multi-step child deletion)
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId } } }), const bookings = await this.prisma.booking.findMany({
this.prisma.loyaltyAccount.deleteMany({ where: { passengerId } }), where: { passengerId },
this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId } } }), select: { id: true },
this.prisma.walletAccount.deleteMany({ where: { passengerId } }), });
this.prisma.notification.deleteMany({ where: { passengerId } }), const bookingIds = bookings.map(b => b.id);
this.prisma.travelerProfile.deleteMany({ where: { passengerId } }),
this.prisma.savedRoute.deleteMany({ where: { passengerId } }), if (bookingIds.length > 0) {
this.prisma.packageBooking.deleteMany({ where: { passengerId } }), const tickets = await this.prisma.ticket.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
this.prisma.ticket.deleteMany({ where: { booking: { passengerId } } }), if (tickets.length > 0) {
this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId } } }), await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: { in: tickets.map(t => t.id) } } });
this.prisma.booking.deleteMany({ where: { passengerId } }), }
this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId } } }), await this.prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } });
this.prisma.journey.deleteMany({ where: { passengerId } }),
this.prisma.passenger.delete({ where: { id: passengerId } }), const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
]); if (foodOrders.length > 0) {
await this.prisma.foodOrderItem.deleteMany({ where: { orderId: { in: foodOrders.map(o => o.id) } } });
}
await this.prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } });
const paymentIntents = await this.prisma.paymentIntent.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (paymentIntents.length > 0) {
await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: { in: paymentIntents.map(p => p.id) } } });
}
await this.prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.journey.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.booking.deleteMany({ where: { id: { in: bookingIds } } });
}
// Package bookings
const packageBookings = await this.prisma.packageBooking.findMany({ where: { passengerId }, select: { id: true } });
if (packageBookings.length > 0) {
const pbIds = packageBookings.map(pb => pb.id);
await this.prisma.packageBookingPassenger.deleteMany({ where: { bookingId: { in: pbIds } } });
await this.prisma.packagePaymentIntent.deleteMany({ where: { packageBookingId: { in: pbIds } } });
await this.prisma.packageBooking.deleteMany({ where: { id: { in: pbIds } } });
}
await this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId } } });
await this.prisma.loyaltyAccount.deleteMany({ where: { passengerId } });
await this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId } } });
await this.prisma.walletAccount.deleteMany({ where: { passengerId } });
await this.prisma.notification.deleteMany({ where: { passengerId } });
await this.prisma.travelerProfile.deleteMany({ where: { passengerId } });
await this.prisma.savedRoute.deleteMany({ where: { passengerId } });
await this.prisma.passenger.delete({ where: { id: passengerId } });
return { deleted: true, passengerId }; return { deleted: true, passengerId };
} }

View File

@@ -51,9 +51,10 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete a route' }) @ApiOperation({ summary: 'Delete a route' })
@ApiParam({ name: 'id', description: 'Route UUID' }) @ApiParam({ name: 'id', description: 'Route UUID' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
@ApiResponse({ status: 200, description: 'Route deleted' }) @ApiResponse({ status: 200, description: 'Route deleted' })
@ApiResponse({ status: 404, description: 'Route not found' }) @ApiResponse({ status: 404, description: 'Route not found' })
deleteRoute(@Param('id') id: string) { return this.service.deleteRoute(id); } deleteRoute(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteRoute(id, cascade === 'true'); }
// ── Route Stops ──────────────────────────────────────────────────────────── // ── Route Stops ────────────────────────────────────────────────────────────

View File

@@ -110,7 +110,7 @@ export class RoutesService {
}); });
} }
async deleteRoute(id: string) { async deleteRoute(id: string, cascade = false) {
const route = await this.prisma.route.findUnique({ const route = await this.prisma.route.findUnique({
where: { id }, where: { id },
include: { include: {
@@ -120,17 +120,75 @@ export class RoutesService {
}); });
if (!route) throw new NotFoundException('Route not found'); if (!route) throw new NotFoundException('Route not found');
const constraints = []; if (!cascade) {
if (route.schedules.length > 0) { const constraints = [];
constraints.push({ if (route.schedules.length > 0) {
entityName: 'schedule', constraints.push({
count: route.schedules.length, entityName: 'schedule',
action: 'delete' as const count: route.schedules.length,
}); action: 'delete' as const
});
}
if (constraints.length > 0) {
throw new DeleteOperationException('Route', `${route.code} (${route.name})`, constraints);
}
} }
if (constraints.length > 0) { if (cascade) {
throw new DeleteOperationException('Route', `${route.code} (${route.name})`, constraints); const scheduleIds = route.schedules.map(s => s.id);
if (scheduleIds.length > 0) {
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await this.prisma.tripLiveStatus.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await this.prisma.menuItem.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
const bookings = await this.prisma.booking.findMany({
where: { OR: [{ scheduleId: { in: scheduleIds } }, { returnScheduleId: { in: scheduleIds } }] },
select: { id: true },
});
if (bookings.length > 0) {
const bookingIds = bookings.map(b => b.id);
const tickets = await this.prisma.ticket.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (tickets.length > 0) {
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: { in: tickets.map(t => t.id) } } });
}
await this.prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } });
const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (foodOrders.length > 0) {
await this.prisma.foodOrderItem.deleteMany({ where: { orderId: { in: foodOrders.map(o => o.id) } } });
}
await this.prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } });
const paymentIntents = await this.prisma.paymentIntent.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (paymentIntents.length > 0) {
await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: { in: paymentIntents.map(p => p.id) } } });
}
await this.prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.journey.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.booking.deleteMany({ where: { id: { in: bookingIds } } });
}
const packages = await this.prisma.travelPackage.findMany({
where: { OR: [{ outboundScheduleId: { in: scheduleIds } }, { returnScheduleId: { in: scheduleIds } }] },
select: { id: true },
});
if (packages.length > 0) {
const packageIds = packages.map(p => p.id);
await this.prisma.packagePriceTier.deleteMany({ where: { packageId: { in: packageIds } } });
await this.prisma.travelPackage.deleteMany({ where: { id: { in: packageIds } } });
}
await this.prisma.trainSchedule.deleteMany({ where: { id: { in: scheduleIds } } });
}
await this.prisma.routeStop.deleteMany({ where: { routeId: id } });
await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId: id } });
} }
await this.prisma.route.delete({ where: { id } }); await this.prisma.route.delete({ where: { id } });

View File

@@ -113,7 +113,8 @@ export class SchedulesController {
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete a schedule' }) @ApiOperation({ summary: 'Delete a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
deleteSchedule(@Param('id') id: string) { return this.service.deleteSchedule(id); } @ApiQuery({ name: 'cascade', required: false, type: Boolean })
deleteSchedule(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteSchedule(id, cascade === 'true'); }
@Get(':id/stops') @Get(':id/stops')
@IsPublic() @IsPublic()

View File

@@ -59,6 +59,7 @@ export class UpdateScheduleDto {
@ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string; @ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string;
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus; @ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
@ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>; @ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>;
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean;
} }
export class UpdateStopTimeDto { export class UpdateStopTimeDto {

View File

@@ -332,7 +332,7 @@ export class SchedulesService {
return this.prisma.trainSchedule.update({ where: { id }, data: { status: dto.status } }); return this.prisma.trainSchedule.update({ where: { id }, data: { status: dto.status } });
} }
async deleteSchedule(id: string) { async deleteSchedule(id: string, cascade = false) {
const schedule = await this.prisma.trainSchedule.findUnique({ const schedule = await this.prisma.trainSchedule.findUnique({
where: { id }, where: { id },
include: { include: {
@@ -344,18 +344,20 @@ export class SchedulesService {
}); });
if (!schedule) throw new NotFoundException('Schedule not found'); if (!schedule) throw new NotFoundException('Schedule not found');
const constraints = []; if (!cascade) {
if ((schedule as any)._count.bookings > 0) { const constraints = [];
constraints.push({ if ((schedule as any)._count.bookings > 0) {
entityName: 'booking', constraints.push({
count: (schedule as any)._count.bookings, entityName: 'booking',
action: 'cancel' as const count: (schedule as any)._count.bookings,
}); action: 'cancel' as const
} });
}
if (constraints.length > 0) { if (constraints.length > 0) {
const scheduleName = `${schedule.train.number} (${schedule.originStation.name}${schedule.destinationStation.name})`; const scheduleName = `${schedule.train.number} (${schedule.originStation.name}${schedule.destinationStation.name})`;
throw new DeleteOperationException('Schedule', scheduleName, constraints); throw new DeleteOperationException('Schedule', scheduleName, constraints);
}
} }
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } }); await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
@@ -364,29 +366,48 @@ export class SchedulesService {
await this.prisma.menuItem.deleteMany({ where: { scheduleId: id } }); await this.prisma.menuItem.deleteMany({ where: { scheduleId: id } });
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } }); await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
// Delete travel packages that reference this schedule (required fields cannot be nulled) // Delete bookings and all their children
// First get packages that reference this schedule const bookings = await this.prisma.booking.findMany({
const packagesToDelete = await this.prisma.travelPackage.findMany({ where: { OR: [{ scheduleId: id }, { returnScheduleId: id }] },
where: { select: { id: true },
OR: [
{ outboundScheduleId: id },
{ returnScheduleId: id }
]
},
select: { id: true }
}); });
if (bookings.length > 0) {
const bookingIds = bookings.map(b => b.id);
// Leaf tables first
const tickets = await this.prisma.ticket.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (tickets.length > 0) {
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: { in: tickets.map(t => t.id) } } });
}
await this.prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } });
const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (foodOrders.length > 0) {
await this.prisma.foodOrderItem.deleteMany({ where: { orderId: { in: foodOrders.map(o => o.id) } } });
}
await this.prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } });
const paymentIntents = await this.prisma.paymentIntent.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
if (paymentIntents.length > 0) {
await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: { in: paymentIntents.map(p => p.id) } } });
}
await this.prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.journey.deleteMany({ where: { bookingId: { in: bookingIds } } });
await this.prisma.booking.deleteMany({ where: { id: { in: bookingIds } } });
}
// Delete price tiers first (they have foreign key to packages) // Delete travel packages that reference this schedule
const packagesToDelete = await this.prisma.travelPackage.findMany({
where: { OR: [{ outboundScheduleId: id }, { returnScheduleId: id }] },
select: { id: true },
});
if (packagesToDelete.length > 0) { if (packagesToDelete.length > 0) {
const packageIds = packagesToDelete.map(p => p.id); const packageIds = packagesToDelete.map(p => p.id);
await this.prisma.packagePriceTier.deleteMany({ await this.prisma.packagePriceTier.deleteMany({ where: { packageId: { in: packageIds } } });
where: { packageId: { in: packageIds } } await this.prisma.travelPackage.deleteMany({ where: { id: { in: packageIds } } });
});
// Now delete the packages
await this.prisma.travelPackage.deleteMany({
where: { id: { in: packageIds } }
});
} }
return this.prisma.trainSchedule.delete({ where: { id } }); return this.prisma.trainSchedule.delete({ where: { id } });
} }
@@ -632,6 +653,7 @@ export class SchedulesService {
} }
if (dto.status) updateData.status = dto.status; if (dto.status) updateData.status = dto.status;
if (dto.isPackageOnly !== undefined) updateData.isPackageOnly = dto.isPackageOnly;
if (Object.keys(updateData).length > 0) { if (Object.keys(updateData).length > 0) {
await this.prisma.trainSchedule.update({ where: { id }, data: updateData }); await this.prisma.trainSchedule.update({ where: { id }, data: updateData });

View File

@@ -157,6 +157,7 @@ export class SearchService {
const schedules = await this.prisma.trainSchedule.findMany({ const schedules = await this.prisma.trainSchedule.findMany({
where: { where: {
status: { in: ['SCHEDULED', 'BOARDING'] }, status: { in: ['SCHEDULED', 'BOARDING'] },
isPackageOnly: false,
OR: [ OR: [
{ departureAt: { gte: windowStart, lt: requestedDate } }, { departureAt: { gte: windowStart, lt: requestedDate } },
{ departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } }, { departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } },
@@ -192,6 +193,7 @@ export class SearchService {
const schedules = await this.prisma.trainSchedule.findMany({ const schedules = await this.prisma.trainSchedule.findMany({
where: { where: {
status: { in: ['SCHEDULED', 'BOARDING'] }, status: { in: ['SCHEDULED', 'BOARDING'] },
isPackageOnly: false,
departureAt: { gte: date < now ? now : date, lt: nextDay }, departureAt: { gte: date < now ? now : date, lt: nextDay },
stopTimes: { some: { stationId: originStationId } }, stopTimes: { some: { stationId: originStationId } },
}, },
@@ -230,6 +232,7 @@ export class SearchService {
this.prisma.trainSchedule.findMany({ this.prisma.trainSchedule.findMany({
where: { where: {
status: { in: ['SCHEDULED', 'BOARDING'] }, status: { in: ['SCHEDULED', 'BOARDING'] },
isPackageOnly: false,
departureAt: { gte: dayStart, lt: dayEnd }, departureAt: { gte: dayStart, lt: dayEnd },
stopTimes: { some: { stationId: originStationId } }, stopTimes: { some: { stationId: originStationId } },
}, },
@@ -238,6 +241,7 @@ export class SearchService {
this.prisma.trainSchedule.findMany({ this.prisma.trainSchedule.findMany({
where: { where: {
status: { in: ['SCHEDULED', 'BOARDING'] }, status: { in: ['SCHEDULED', 'BOARDING'] },
isPackageOnly: false,
departureAt: { gte: dayStart, lt: leg2WindowEnd }, departureAt: { gte: dayStart, lt: leg2WindowEnd },
}, },
include: SCHEDULE_INCLUDE, include: SCHEDULE_INCLUDE,

View File

@@ -136,9 +136,10 @@ export class StationsController {
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete station' }) @ApiOperation({ summary: 'Delete station' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
@ApiResponse({ status: 200, description: 'Station deleted successfully' }) @ApiResponse({ status: 200, description: 'Station deleted successfully' })
@ApiResponse({ status: 404, description: 'Station not found' }) @ApiResponse({ status: 404, description: 'Station not found' })
remove(@Param('id') id: string) { remove(@Param('id') id: string, @Query('cascade') cascade?: string) {
return this.service.remove(id); return this.service.remove(id, cascade === 'true');
} }
} }

View File

@@ -96,7 +96,7 @@ export class StationsService {
return updatedStation; return updatedStation;
} }
async remove(id: string) { async remove(id: string, cascade = false) {
const station = await this.prisma.station.findUnique({ const station = await this.prisma.station.findUnique({
where: { id }, where: { id },
include: { include: {
@@ -107,24 +107,34 @@ export class StationsService {
}); });
if (!station) throw new NotFoundException('Station not found'); if (!station) throw new NotFoundException('Station not found');
const [routeStopCount, originCount, destCount, stopTimeCount] = await Promise.all([ if (!cascade) {
this.prisma.routeStop.count({ where: { stationId: id } }), const [routeStopCount, originCount, destCount, stopTimeCount] = await Promise.all([
this.prisma.trainSchedule.count({ where: { originStationId: id } }), this.prisma.routeStop.count({ where: { stationId: id } }),
this.prisma.trainSchedule.count({ where: { destinationStationId: id } }), this.prisma.trainSchedule.count({ where: { originStationId: id } }),
(station as any)._count.stopTimes as number, this.prisma.trainSchedule.count({ where: { destinationStationId: id } }),
]); (station as any)._count.stopTimes as number,
]);
const constraints = []; const constraints = [];
if (routeStopCount > 0) if (routeStopCount > 0)
constraints.push({ entityName: 'route', count: routeStopCount, action: 'delete' as const }); constraints.push({ entityName: 'route', count: routeStopCount, action: 'delete' as const });
const scheduleCount = originCount + destCount; const scheduleCount = originCount + destCount;
if (scheduleCount > 0) if (scheduleCount > 0)
constraints.push({ entityName: 'schedule', count: scheduleCount, action: 'delete' as const }); constraints.push({ entityName: 'schedule', count: scheduleCount, action: 'delete' as const });
if (stopTimeCount > 0) if (stopTimeCount > 0)
constraints.push({ entityName: 'stop time', count: stopTimeCount, action: 'delete' as const }); constraints.push({ entityName: 'stop time', count: stopTimeCount, action: 'delete' as const });
if (constraints.length > 0) if (constraints.length > 0)
throw new DeleteOperationException('Station', `${station.name} (${station.code})`, constraints); throw new DeleteOperationException('Station', `${station.name} (${station.code})`, constraints);
}
if (cascade) {
await this.prisma.tripStopTime.deleteMany({ where: { stationId: id } });
await this.prisma.trainSchedule.deleteMany({
where: { OR: [{ originStationId: id }, { destinationStationId: id }] },
});
await this.prisma.routeStop.deleteMany({ where: { stationId: id } });
}
const deleted = await this.prisma.station.delete({ where: { id } }); const deleted = await this.prisma.station.delete({ where: { id } });

View File

@@ -38,6 +38,8 @@ function BookingsPageContent() {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null); const [bookingToDelete, setBookingToDelete] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const [deleteCascade, setDeleteCascade] = useState(false);
const [deleteCascadeChecked, setDeleteCascadeChecked] = useState(false);
const [successMessage, setSuccessMessage] = useState(''); const [successMessage, setSuccessMessage] = useState('');
const [exportModalOpen, setExportModalOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
@@ -62,17 +64,27 @@ function BookingsPageContent() {
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/bookings/${id}`), mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/bookings/${id}${cascade ? '?cascade=true' : ''}`),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookings'] }); queryClient.invalidateQueries({ queryKey: ['bookings'] });
setDeleteConfirmOpen(false); setDeleteConfirmOpen(false);
setBookingToDelete(null); setBookingToDelete(null);
setDeleteError(null); setDeleteError(null);
setDeleteCascade(false);
setDeleteCascadeChecked(false);
setSuccessMessage('Booking deleted successfully'); setSuccessMessage('Booking deleted successfully');
setTimeout(() => setSuccessMessage(''), 3000); setTimeout(() => setSuccessMessage(''), 3000);
}, },
onError: (error: any) => { onError: (error: any) => {
setDeleteError(error?.response?.data?.message || error?.message || 'Failed to delete booking'); const msg = error?.response?.data?.message || error?.message || 'Failed to delete booking';
const isFkError = msg?.includes('Cannot delete') || error?.response?.status === 400;
if (isFkError && !deleteCascade) {
setDeleteCascade(true);
setDeleteCascadeChecked(false);
setDeleteError(Array.isArray(msg) ? msg.join(' ') : msg);
} else {
setDeleteError(Array.isArray(msg) ? msg.join(' ') : msg);
}
}, },
}); });
@@ -246,7 +258,7 @@ function BookingsPageContent() {
const actions = [ const actions = [
{ label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye }, { label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye },
{ label: 'Delete', onClick: (b: any) => { setDeleteError(null); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, { label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 },
]; ];
return ( return (
@@ -493,12 +505,16 @@ function BookingsPageContent() {
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirmOpen} isOpen={deleteConfirmOpen}
onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); setDeleteError(null); }} onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); }}
onConfirm={async () => { if (bookingToDelete) await deleteMutation.mutateAsync(bookingToDelete.id); }} onConfirm={async () => { if (bookingToDelete) await deleteMutation.mutateAsync({ id: bookingToDelete.id, cascade: deleteCascade && deleteCascadeChecked }); }}
title="Delete Booking" title="Delete Booking"
message={`Permanently delete booking ${bookingToDelete?.bookingRef}? This cannot be undone and will release all associated seats.`} message={`Permanently delete booking ${bookingToDelete?.bookingRef}? This cannot be undone and will release all associated seats.`}
confirmText="Delete" cancelText="Cancel" isLoading={deleteMutation.isPending} isDanger confirmText="Delete" cancelText="Cancel" isLoading={deleteMutation.isPending} isDanger
error={deleteError ?? undefined} error={deleteError ?? undefined}
warning={!deleteCascade ? undefined : undefined}
cascadeWarning={deleteCascade ? "This booking has related tickets, payments, or modification records that will also be permanently deleted." : undefined}
cascadeChecked={deleteCascadeChecked}
onCascadeChange={(checked) => setDeleteCascadeChecked(checked)}
/> />
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md"> <Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md">

View File

@@ -15,7 +15,7 @@ export default function ClassesPage() {
const [filters, setFilters] = useState({ search: '' }); const [filters, setFilters] = useState({ search: '' });
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingClass, setEditingClass] = useState<any>(null); const [editingClass, setEditingClass] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null; error?: string }>({ isOpen: false, class: null }); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, class: null });
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>(''); const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -56,13 +56,18 @@ export default function ClassesPage() {
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: seatClassesApi.delete, mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => seatClassesApi.delete(id, cascade),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['classes'] }); queryClient.invalidateQueries({ queryKey: ['classes'] });
}, },
onError: (e: any) => { onError: (e: any) => {
const msg = e?.response?.data?.message || e?.message || 'Failed to delete class'; const msg = e?.response?.data?.message || e?.message || 'Failed to delete class';
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); const isFkError = msg?.includes('Cannot delete') || e?.response?.status === 400;
if (isFkError && !deleteConfirm.cascade) {
setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg }));
} else {
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg }));
}
}, },
}); });
@@ -99,7 +104,7 @@ export default function ClassesPage() {
const confirmDelete = async () => { const confirmDelete = async () => {
if (!deleteConfirm.class) return; if (!deleteConfirm.class) return;
try { try {
await deleteMutation.mutateAsync(deleteConfirm.class.id); await deleteMutation.mutateAsync({ id: deleteConfirm.class.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked });
setDeleteConfirm({ isOpen: false, class: null }); setDeleteConfirm({ isOpen: false, class: null });
} catch { } catch {
// error is set by onError handler // error is set by onError handler
@@ -239,7 +244,10 @@ export default function ClassesPage() {
isDanger={true} isDanger={true}
isLoading={deleteMutation.isPending} isLoading={deleteMutation.isPending}
error={deleteConfirm.error} error={deleteConfirm.error}
warning="This class may be used by coaches and fare rules. Deleting it may impact seat assignments and pricing." warning={!deleteConfirm.cascade ? "This class may be used by coaches and fare rules. Deleting it may impact seat assignments and pricing." : undefined}
cascadeWarning={deleteConfirm.cascade ? "This class has related fare rules that will also be permanently deleted." : undefined}
cascadeChecked={deleteConfirm.cascadeChecked}
onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))}
/> />
{/* Add/Edit Modal */} {/* Add/Edit Modal */}

View File

@@ -145,8 +145,9 @@ export default function CoachesPage() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<any>(null); const [editingItem, setEditingItem] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null }); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, item: null });
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>(''); const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
const [isBedCoach, setIsBedCoach] = useState(false);
const [exportUtilModalOpen, setExportUtilModalOpen] = useState(false); const [exportUtilModalOpen, setExportUtilModalOpen] = useState(false);
const [exportUtilFormat, setExportUtilFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); const [exportUtilFormat, setExportUtilFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
@@ -216,7 +217,7 @@ export default function CoachesPage() {
}); });
const deleteCoachMutation = useMutation({ const deleteCoachMutation = useMutation({
mutationFn: fleetApi.deleteCoach, mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => fleetApi.deleteCoach(id, cascade),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coaches'] }); queryClient.invalidateQueries({ queryKey: ['coaches'] });
}, },
@@ -276,12 +277,17 @@ export default function CoachesPage() {
if (deleteConfirm.item?.isCoachType) { if (deleteConfirm.item?.isCoachType) {
await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id); await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
} else { } else {
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id); await deleteCoachMutation.mutateAsync({ id: deleteConfirm.item.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked });
} }
setDeleteConfirm({ isOpen: false, item: null }); setDeleteConfirm({ isOpen: false, item: null });
} catch (err: any) { } catch (err: any) {
const msg = err?.response?.data?.message || err?.message || 'Delete failed'; const msg = err?.response?.data?.message || err?.message || 'Delete failed';
setDeleteConfirm((prev) => ({ ...prev, error: msg })); const isFkError = msg?.includes('Cannot delete') || err?.response?.status === 400;
if (isFkError && !deleteConfirm.item?.isCoachType && !deleteConfirm.cascade) {
setDeleteConfirm((prev) => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg }));
} else {
setDeleteConfirm((prev) => ({ ...prev, error: msg }));
}
} }
}; };
@@ -454,6 +460,7 @@ export default function CoachesPage() {
onClick: (item: any) => { onClick: (item: any) => {
setEditingItem({ ...item, isCoach: true }); setEditingItem({ ...item, isCoach: true });
setSelectedCoachTypeId(item.coachTypeId || ''); setSelectedCoachTypeId(item.coachTypeId || '');
setIsBedCoach(!!(item.bedCategory || item.coachType?.name?.toLowerCase().includes('bed')));
setShowModal(true); setShowModal(true);
}, },
variant: 'secondary' as const, variant: 'secondary' as const,
@@ -479,6 +486,7 @@ export default function CoachesPage() {
onClick={() => { onClick={() => {
setEditingItem(null); setEditingItem(null);
setSelectedCoachTypeId(''); setSelectedCoachTypeId('');
setIsBedCoach(false);
setSearch(''); setSearch('');
setShowModal(true); setShowModal(true);
}} }}
@@ -686,11 +694,14 @@ export default function CoachesPage() {
isDanger={true} isDanger={true}
isLoading={deleteCoachTypeMutation.isPending || deleteCoachMutation.isPending} isLoading={deleteCoachTypeMutation.isPending || deleteCoachMutation.isPending}
error={deleteConfirm.error} error={deleteConfirm.error}
warning={ warning={!deleteConfirm.cascade ? (
deleteConfirm.item?.isCoachType deleteConfirm.item?.isCoachType
? 'This coach type may have coaches assigned. Deleting it may impact these systems.' ? 'This coach type may have coaches assigned. Deleting it may impact these systems.'
: 'This coach may be assigned to schedules. Deleting it may impact these systems.' : 'This coach may be assigned to schedules. Deleting it may impact these systems.'
} ) : undefined}
cascadeWarning={deleteConfirm.cascade ? "This coach has related assignments or seats that will also be permanently deleted." : undefined}
cascadeChecked={deleteConfirm.cascadeChecked}
onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))}
/> />
{/* Add/Edit Modal */} {/* Add/Edit Modal */}
@@ -700,6 +711,7 @@ export default function CoachesPage() {
setShowModal(false); setShowModal(false);
setEditingItem(null); setEditingItem(null);
setSelectedCoachTypeId(''); setSelectedCoachTypeId('');
setIsBedCoach(false);
}} }}
title={ title={
activeTab === 'types' activeTab === 'types'
@@ -779,8 +791,14 @@ export default function CoachesPage() {
<select <select
name="coachTypeId" name="coachTypeId"
className="input" className="input"
defaultValue={editingItem?.coachTypeId || ''} value={selectedCoachTypeId}
onChange={(e) => setSelectedCoachTypeId(e.target.value)} onChange={(e) => {
const id = e.target.value;
setSelectedCoachTypeId(id);
const ct = coachTypesArray.find((c: any) => c.id === id);
const name = ct?.name?.toLowerCase() ?? '';
setIsBedCoach(name.includes('bed') || name.includes('sleeper'));
}}
required required
> >
<option value="">Select Coach Type</option> <option value="">Select Coach Type</option>
@@ -804,54 +822,35 @@ export default function CoachesPage() {
/> />
</div> </div>
{(() => { {isBedCoach && (
const selectedCoachType = coachTypesArray.find((ct: any) => ct.id === (selectedCoachTypeId || editingItem?.coachTypeId)); <>
const isBedType = selectedCoachType && <div>
(selectedCoachType.name?.toLowerCase().includes('bed') || <label className="label">Bed Category *</label>
selectedCoachType.name?.toLowerCase().includes('sleeper') || <select
selectedCoachType.type?.toLowerCase().includes('sleeper')); name="bedCategory"
className="input"
const derivedBedCategory = editingItem?.isCoach && selectedCoachType defaultValue={editingItem?.bedCategory || ''}
? (selectedCoachType.name?.toLowerCase().includes('vip') ? 'VIP_BED' : 'ECONOMY_BED') required
: (editingItem?.bedCategory || ''); >
<option value="">Select bed category</option>
return isBedType ? ( <option value="ECONOMY_BED">Economy Bed (3 cols × 2 rows)</option>
<> <option value="VIP_BED">VIP Bed (2 cols × 2 rows)</option>
<div> </select>
<label className="label">Bed Category</label> </div>
<select <div>
name="bedCategory" <label className="label">Beds Per Room</label>
className="input" <select
defaultValue={derivedBedCategory} name="bedsPerRoom"
> className="input"
<option value="">Select bed category</option> defaultValue={editingItem?.bedsPerRoom || ''}
<option value="ECONOMY_BED">Economy Bed</option> >
<option value="VIP_BED">VIP Bed</option> <option value="">Auto (VIP: 4, Economy: 6)</option>
</select> <option value="4">4 beds per room (VIP)</option>
<p className="text-xs text-muted-foreground mt-1"> <option value="6">6 beds per room (Economy)</option>
Select if this is a bed coach </select>
</p> </div>
</div> </>
)}
<div>
<label className="label">Beds Per Room</label>
<select
name="bedsPerRoom"
className="input"
defaultValue={editingItem?.bedsPerRoom || ''}
>
<option value="">Auto (VIP: 4, Economy: 6)</option>
<option value="2">2 beds per room</option>
<option value="4">4 beds per room</option>
<option value="6">6 beds per room</option>
</select>
<p className="text-xs text-muted-foreground mt-1">
Only applies to bed coaches
</p>
</div>
</>
) : null;
})()}
<div> <div>
<label className="label">Arrangement *</label> <label className="label">Arrangement *</label>
@@ -903,6 +902,7 @@ export default function CoachesPage() {
setShowModal(false); setShowModal(false);
setEditingItem(null); setEditingItem(null);
setSelectedCoachTypeId(''); setSelectedCoachTypeId('');
setIsBedCoach(false);
}} }}
> >
Cancel Cancel

View File

@@ -2,10 +2,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Edit, Loader2, RefreshCw } from 'lucide-react'; import { Edit, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
interface CurrencyRate { interface CurrencyRate {
@@ -29,6 +30,9 @@ export default function CurrenciesPage() {
const [editingRate, setEditingRate] = useState<CurrencyRate | null>(null); const [editingRate, setEditingRate] = useState<CurrencyRate | null>(null);
const [rateInput, setRateInput] = useState(''); const [rateInput, setRateInput] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [addForm, setAddForm] = useState({ code: '', name: '', symbol: '', exchangeRate: '' });
const [deleteConfirm, setDeleteConfirm] = useState<CurrencyRate | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: currencies = [], isLoading } = useQuery<CurrencyRate[]>({ const { data: currencies = [], isLoading } = useQuery<CurrencyRate[]>({
@@ -49,6 +53,26 @@ export default function CurrenciesPage() {
}, },
}); });
const createMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/currencies', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setShowAddModal(false);
setAddForm({ code: '', name: '', symbol: '', exchangeRate: '' });
setError(null);
},
onError: (err: any) => setError(err.response?.data?.message || 'Failed to add currency'),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/currencies/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setDeleteConfirm(null);
},
onError: (err: any) => setError(err.response?.data?.message || 'Failed to delete currency'),
});
const syncMutation = useMutation({ const syncMutation = useMutation({
mutationFn: () => apiClient.post('/currencies/sync-rates', {}), mutationFn: () => apiClient.post('/currencies/sync-rates', {}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['currencies'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['currencies'] }),
@@ -121,12 +145,8 @@ export default function CurrenciesPage() {
]; ];
const actions = [ const actions = [
{ { label: 'Edit', onClick: handleEdit, variant: 'secondary' as const, icon: Edit },
label: 'Edit Rate', { label: 'Delete', onClick: (c: CurrencyRate) => setDeleteConfirm(c), variant: 'danger' as const, icon: Trash2 },
onClick: handleEdit,
variant: 'secondary' as const,
icon: Edit,
},
]; ];
return ( return (
@@ -138,14 +158,17 @@ export default function CurrenciesPage() {
Manage ETB exchange rates for display currencies (DJF, USD) Manage ETB exchange rates for display currencies (DJF, USD)
</p> </p>
</div> </div>
<ActionButton <div className="flex gap-2">
icon={RefreshCw} <ActionButton icon={Plus} onClick={() => { setError(null); setShowAddModal(true); }}>Add Currency</ActionButton>
variant="secondary" <ActionButton
onClick={() => syncMutation.mutate()} icon={RefreshCw}
loading={syncMutation.isPending} variant="secondary"
> onClick={() => syncMutation.mutate()}
Sync Rates loading={syncMutation.isPending}
</ActionButton> >
Sync Rates
</ActionButton>
</div>
</div> </div>
{error && !editingRate && ( {error && !editingRate && (
@@ -204,6 +227,68 @@ export default function CurrenciesPage() {
<p> Rates apply globally; changes take effect immediately on the next booking or fare quote</p> <p> Rates apply globally; changes take effect immediately on the next booking or fare quote</p>
</div> </div>
<Modal
isOpen={showAddModal}
onClose={() => { setShowAddModal(false); setError(null); }}
title="Add Currency"
size="sm"
>
<div className="space-y-4">
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">{error}</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Code *</label>
<input className="input uppercase" placeholder="e.g., EUR" maxLength={5}
value={addForm.code} onChange={(e) => setAddForm({ ...addForm, code: e.target.value.toUpperCase() })} />
</div>
<div>
<label className="label">Symbol *</label>
<input className="input" placeholder="e.g., €"
value={addForm.symbol} onChange={(e) => setAddForm({ ...addForm, symbol: e.target.value })} />
</div>
</div>
<div>
<label className="label">Name *</label>
<input className="input" placeholder="e.g., Euro"
value={addForm.name} onChange={(e) => setAddForm({ ...addForm, name: e.target.value })} />
</div>
<div>
<label className="label">Exchange Rate (1 ETB = ? {addForm.code || '...'}) *</label>
<input type="number" min="0.0001" step="0.0001" className="input" placeholder="e.g., 0.018"
value={addForm.exchangeRate} onChange={(e) => setAddForm({ ...addForm, exchangeRate: e.target.value })} />
</div>
<div className="flex gap-2 justify-end pt-2">
<ActionButton variant="secondary" onClick={() => { setShowAddModal(false); setError(null); }}>Cancel</ActionButton>
<ActionButton
loading={createMutation.isPending}
onClick={() => {
if (!addForm.code || !addForm.name || !addForm.symbol || !addForm.exchangeRate) {
setError('All fields are required'); return;
}
const rate = parseFloat(addForm.exchangeRate);
if (isNaN(rate) || rate <= 0) { setError('Exchange rate must be a positive number'); return; }
createMutation.mutate({ code: addForm.code, name: addForm.name, symbol: addForm.symbol, exchangeRate: rate });
}}
>
Add Currency
</ActionButton>
</div>
</div>
</Modal>
<ConfirmDialog
isOpen={!!deleteConfirm}
onClose={() => setDeleteConfirm(null)}
onConfirm={() => deleteMutation.mutate(deleteConfirm!.id)}
title="Delete Currency"
message={`Delete ${deleteConfirm?.code} (${CURRENCY_META[deleteConfirm?.code ?? '']?.name ?? deleteConfirm?.code})? This will remove the exchange rate record.`}
confirmText="Delete"
isDanger
isLoading={deleteMutation.isPending}
/>
<Modal <Modal
isOpen={!!editingRate} isOpen={!!editingRate}
onClose={() => { setEditingRate(null); setError(null); }} onClose={() => { setEditingRate(null); setError(null); }}

View File

@@ -214,21 +214,31 @@ export default function PackageBookingsPage() {
<section> <section>
<SectionHeader title={`Passengers (${b.passengers.length})`} /> <SectionHeader title={`Passengers (${b.passengers.length})`} />
<div className="divide-y divide-muted rounded-lg border border-muted overflow-hidden"> <div className="divide-y divide-muted rounded-lg border border-muted overflow-hidden">
{b.passengers.map((p: any, i: number) => ( {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"> const isChild = i >= (b.adultCount ?? b.passengerCount);
<div className="flex items-center gap-3"> return (
<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 key={i} className="flex items-center justify-between px-4 py-3 bg-muted/20 hover:bg-muted/40 transition-colors">
<div> <div className="flex items-center gap-3">
<p className="text-sm font-semibold">{p.passengerName}</p> <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>
<p className="text-xs text-muted-foreground"> <div>
{p.dateOfBirth ? new Date(p.dateOfBirth).toLocaleDateString() : ''} <p className="text-sm font-semibold">{p.passengerName}</p>
{p.idDocumentType ? ` · ${p.idDocumentType}` : ''} <p className="text-xs text-muted-foreground">
{p.passportNumber ? ` · ${p.passportNumber}` : ''} {p.dateOfBirth ? new Date(p.dateOfBirth).toLocaleDateString() : ''}
</p> {p.idDocumentType ? ` · ${p.idDocumentType}` : ''}
{p.passportNumber ? ` · ${p.passportNumber}` : ''}
</p>
</div>
</div> </div>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${
isChild
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400'
: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
}`}>
{isChild ? 'CHILD' : 'ADULT'}
</span>
</div> </div>
</div> );
))} })}
</div> </div>
</section> </section>
)} )}

View File

@@ -8,7 +8,7 @@ import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import { packagesApi, stationsApi, schedulesApi } from '@/lib/api'; import { packagesApi, stationsApi, schedulesApi, seatClassesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency } from '@/lib/utils';
const toLocal = (iso?: string) => { const toLocal = (iso?: string) => {
@@ -42,11 +42,12 @@ export default function PackagesPage() {
const [deactivateConfirm, setDeactivateConfirm] = useState<any>(null); const [deactivateConfirm, setDeactivateConfirm] = useState<any>(null);
const [tiersPackage, setTiersPackage] = useState<any>(null); const [tiersPackage, setTiersPackage] = useState<any>(null);
const [editingTier, setEditingTier] = useState<any>(null); const [editingTier, setEditingTier] = useState<any>(null);
const [tierForm, setTierForm] = useState({ seatType: '', label: '', priceMinor: '', availableSeats: '' }); const [tierForm, setTierForm] = useState({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' });
const [deleteTierConfirm, setDeleteTierConfirm] = useState<any>(null); const [deleteTierConfirm, setDeleteTierConfirm] = useState<any>(null);
const [tierError, setTierError] = useState<string | null>(null); const [tierError, setTierError] = useState<string | null>(null);
const [deletePackageConfirm, setDeletePackageConfirm] = useState<any>(null); const [deletePackageConfirm, setDeletePackageConfirm] = useState<any>(null);
const [deletePackageError, setDeletePackageError] = useState<string | null>(null); const [deletePackageError, setDeletePackageError] = useState<string | null>(null);
const [deletePackageCascade, setDeletePackageCascade] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
@@ -64,8 +65,14 @@ export default function PackagesPage() {
queryFn: () => schedulesApi.getAll(), queryFn: () => schedulesApi.getAll(),
}); });
const { data: seatClassesData } = useQuery({
queryKey: ['seat-classes-all'],
queryFn: () => seatClassesApi.getAll(),
});
const stations: any[] = stationsData?.items || stationsData?.data || (Array.isArray(stationsData) ? stationsData : []); const stations: any[] = stationsData?.items || stationsData?.data || (Array.isArray(stationsData) ? stationsData : []);
const schedules: any[] = schedulesData?.items || schedulesData?.data || (Array.isArray(schedulesData) ? schedulesData : []); const schedules: any[] = schedulesData?.items || schedulesData?.data || (Array.isArray(schedulesData) ? schedulesData : []);
const seatClasses: any[] = Array.isArray(seatClassesData) ? seatClassesData : (seatClassesData as any)?.items || (seatClassesData as any)?.data || [];
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: packagesApi.create, mutationFn: packagesApi.create,
@@ -87,7 +94,7 @@ export default function PackagesPage() {
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setDeactivateConfirm(null); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setDeactivateConfirm(null); },
}); });
const emptyTierForm = { seatType: '', label: '', priceMinor: '', availableSeats: '' }; const emptyTierForm = { seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' };
const addTierMutation = useMutation({ const addTierMutation = useMutation({
mutationFn: ({ packageId, data }: { packageId: string; data: any }) => packagesApi.addTier(packageId, data), mutationFn: ({ packageId, data }: { packageId: string; data: any }) => packagesApi.addTier(packageId, data),
@@ -114,11 +121,12 @@ export default function PackagesPage() {
}); });
const deletePackageMutation = useMutation({ const deletePackageMutation = useMutation({
mutationFn: (id: string) => packagesApi.remove(id), mutationFn: ({ id, cascade }: { id: string; cascade: boolean }) => packagesApi.remove(id, cascade),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['packages'] }); queryClient.invalidateQueries({ queryKey: ['packages'] });
setDeletePackageConfirm(null); setDeletePackageConfirm(null);
setDeletePackageError(null); setDeletePackageError(null);
setDeletePackageCascade(false);
}, },
onError: (e: any) => setDeletePackageError(e?.response?.data?.message || e?.message || 'Failed to delete package'), onError: (e: any) => setDeletePackageError(e?.response?.data?.message || e?.message || 'Failed to delete package'),
}); });
@@ -135,13 +143,19 @@ export default function PackagesPage() {
const openEditTier = (tier: any) => { const openEditTier = (tier: any) => {
setEditingTier(tier); setEditingTier(tier);
setTierForm({ seatType: tier.seatType, label: tier.label, priceMinor: String(tier.priceMinor), availableSeats: String(tier.availableSeats) }); setTierForm({ seatClassId: tier.seatClassId ?? '', seatType: tier.seatType, label: tier.label, priceMinor: String(tier.priceMinor), availableSeats: String(tier.availableSeats) });
setTierError(null); setTierError(null);
}; };
const handleTierSubmit = async (e: React.FormEvent) => { const handleTierSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const payload = { seatType: tierForm.seatType, label: tierForm.label, priceMinor: parseInt(tierForm.priceMinor), availableSeats: parseInt(tierForm.availableSeats) }; const payload: any = {
seatType: tierForm.seatType,
label: tierForm.label,
priceMinor: parseInt(tierForm.priceMinor),
availableSeats: parseInt(tierForm.availableSeats),
...(tierForm.seatClassId ? { seatClassId: tierForm.seatClassId } : {}),
};
if (editingTier) { if (editingTier) {
await updateTierMutation.mutateAsync({ tierId: editingTier.id, data: payload }); await updateTierMutation.mutateAsync({ tierId: editingTier.id, data: payload });
} else { } else {
@@ -267,7 +281,7 @@ export default function PackagesPage() {
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit }, { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit },
{ {
label: 'Tiers', icon: Layers, variant: 'secondary' as const, label: 'Tiers', icon: Layers, variant: 'secondary' as const,
onClick: (p: any) => { setTiersPackage(p); setEditingTier(null); setTierForm({ seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }, onClick: (p: any) => { setTiersPackage(p); setEditingTier(null); setTierForm({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); },
}, },
{ {
label: 'Activate', icon: CheckCircle, variant: 'primary' as const, label: 'Activate', icon: CheckCircle, variant: 'primary' as const,
@@ -281,7 +295,7 @@ export default function PackagesPage() {
}, },
{ {
label: 'Delete', icon: Trash2, variant: 'danger' as const, label: 'Delete', icon: Trash2, variant: 'danger' as const,
onClick: (p: any) => { setDeletePackageError(null); setDeletePackageConfirm(p); }, onClick: (p: any) => { setDeletePackageError(null); setDeletePackageCascade(false); setDeletePackageConfirm(p); },
}, },
]; ];
@@ -432,8 +446,12 @@ export default function PackagesPage() {
{(tiersPackage.priceTiers ?? []).map((t: any) => ( {(tiersPackage.priceTiers ?? []).map((t: any) => (
<div key={t.id} className="flex items-center justify-between rounded border border-border px-3 py-2"> <div key={t.id} className="flex items-center justify-between rounded border border-border px-3 py-2">
<div> <div>
<span className="font-medium text-sm">{t.label}</span> <span className="font-medium text-sm">{t.seatType}</span>
<span className="ml-2 text-xs text-muted-foreground">({t.seatType})</span> {t.seatClassId && (
<span className="ml-2 text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded font-medium">
{seatClasses.find((sc: any) => sc.id === t.seatClassId)?.coachType.type ?? 'Linked'}
</span>
)}
<div className="text-xs text-muted-foreground mt-0.5"> <div className="text-xs text-muted-foreground mt-0.5">
{formatCurrency(t.priceMinor, 'ETB')} · {t.bookedSeats}/{t.availableSeats} booked {formatCurrency(t.priceMinor, 'ETB')} · {t.bookedSeats}/{t.availableSeats} booked
</div> </div>
@@ -454,16 +472,31 @@ export default function PackagesPage() {
<div className="border-t border-border pt-4"> <div className="border-t border-border pt-4">
<p className="text-sm font-semibold mb-3">{editingTier ? 'Edit Tier' : 'Add New Tier'}</p> <p className="text-sm font-semibold mb-3">{editingTier ? 'Edit Tier' : 'Add New Tier'}</p>
<form onSubmit={handleTierSubmit} className="grid grid-cols-2 gap-3"> <form onSubmit={handleTierSubmit} className="grid grid-cols-2 gap-3">
<div> <div className="col-span-2">
<label className="label">Seat Type *</label> <label className="label">Seat Class *</label>
<input className="input" placeholder="e.g., HSC" required <select
value={tierForm.seatType} onChange={(e) => setTierForm((f) => ({ ...f, seatType: e.target.value }))} /> className="input"
</div> required
<div> value={tierForm.seatClassId}
<label className="label">Label *</label> onChange={(e) => {
<input className="input" placeholder="e.g., Regular Seat (HSC)" required const sc = seatClasses.find((c: any) => c.id === e.target.value);
value={tierForm.label} onChange={(e) => setTierForm((f) => ({ ...f, label: e.target.value }))} /> setTierForm((f) => ({
...f,
seatClassId: e.target.value,
seatType: sc?.name ?? f.seatType,
label: sc?.name ?? f.label,
}));
}}
>
<option value="">Select seat class</option>
{seatClasses.map((sc: any) => (
<option key={sc.id} value={sc.id}>
{sc.name}{sc.description ? `${sc.description}` : ''}
</option>
))}
</select>
</div> </div>
<div> <div>
<label className="label">Price (minor/cents) *</label> <label className="label">Price (minor/cents) *</label>
<input type="number" min="0" className="input" placeholder="e.g., 1023200" required <input type="number" min="0" className="input" placeholder="e.g., 1023200" required
@@ -476,7 +509,7 @@ export default function PackagesPage() {
</div> </div>
<div className="col-span-2 flex justify-end gap-2"> <div className="col-span-2 flex justify-end gap-2">
{editingTier && ( {editingTier && (
<ActionButton type="button" variant="secondary" onClick={() => { setEditingTier(null); setTierForm({ seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }}>Cancel</ActionButton> <ActionButton type="button" variant="secondary" onClick={() => { setEditingTier(null); setTierForm({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }}>Cancel</ActionButton>
)} )}
<ActionButton type="submit" loading={addTierMutation.isPending || updateTierMutation.isPending}> <ActionButton type="submit" loading={addTierMutation.isPending || updateTierMutation.isPending}>
{editingTier ? 'Update Tier' : 'Add Tier'} {editingTier ? 'Update Tier' : 'Add Tier'}
@@ -491,14 +524,17 @@ export default function PackagesPage() {
{/* Delete Package Confirmation */} {/* Delete Package Confirmation */}
<ConfirmDialog <ConfirmDialog
isOpen={!!deletePackageConfirm} isOpen={!!deletePackageConfirm}
onClose={() => { setDeletePackageConfirm(null); setDeletePackageError(null); }} onClose={() => { setDeletePackageConfirm(null); setDeletePackageError(null); setDeletePackageCascade(false); }}
onConfirm={() => deletePackageMutation.mutate(deletePackageConfirm.id)} onConfirm={() => deletePackageMutation.mutate({ id: deletePackageConfirm.id, cascade: deletePackageCascade })}
title="Delete Package" title="Delete Package"
message={`Delete "${deletePackageConfirm?.name}"? This will also remove all price tiers and cannot be undone.`} message={`Delete "${deletePackageConfirm?.name}"? This will also remove all price tiers and cannot be undone.`}
confirmText="Delete" confirmText="Delete"
isDanger isDanger
isLoading={deletePackageMutation.isPending} isLoading={deletePackageMutation.isPending}
error={deletePackageError ?? undefined} error={deletePackageError ?? undefined}
cascadeWarning={deletePackageError ? 'This package has active bookings or related records. Check the box below to force delete everything.' : undefined}
cascadeChecked={deletePackageCascade}
onCascadeChange={setDeletePackageCascade}
/> />
{/* Delete Tier Confirmation */} {/* Delete Tier Confirmation */}

View File

@@ -40,6 +40,7 @@ export default function PassengersPage() {
const [selectedPassenger, setSelectedPassenger] = useState<any>(null); const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null }); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const [deleteCascade, setDeleteCascade] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
const [exportDateFrom, setExportDateFrom] = useState(''); const [exportDateFrom, setExportDateFrom] = useState('');
@@ -51,11 +52,12 @@ export default function PassengersPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/passengers/${id}`), mutationFn: ({ id, cascade }: { id: string; cascade: boolean }) => passengersApi.delete(id, cascade),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['passengers'] }); queryClient.invalidateQueries({ queryKey: ['passengers'] });
setDeleteConfirm({ isOpen: false, passenger: null }); setDeleteConfirm({ isOpen: false, passenger: null });
setDeleteError(null); setDeleteError(null);
setDeleteCascade(false);
}, },
onError: (error: any) => { onError: (error: any) => {
setDeleteError(error?.response?.data?.message || error?.message || 'Failed to delete passenger'); setDeleteError(error?.response?.data?.message || error?.message || 'Failed to delete passenger');
@@ -154,7 +156,7 @@ export default function PassengersPage() {
const actions = [ const actions = [
{ label: 'View Details', onClick: (p: any) => setSelectedPassenger(p), variant: 'secondary' as const, icon: Eye }, { label: 'View Details', onClick: (p: any) => setSelectedPassenger(p), variant: 'secondary' as const, icon: Eye },
{ label: 'Delete', onClick: (p: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, passenger: p }); }, variant: 'danger' as const, icon: Trash2 }, { label: 'Delete', onClick: (p: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteConfirm({ isOpen: true, passenger: p }); }, variant: 'danger' as const, icon: Trash2 },
]; ];
return ( return (
@@ -229,18 +231,20 @@ export default function PassengersPage() {
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirm.isOpen} isOpen={deleteConfirm.isOpen}
onClose={() => { setDeleteConfirm({ isOpen: false, passenger: null }); setDeleteError(null); }} onClose={() => { setDeleteConfirm({ isOpen: false, passenger: null }); setDeleteError(null); setDeleteCascade(false); }}
onConfirm={async () => { onConfirm={async () => {
if (deleteConfirm.passenger) { if (deleteConfirm.passenger) {
await deleteMutation.mutateAsync(deleteConfirm.passenger.id); await deleteMutation.mutateAsync({ id: deleteConfirm.passenger.id, cascade: deleteCascade });
} }
}} }}
title="Delete Passenger" title="Delete Passenger"
message={`Are you sure you want to delete ${deleteConfirm.passenger?.fullName}?`} message={`Are you sure you want to delete ${deleteConfirm.passenger?.fullName}?`}
confirmText="Delete" isDanger confirmText="Delete" isDanger
isLoading={deleteMutation.isPending} isLoading={deleteMutation.isPending}
warning="This passenger may have active bookings, loyalty points, and wallet balance. Deleting will impact these systems and records."
error={deleteError ?? undefined} error={deleteError ?? undefined}
cascadeWarning={deleteError ? 'This passenger has related records (bookings, loyalty, wallet). Check the box below to force delete everything.' : undefined}
cascadeChecked={deleteCascade}
onCascadeChange={setDeleteCascade}
/> />
{/* Passenger Details Modal */} {/* Passenger Details Modal */}

View File

@@ -171,7 +171,7 @@ export default function RoutesPage() {
const [originStationId, setOriginStationId] = useState(''); const [originStationId, setOriginStationId] = useState('');
const [destinationStationId, setDestinationStationId] = useState(''); const [destinationStationId, setDestinationStationId] = useState('');
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined); const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string }>({ isOpen: false, route: null }); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null });
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -207,13 +207,18 @@ export default function RoutesPage() {
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: routesApi.delete, mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => routesApi.delete(id, cascade),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['routes'] }); queryClient.invalidateQueries({ queryKey: ['routes'] });
}, },
onError: (e: any) => { onError: (e: any) => {
const msg = e?.response?.data?.message || e?.message || 'Failed to delete route'; const msg = e?.response?.data?.message || e?.message || 'Failed to delete route';
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); const isFkError = msg?.includes('Cannot delete') || e?.response?.status === 400;
if (isFkError && !deleteConfirm.cascade) {
setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg }));
} else {
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg }));
}
}, },
}); });
@@ -325,7 +330,7 @@ export default function RoutesPage() {
const confirmDelete = async () => { const confirmDelete = async () => {
if (!deleteConfirm.route) return; if (!deleteConfirm.route) return;
try { try {
await deleteMutation.mutateAsync(deleteConfirm.route.id); await deleteMutation.mutateAsync({ id: deleteConfirm.route.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked });
setDeleteConfirm({ isOpen: false, route: null }); setDeleteConfirm({ isOpen: false, route: null });
} catch { } catch {
// error is set by onError handler // error is set by onError handler
@@ -471,7 +476,10 @@ export default function RoutesPage() {
isDanger={true} isDanger={true}
isLoading={deleteMutation.isPending} isLoading={deleteMutation.isPending}
error={deleteConfirm.error} error={deleteConfirm.error}
warning="This route may be referenced by schedules and bookings. Deleting it may impact these systems." warning={!deleteConfirm.cascade ? "This route may be referenced by schedules and bookings. Deleting it may impact these systems." : undefined}
cascadeWarning={deleteConfirm.cascade ? "This route has related schedules that will also be permanently deleted." : undefined}
cascadeChecked={deleteConfirm.cascadeChecked}
onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))}
/> />
<Modal <Modal

View File

@@ -22,6 +22,7 @@ interface Schedule {
originStation?: { id: string; name: string }; originStation?: { id: string; name: string };
destinationStation?: { id: string; name: string }; destinationStation?: { id: string; name: string };
coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>; coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>;
isPackageOnly?: boolean;
} }
interface Train { interface Train {
@@ -51,7 +52,7 @@ export default function SchedulesPage() {
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null); const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
const [selectedSchedules, setSelectedSchedules] = useState<Set<string>>(new Set()); const [selectedSchedules, setSelectedSchedules] = useState<Set<string>>(new Set());
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean; error?: string }>( const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean; error?: string; cascade?: boolean; cascadeChecked?: boolean }>(
{ isOpen: false, item: null } { isOpen: false, item: null }
); );
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -105,6 +106,7 @@ export default function SchedulesPage() {
arrivalAt: '', arrivalAt: '',
status: 'SCHEDULED', status: 'SCHEDULED',
coachIds: [] as string[], coachIds: [] as string[],
isPackageOnly: false,
}); });
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
@@ -194,13 +196,18 @@ export default function SchedulesPage() {
}); });
const deleteScheduleMutation = useMutation({ const deleteScheduleMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/schedules/${id}`), mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/schedules/${id}${cascade ? '?cascade=true' : ''}`),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['schedules'] }); queryClient.invalidateQueries({ queryKey: ['schedules'] });
}, },
onError: (err: any) => { onError: (err: any) => {
const msg = err?.response?.data?.message || err?.message || 'Failed to delete schedule'; const msg = err?.response?.data?.message || err?.message || 'Failed to delete schedule';
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); const isFkError = msg?.includes('Cannot delete') || err?.response?.status === 400;
if (isFkError && !deleteConfirm.cascade) {
setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg }));
} else {
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg }));
}
}, },
}); });
@@ -279,6 +286,7 @@ export default function SchedulesPage() {
departureAt: depLocal.toISOString(), departureAt: depLocal.toISOString(),
arrivalAt: arrLocal.toISOString(), arrivalAt: arrLocal.toISOString(),
status: editForm.status, status: editForm.status,
isPackageOnly: editForm.isPackageOnly,
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({ coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
coachId, coachId,
positionNumber: idx + 1, positionNumber: idx + 1,
@@ -307,7 +315,7 @@ export default function SchedulesPage() {
const ids = deleteConfirm.item as string[]; const ids = deleteConfirm.item as string[];
await bulkDeleteMutation.mutateAsync(ids); await bulkDeleteMutation.mutateAsync(ids);
} else if (deleteConfirm.item) { } else if (deleteConfirm.item) {
await deleteScheduleMutation.mutateAsync(deleteConfirm.item.id); await deleteScheduleMutation.mutateAsync({ id: deleteConfirm.item.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked });
} }
setDeleteConfirm({ isOpen: false, item: null }); setDeleteConfirm({ isOpen: false, item: null });
} catch { } catch {
@@ -336,6 +344,7 @@ export default function SchedulesPage() {
arrivalAt: arrStr, arrivalAt: arrStr,
status: schedule.status, status: schedule.status,
coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [], coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [],
isPackageOnly: schedule.isPackageOnly ?? false,
}); });
setError(null); setError(null);
setShowEditModal(true); setShowEditModal(true);
@@ -455,9 +464,14 @@ export default function SchedulesPage() {
key: 'status', key: 'status',
label: 'Status', label: 'Status',
render: (schedule: Schedule) => ( render: (schedule: Schedule) => (
<span className={`edr-badge ${statusMap[schedule.status] || 'edr-badge-info'}`}> <div className="flex items-center gap-2">
{schedule.status} <span className={`edr-badge ${statusMap[schedule.status] || 'edr-badge-info'}`}>
</span> {schedule.status}
</span>
{schedule.isPackageOnly && (
<span className="edr-badge edr-badge-warning">PKG</span>
)}
</div>
), ),
}, },
] as any; ] as any;
@@ -649,7 +663,10 @@ export default function SchedulesPage() {
isDanger={true} isDanger={true}
isLoading={deleteScheduleMutation.isPending || bulkDeleteMutation.isPending} isLoading={deleteScheduleMutation.isPending || bulkDeleteMutation.isPending}
error={deleteConfirm.error} error={deleteConfirm.error}
warning="Schedules with existing bookings cannot be deleted." warning={!deleteConfirm.cascade ? "Schedules with existing bookings cannot be deleted." : undefined}
cascadeWarning={deleteConfirm.cascade ? "This schedule has related bookings or tickets that will also be permanently deleted." : undefined}
cascadeChecked={deleteConfirm.cascadeChecked}
onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))}
/> />
<Modal <Modal
@@ -1040,6 +1057,20 @@ export default function SchedulesPage() {
</select> </select>
</div> </div>
<div className="flex items-center gap-3 p-3 rounded-lg border border-border">
<input
type="checkbox"
id="isPackageOnly"
checked={editForm.isPackageOnly}
onChange={(e) => setEditForm({ ...editForm, isPackageOnly: e.target.checked })}
className="w-4 h-4 rounded"
/>
<label htmlFor="isPackageOnly" className="text-sm cursor-pointer">
<span className="font-medium">Package Only</span>
<span className="block text-xs text-muted-foreground">Hide from public search reserved for package bookings</span>
</label>
</div>
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<label className="label">Coaches (Optional)</label> <label className="label">Coaches (Optional)</label>

View File

@@ -14,7 +14,7 @@ export default function StationsPage() {
const [filters, setFilters] = useState({ search: '', country: '', operational: '' }); const [filters, setFilters] = useState({ search: '', country: '', operational: '' });
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingStation, setEditingStation] = useState<any>(null); const [editingStation, setEditingStation] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; station: any | null; error?: string }>({ isOpen: false, station: null }); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; station: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, station: null });
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -46,13 +46,18 @@ export default function StationsPage() {
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: stationsApi.delete, mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => stationsApi.delete(id, cascade),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stations'] }); queryClient.invalidateQueries({ queryKey: ['stations'] });
}, },
onError: (e: any) => { onError: (e: any) => {
const msg = e?.response?.data?.message || e?.message || 'Failed to delete station'; const msg = e?.response?.data?.message || e?.message || 'Failed to delete station';
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); const isFkError = msg?.includes('Cannot delete') || e?.response?.status === 400;
if (isFkError && !deleteConfirm.cascade) {
setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg }));
} else {
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg }));
}
}, },
}); });
@@ -92,7 +97,7 @@ export default function StationsPage() {
const confirmDelete = async () => { const confirmDelete = async () => {
if (!deleteConfirm.station) return; if (!deleteConfirm.station) return;
try { try {
await deleteMutation.mutateAsync(deleteConfirm.station.id); await deleteMutation.mutateAsync({ id: deleteConfirm.station.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked });
setDeleteConfirm({ isOpen: false, station: null }); setDeleteConfirm({ isOpen: false, station: null });
} catch { } catch {
// error is set by onError handler // error is set by onError handler
@@ -244,7 +249,10 @@ export default function StationsPage() {
isDanger={true} isDanger={true}
isLoading={deleteMutation.isPending} isLoading={deleteMutation.isPending}
error={deleteConfirm.error} error={deleteConfirm.error}
warning="This station may be referenced by routes, schedules, and bookings. Deleting it may impact these systems." warning={!deleteConfirm.cascade ? "This station may be referenced by routes, schedules, and bookings. Deleting it may impact these systems." : undefined}
cascadeWarning={deleteConfirm.cascade ? "This station has related records (route stops, schedules, or stop times) that will also be permanently deleted." : undefined}
cascadeChecked={deleteConfirm.cascadeChecked}
onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))}
/> />
{/* Add/Edit Modal */} {/* Add/Edit Modal */}

View File

@@ -16,7 +16,7 @@ export default function TrainsPage() {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingTrain, setEditingTrain] = useState<TrainType | null>(null); const [editingTrain, setEditingTrain] = useState<TrainType | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null; error?: string }>({ isOpen: false, train: null }); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, train: null });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -50,13 +50,18 @@ export default function TrainsPage() {
}); });
const deleteTrainMutation = useMutation({ const deleteTrainMutation = useMutation({
mutationFn: (id: string) => fleetApi.deleteTrain(id), mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => fleetApi.deleteTrain(id, cascade),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trains'] }); queryClient.invalidateQueries({ queryKey: ['trains'] });
}, },
onError: (error: any) => { onError: (error: any) => {
const msg = error?.response?.data?.message || error?.message || 'Failed to delete train'; const msg = error?.response?.data?.message || error?.message || 'Failed to delete train';
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); const isFkError = msg?.includes('Cannot delete') || error?.response?.status === 400;
if (isFkError && !deleteConfirm.cascade) {
setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg }));
} else {
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg }));
}
}, },
}); });
@@ -77,7 +82,7 @@ export default function TrainsPage() {
const confirmDelete = async () => { const confirmDelete = async () => {
if (!deleteConfirm.train) return; if (!deleteConfirm.train) return;
try { try {
await deleteTrainMutation.mutateAsync(deleteConfirm.train.id); await deleteTrainMutation.mutateAsync({ id: deleteConfirm.train.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked });
setDeleteConfirm({ isOpen: false, train: null }); setDeleteConfirm({ isOpen: false, train: null });
} catch { } catch {
// error is set by onError handler // error is set by onError handler
@@ -237,7 +242,10 @@ export default function TrainsPage() {
isDanger={true} isDanger={true}
isLoading={deleteTrainMutation.isPending} isLoading={deleteTrainMutation.isPending}
error={deleteConfirm.error} error={deleteConfirm.error}
warning="This train may be assigned to schedules and trips. Deleting it may impact these systems and associated bookings." warning={!deleteConfirm.cascade ? "This train may be assigned to schedules and trips. Deleting it may impact these systems and associated bookings." : undefined}
cascadeWarning={deleteConfirm.cascade ? "This train has related schedules that will also be permanently deleted." : undefined}
cascadeChecked={deleteConfirm.cascadeChecked}
onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))}
/> />
{/* Add/Edit Modal */} {/* Add/Edit Modal */}

View File

@@ -17,6 +17,9 @@ interface ConfirmDialogProps {
isDanger?: boolean; isDanger?: boolean;
warning?: string; warning?: string;
error?: string; error?: string;
cascadeWarning?: string;
onCascadeChange?: (checked: boolean) => void;
cascadeChecked?: boolean;
} }
export default function ConfirmDialog({ export default function ConfirmDialog({
@@ -31,6 +34,9 @@ export default function ConfirmDialog({
isDanger = false, isDanger = false,
warning, warning,
error, error,
cascadeWarning,
onCascadeChange,
cascadeChecked = false,
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
@@ -129,6 +135,26 @@ export default function ConfirmDialog({
</div> </div>
); );
})()} })()}
{cascadeWarning && (
<div className="rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 px-4 py-3 space-y-3">
<div className="flex gap-3 items-start">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" />
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">{cascadeWarning}</p>
</div>
<label className="flex items-start gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={cascadeChecked}
onChange={e => onCascadeChange?.(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-amber-400 accent-red-600 cursor-pointer"
/>
<span className="text-xs font-medium text-amber-900 dark:text-amber-200">
I understand delete this record and all related data
</span>
</label>
</div>
)}
</div> </div>
{/* Footer */} {/* Footer */}
@@ -140,6 +166,7 @@ export default function ConfirmDialog({
variant={isDanger ? 'danger' : 'primary'} variant={isDanger ? 'danger' : 'primary'}
onClick={onConfirm} onClick={onConfirm}
loading={isLoading} loading={isLoading}
disabled={!!cascadeWarning && !cascadeChecked}
> >
{confirmText} {confirmText}
</ActionButton> </ActionButton>

View File

@@ -61,6 +61,7 @@ export const passengersApi = {
}, },
getById: (id: string) => apiClient.get<any>(`/passengers/${id}`), getById: (id: string) => apiClient.get<any>(`/passengers/${id}`),
verify: (nationalId: string) => apiClient.post<any>('/passengers/verify-fayda', { nationalId }), verify: (nationalId: string) => apiClient.post<any>('/passengers/verify-fayda', { nationalId }),
delete: (id: string, cascade?: boolean) => apiClient.delete(`/passengers/${id}${cascade ? '?cascade=true' : ''}`),
}; };
// Stations API // Stations API
export const stationsApi = { export const stationsApi = {
@@ -79,7 +80,7 @@ export const stationsApi = {
getById: (id: string) => apiClient.get<any>(`/stations/${id}`), getById: (id: string) => apiClient.get<any>(`/stations/${id}`),
create: (data: any) => apiClient.post<any>('/stations', data), create: (data: any) => apiClient.post<any>('/stations', data),
update: (id: string, data: any) => apiClient.patch<any>(`/stations/${id}`, data), update: (id: string, data: any) => apiClient.patch<any>(`/stations/${id}`, data),
delete: (id: string) => apiClient.delete(`/stations/${id}`), delete: (id: string, cascade?: boolean) => apiClient.delete(`/stations/${id}${cascade ? '?cascade=true' : ''}`),
}; };
// Fleet API // Fleet API
@@ -108,11 +109,11 @@ export const fleetApi = {
}, },
createTrain: (data: any) => apiClient.post<any>('/fleet/trains', data), createTrain: (data: any) => apiClient.post<any>('/fleet/trains', data),
updateTrain: (id: string, data: any) => apiClient.patch<any>(`/fleet/trains/${id}`, data), updateTrain: (id: string, data: any) => apiClient.patch<any>(`/fleet/trains/${id}`, data),
deleteTrain: (id: string) => apiClient.delete(`/fleet/trains/${id}`), deleteTrain: (id: string, cascade?: boolean) => apiClient.delete(`/fleet/trains/${id}${cascade ? '?cascade=true' : ''}`),
restoreTrain: (id: string) => apiClient.patch<any>(`/fleet/trains/${id}/restore`, {}), restoreTrain: (id: string) => apiClient.patch<any>(`/fleet/trains/${id}/restore`, {}),
createCoach: (data: any) => apiClient.post<any>('/fleet/coaches', data), createCoach: (data: any) => apiClient.post<any>('/fleet/coaches', data),
updateCoach: (id: string, data: any) => apiClient.patch<any>(`/fleet/coaches/${id}`, data), updateCoach: (id: string, data: any) => apiClient.patch<any>(`/fleet/coaches/${id}`, data),
deleteCoach: (id: string) => apiClient.delete(`/fleet/coaches/${id}`), deleteCoach: (id: string, cascade?: boolean) => apiClient.delete(`/fleet/coaches/${id}${cascade ? '?cascade=true' : ''}`),
generateSeatMap: (data: any) => apiClient.post<any>('/fleet/seatmap/generate', data), generateSeatMap: (data: any) => apiClient.post<any>('/fleet/seatmap/generate', data),
}; };
@@ -362,7 +363,7 @@ export const seatClassesApi = {
getById: (id: string) => apiClient.get<any>(`/fleet/classes/${id}`), getById: (id: string) => apiClient.get<any>(`/fleet/classes/${id}`),
create: (data: any) => apiClient.post<any>('/fleet/classes', data), create: (data: any) => apiClient.post<any>('/fleet/classes', data),
update: (id: string, data: any) => apiClient.patch<any>(`/fleet/classes/${id}`, data), update: (id: string, data: any) => apiClient.patch<any>(`/fleet/classes/${id}`, data),
delete: (id: string) => apiClient.delete(`/fleet/classes/${id}`), delete: (id: string, cascade?: boolean) => apiClient.delete(`/fleet/classes/${id}${cascade ? '?cascade=true' : ''}`),
}; };
// Food & Dining API // Food & Dining API
@@ -409,7 +410,7 @@ export const packagesApi = {
update: (id: string, data: any) => apiClient.patch<any>(`/packages/${id}`, data), update: (id: string, data: any) => apiClient.patch<any>(`/packages/${id}`, data),
activate: (id: string) => apiClient.patch<any>(`/packages/${id}/activate`, {}), activate: (id: string) => apiClient.patch<any>(`/packages/${id}/activate`, {}),
deactivate: (id: string) => apiClient.patch<any>(`/packages/${id}/deactivate`, {}), deactivate: (id: string) => apiClient.patch<any>(`/packages/${id}/deactivate`, {}),
remove: (id: string) => apiClient.delete(`/packages/${id}`), remove: (id: string, cascade?: boolean) => apiClient.delete(`/packages/${id}${cascade ? '?cascade=true' : ''}`),
getBookings: async (params?: any) => { getBookings: async (params?: any) => {
const cleanParams = Object.fromEntries( const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null) Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)

View File

@@ -30,8 +30,8 @@ export const routesApi = {
return apiClient.patch<Route>(`/routes/${id}`, data); return apiClient.patch<Route>(`/routes/${id}`, data);
}, },
delete: (id: string) => { delete: (id: string, cascade?: boolean) => {
return apiClient.delete(`/routes/${id}`); return apiClient.delete(`/routes/${id}${cascade ? '?cascade=true' : ''}`);
}, },
getFareRules: (routeId: string) => { getFareRules: (routeId: string) => {

View File

@@ -10,7 +10,7 @@ import { apiClient } from '@/lib/api-client';
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { CheckCircle, Copy, Train, FileText } from 'lucide-react'; import { CheckCircle, Copy, Train, FileText } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { isChild, calculatePassengerFare } from '@/utils/fare-utils'; import { isChild, isFirstChild } from '@/utils/fare-utils';
type BookingWithTicket = { type BookingWithTicket = {
id: string; id: string;
@@ -27,7 +27,7 @@ type BookingWithTicket = {
export default function ConfirmationPage() { export default function ConfirmationPage() {
const router = useRouter(); const router = useRouter();
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking, packageName, packageTierPriceMinor } = useBookingStore(); const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking, packageName, packageId, reviewedTotalMinor, reviewedPassengerFares } = useBookingStore();
// The currency/amount actually confirmed for the payment option the user selected — // The currency/amount actually confirmed for the payment option the user selected —
// null when no payment step ran (e.g. a fully-discounted, zero-amount booking). // null when no payment step ran (e.g. a fully-discounted, zero-amount booking).
const { selectedCurrency: paidCurrency, paidAmountMinor } = usePaymentStore(); const { selectedCurrency: paidCurrency, paidAmountMinor } = usePaymentStore();
@@ -92,14 +92,32 @@ export default function ConfirmationPage() {
const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule; const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
// Prefer the amount/currency actually confirmed for the selected payment option; // Prefer the amount/currency actually confirmed for the selected payment option;
// only fall back to the ETB booking fare when no payment step ran (e.g. $0 total). // only fall back to the ETB booking fare when no payment step ran (e.g. $0 total).
const totalFare = paidAmountMinor const voucherCurrency = 'ETB';
?? _booking?.totalMinor
?? passengers.reduce((s) => s + (activeSchedule?.baseFareAdult || 0), 0);
const voucherCurrency = paidAmountMinor != null ? paidCurrency : 'ETB';
const farePerPassenger = Math.round(totalFare / passengers.length);
const createdAt = _booking?.createdAt || new Date().toISOString(); const createdAt = _booking?.createdAt || new Date().toISOString();
const status = _booking?.status || 'CONFIRMED'; const status = _booking?.status || 'CONFIRMED';
// Compute per-passenger fares using the same logic as the review/payment pages.
// reviewedPassengerFares is the authoritative source; rebuild from package context
// as a fallback so free children always show ETB 0.00 on their voucher.
const { packageTierPriceMinor } = useBookingStore.getState();
const isPackageBooking = packageTierPriceMinor != null;
const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
const pkgMultiplier = isPackageBooking && isRoundTrip ? 2 : 1;
const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgMultiplier : 0;
const pkgChildFare = pkgAdultFare;
const getVoucherFare = (idx: number): number => {
if (reviewedPassengerFares?.[idx] != null) return reviewedPassengerFares[idx].fareMinor;
if (isPackageBooking) {
const isPkgChild = idx >= adultCount;
const isFreeChild = isPkgChild && (idx - adultCount) < adultCount;
if (isFreeChild) return 0;
return isPkgChild ? pkgChildFare : pkgAdultFare;
}
const totalFare = reviewedTotalMinor ?? paidAmountMinor ?? _booking?.totalMinor ?? 0;
return Math.round(totalFare / passengers.length);
};
const outbound = { const outbound = {
trainNumber: activeSchedule?.trainNumber || 'N/A', trainNumber: activeSchedule?.trainNumber || 'N/A',
trainName: 'EDR Express', trainName: 'EDR Express',
@@ -137,7 +155,7 @@ export default function ConfirmationPage() {
outboundSchedule: outbound, outboundSchedule: outbound,
inboundSchedule: inbound, inboundSchedule: inbound,
isRoundTrip, isRoundTrip,
fareMinor: farePerPassenger, fareMinor: getVoucherFare(i),
currency: voucherCurrency, currency: voucherCurrency,
createdAt, createdAt,
}); });
@@ -332,26 +350,10 @@ export default function ConfirmationPage() {
<p className="text-sm text-gray-600 dark:text-gray-400">Total paid</p> <p className="text-sm text-gray-600 dark:text-gray-400">Total paid</p>
<p className="font-semibold text-gray-900 dark:text-gray-100"> <p className="font-semibold text-gray-900 dark:text-gray-100">
{(() => { {(() => {
if (reviewedTotalMinor != null) return `ETB ${(reviewedTotalMinor / 100).toFixed(2)}`;
if (paidAmountMinor != null) return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`; if (paidAmountMinor != null) return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`;
if (_booking?.totalMinor != null) return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`; if (_booking?.totalMinor != null) return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`;
// Recompute the same way the payment page does return 'ETB 0.00';
const isPackage = !!packageTierPriceMinor;
const pkgRoundTripMultiplier = isPackage && isRoundTrip ? 2 : 1;
const pkgAdultFare = isPackage ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0;
const pkgChildFare = isPackage ? Math.round(pkgAdultFare * 0.1) : 0;
const fallback = isPackage
? passengers.reduce((sum, p) => sum + (isChild(p) ? pkgChildFare : pkgAdultFare), 0)
: isRoundTrip
? passengers.reduce((sum, p, i) => {
const outFare = (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0);
const inFare = (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0);
return sum + calculatePassengerFare(passengers, i, outFare) + calculatePassengerFare(passengers, i, inFare);
}, 0)
: passengers.reduce((sum, p, i) => {
const fare = (p as any).seatFareMinor ?? (selectedSchedule?.baseFareAdult || 0);
return sum + calculatePassengerFare(passengers, i, fare);
}, 0);
return `ETB ${(fallback / 100).toFixed(2)}`;
})()} })()}
</p> </p>
</div> </div>
@@ -393,20 +395,27 @@ export default function ConfirmationPage() {
</div> </div>
<div> <div>
<p className="text-gray-600 dark:text-gray-400">Seat(s)</p> <p className="text-gray-600 dark:text-gray-400">Seat(s)</p>
{isRoundTrip ? ( {(() => {
<div className="space-y-0.5"> const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
const isFreeChild = packageId
? index >= adultCount && (index - adultCount) < adultCount
: isChild(passenger) && isFirstChild(passengers, index);
if (isFreeChild) return <p className="font-semibold text-gray-900 dark:text-gray-100"></p>;
return isRoundTrip ? (
<div className="space-y-0.5">
<p className="font-semibold text-gray-900 dark:text-gray-100">
Outbound: {(passenger as any).outboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>{(passenger as any).outboundCoachNumber}</span>} {(passenger as any).outboundSeatNumber || 'Auto-assigned at boarding'}
</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
Return: {(passenger as any).inboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>{(passenger as any).inboundCoachNumber}</span>} {(passenger as any).inboundSeatNumber || 'Auto-assigned at boarding'}
</p>
</div>
) : (
<p className="font-semibold text-gray-900 dark:text-gray-100"> <p className="font-semibold text-gray-900 dark:text-gray-100">
Outbound: {(passenger as any).outboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>{(passenger as any).outboundCoachNumber}</span>} {(passenger as any).outboundSeatNumber || 'Auto-assigned at boarding'} {passenger.coachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>(Coach {passenger.coachNumber})</span>} {passenger.seatNumber || 'Auto-assigned at boarding'}
</p> </p>
<p className="font-semibold text-gray-900 dark:text-gray-100"> );
Return: {(passenger as any).inboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>{(passenger as any).inboundCoachNumber}</span>} {(passenger as any).inboundSeatNumber || 'Auto-assigned at boarding'} })()}
</p>
</div>
) : (
<p className="font-semibold text-gray-900 dark:text-gray-100">
{passenger.coachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>(Coach {passenger.coachNumber})</span>} {passenger.seatNumber || 'Auto-assigned at boarding'}
</p>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -9,7 +9,7 @@ import { useState, useEffect } from "react";
import { PaymentMethod } from "@/types"; import { PaymentMethod } from "@/types";
import { format } from "date-fns"; import { format } from "date-fns";
import { formatTime, getTimePeriod } from '@/utils/format'; import { formatTime, getTimePeriod } from '@/utils/format';
import { calculatePassengerFare, isChild, isFirstChild, formatFare } from '@/utils/fare-utils'; import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
import { import {
CreditCard, CreditCard,
Smartphone, Smartphone,
@@ -28,7 +28,7 @@ const getIconForMethod = (methodId: string) => {
export default function PaymentPage() { export default function PaymentPage() {
const router = useRouter(); const router = useRouter();
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, packageTierPriceMinor } = useBookingStore(); const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, reviewedTotalMinor, reviewedPassengerFares } = useBookingStore();
const { setPaymentIntent, updateStatus, setCurrency, setPaidAmount } = usePaymentStore(); const { setPaymentIntent, updateStatus, setCurrency, setPaidAmount } = usePaymentStore();
const [selectedMethod, setSelectedMethod] = useState<string | null>(null); const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null); const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
@@ -36,10 +36,7 @@ export default function PaymentPage() {
const [paymentError, setPaymentError] = useState<string | null>(null); const [paymentError, setPaymentError] = useState<string | null>(null);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
const isPackage = !!packageTierPriceMinor; const isPackage = !!packageName;
const pkgRoundTripMultiplier = isPackage && isRoundTrip ? 2 : 1;
const pkgAdultFare = isPackage ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0;
const pkgChildFare = isPackage ? Math.round(pkgAdultFare * 0.1) : 0;
const displayCurrency = 'ETB' as const; const displayCurrency = 'ETB' as const;
@@ -64,62 +61,42 @@ export default function PaymentPage() {
enabled: !!bookingId, enabled: !!bookingId,
}); });
// Per-leg totals across all passengers. // Per-leg subtotals for the journey header — sum each paying passenger's reviewed fare
// Package: one leg = pkgAdultFare/pkgChildFare (already ×1 per leg; pkgAdultFare already has ×2 for round-trip baked in via pkgRoundTripMultiplier — so per-leg is packageTierPriceMinor). // split equally across both legs. This guarantees leg totals are consistent with the
const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; // per-passenger breakdown rows and the overall reviewed total.
const childCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length; const outboundBaseFare = isRoundTrip
const pkgPerLegAdultFare = isPackage ? packageTierPriceMinor! : 0; ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0)
const pkgPerLegChildFare = isPackage ? Math.round(pkgPerLegAdultFare * 0.1) : 0; : 0;
const pkgPerLegTotal = isPackage ? adultCount * pkgPerLegAdultFare + childCount * pkgPerLegChildFare : 0; const inboundBaseFare = isRoundTrip
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0)
: 0;
// Prefer each passenger's own seat fare (set during seat selection) over the schedule's // reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display —
// flat baseFareAdult — bed coaches price Upper/Middle/Lower berths differently, so a // they were computed and shown to the user on the review page, so the Total here must match.
// single schedule-level fare can't correctly represent every passenger's actual seat. // The API booking-amount is used only as the charge amount sent to the payment provider.
const outboundBaseFare = isPackage const reviewedTotal = reviewedTotalMinor ?? (reviewedPassengerFares?.reduce((s, f) => s + f.fareMinor, 0) ?? null);
? pkgPerLegTotal const totalAmountDisplay = reviewedTotal != null ? reviewedTotal / 100 : (bookingAmountData != null ? bookingAmountData.amount : null);
: (isRoundTrip && outboundSchedule ? passengers.reduce((sum, p, i) => { const totalAmount = bookingAmountData != null
const fare = (p as any).outboundSeatFareMinor ?? (outboundSchedule.baseFareAdult || 0); ? Math.round(bookingAmountData.amount * 100)
return sum + calculatePassengerFare(passengers, i, fare); : (reviewedTotal ?? 0);
}, 0) : 0);
const inboundBaseFare = isPackage
? pkgPerLegTotal
: (isRoundTrip && inboundSchedule ? passengers.reduce((sum, p, i) => {
const fare = (p as any).inboundSeatFareMinor ?? (inboundSchedule.baseFareAdult || 0);
return sum + calculatePassengerFare(passengers, i, fare);
}, 0) : 0);
const baseFare = isPackage
? adultCount * pkgAdultFare + childCount * pkgChildFare
: isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, p, i) => {
const scheduleFare = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0;
const farePerPassenger = (p as any).seatFareMinor ?? scheduleFare;
return sum + calculatePassengerFare(passengers, i, farePerPassenger);
}, 0);
// For package bookings the client-side baseFare is authoritative — it applies the
// round-trip multiplier and child pricing correctly, whereas booking.totalMinor in
// the DB may have been stored as a single-leg amount for older bookings.
// For regular bookings the API is the source of truth.
const totalAmountDisplay = isPackage
? baseFare / 100
: bookingAmountData != null ? bookingAmountData.amount : null;
const totalAmount = isPackage
? baseFare
: bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : baseFare;
const confirmedCurrency = bookingAmountData?.currency || amountCurrency; const confirmedCurrency = bookingAmountData?.currency || amountCurrency;
// Persist the amount/currency actually confirmed for the selected payment option so // Show loading spinner only when the API hasn't responded AND we have no review-page
// downstream screens (e.g. the voucher) use it instead of a default ETB fare. // total to fall back on — once reviewedTotalMinor is set the button is always enabled.
const awaitingAmount = !isPackage && loadingAmount && totalAmountDisplay === null;
useEffect(() => { useEffect(() => {
if (isPackage) { // Always store the reviewed total (minor, ETB) as the paid amount — it's what was
// shown to the user and matches the fare breakdown. The API amount is only used as
// the charge sent to the provider (may differ due to currency conversion).
if (reviewedTotal != null) {
setCurrency('ETB'); setCurrency('ETB');
setPaidAmount(totalAmount); setPaidAmount(reviewedTotal);
} else if (bookingAmountData != null) { } else if (bookingAmountData != null) {
setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD'); setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD');
setPaidAmount(totalAmount); setPaidAmount(Math.round(bookingAmountData.amount * 100));
} }
}, [isPackage, bookingAmountData, confirmedCurrency, totalAmount, setCurrency, setPaidAmount]); }, [bookingAmountData, confirmedCurrency, reviewedTotal, setCurrency, setPaidAmount]);
const paymentMutation = useMutation({ const paymentMutation = useMutation({
mutationFn: async (data: any) => { mutationFn: async (data: any) => {
@@ -289,28 +266,14 @@ export default function PaymentPage() {
<JourneyLeg schedule={selectedSchedule} label="Your journey" /> <JourneyLeg schedule={selectedSchedule} label="Your journey" />
)} )}
{/* Fare breakdown — same first-child-free logic as the review page */} {/* Fare breakdown — sourced directly from review page to guarantee totals match */}
<div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700 space-y-2"> <div className="pt-2 border-t-2 border-gray-200 dark:border-gray-700 space-y-2">
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">Fare breakdown</h3> <h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">Fare breakdown</h3>
{passengers.map((p, i) => { {passengers.map((p, i) => {
const reviewed = reviewedPassengerFares?.[i];
const isChildPassenger = isChild(p); const isChildPassenger = isChild(p);
const isFreeChild = reviewed?.isFree ?? (isChildPassenger && isFirstChild(passengers, i));
let passengerTotal: number; const passengerTotal = reviewed?.fareMinor ?? 0;
let isFreeChild = false;
if (isPackage) {
passengerTotal = isChildPassenger ? pkgChildFare : pkgAdultFare;
} else {
// Prefer this passenger's actual seat fare (varies by berth for bed coaches)
// over the schedule's flat baseFareAdult.
const outFare = (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0);
const inFare = (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0);
const onewayFare = (p as any).seatFareMinor ?? (selectedSchedule?.baseFareAdult || 0);
const outboundFare = calculatePassengerFare(passengers, i, outFare);
const inboundFare = calculatePassengerFare(passengers, i, inFare);
const oneWayFare = calculatePassengerFare(passengers, i, onewayFare);
passengerTotal = isRoundTrip ? outboundFare + inboundFare : oneWayFare;
isFreeChild = isChildPassenger && isFirstChild(passengers, i);
}
return ( return (
<div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0"> <div key={i} className="border-b border-gray-100 dark:border-gray-800 pb-2 last:border-0">
@@ -319,9 +282,9 @@ export default function PaymentPage() {
{p.name || `Passenger ${i + 1}`} {p.name || `Passenger ${i + 1}`}
{isChildPassenger && ( {isChildPassenger && (
<span className={`text-xs font-semibold ml-1 ${ <span className={`text-xs font-semibold ml-1 ${
isPackage ? 'text-blue-600' : isFreeChild ? 'text-green-600' : 'text-blue-600' isFreeChild ? 'text-green-600' : 'text-blue-600'
}`}> }`}>
({isPackage ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'}) ({isFreeChild ? 'CHILD - FREE' : 'CHILD - FULL FARE'})
</span> </span>
)} )}
</span> </span>
@@ -329,25 +292,15 @@ export default function PaymentPage() {
{formatFare(passengerTotal, displayCurrency)} {formatFare(passengerTotal, displayCurrency)}
</span> </span>
</div> </div>
{isRoundTrip && ( {isRoundTrip && !isFreeChild && (
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400"> <div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
<div className="flex justify-between"> <div className="flex justify-between">
<span>Outbound {!isPackage && isFreeChild ? '(Free)' : ''}</span> <span>Outbound</span>
<span>{formatFare( <span>{formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}</span>
isPackage
? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)
: calculatePassengerFare(passengers, i, (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0)),
displayCurrency
)}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span>Return {!isPackage && isFreeChild ? '(Free)' : ''}</span> <span>Return</span>
<span>{formatFare( <span>{formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}</span>
isPackage
? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)
: calculatePassengerFare(passengers, i, (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0)),
displayCurrency
)}</span>
</div> </div>
</div> </div>
)} )}
@@ -360,7 +313,7 @@ export default function PaymentPage() {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span> <span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
<span className="text-xl font-bold text-primary flex items-center gap-1.5"> <span className="text-xl font-bold text-primary flex items-center gap-1.5">
{(!isPackage && (loadingAmount || totalAmountDisplay === null)) ? ( {awaitingAmount ? (
<Loader2 className="w-4 h-4 animate-spin text-primary" /> <Loader2 className="w-4 h-4 animate-spin text-primary" />
) : ( ) : (
<>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}</> <>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}</>
@@ -376,14 +329,14 @@ export default function PaymentPage() {
)} )}
<button <button
onClick={handlePayment} onClick={handlePayment}
disabled={!selectedMethod || isProcessing || (!isPackage && (loadingAmount || totalAmountDisplay === null))} disabled={!selectedMethod || isProcessing || awaitingAmount}
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed" className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isProcessing ? ( {isProcessing ? (
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Processing... <Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span> </span>
) : (!isPackage && (loadingAmount || totalAmountDisplay === null)) ? ( ) : awaitingAmount ? (
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Calculating amount... <Loader2 className="w-4 h-4 animate-spin" /> Calculating amount...
</span> </span>
@@ -522,7 +475,7 @@ export default function PaymentPage() {
<div className="flex items-center justify-between mb-2.5"> <div className="flex items-center justify-between mb-2.5">
<span className="text-sm text-gray-600 dark:text-gray-400">Total</span> <span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
<span className="text-lg font-bold text-primary flex items-center gap-1.5"> <span className="text-lg font-bold text-primary flex items-center gap-1.5">
{(!isPackage && (loadingAmount || totalAmountDisplay === null)) ? ( {awaitingAmount ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" /> <Loader2 className="w-3.5 h-3.5 animate-spin" />
) : ( ) : (
<>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}</> <>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)}</>
@@ -539,14 +492,14 @@ export default function PaymentPage() {
</button> </button>
<button <button
onClick={handlePayment} onClick={handlePayment}
disabled={!selectedMethod || isProcessing || (!isPackage && (loadingAmount || totalAmountDisplay === null))} disabled={!selectedMethod || isProcessing || awaitingAmount}
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed" className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isProcessing ? ( {isProcessing ? (
<span className="flex items-center justify-center gap-1.5"> <span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Processing... <Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span> </span>
) : (!isPackage && (loadingAmount || totalAmountDisplay === null)) ? ( ) : awaitingAmount ? (
<span className="flex items-center justify-center gap-1.5"> <span className="flex items-center justify-center gap-1.5">
<Loader2 className="w-4 h-4 animate-spin" /> Calculating... <Loader2 className="w-4 h-4 animate-spin" /> Calculating...
</span> </span>

View File

@@ -47,11 +47,12 @@ function getPassengerIdFromToken(token: string): string | null {
export default function ReviewPage() { export default function ReviewPage() {
const router = useRouter(); const router = useRouter();
const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, seatHold, setBookingId, setPNR, createAccount, passengerId: storedPassengerId, searchCriteria, packageId, priceTierId, packageTierPriceMinor, packageName } = useBookingStore(); const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, seatHold, setBookingId, setPNR, setReviewedTotal, createAccount, passengerId: storedPassengerId, searchCriteria, packageId, priceTierId, packageTierPriceMinor, packageName } = useBookingStore();
const { user, isAuthenticated } = useAuthStore(); const { user, isAuthenticated } = useAuthStore();
const [timeLeft, setTimeLeft] = useState<string>(''); const [timeLeft, setTimeLeft] = useState<string>('');
const [seatDetails, setSeatDetails] = useState<Record<string, string>>({}); const [seatDetails, setSeatDetails] = useState<Record<string, string>>({});
const [fareBreakdown, setFareBreakdown] = useState<any>(null); const [fareBreakdown, setFareBreakdown] = useState<any>(null);
const [computedTotal, setComputedTotal] = useState<number>(0);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
@@ -266,6 +267,16 @@ export default function ReviewPage() {
} }
// Build booking request for authenticated users // Build booking request for authenticated users
// For package bookings, free children (first child per adult, no seat assigned)
// are excluded from the passengers array — the backend derives them from adultCount/childCount.
const bookingPassengers = passengers.filter((p, i) => {
if (packageId) {
const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount;
return !isFreePkgChild;
}
return !(isChild(p) && isFirstChild(passengers, i));
});
bookingData = { bookingData = {
passengerId: passengerId, passengerId: passengerId,
scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id, scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id,
@@ -275,11 +286,12 @@ export default function ReviewPage() {
seatClassId: seatClassId, seatClassId: seatClassId,
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
displayCurrency: displayCurrency, displayCurrency: displayCurrency,
passengers: passengers.map((p) => { passengers: bookingPassengers.map((p) => {
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId;
return { return {
seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), ...(seatId ? { seatId } : {}),
...(isRoundTrip && { returnSeatId: (p as any).inboundSeatId || '' }), ...(isRoundTrip && (p as any).inboundSeatId ? { returnSeatId: (p as any).inboundSeatId } : {}),
passengerName: p.name, passengerName: p.name,
dateOfBirth: p.dateOfBirth, dateOfBirth: p.dateOfBirth,
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
@@ -310,6 +322,16 @@ export default function ReviewPage() {
if (priceTierId) bookingData.priceTierId = priceTierId; if (priceTierId) bookingData.priceTierId = priceTierId;
} else { } else {
// For guests: send full passenger details array // For guests: send full passenger details array
// For package bookings, free children (first child per adult, no seat assigned)
// are excluded from the passengers array — the backend derives them from adultCount/childCount.
const guestBookingPassengers = passengers.filter((p, i) => {
if (packageId) {
const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount;
return !isFreePkgChild;
}
return !(isChild(p) && isFirstChild(passengers, i));
});
bookingData = { bookingData = {
scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id, scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id,
holdId: seatHold?.holdId || '', holdId: seatHold?.holdId || '',
@@ -318,11 +340,12 @@ export default function ReviewPage() {
seatClassId: seatClassId, seatClassId: seatClassId,
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
displayCurrency: displayCurrency, displayCurrency: displayCurrency,
passengers: passengers.map(p => { passengers: guestBookingPassengers.map(p => {
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId;
return { return {
seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), ...(seatId ? { seatId } : {}),
...(isRoundTrip && { returnSeatId: (p as any).inboundSeatId || '' }), ...(isRoundTrip && (p as any).inboundSeatId ? { returnSeatId: (p as any).inboundSeatId } : {}),
passengerName: p.name, passengerName: p.name,
dateOfBirth: p.dateOfBirth, dateOfBirth: p.dateOfBirth,
idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT',
@@ -362,6 +385,20 @@ export default function ReviewPage() {
localStorage.setItem('deviceId', bookingData.deviceId); localStorage.setItem('deviceId', bookingData.deviceId);
} }
// Persist the review-page total before navigating so payment/confirmation use the same figure
const passengerFares = passengers.map((p, i) => {
const isChildPassenger = isPackageBooking ? i >= adultPassengerCount : isChild(p);
const line = fareBreakdown?.passengers?.[i];
const isFreeChild = isPackageBooking
? (isChildPassenger && (i - adultPassengerCount) < adultPassengerCount)
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
const seatFare = getPassengerSeatFare(p);
const fareMinor = isPackageBooking
? (isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare))
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
return { fareMinor, isFree: isFreeChild };
});
setReviewedTotal(computedTotal, passengerFares);
await createBookingMutation.mutateAsync(bookingData); await createBookingMutation.mutateAsync(bookingData);
} catch (error) { } catch (error) {
alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.'); alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.');
@@ -437,22 +474,22 @@ export default function ReviewPage() {
fetchFareBreakdown(scheduleId, searchCriteria.originStationId, searchCriteria.destinationStationId); fetchFareBreakdown(scheduleId, searchCriteria.originStationId, searchCriteria.destinationStationId);
}, [fetchFareBreakdown, isRoundTrip, outboundSchedule?.id, selectedSchedule?.id, searchCriteria?.originStationId, searchCriteria?.destinationStationId]); }, [fetchFareBreakdown, isRoundTrip, outboundSchedule?.id, selectedSchedule?.id, searchCriteria?.originStationId, searchCriteria?.destinationStationId]);
if (isRoundTrip && (!outboundSchedule || !inboundSchedule || !passengers.length)) {
return null;
}
if (!isRoundTrip && (!selectedSchedule || !passengers.length)) {
return null;
}
const isPackageBooking = packageTierPriceMinor !== null; const isPackageBooking = packageTierPriceMinor !== null;
// packageTierPriceMinor is the per-adult fare for ONE leg. // packageTierPriceMinor is the per-adult fare for ONE leg.
// Round-trip packages multiply by 2; children pay 10% of the adult fare. // Round-trip packages multiply by 2.
const pkgRoundTripMultiplier = isPackageBooking && isRoundTrip ? 2 : 1; // First child per adult travels FREE; additional children pay full adult fare.
const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0;
const pkgChildFare = isPackageBooking ? Math.round(pkgAdultFare * 0.1) : 0;
const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
const childPassengerCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length; const childPassengerCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length;
const pkgRoundTripMultiplier = isPackageBooking && isRoundTrip ? 2 : 1;
const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0;
const pkgPaidChildrenCount = Math.max(0, childPassengerCount - adultPassengerCount);
// Paid children pay full adult fare
const pkgChildFare = pkgAdultFare; // full fare for paid children
// For package bookings, passengers are initialized without dateOfBirth so isChild() is
// unreliable. Use the stored adultCount from searchCriteria to determine category by index.
const isPackageChild = (index: number) =>
isPackageBooking ? index >= adultPassengerCount : isChild(passengers[index]);
// Per-seat fare captured on the seats page (bed-position-aware, computed locally from // Per-seat fare captured on the seats page (bed-position-aware, computed locally from
// the schedule's own coachTypes/classes) is guaranteed correct for berths, unlike the // the schedule's own coachTypes/classes) is guaranteed correct for berths, unlike the
@@ -467,7 +504,7 @@ export default function ReviewPage() {
}; };
const total = isPackageBooking const total = isPackageBooking
? adultPassengerCount * pkgAdultFare + childPassengerCount * pkgChildFare ? adultPassengerCount * pkgAdultFare + pkgPaidChildrenCount * pkgChildFare
: passengers.reduce((sum, p, i) => { : passengers.reduce((sum, p, i) => {
const isChildPassenger = isChild(p); const isChildPassenger = isChild(p);
const line = fareBreakdown?.passengers?.[i]; const line = fareBreakdown?.passengers?.[i];
@@ -477,6 +514,19 @@ export default function ReviewPage() {
return sum + (seatFare ?? line?.fareMinor ?? 0); return sum + (seatFare ?? line?.fareMinor ?? 0);
}, 0); }, 0);
// Keep computedTotal in sync so handleConfirm can persist it to the store
useEffect(() => { setComputedTotal(total); }, [total]);
// For package bookings, determine if a child is free (first per adult) or paid.
// Children are ordered after adults in the passengers array (set on package detail page).
const isPkgFreeChild = (index: number) => {
if (!isPackageBooking) return false;
if (!isPackageChild(index)) return false;
// childIndex = position among children (0-based)
const childIndex = index - adultPassengerCount;
return childIndex < adultPassengerCount; // first adultCount children are free
};
// Shared fare sidebar — rendered in right column (desktop) and inline (mobile) // Shared fare sidebar — rendered in right column (desktop) and inline (mobile)
const FareSidebar = () => ( const FareSidebar = () => (
<div className="card space-y-3"> <div className="card space-y-3">
@@ -485,11 +535,13 @@ export default function ReviewPage() {
</h2> </h2>
{passengers.map((p, i) => { {passengers.map((p, i) => {
const line = fareBreakdown?.passengers?.[i]; const line = fareBreakdown?.passengers?.[i];
const isChildPassenger = isChild(p); const isChildPassenger = isPackageChild(i);
const isFreeChild = !isPackageBooking && (line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i))); const isFreeChild = isPackageBooking
? isPkgFreeChild(i)
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
const seatFare = getPassengerSeatFare(p); const seatFare = getPassengerSeatFare(p);
const passengerTotal = isPackageBooking const passengerTotal = isPackageBooking
? (isChildPassenger ? pkgChildFare : pkgAdultFare) ? (isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare))
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0)); : (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
return ( return (
@@ -499,9 +551,9 @@ export default function ReviewPage() {
{p.name || `Passenger ${i + 1}`} {p.name || `Passenger ${i + 1}`}
{isChildPassenger && ( {isChildPassenger && (
<span className={`text-xs font-semibold ml-1 ${ <span className={`text-xs font-semibold ml-1 ${
isPackageBooking ? 'text-blue-600' : isFreeChild ? 'text-green-600' : 'text-blue-600' isFreeChild ? 'text-green-600' : 'text-blue-600'
}`}> }`}>
({isPackageBooking ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'}) ({isFreeChild ? 'CHILD - FREE' : 'CHILD - FULL FARE'})
</span> </span>
)} )}
</span> </span>
@@ -810,7 +862,7 @@ export default function ReviewPage() {
<p className="text-xs text-gray-500 dark:text-gray-400">Outbound Seat</p> <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 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).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 as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}
</p> </p>
{(p as any).outboundSeatId && ( {(p as any).outboundSeatId && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(outboundSchedule)}</p> <p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(outboundSchedule)}</p>
@@ -820,7 +872,7 @@ export default function ReviewPage() {
<p className="text-xs text-gray-500 dark:text-gray-400">Return Seat</p> <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 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).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 as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}
</p> </p>
{(p as any).inboundSeatId && ( {(p as any).inboundSeatId && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(inboundSchedule)}</p> <p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(inboundSchedule)}</p>
@@ -832,7 +884,7 @@ export default function ReviewPage() {
<p className="text-sm text-gray-600 dark:text-gray-400">Seat</p> <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 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.coachNumber && <span className="text-gray-500 dark:text-gray-400 ml-1">{p.coachNumber} </span>}
{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'} {p.seatId ? (seatDetails[p.seatId] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}
</p> </p>
{p.seatId && ( {p.seatId && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(selectedSchedule)}</p> <p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(selectedSchedule)}</p>

View File

@@ -233,22 +233,26 @@ export default function SeatsPage() {
? (currentJourneyType === "inbound" ? originalFaresRef.current.inbound : originalFaresRef.current.outbound) ? (currentJourneyType === "inbound" ? originalFaresRef.current.inbound : originalFaresRef.current.outbound)
: originalFaresRef.current.oneWay; : originalFaresRef.current.oneWay;
// Child seat allocation rule: let A = adults, C = children (isChild = under 5). // Seat eligibility:
// If C > A, only A - 1 children get their own seat and the rest share with an adult. // - All adults always need their own seat.
// If C <= A, no child gets a separate seat — all of them share with an adult. // - For package bookings: first child per adult travels free with no seat;
// Adults always need their own seat. // additional children (beyond one per adult) pay full fare and need a seat.
// Free children are pre-marked with a child DOB on the package detail page.
// - For regular bookings: same "first child per adult free" rule applies.
// In both cases: children are identified by isChild() (DOB < 5 years).
// Free children = first `adultCount` children (by position); paid children = the rest.
const seatEligibility = useMemo(() => { const seatEligibility = useMemo(() => {
const adultIndices = passengers.map((_, i) => i).filter((i) => !isChild(passengers[i])); const adultIndices = passengers.map((_, i) => i).filter((i) => !isChild(passengers[i]));
const childIndices = passengers.map((_, i) => i).filter((i) => isChild(passengers[i])); const childIndices = passengers.map((_, i) => i).filter((i) => isChild(passengers[i]));
const adultCount = adultIndices.length; const adultCount = adultIndices.length;
const childCount = childIndices.length; // First `adultCount` children are free (no seat); the rest are paid (need a seat).
const eligibleChildCount = childCount > adultCount ? Math.max(adultCount - 1, 0) : 0; const freeChildIndices = new Set(childIndices.slice(0, adultCount));
const eligibleChildIndices = childIndices.slice(0, eligibleChildCount); const paidChildIndices = childIndices.slice(adultCount);
const eligibleSet = new Set<number>([...adultIndices, ...eligibleChildIndices]); const eligibleSet = new Set<number>([...adultIndices, ...paidChildIndices]);
// Children who don't get their own seat share with an adult (round-robin, for display). // Free children share with an adult (round-robin, for display).
const sharingWithAdult = new Map<number, string>(); const sharingWithAdult = new Map<number, string>();
childIndices.slice(eligibleChildCount).forEach((childIdx, offset) => { Array.from(freeChildIndices).forEach((childIdx, offset) => {
const adultIdx = adultIndices[offset % Math.max(adultIndices.length, 1)]; const adultIdx = adultIndices[offset % Math.max(adultIndices.length, 1)];
if (adultIdx != null) { if (adultIdx != null) {
sharingWithAdult.set(childIdx, passengers[adultIdx]?.name || `Adult ${adultIdx + 1}`); sharingWithAdult.set(childIdx, passengers[adultIdx]?.name || `Adult ${adultIdx + 1}`);

View File

@@ -21,6 +21,9 @@ import {
Tag, Tag,
Shield, Shield,
X, X,
Star,
Bed,
Armchair,
} from "lucide-react"; } from "lucide-react";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -58,6 +61,13 @@ interface Schedule {
routeStops?: RouteStop[]; routeStops?: RouteStop[];
} }
interface CoachTypeInfo {
id: string;
name: string;
code: string;
type: string; // 'passenger' | 'sleeper' | 'dining' | 'baggage'
}
interface PriceTier { interface PriceTier {
id: string; id: string;
packageId: string; packageId: string;
@@ -67,6 +77,7 @@ interface PriceTier {
currency: string; currency: string;
availableSeats: number; availableSeats: number;
bookedSeats: number; bookedSeats: number;
seatClass?: { coachType?: CoachTypeInfo };
} }
interface PackageDetail { interface PackageDetail {
@@ -192,101 +203,157 @@ function JourneyCard({ schedule, label }: { schedule: Schedule; label: string })
); );
} }
// ─── Price Tiers Panel ──────────────────────────────────────────────────────── // ─── Coach Type Group Panel (Step 1 + Step 2 inline) ─────────────────────────
function groupTiersByCoachType(tiers: PriceTier[]): Array<{
coachTypeId: string;
coachTypeName: string;
coachTypeCode: string;
coachTypeType: string;
tiers: PriceTier[];
minPrice: number;
currency: string;
}> {
const map = new Map<string, { coachTypeId: string; coachTypeName: string; coachTypeCode: string; coachTypeType: string; tiers: PriceTier[] }>();
for (const tier of tiers) {
const ct = tier.seatClass?.coachType;
const key = ct?.id ?? `__ungrouped__${tier.seatType}`;
if (!map.has(key)) {
map.set(key, {
coachTypeId: ct?.id ?? key,
coachTypeName: ct?.name ?? tier.seatType,
coachTypeCode: ct?.code ?? '',
coachTypeType: ct?.type ?? 'passenger',
tiers: [],
});
}
map.get(key)!.tiers.push(tier);
}
return Array.from(map.values()).map((g) => ({
...g,
minPrice: Math.min(...g.tiers.map((t) => t.priceMinor)),
currency: g.tiers[0]?.currency ?? 'ETB',
}));
}
function getCoachIcon(coachTypeType: string) {
const lower = coachTypeType.toLowerCase();
if (lower.includes('sleeper')) return Star;
if (lower.includes('bed') || lower.includes('sleep')) return Bed;
return Armchair;
}
// Capitalise first letter of each word, replace underscores with spaces
function formatCoachTypeLabel(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
function PriceTiersPanel({ function PriceTiersPanel({
tiers, tiers,
selectedTierId,
onSelect,
onBookNow, onBookNow,
isRoundTrip, isRoundTrip,
}: { }: {
tiers: PriceTier[]; tiers: PriceTier[];
selectedTierId: string | null; onBookNow: (coachTypeId: string) => void;
onSelect: (id: string) => void;
onBookNow: () => void;
isRoundTrip: boolean; isRoundTrip: boolean;
}) { }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const priceMultiplier = isRoundTrip ? 2 : 1; const priceMultiplier = isRoundTrip ? 2 : 1;
const groups = groupTiersByCoachType(tiers ?? []);
if (!tiers?.length) {
return (
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800">
<p className="text-sm text-gray-400 text-center py-4">No price tiers available</p>
</div>
);
}
return ( return (
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800"> <div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800 space-y-3">
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-base font-bold text-gray-900 dark:text-white">Select Coach Type</h2>
Select Seat Type {groups.map((group) => {
</h2> const CoachIcon = getCoachIcon(group.coachTypeType);
const allSoldOut = group.tiers.every((t) => t.availableSeats === 0);
{!tiers?.length ? ( const isSelected = selectedId === group.coachTypeId;
<p className="text-sm text-gray-400 text-center py-4"> return (
No price tiers available <div
</p> key={group.coachTypeId}
) : ( className={`rounded-xl border-2 overflow-hidden transition-colors ${
<div className="space-y-2.5"> allSoldOut
{tiers.map((tier) => { ? 'border-gray-200 dark:border-gray-700 opacity-50'
const soldOut = tier.availableSeats === 0; : isSelected
const selected = tier.id === selectedTierId; ? 'border-primary'
return ( : 'border-gray-200 dark:border-gray-700 cursor-pointer hover:border-primary/50'
<div }`}
key={tier.id} onClick={() => !allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)}
onClick={() => !soldOut && onSelect(tier.id)} >
className={`rounded-xl border-2 p-3.5 transition-all ${ {/* Coach type header */}
soldOut <div className="flex items-center gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-800/60">
? "border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed" <div className={`w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 ${
: selected isSelected ? 'bg-primary' : 'bg-primary/10'
? "border-primary bg-primary/5" }`}>
: "border-gray-200 dark:border-gray-700 hover:border-primary/50 hover:shadow-sm cursor-pointer" <CoachIcon className={`w-5 h-5 ${isSelected ? 'text-white' : 'text-primary'}`} />
}`}
>
{/* Row 1: radio + full label */}
<div className="flex items-start gap-2.5">
<div
className={`w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center mt-0.5 transition-colors ${
selected
? "border-primary bg-primary"
: "border-gray-300 dark:border-gray-600"
}`}
>
{selected && <div className="w-1.5 h-1.5 rounded-full bg-white" />}
</div>
<p className="text-sm font-semibold text-gray-900 dark:text-white leading-snug">
{tier.label.trim()}
</p>
</div>
{/* Row 2: seatType badge + seats + price */}
<div className="flex items-center justify-between mt-2 pl-[26px]">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">
{tier.seatType.trim()}
</span>
{soldOut ? (
<span className="text-[10px] font-bold text-red-500 bg-red-50 dark:bg-red-900/20 px-1.5 py-0.5 rounded">
SOLD OUT
</span>
) : (
<span className="text-[10px] text-gray-400">
{tier.availableSeats} left
</span>
)}
</div>
<p className="text-sm font-extrabold text-primary">
{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 className="flex-1 min-w-0">
})} <p className="text-sm font-bold text-gray-900 dark:text-white">
</div> {formatCoachTypeLabel(group.coachTypeType)}
)} </p>
<p className="text-xs text-gray-500 dark:text-gray-400">
From {formatPrice(group.minPrice * priceMultiplier, group.currency)}
{allSoldOut && <span className="ml-2 text-red-500 font-semibold">· Sold out</span>}
</p>
</div>
{!allSoldOut && (
<div className={`w-5 h-5 rounded-full border-2 flex-shrink-0 flex items-center justify-center ${
isSelected ? 'border-primary bg-primary' : 'border-gray-300 dark:border-gray-600'
}`}>
{isSelected && <Check className="w-3 h-3 text-white" />}
</div>
)}
</div>
{/* All available classes for this coach type */}
<div className="px-4 py-3 space-y-2">
{group.tiers.map((tier) => {
const soldOut = tier.availableSeats === 0;
return (
<div
key={tier.id}
className={`flex items-start gap-2 py-1.5 ${soldOut ? 'opacity-50' : ''}`}
>
<div className="w-1.5 h-1.5 rounded-full bg-primary flex-shrink-0 mt-1.5" />
<div>
<p className="text-sm text-gray-700 dark:text-gray-300">{tier.seatType.trim()}</p>
<p className="text-xs">
<span className="font-bold text-primary">{formatPrice(tier.priceMinor * priceMultiplier, tier.currency)}</span>
{soldOut ? (
<span className="ml-2 font-bold text-red-500">Sold out</span>
) : (
<span className="ml-2 text-gray-400">{tier.availableSeats} left</span>
)}
</p>
</div>
</div>
);
})}
</div>
{/* Book Now — only when this group is selected */}
{isSelected && !allSoldOut && (
<div className="px-4 pb-4">
<button
type="button"
onClick={(e) => { e.stopPropagation(); onBookNow(group.coachTypeId); }}
className="w-full py-2.5 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> </div>
); );
} }
@@ -294,8 +361,7 @@ function PriceTiersPanel({
// ─── Passenger count picker ────────────────────────────────────────────────── // ─── Passenger count picker ──────────────────────────────────────────────────
const PKG_MAX_ADULTS = 5; const PKG_MAX_ADULTS = 5;
const PKG_MAX_CHILDREN = 2; const PKG_CHILDREN_PER_ADULT = 5;
const PKG_CHILD_FARE_RATIO = 0.1;
function PassengerCountModal({ function PassengerCountModal({
tier, tier,
@@ -319,8 +385,11 @@ function PassengerCountModal({
const [departureStationId, setDepartureStationId] = useState(''); const [departureStationId, setDepartureStationId] = useState('');
const [showStationError, setShowStationError] = useState(false); const [showStationError, setShowStationError] = useState(false);
const remaining = tier.availableSeats - tier.bookedSeats; const remaining = tier.availableSeats - tier.bookedSeats;
const childFareMinor = Math.round(tier.priceMinor * PKG_CHILD_FARE_RATIO); // First child per adult travels free (no seat); additional children pay full adult fare
const totalMinor = (adultCount * tier.priceMinor + childCount * childFareMinor) * priceMultiplier; const freeChildren = Math.min(childCount, adultCount);
const paidChildren = Math.max(0, childCount - adultCount);
// Only paid children need seats; free children share with an adult
const totalMinor = (adultCount * tier.priceMinor + paidChildren * tier.priceMinor) * priceMultiplier;
return ( return (
<> <>
@@ -335,15 +404,15 @@ function PassengerCountModal({
</div> </div>
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10"> <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-xs text-gray-400 uppercase tracking-wide font-semibold">Coach type</p>
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.label.trim()}</p> <p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}</p>
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult</p> <p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · prices from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)</p>
</div> </div>
<div className="px-6 py-5 space-y-4"> <div className="px-6 py-5 space-y-4">
{[ {[
{ label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: setAdultCount }, { label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: (v: number) => { setAdultCount(v); const newMax = Math.min(v * PKG_CHILDREN_PER_ADULT, v + Math.max(0, remaining - v)); setChildCount(c => Math.min(c, newMax)); } },
{ label: "Children", sub: `Under 5 · max ${PKG_MAX_CHILDREN} · 10% of adult fare`, value: childCount, min: 0, max: Math.min(PKG_MAX_CHILDREN, remaining - adultCount), set: setChildCount }, { label: "Children", sub: `Under 5 · max ${PKG_CHILDREN_PER_ADULT} per adult · 1st per adult FREE (no seat)`, value: childCount, min: 0, max: Math.min(adultCount * PKG_CHILDREN_PER_ADULT, adultCount + Math.max(0, remaining - adultCount)), set: setChildCount },
].map(({ label, sub, value, min, max, set }) => ( ].map(({ label, sub, value, min, max, set }) => (
<div key={label} className="flex items-center justify-between"> <div key={label} className="flex items-center justify-between">
<div> <div>
@@ -366,6 +435,12 @@ function PassengerCountModal({
</div> </div>
))} ))}
{freeChildren > 0 && (
<div className="flex items-center justify-between text-xs text-green-600 dark:text-green-400">
<span>{freeChildren} child{freeChildren > 1 ? 'ren' : ''} travel free (no seat)</span>
<span>ETB 0.00</span>
</div>
)}
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800"> <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{priceMultiplier === 2 ? ' (round-trip)' : ''}</span> <span className="text-sm text-gray-500">Total{priceMultiplier === 2 ? ' (round-trip)' : ''}</span>
<span className="text-base font-extrabold text-primary">{formatPrice(totalMinor, tier.currency)}</span> <span className="text-base font-extrabold text-primary">{formatPrice(totalMinor, tier.currency)}</span>
@@ -426,7 +501,7 @@ export default function PackageDetailPage() {
const id = params?.id as string; const id = params?.id as string;
const { clearBooking, setSearchCriteria, setSelectedSchedule, setOutboundSchedule, setInboundSchedule, setPassengers, setPackageContext } = useBookingStore(); const { clearBooking, setSearchCriteria, setSelectedSchedule, setOutboundSchedule, setInboundSchedule, setPassengers, setPackageContext } = useBookingStore();
const [selectedTierId, setSelectedTierId] = useState<string | null>(null); const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string | null>(null);
const [passengerModalOpen, setPassengerModalOpen] = useState(false); const [passengerModalOpen, setPassengerModalOpen] = useState(false);
const [bookingContextLoading, setBookingContextLoading] = useState(false); const [bookingContextLoading, setBookingContextLoading] = useState(false);
const [bookingContextError, setBookingContextError] = useState<string | null>(null); const [bookingContextError, setBookingContextError] = useState<string | null>(null);
@@ -443,17 +518,21 @@ export default function PackageDetailPage() {
? pkg.outboundSchedule.routeStops.map((rs) => rs.station).filter(Boolean) ? pkg.outboundSchedule.routeStops.map((rs) => rs.station).filter(Boolean)
: []; : [];
const selectedTier = pkg?.priceTiers?.find((t) => t.id === selectedTierId); // For the passenger modal, use the cheapest available tier in the selected coach type group
const groups = pkg ? groupTiersByCoachType(pkg.priceTiers) : [];
const selectedGroup = groups.find((g) => g.coachTypeId === selectedCoachTypeId);
// Representative tier for the modal header (cheapest available)
const representativeTier = selectedGroup?.tiers.find((t) => t.availableSeats > 0) ?? selectedGroup?.tiers[0] ?? null;
const isRoundTripPkg = pkg?.journeyType === 'ROUND_TRIP'; const isRoundTripPkg = pkg?.journeyType === 'ROUND_TRIP';
const handleBookNow = async (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => { const handleBookNow = async (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => {
if (!selectedTier || !pkg) return; if (!representativeTier || !pkg) return;
setBookingContextLoading(true); setBookingContextLoading(true);
setBookingContextError(null); setBookingContextError(null);
try { try {
const ctx: any = await apiClient.get( const ctx: any = await apiClient.get(
`/packages/${id}/booking-context?tierId=${selectedTier.id}&adultCount=${adultCount}&childCount=${childCount}`, `/packages/${id}/booking-context?tierId=${representativeTier.id}&adultCount=${adultCount}&childCount=${childCount}`,
); );
clearBooking(); clearBooking();
@@ -473,7 +552,7 @@ export default function PackageDetailPage() {
duration: s.durationMinutes ? `${Math.floor(s.durationMinutes / 60)}h ${s.durationMinutes % 60}m` : "", duration: s.durationMinutes ? `${Math.floor(s.durationMinutes / 60)}h ${s.durationMinutes % 60}m` : "",
baseFareAdult: Math.round(ctx.totalMinor / passengerCount), baseFareAdult: Math.round(ctx.totalMinor / passengerCount),
baseFareChild: 0, baseFareChild: 0,
displayCurrency: selectedTier.currency, displayCurrency: representativeTier.currency,
selectedSeatClass: ctx.seatClassId, selectedSeatClass: ctx.seatClassId,
selectedSeatClassName: ctx.seatClassName ?? "", selectedSeatClassName: ctx.seatClassName ?? "",
seatClassName: ctx.seatClassName ?? "", seatClassName: ctx.seatClassName ?? "",
@@ -501,16 +580,25 @@ export default function PackageDetailPage() {
} }
setPassengers( setPassengers(
Array.from({ length: passengerCount }, (_, i) => ({ Array.from({ length: passengerCount }, (_, i) => {
name: "", // First `adultCount` entries are adults; remaining are children.
dateOfBirth: "", // Among children, the first `adultCount` are free (one per adult, no seat).
nationality: "ETHIOPIAN", const isChildPassenger = i >= adultCount;
isPrimaryPassenger: i === 0, const childIndex = i - adultCount; // 0-based index among children
})), const isFreeChild = isChildPassenger && childIndex < adultCount;
return {
name: "",
// Free children travel without a seat — give them a synthetic DOB that
// makes isChild() return true so seat eligibility logic excludes them.
dateOfBirth: isFreeChild ? new Date(Date.now() - 2 * 365.25 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) : "",
nationality: "ETHIOPIAN",
isPrimaryPassenger: i === 0,
};
}),
); );
// Store per-adult tier price (×1 leg); review page applies round-trip multiplier and child pricing // Store per-adult tier price (×1 leg); review page applies round-trip multiplier and child pricing
setPackageContext(id, selectedTier.id, selectedTier.priceMinor, pkg.name, departureStationId, departureStationName); setPackageContext(id, representativeTier.id, representativeTier.priceMinor, pkg.name, departureStationId, departureStationName);
router.push("/booking/passengers"); router.push("/booking/passengers");
} catch (err: any) { } catch (err: any) {
@@ -557,9 +645,9 @@ export default function PackageDetailPage() {
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Passenger count modal */} {/* Passenger count modal */}
{passengerModalOpen && selectedTier && ( {passengerModalOpen && representativeTier && (
<PassengerCountModal <PassengerCountModal
tier={selectedTier} tier={representativeTier}
onClose={() => { setPassengerModalOpen(false); setBookingContextError(null); }} onClose={() => { setPassengerModalOpen(false); setBookingContextError(null); }}
onConfirm={handleBookNow} onConfirm={handleBookNow}
loading={bookingContextLoading} loading={bookingContextLoading}
@@ -751,9 +839,7 @@ export default function PackageDetailPage() {
<div className="lg:hidden"> <div className="lg:hidden">
<PriceTiersPanel <PriceTiersPanel
tiers={pkg.priceTiers} tiers={pkg.priceTiers}
selectedTierId={selectedTierId} onBookNow={(coachTypeId) => { setSelectedCoachTypeId(coachTypeId); setPassengerModalOpen(true); }}
onSelect={setSelectedTierId}
onBookNow={() => setPassengerModalOpen(true)}
isRoundTrip={isRoundTripPkg} isRoundTrip={isRoundTripPkg}
/> />
</div> </div>
@@ -764,9 +850,7 @@ export default function PackageDetailPage() {
<div className="sticky top-20"> <div className="sticky top-20">
<PriceTiersPanel <PriceTiersPanel
tiers={pkg.priceTiers} tiers={pkg.priceTiers}
selectedTierId={selectedTierId} onBookNow={(coachTypeId) => { setSelectedCoachTypeId(coachTypeId); setPassengerModalOpen(true); }}
onSelect={setSelectedTierId}
onBookNow={() => setPassengerModalOpen(true)}
isRoundTrip={isRoundTripPkg} isRoundTrip={isRoundTripPkg}
/> />
</div> </div>

View File

@@ -118,6 +118,12 @@ interface BookingState {
packageTierPriceMinor: number | null; packageTierPriceMinor: number | null;
packageDepartureStationId: string | null; packageDepartureStationId: string | null;
packageDepartureStationName: string | null; packageDepartureStationName: string | null;
// The exact total (minor units, ETB) computed on the review page — used as the
// single source of truth on payment and confirmation pages to prevent drift.
reviewedTotalMinor: number | null;
// Per-passenger fare breakdown computed on the review page — guarantees line items
// on the payment page sum to exactly reviewedTotalMinor.
reviewedPassengerFares: Array<{ fareMinor: number; isFree: boolean }> | null;
setSearchCriteria: (criteria: SearchCriteria) => void; setSearchCriteria: (criteria: SearchCriteria) => void;
setSelectedSchedule: (schedule: SelectedSchedule) => void; setSelectedSchedule: (schedule: SelectedSchedule) => void;
@@ -131,6 +137,7 @@ interface BookingState {
setCreateAccount: (create: boolean) => void; setCreateAccount: (create: boolean) => void;
setPassengerId: (id: string | null) => void; setPassengerId: (id: string | null) => void;
setPackageContext: (packageId: string, priceTierId: string, priceMinor: number, packageName?: string, departureStationId?: string, departureStationName?: string) => void; setPackageContext: (packageId: string, priceTierId: string, priceMinor: number, packageName?: string, departureStationId?: string, departureStationName?: string) => void;
setReviewedTotal: (totalMinor: number, passengerFares: Array<{ fareMinor: number; isFree: boolean }>) => void;
clearBooking: () => void; clearBooking: () => void;
} }
@@ -153,6 +160,8 @@ export const useBookingStore = create<BookingState>()(persist(
packageTierPriceMinor: null, packageTierPriceMinor: null,
packageDepartureStationId: null, packageDepartureStationId: null,
packageDepartureStationName: null, packageDepartureStationName: null,
reviewedTotalMinor: null,
reviewedPassengerFares: null,
setSearchCriteria: (criteria) => set({ searchCriteria: criteria }), setSearchCriteria: (criteria) => set({ searchCriteria: criteria }),
setPackageContext: (packageId, priceTierId, priceMinor, packageName, departureStationId, departureStationName) => set({ packageId, packageName: packageName ?? null, priceTierId, packageTierPriceMinor: priceMinor, packageDepartureStationId: departureStationId ?? null, packageDepartureStationName: departureStationName ?? null }), setPackageContext: (packageId, priceTierId, priceMinor, packageName, departureStationId, departureStationName) => set({ packageId, packageName: packageName ?? null, priceTierId, packageTierPriceMinor: priceMinor, packageDepartureStationId: departureStationId ?? null, packageDepartureStationName: departureStationName ?? null }),
@@ -166,6 +175,7 @@ export const useBookingStore = create<BookingState>()(persist(
setPaymentMethod: (method) => set({ selectedPaymentMethod: method }), setPaymentMethod: (method) => set({ selectedPaymentMethod: method }),
setCreateAccount: (create) => set({ createAccount: create }), setCreateAccount: (create) => set({ createAccount: create }),
setPassengerId: (id) => set({ passengerId: id }), setPassengerId: (id) => set({ passengerId: id }),
setReviewedTotal: (totalMinor, passengerFares) => set({ reviewedTotalMinor: totalMinor, reviewedPassengerFares: passengerFares }),
clearBooking: () => set({ clearBooking: () => set({
searchCriteria: null, searchCriteria: null,
selectedSchedule: null, selectedSchedule: null,
@@ -184,6 +194,8 @@ export const useBookingStore = create<BookingState>()(persist(
packageTierPriceMinor: null, packageTierPriceMinor: null,
packageDepartureStationId: null, packageDepartureStationId: null,
packageDepartureStationName: null, packageDepartureStationName: null,
reviewedTotalMinor: null,
reviewedPassengerFares: null,
}), }),
} as BookingState)), } as BookingState)),
{ {