diff --git a/apps/edr-passenger-api/prisma/migrations/20260706081216_add_package_booking_adult_child_count/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260706081216_add_package_booking_adult_child_count/migration.sql new file mode 100644 index 000000000..a43444c92 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260706081216_add_package_booking_adult_child_count/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "PackageBooking" ADD COLUMN "adultCount" INTEGER NOT NULL DEFAULT 1, +ADD COLUMN "childCount" INTEGER NOT NULL DEFAULT 0; diff --git a/apps/edr-passenger-api/prisma/migrations/20260706083104_add_price_tier_seat_class_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260706083104_add_price_tier_seat_class_id/migration.sql new file mode 100644 index 000000000..015402584 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260706083104_add_price_tier_seat_class_id/migration.sql @@ -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; diff --git a/apps/edr-passenger-api/prisma/migrations/20260706131529_add_schedule_is_package_only/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260706131529_add_schedule_is_package_only/migration.sql new file mode 100644 index 000000000..050cfd093 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260706131529_add_schedule_is_package_only/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "TrainSchedule" ADD COLUMN "isPackageOnly" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 83c2b884e..0cd1756fd 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -100,6 +100,7 @@ model SeatClass { fareRules FareRule[] routeFareRules RouteFareRule[] segmentFares SegmentFareRule[] + packagePriceTiers PackagePriceTier[] @@unique([coachTypeId, name]) @@index([coachTypeId]) @@index([coachTypeId, nationalityType, bedPosition]) @@ -367,6 +368,7 @@ model TrainSchedule { onTimePercent Int @default(100) carbonRating String @default("A") notes String? + isPackageOnly Boolean @default(false) train Train @relation(fields: [trainId], references: [id]) route Route? @relation(fields: [routeId], references: [id]) originStation Station @relation("OriginTrips", fields: [originStationId], references: [id]) @@ -1448,6 +1450,7 @@ model TravelPackage { model PackagePriceTier { id String @id @default(uuid()) packageId String + seatClassId String? seatType String label String priceMinor Int @@ -1456,6 +1459,7 @@ model PackagePriceTier { bookedSeats Int @default(0) package TravelPackage @relation(fields: [packageId], references: [id]) + seatClass SeatClass? @relation(fields: [seatClassId], references: [id]) bookings Booking[] packageBookings PackageBooking[] inquiries PackageInquiry[] @@ -1474,6 +1478,8 @@ model PackageBooking { contactPhone String? status BookingStatus @default(PENDING_PAYMENT) passengerCount Int @default(1) + adultCount Int @default(1) + childCount Int @default(0) totalMinor Int currency String @default("ETB") displayCurrency Currency? diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 834c69736..1e41076e7 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -8,7 +8,6 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; import { IamModule as TriaIamModule } from '@tria-plc/iamapi-common/iam.module'; 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 { EDR_PASSENGER_APPLICATION, @@ -98,16 +97,6 @@ import { SegmentFareSeeder } from './seed/segment-fare.seeder'; TriaIamModule.forRoot({ applications: [EDR_PASSENGER_APPLICATION], 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, PrismaModule, diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 96bd4b387..9a2053943 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -470,10 +470,11 @@ export class BookingsController { @ApiOperation({ 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: 404, description: 'Booking not found' }) - delete(@Param('id') id: string) { - return this.service.delete(id); + delete(@Param('id') id: string, @Query('cascade') cascade?: string) { + return this.service.delete(id, cascade === 'true'); } @Patch(':id') diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 57097c9cd..a2ea23e09 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -31,9 +31,10 @@ function resolvePackageRoundTripTotal( if (!booking.packageId || booking.bookingType !== 'ROUND_TRIP' || !tierPriceMinor) { return booking.totalMinor; } + // First child per adult is free; additional children pay full adult fare const adultFareMinor = tierPriceMinor * 2; - const childFareMinor = Math.round(adultFareMinor * 0.1); - return adultCount * adultFareMinor + childCount * childFareMinor; + const paidChildren = Math.max(0, childCount - adultCount); + return adultCount * adultFareMinor + paidChildren * adultFareMinor; } function calculateAge(dateOfBirth: Date): number { @@ -557,7 +558,10 @@ export class BookingsService { if (p.category === PassengerCategory.ADULT) { fareMinor = fareCalculation.baseFareMinor; } 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 { if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; } else fareMinor = fareCalculation.baseFareMinor; @@ -704,8 +708,11 @@ export class BookingsService { outboundFareMinor = outboundFare.baseFareMinor; returnFareMinor = returnFare.baseFareMinor; } else if (dto.packageId) { - outboundFareMinor = Math.round(outboundFare.baseFareMinor * 0.1); - returnFareMinor = Math.round(returnFare.baseFareMinor * 0.1); + // Free children (first per adult) get fareMinor=0; paid children pay full adult fare. + 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 { if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; } else outboundFareMinor = outboundFare.baseFareMinor; @@ -1255,19 +1262,19 @@ export class BookingsService { childCount: number, ) { const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } }); - // For round-trip packages the caller splits the tier price across legs, so - // priceMinor here is already the per-leg amount. Children pay 10% of adult fare. - const childFareMinor = Math.round(tier.priceMinor * 0.1); + // First child per adult travels free (no seat); additional children pay full adult fare. + const freeChildrenCount = Math.min(childCount, adultCount); + const paidChildrenCount = Math.max(0, childCount - adultCount); const adultFareMinor = tier.priceMinor * adultCount; - const childTotalMinor = childFareMinor * childCount; + const childTotalMinor = tier.priceMinor * paidChildrenCount; const totalBaseFareMinor = adultFareMinor + childTotalMinor; return { baseFareMinor: tier.priceMinor, adultCount, adultFareMinor, childCount, - freeChildrenCount: 0, - paidChildrenCount: childCount, + freeChildrenCount, + paidChildrenCount, childFareMinor: childTotalMinor, totalBaseFareMinor, discountMinor: 0, @@ -1555,21 +1562,51 @@ 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 } }); if (!booking) throw new NotFoundException('Booking not found'); - - // Check usage before allowing deletion - const usage = await this.checkBookingUsage(id); - if (usage.isInUse && usage.constraints) { - throw new DeleteOperationException('Booking', booking.bookingRef, usage.constraints); + + if (!cascade) { + const usage = await this.checkBookingUsage(id); + if (usage.isInUse && usage.constraints) { + throw new DeleteOperationException('Booking', booking.bookingRef, usage.constraints); + } } - + 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.booking.delete({ where: { id } }); - + return { deleted: true, bookingRef: booking.bookingRef }; } diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts index 2ccc79b70..b2708af5a 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts @@ -32,10 +32,6 @@ export class CurrenciesService { async createCurrency(dto: CreateCurrencyDto) { const { code, name, symbol, baseCurrencyCode = 'ETB', exchangeRate } = dto; - if (!['ETB', 'USD', 'DJF'].includes(code.toUpperCase())) { - throw new BadRequestException('Unsupported currency code'); - } - if (exchangeRate <= 0) { throw new BadRequestException('Exchange rate must be positive'); } diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index 24f9981fc..7422e45b0 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -76,10 +76,11 @@ export class FleetController { @Delete('classes/:id') @ApiOperation({ summary: 'Delete a class' }) @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: 404, description: 'Class not found' }) - deleteClass(@Param('id') id: string) { - return this.service.deleteClass(id); + deleteClass(@Param('id') id: string, @Query('cascade') cascade?: string) { + return this.service.deleteClass(id, cascade === 'true'); } // Seat Class Endpoints (DEPRECATED - use Classes endpoints instead) @@ -112,10 +113,11 @@ export class FleetController { @Delete('seat-classes/:id') @ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' }) @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: 404, description: 'Class not found' }) - deleteSeatClass(@Param('id') id: string) { - return this.service.deleteClass(id); + deleteSeatClass(@Param('id') id: string, @Query('cascade') cascade?: string) { + return this.service.deleteClass(id, cascade === 'true'); } // Train Endpoints @@ -147,10 +149,11 @@ export class FleetController { @Delete('trains/:id') @ApiOperation({ summary: 'Delete a train service' }) @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: 404, description: 'Train not found' }) - deleteTrain(@Param('id') id: string) { - return this.service.deleteTrain(id); + deleteTrain(@Param('id') id: string, @Query('cascade') cascade?: string) { + return this.service.deleteTrain(id, cascade === 'true'); } @Patch('trains/:id/restore') @@ -309,10 +312,11 @@ export class FleetController { @Delete('coaches/:id') @ApiOperation({ summary: 'Delete a coach' }) @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: 404, description: 'Coach not found' }) - deleteCoach(@Param('id') id: string) { - return this.service.deleteCoach(id); + deleteCoach(@Param('id') id: string, @Query('cascade') cascade?: string) { + return this.service.deleteCoach(id, cascade === 'true'); } @Post('assignments') diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index a725b9cd4..dc29f4ecc 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -273,7 +273,7 @@ export class FleetService { }); } - async deleteClass(id: string) { + async deleteClass(id: string, cascade = false) { const seatClass = await this.prisma.seatClass.findUnique({ where: { id }, include: { @@ -284,19 +284,27 @@ export class FleetService { }); if (!seatClass) throw new NotFoundException('Seat class not found'); - const totalFareRules = seatClass.fareRules.length + seatClass.routeFareRules.length + seatClass.segmentFares.length; - const constraints = []; - - if (totalFareRules > 0) { - constraints.push({ - entityName: 'fare rule', - count: totalFareRules, - action: 'delete' as const - }); + if (!cascade) { + const totalFareRules = seatClass.fareRules.length + seatClass.routeFareRules.length + seatClass.segmentFares.length; + const constraints = []; + + if (totalFareRules > 0) { + constraints.push({ + entityName: 'fare rule', + count: totalFareRules, + action: 'delete' as const + }); + } + + if (constraints.length > 0) { + throw new DeleteOperationException('Seat Class', seatClass.name, constraints); + } } - - if (constraints.length > 0) { - throw new DeleteOperationException('Seat Class', seatClass.name, constraints); + + if (cascade) { + 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 } }); @@ -354,24 +362,78 @@ export class FleetService { }); } - async deleteTrain(id: string) { + async deleteTrain(id: string, cascade = false) { const train = await this.prisma.train.findUnique({ where: { id }, include: { schedules: true }, }); if (!train) throw new NotFoundException('Train not found'); - const constraints = []; - if (train.schedules.length > 0) { - constraints.push({ - entityName: 'schedule', - count: train.schedules.length, - action: 'delete' as const - }); + if (!cascade) { + const constraints = []; + if (train.schedules.length > 0) { + constraints.push({ + entityName: 'schedule', + 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) { - throw new DeleteOperationException('Train', `${train.number} (${train.name})`, constraints); + + if (cascade && train.schedules.length > 0) { + 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 } }); @@ -477,7 +539,7 @@ export class FleetService { }); } - async deleteCoach(id: string) { + async deleteCoach(id: string, cascade = false) { const coach = await this.prisma.coach.findUnique({ where: { id }, include: { @@ -493,47 +555,60 @@ export class FleetService { }); if (!coach) throw new NotFoundException('Coach not found'); - const constraints = []; - - if ((coach as any).assignments.length > 0) { - constraints.push({ - entityName: 'schedule assignment', - count: (coach as any).assignments.length, - action: 'reassign' as const - }); + if (!cascade) { + const constraints = []; + + if ((coach as any).assignments.length > 0) { + constraints.push({ + entityName: 'schedule assignment', + count: (coach as any).assignments.length, + 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 (bookedSeats.length > 0) { - constraints.push({ - entityName: 'booked seat', - count: bookedSeats.length, - action: 'complete' as const - }); + if (cascade) { + const seatIds = (coach as any).seats.map((s: any) => s.id); + if (seatIds.length > 0) { + await this.prisma.bookingSeat.deleteMany({ where: { seatId: { in: seatIds } } }); + await this.prisma.seatBlock.deleteMany({ where: { seatId: { in: seatIds } } }); + 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 } }); return this.prisma.coach.delete({ where: { id } }); diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts index de61f92ca..07e324336 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -142,8 +142,9 @@ export class PackagesController { @UseGuards(IamGuard) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete package (admin)' }) - remove(@Param('id') id: string) { - return this.service.remove(id); + @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete even with active bookings' }) + remove(@Param('id') id: string, @Query('cascade') cascade?: string) { + return this.service.remove(id, cascade === 'true'); } @Patch(':id/activate') diff --git a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts index 0b7a25bba..d7257f179 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts @@ -3,6 +3,9 @@ import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreatePriceTierDto { + @ApiPropertyOptional({ description: 'SeatClass ID to link this tier to a specific seat class' }) + @IsOptional() @IsUUID() seatClassId?: string; + @ApiProperty({ example: 'HSC' }) @IsString() seatType: string; @@ -31,6 +34,7 @@ export class UpdateInquiryStatusDto { } export class UpdatePriceTierDto { + @ApiPropertyOptional() @IsOptional() @IsUUID() seatClassId?: string; @ApiPropertyOptional() @IsOptional() @IsString() seatType?: string; @ApiPropertyOptional() @IsOptional() @IsString() label?: string; @ApiPropertyOptional() @IsOptional() @IsInt() @Min(0) priceMinor?: number; diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index 294273d30..a474b1edf 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -8,9 +8,12 @@ import { GuestBookingService } from '../bookings/guest-booking.service'; /** Package-specific fare rules */ const PKG_MAX_ADULTS = 5; -const PKG_MAX_CHILDREN = 2; -const PKG_CHILD_FARE_RATIO = 0.1; +const PKG_CHILDREN_PER_ADULT = 5; // max 5 children per adult +/** + * First child per adult travels FREE (no seat). + * Additional children beyond one per adult pay the full adult fare. + */ function calculatePackageFareBreakdown( priceMinor: number, isRoundTrip: boolean, @@ -19,9 +22,10 @@ function calculatePackageFareBreakdown( ) { const multiplier = isRoundTrip ? 2 : 1; const adultFareMinor = priceMinor * multiplier; - const childFareMinor = Math.round(adultFareMinor * PKG_CHILD_FARE_RATIO); - const totalMinor = adultCount * adultFareMinor + childCount * childFareMinor; - return { adultFareMinor, childFareMinor, totalMinor, multiplier }; + const freeChildren = Math.min(childCount, adultCount); + const paidChildren = Math.max(0, childCount - adultCount); + const totalMinor = adultCount * adultFareMinor + paidChildren * adultFareMinor; + return { adultFareMinor, freeChildren, paidChildren, totalMinor, multiplier }; } 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 > 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; - if (passengerCount > remaining) - throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`); - const isRoundTrip = !!pkg.returnScheduleId; - const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown( + const { adultFareMinor, freeChildren, paidChildren, totalMinor } = calculatePackageFareBreakdown( 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 - let seatClassId: string | null = null; + let seatClassId: string | null = tier.seatClassId ?? null; + let seatClassName: string | null = null; let coachTypeId: string | null = null; for (const a of pkg.outboundSchedule.coachAssignments) { - const sc = a.coach.coachType?.seatClasses?.find( - (s: any) => s.name.toLowerCase().includes(tier.seatType.toLowerCase()) || - tier.seatType.toLowerCase().includes(s.name.toLowerCase()), - ); - if (sc) { seatClassId = sc.id; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; } + const sc = seatClassId + ? a.coach.coachType?.seatClasses?.find((s: any) => s.id === seatClassId) + : a.coach.coachType?.seatClasses?.find( + (s: any) => s.name.toLowerCase().includes(tier.seatType.toLowerCase()) || + tier.seatType.toLowerCase().includes(s.name.toLowerCase()), + ); + if (sc) { seatClassId = sc.id; seatClassName = sc.name; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; } } if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) { const first = pkg.outboundSchedule.coachAssignments[0]; @@ -102,16 +112,19 @@ export class PackagesService { tierLabel: tier.label, seatType: tier.seatType, seatClassId, + seatClassName, coachTypeId, adultCount, childCount, passengerCount, isRoundTrip, pricePerAdultMinor: adultFareMinor, - pricePerChildMinor: childFareMinor, - childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`, + pricePerChildMinor: adultFareMinor, // paid children pay full 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, - maxChildren: PKG_MAX_CHILDREN, + maxChildren: adultCount * PKG_CHILDREN_PER_ADULT, totalMinor, currency: tier.currency, remainingSeats: remaining, @@ -203,7 +216,7 @@ export class PackagesService { const pkg = await this.prisma.travelPackage.findUnique({ where: { id }, include: { - priceTiers: true, + priceTiers: { include: { seatClass: { include: { coachType: true } } } }, outboundSchedule: { include: { originStation: true, @@ -312,25 +325,31 @@ export class PackagesService { return this.prisma.packagePriceTier.delete({ where: { id: tierId } }); } - async remove(id: string) { - const pkg = await this.prisma.travelPackage.findUnique({ - where: { id }, - include: { bookings: { select: { id: true, status: true } } }, - }); + async remove(id: string, cascade = false) { + const pkg = await this.prisma.travelPackage.findUnique({ where: { id } }); 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 bookingIds = pkg.bookings.map((b) => b.id); - if (bookingIds.length > 0) { - 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 } }); + const packageBookings = await this.prisma.packageBooking.findMany({ + where: { packageId: id }, + select: { id: true, status: true }, }); + + 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 }; } @@ -369,18 +388,21 @@ export class PackagesService { 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 (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 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 { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown( + const { adultFareMinor, freeChildren, paidChildren, totalMinor } = calculatePackageFareBreakdown( 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 displayTotalMinor = displayCurrency !== Currency.ETB @@ -398,6 +420,8 @@ export class PackagesService { contactPhone: dto.contactPhone, promoCode: dto.promoCode, passengerCount, + adultCount, + childCount, totalMinor, currency: 'ETB', displayCurrency, @@ -427,7 +451,7 @@ export class PackagesService { }), this.prisma.packagePriceTier.update({ where: { id: dto.priceTierId }, - data: { bookedSeats: { increment: passengerCount } }, + data: { bookedSeats: { increment: seatsNeeded } }, }), ]); @@ -438,8 +462,10 @@ export class PackagesService { adultCount, adultFareMinor, childCount, - childFareMinor, - childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`, + freeChildrenCount: freeChildren, + paidChildrenCount: paidChildren, + paidChildFareMinor: adultFareMinor, + childFareNote: `First child per adult travels free (no seat); additional children pay full adult fare`, totalMinor, currency: 'ETB', displayCurrency, diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index a9fbfc7d2..f0b719386 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -508,10 +508,11 @@ Returns saved passenger details with generated IDs and confirmation.`, summary: 'Delete passenger (admin only)', 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: 404, description: 'Passenger not found' }) - deletePassenger(@Param('id') id: string) { - return this.service.deletePassenger(id); + deletePassenger(@Param('id') id: string, @Query('cascade') cascade?: string) { + return this.service.deletePassenger(id, cascade === 'true'); } @Get(':id/usage') diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index d41fcbfed..84be880be 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -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 let passenger = await this.prisma.passenger.findUnique({ where: { id }, @@ -451,29 +451,67 @@ export class PassengersService { const passengerId = passenger.id; - // Check usage before allowing deletion - const usage = await this.checkPassengerUsage(passengerId); - if (usage.isInUse && usage.constraints) { - const passengerName = (passenger as any).user?.fullName || `Passenger ${passengerId.slice(-8)}`; - throw new DeleteOperationException('Passenger', passengerName, usage.constraints); + if (!cascade) { + const usage = await this.checkPassengerUsage(passengerId); + if (usage.isInUse && usage.constraints) { + const passengerName = (passenger as any).user?.fullName || `Passenger ${passengerId.slice(-8)}`; + throw new DeleteOperationException('Passenger', passengerName, usage.constraints); + } } - await this.prisma.$transaction([ - this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId } } }), - this.prisma.loyaltyAccount.deleteMany({ where: { passengerId } }), - this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId } } }), - this.prisma.walletAccount.deleteMany({ where: { passengerId } }), - this.prisma.notification.deleteMany({ where: { passengerId } }), - this.prisma.travelerProfile.deleteMany({ where: { passengerId } }), - this.prisma.savedRoute.deleteMany({ where: { passengerId } }), - this.prisma.packageBooking.deleteMany({ where: { passengerId } }), - this.prisma.ticket.deleteMany({ where: { booking: { passengerId } } }), - this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId } } }), - this.prisma.booking.deleteMany({ where: { passengerId } }), - this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId } } }), - this.prisma.journey.deleteMany({ where: { passengerId } }), - this.prisma.passenger.delete({ where: { id: passengerId } }), - ]); + // Resolve booking IDs first (needed for multi-step child deletion) + const bookings = await this.prisma.booking.findMany({ + where: { passengerId }, + select: { id: true }, + }); + const bookingIds = bookings.map(b => b.id); + + if (bookingIds.length > 0) { + 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 } } }); + } + + // 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 }; } diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts index 1cd382862..f432cf072 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts @@ -51,9 +51,10 @@ Route stops carry distanceKm for fare-by-distance calculations.`, @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Delete a route' }) @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: 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 ──────────────────────────────────────────────────────────── diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index 479f6b0ca..e459e3663 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -110,7 +110,7 @@ export class RoutesService { }); } - async deleteRoute(id: string) { + async deleteRoute(id: string, cascade = false) { const route = await this.prisma.route.findUnique({ where: { id }, include: { @@ -120,19 +120,77 @@ export class RoutesService { }); if (!route) throw new NotFoundException('Route not found'); - const constraints = []; - if (route.schedules.length > 0) { - constraints.push({ - entityName: 'schedule', - count: route.schedules.length, - action: 'delete' as const - }); + if (!cascade) { + const constraints = []; + if (route.schedules.length > 0) { + constraints.push({ + entityName: 'schedule', + 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) { - throw new DeleteOperationException('Route', `${route.code} (${route.name})`, constraints); + if (cascade) { + 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 } }); return { deleted: true, id }; } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 1a5547d48..3cb95104d 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -113,7 +113,8 @@ export class SchedulesController { @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Delete a schedule' }) @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') @IsPublic() diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 80f3ad13b..3844e57cf 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -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({ 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({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean; } export class UpdateStopTimeDto { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 19aa5ed0e..92a0a66db 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -332,7 +332,7 @@ export class SchedulesService { 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({ where: { id }, include: { @@ -344,18 +344,20 @@ export class SchedulesService { }); if (!schedule) throw new NotFoundException('Schedule not found'); - const constraints = []; - if ((schedule as any)._count.bookings > 0) { - constraints.push({ - entityName: 'booking', - count: (schedule as any)._count.bookings, - action: 'cancel' as const - }); - } - - if (constraints.length > 0) { - const scheduleName = `${schedule.train.number} (${schedule.originStation.name} → ${schedule.destinationStation.name})`; - throw new DeleteOperationException('Schedule', scheduleName, constraints); + if (!cascade) { + const constraints = []; + if ((schedule as any)._count.bookings > 0) { + constraints.push({ + entityName: 'booking', + count: (schedule as any)._count.bookings, + action: 'cancel' as const + }); + } + + if (constraints.length > 0) { + const scheduleName = `${schedule.train.number} (${schedule.originStation.name} → ${schedule.destinationStation.name})`; + throw new DeleteOperationException('Schedule', scheduleName, constraints); + } } await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } }); @@ -363,30 +365,49 @@ export class SchedulesService { await this.prisma.tripLiveStatus.deleteMany({ where: { scheduleId: id } }); await this.prisma.menuItem.deleteMany({ where: { scheduleId: id } }); await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } }); - - // Delete travel packages that reference this schedule (required fields cannot be nulled) - // First get packages that reference this schedule - const packagesToDelete = await this.prisma.travelPackage.findMany({ - where: { - OR: [ - { outboundScheduleId: id }, - { returnScheduleId: id } - ] - }, - select: { id: true } + + // Delete bookings and all their children + const bookings = await this.prisma.booking.findMany({ + where: { OR: [{ scheduleId: 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 travel packages that reference this schedule + const packagesToDelete = await this.prisma.travelPackage.findMany({ + where: { OR: [{ outboundScheduleId: id }, { returnScheduleId: id }] }, + select: { id: true }, }); - - // Delete price tiers first (they have foreign key to packages) if (packagesToDelete.length > 0) { const packageIds = packagesToDelete.map(p => p.id); - await this.prisma.packagePriceTier.deleteMany({ - where: { packageId: { in: packageIds } } - }); - - // Now delete the packages - await this.prisma.travelPackage.deleteMany({ - where: { id: { in: packageIds } } - }); + await this.prisma.packagePriceTier.deleteMany({ where: { packageId: { in: packageIds } } }); + await this.prisma.travelPackage.deleteMany({ where: { id: { in: packageIds } } }); } return this.prisma.trainSchedule.delete({ where: { id } }); } @@ -632,6 +653,7 @@ export class SchedulesService { } if (dto.status) updateData.status = dto.status; + if (dto.isPackageOnly !== undefined) updateData.isPackageOnly = dto.isPackageOnly; if (Object.keys(updateData).length > 0) { await this.prisma.trainSchedule.update({ where: { id }, data: updateData }); diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 621e5487d..2942625ca 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -157,6 +157,7 @@ export class SearchService { const schedules = await this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, + isPackageOnly: false, OR: [ { departureAt: { gte: windowStart, lt: requestedDate } }, { departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } }, @@ -192,6 +193,7 @@ export class SearchService { const schedules = await this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, + isPackageOnly: false, departureAt: { gte: date < now ? now : date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, }, @@ -230,6 +232,7 @@ export class SearchService { this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, + isPackageOnly: false, departureAt: { gte: dayStart, lt: dayEnd }, stopTimes: { some: { stationId: originStationId } }, }, @@ -238,6 +241,7 @@ export class SearchService { this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, + isPackageOnly: false, departureAt: { gte: dayStart, lt: leg2WindowEnd }, }, include: SCHEDULE_INCLUDE, diff --git a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts index a89ecd87d..2b1842c2a 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts @@ -136,9 +136,10 @@ export class StationsController { @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @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: 404, description: 'Station not found' }) - remove(@Param('id') id: string) { - return this.service.remove(id); + remove(@Param('id') id: string, @Query('cascade') cascade?: string) { + return this.service.remove(id, cascade === 'true'); } } diff --git a/apps/edr-passenger-api/src/modules/stations/stations.service.ts b/apps/edr-passenger-api/src/modules/stations/stations.service.ts index ec2480b21..ff40a30ab 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.service.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.service.ts @@ -96,7 +96,7 @@ export class StationsService { return updatedStation; } - async remove(id: string) { + async remove(id: string, cascade = false) { const station = await this.prisma.station.findUnique({ where: { id }, include: { @@ -107,24 +107,34 @@ export class StationsService { }); if (!station) throw new NotFoundException('Station not found'); - const [routeStopCount, originCount, destCount, stopTimeCount] = await Promise.all([ - this.prisma.routeStop.count({ where: { stationId: id } }), - this.prisma.trainSchedule.count({ where: { originStationId: id } }), - this.prisma.trainSchedule.count({ where: { destinationStationId: id } }), - (station as any)._count.stopTimes as number, - ]); + if (!cascade) { + const [routeStopCount, originCount, destCount, stopTimeCount] = await Promise.all([ + this.prisma.routeStop.count({ where: { stationId: id } }), + this.prisma.trainSchedule.count({ where: { originStationId: id } }), + this.prisma.trainSchedule.count({ where: { destinationStationId: id } }), + (station as any)._count.stopTimes as number, + ]); - const constraints = []; - if (routeStopCount > 0) - constraints.push({ entityName: 'route', count: routeStopCount, action: 'delete' as const }); - const scheduleCount = originCount + destCount; - if (scheduleCount > 0) - constraints.push({ entityName: 'schedule', count: scheduleCount, action: 'delete' as const }); - if (stopTimeCount > 0) - constraints.push({ entityName: 'stop time', count: stopTimeCount, action: 'delete' as const }); + const constraints = []; + if (routeStopCount > 0) + constraints.push({ entityName: 'route', count: routeStopCount, action: 'delete' as const }); + const scheduleCount = originCount + destCount; + if (scheduleCount > 0) + constraints.push({ entityName: 'schedule', count: scheduleCount, action: 'delete' as const }); + if (stopTimeCount > 0) + constraints.push({ entityName: 'stop time', count: stopTimeCount, action: 'delete' as const }); - if (constraints.length > 0) - throw new DeleteOperationException('Station', `${station.name} (${station.code})`, constraints); + if (constraints.length > 0) + 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 } }); diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 974c25470..be2785d43 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -38,6 +38,8 @@ function BookingsPageContent() { const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [bookingToDelete, setBookingToDelete] = useState(null); const [deleteError, setDeleteError] = useState(null); + const [deleteCascade, setDeleteCascade] = useState(false); + const [deleteCascadeChecked, setDeleteCascadeChecked] = useState(false); const [successMessage, setSuccessMessage] = useState(''); const [exportModalOpen, setExportModalOpen] = useState(false); const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); @@ -62,17 +64,27 @@ function BookingsPageContent() { }); 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: () => { queryClient.invalidateQueries({ queryKey: ['bookings'] }); setDeleteConfirmOpen(false); setBookingToDelete(null); setDeleteError(null); + setDeleteCascade(false); + setDeleteCascadeChecked(false); setSuccessMessage('Booking deleted successfully'); setTimeout(() => setSuccessMessage(''), 3000); }, 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 = [ { 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 ( @@ -493,12 +505,16 @@ function BookingsPageContent() { { setDeleteConfirmOpen(false); setBookingToDelete(null); setDeleteError(null); }} - onConfirm={async () => { if (bookingToDelete) await deleteMutation.mutateAsync(bookingToDelete.id); }} + onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); }} + onConfirm={async () => { if (bookingToDelete) await deleteMutation.mutateAsync({ id: bookingToDelete.id, cascade: deleteCascade && deleteCascadeChecked }); }} title="Delete Booking" message={`Permanently delete booking ${bookingToDelete?.bookingRef}? This cannot be undone and will release all associated seats.`} confirmText="Delete" cancelText="Cancel" isLoading={deleteMutation.isPending} isDanger 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)} /> setExportModalOpen(false)} title="Export Bookings" size="md"> diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx index 65671959b..348268ca2 100644 --- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx @@ -15,7 +15,7 @@ export default function ClassesPage() { const [filters, setFilters] = useState({ search: '' }); const [showModal, setShowModal] = useState(false); const [editingClass, setEditingClass] = useState(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(''); const queryClient = useQueryClient(); @@ -56,13 +56,18 @@ export default function ClassesPage() { }); const deleteMutation = useMutation({ - mutationFn: seatClassesApi.delete, + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => seatClassesApi.delete(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['classes'] }); }, onError: (e: any) => { const msg = e?.response?.data?.message || e?.message || 'Failed to delete class'; - setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + const 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 () => { if (!deleteConfirm.class) return; try { - await deleteMutation.mutateAsync(deleteConfirm.class.id); + await deleteMutation.mutateAsync({ id: deleteConfirm.class.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); setDeleteConfirm({ isOpen: false, class: null }); } catch { // error is set by onError handler @@ -239,7 +244,10 @@ export default function ClassesPage() { isDanger={true} isLoading={deleteMutation.isPending} error={deleteConfirm.error} - warning="This class may be used by coaches and fare rules. Deleting it may impact seat assignments and pricing." + 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 */} diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index 91eec0082..eb8ebf036 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -145,8 +145,9 @@ export default function CoachesPage() { const [search, setSearch] = useState(''); const [showModal, setShowModal] = useState(false); const [editingItem, setEditingItem] = useState(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(''); + const [isBedCoach, setIsBedCoach] = useState(false); const [exportUtilModalOpen, setExportUtilModalOpen] = useState(false); const [exportUtilFormat, setExportUtilFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); @@ -216,7 +217,7 @@ export default function CoachesPage() { }); const deleteCoachMutation = useMutation({ - mutationFn: fleetApi.deleteCoach, + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => fleetApi.deleteCoach(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['coaches'] }); }, @@ -276,12 +277,17 @@ export default function CoachesPage() { if (deleteConfirm.item?.isCoachType) { await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id); } else { - await deleteCoachMutation.mutateAsync(deleteConfirm.item.id); + await deleteCoachMutation.mutateAsync({ id: deleteConfirm.item.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); } setDeleteConfirm({ isOpen: false, item: null }); } catch (err: any) { 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) => { setEditingItem({ ...item, isCoach: true }); setSelectedCoachTypeId(item.coachTypeId || ''); + setIsBedCoach(!!(item.bedCategory || item.coachType?.name?.toLowerCase().includes('bed'))); setShowModal(true); }, variant: 'secondary' as const, @@ -479,6 +486,7 @@ export default function CoachesPage() { onClick={() => { setEditingItem(null); setSelectedCoachTypeId(''); + setIsBedCoach(false); setSearch(''); setShowModal(true); }} @@ -686,11 +694,14 @@ export default function CoachesPage() { isDanger={true} isLoading={deleteCoachTypeMutation.isPending || deleteCoachMutation.isPending} error={deleteConfirm.error} - warning={ + warning={!deleteConfirm.cascade ? ( deleteConfirm.item?.isCoachType ? '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.' - } + ) : 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 */} @@ -700,6 +711,7 @@ export default function CoachesPage() { setShowModal(false); setEditingItem(null); setSelectedCoachTypeId(''); + setIsBedCoach(false); }} title={ activeTab === 'types' @@ -779,8 +791,14 @@ export default function CoachesPage() { - - - - -

- Select if this is a bed coach -

- - -
- - -

- Only applies to bed coaches -

-
- - ) : null; - })()} + {isBedCoach && ( + <> +
+ + +
+
+ + +
+ + )}
@@ -903,6 +902,7 @@ export default function CoachesPage() { setShowModal(false); setEditingItem(null); setSelectedCoachTypeId(''); + setIsBedCoach(false); }} > Cancel diff --git a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx index 8de163da9..4cf393713 100644 --- a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx @@ -2,10 +2,11 @@ import { useState } from 'react'; 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 Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { apiClient } from '@/lib/api-client'; interface CurrencyRate { @@ -29,6 +30,9 @@ export default function CurrenciesPage() { const [editingRate, setEditingRate] = useState(null); const [rateInput, setRateInput] = useState(''); const [error, setError] = useState(null); + const [showAddModal, setShowAddModal] = useState(false); + const [addForm, setAddForm] = useState({ code: '', name: '', symbol: '', exchangeRate: '' }); + const [deleteConfirm, setDeleteConfirm] = useState(null); const queryClient = useQueryClient(); const { data: currencies = [], isLoading } = useQuery({ @@ -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({ mutationFn: () => apiClient.post('/currencies/sync-rates', {}), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['currencies'] }), @@ -121,12 +145,8 @@ export default function CurrenciesPage() { ]; const actions = [ - { - label: 'Edit Rate', - onClick: handleEdit, - variant: 'secondary' as const, - icon: Edit, - }, + { label: 'Edit', onClick: handleEdit, variant: 'secondary' as const, icon: Edit }, + { label: 'Delete', onClick: (c: CurrencyRate) => setDeleteConfirm(c), variant: 'danger' as const, icon: Trash2 }, ]; return ( @@ -138,14 +158,17 @@ export default function CurrenciesPage() { Manage ETB exchange rates for display currencies (DJF, USD)

- syncMutation.mutate()} - loading={syncMutation.isPending} - > - Sync Rates - +
+ { setError(null); setShowAddModal(true); }}>Add Currency + syncMutation.mutate()} + loading={syncMutation.isPending} + > + Sync Rates + +
{error && !editingRate && ( @@ -204,6 +227,68 @@ export default function CurrenciesPage() {

• Rates apply globally; changes take effect immediately on the next booking or fare quote

+ { setShowAddModal(false); setError(null); }} + title="Add Currency" + size="sm" + > +
+ {error && ( +
{error}
+ )} +
+
+ + setAddForm({ ...addForm, code: e.target.value.toUpperCase() })} /> +
+
+ + setAddForm({ ...addForm, symbol: e.target.value })} /> +
+
+
+ + setAddForm({ ...addForm, name: e.target.value })} /> +
+
+ + setAddForm({ ...addForm, exchangeRate: e.target.value })} /> +
+
+ { setShowAddModal(false); setError(null); }}>Cancel + { + 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 + +
+
+
+ + 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} + /> + { setEditingRate(null); setError(null); }} diff --git a/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx index 2912eeee3..88301428d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx @@ -214,21 +214,31 @@ export default function PackageBookingsPage() {
- {b.passengers.map((p: any, i: number) => ( -
-
- {i + 1} -
-

{p.passengerName}

-

- {p.dateOfBirth ? new Date(p.dateOfBirth).toLocaleDateString() : ''} - {p.idDocumentType ? ` · ${p.idDocumentType}` : ''} - {p.passportNumber ? ` · ${p.passportNumber}` : ''} -

+ {b.passengers.map((p: any, i: number) => { + const isChild = i >= (b.adultCount ?? b.passengerCount); + return ( +
+
+ {i + 1} +
+

{p.passengerName}

+

+ {p.dateOfBirth ? new Date(p.dateOfBirth).toLocaleDateString() : ''} + {p.idDocumentType ? ` · ${p.idDocumentType}` : ''} + {p.passportNumber ? ` · ${p.passportNumber}` : ''} +

+
+ + {isChild ? 'CHILD' : 'ADULT'} +
-
- ))} + ); + })}
)} diff --git a/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx b/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx index 601cb17c6..b08be0234 100644 --- a/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx @@ -8,7 +8,7 @@ import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; 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'; const toLocal = (iso?: string) => { @@ -42,11 +42,12 @@ export default function PackagesPage() { const [deactivateConfirm, setDeactivateConfirm] = useState(null); const [tiersPackage, setTiersPackage] = useState(null); const [editingTier, setEditingTier] = useState(null); - const [tierForm, setTierForm] = useState({ seatType: '', label: '', priceMinor: '', availableSeats: '' }); + const [tierForm, setTierForm] = useState({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); const [deleteTierConfirm, setDeleteTierConfirm] = useState(null); const [tierError, setTierError] = useState(null); const [deletePackageConfirm, setDeletePackageConfirm] = useState(null); const [deletePackageError, setDeletePackageError] = useState(null); + const [deletePackageCascade, setDeletePackageCascade] = useState(false); const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ @@ -64,8 +65,14 @@ export default function PackagesPage() { 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 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({ mutationFn: packagesApi.create, @@ -87,7 +94,7 @@ export default function PackagesPage() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setDeactivateConfirm(null); }, }); - const emptyTierForm = { seatType: '', label: '', priceMinor: '', availableSeats: '' }; + const emptyTierForm = { seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }; const addTierMutation = useMutation({ mutationFn: ({ packageId, data }: { packageId: string; data: any }) => packagesApi.addTier(packageId, data), @@ -114,11 +121,12 @@ export default function PackagesPage() { }); const deletePackageMutation = useMutation({ - mutationFn: (id: string) => packagesApi.remove(id), + mutationFn: ({ id, cascade }: { id: string; cascade: boolean }) => packagesApi.remove(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setDeletePackageConfirm(null); setDeletePackageError(null); + setDeletePackageCascade(false); }, 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) => { 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); }; const handleTierSubmit = async (e: React.FormEvent) => { 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) { await updateTierMutation.mutateAsync({ tierId: editingTier.id, data: payload }); } else { @@ -267,7 +281,7 @@ export default function PackagesPage() { { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit }, { 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, @@ -281,7 +295,7 @@ export default function PackagesPage() { }, { 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) => (
- {t.label} - ({t.seatType}) + {t.seatType} + {t.seatClassId && ( + + {seatClasses.find((sc: any) => sc.id === t.seatClassId)?.coachType.type ?? 'Linked'} + + )}
{formatCurrency(t.priceMinor, 'ETB')} · {t.bookedSeats}/{t.availableSeats} booked
@@ -454,16 +472,31 @@ export default function PackagesPage() {

{editingTier ? 'Edit Tier' : 'Add New Tier'}

-
- - setTierForm((f) => ({ ...f, seatType: e.target.value }))} /> -
-
- - setTierForm((f) => ({ ...f, label: e.target.value }))} /> +
+ +
+
{editingTier && ( - { setEditingTier(null); setTierForm({ seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }}>Cancel + { setEditingTier(null); setTierForm({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }}>Cancel )} {editingTier ? 'Update Tier' : 'Add Tier'} @@ -491,14 +524,17 @@ export default function PackagesPage() { {/* Delete Package Confirmation */} { setDeletePackageConfirm(null); setDeletePackageError(null); }} - onConfirm={() => deletePackageMutation.mutate(deletePackageConfirm.id)} + onClose={() => { setDeletePackageConfirm(null); setDeletePackageError(null); setDeletePackageCascade(false); }} + onConfirm={() => deletePackageMutation.mutate({ id: deletePackageConfirm.id, cascade: deletePackageCascade })} title="Delete Package" message={`Delete "${deletePackageConfirm?.name}"? This will also remove all price tiers and cannot be undone.`} confirmText="Delete" isDanger isLoading={deletePackageMutation.isPending} 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 */} diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx index 9765195e2..cff41ede2 100644 --- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -40,6 +40,7 @@ export default function PassengersPage() { const [selectedPassenger, setSelectedPassenger] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null }); const [deleteError, setDeleteError] = useState(null); + const [deleteCascade, setDeleteCascade] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false); const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); const [exportDateFrom, setExportDateFrom] = useState(''); @@ -51,11 +52,12 @@ export default function PassengersPage() { const queryClient = useQueryClient(); const deleteMutation = useMutation({ - mutationFn: (id: string) => apiClient.delete(`/passengers/${id}`), + mutationFn: ({ id, cascade }: { id: string; cascade: boolean }) => passengersApi.delete(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['passengers'] }); setDeleteConfirm({ isOpen: false, passenger: null }); setDeleteError(null); + setDeleteCascade(false); }, onError: (error: any) => { setDeleteError(error?.response?.data?.message || error?.message || 'Failed to delete passenger'); @@ -154,7 +156,7 @@ export default function PassengersPage() { const actions = [ { 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 ( @@ -229,18 +231,20 @@ export default function PassengersPage() { { setDeleteConfirm({ isOpen: false, passenger: null }); setDeleteError(null); }} + onClose={() => { setDeleteConfirm({ isOpen: false, passenger: null }); setDeleteError(null); setDeleteCascade(false); }} onConfirm={async () => { if (deleteConfirm.passenger) { - await deleteMutation.mutateAsync(deleteConfirm.passenger.id); + await deleteMutation.mutateAsync({ id: deleteConfirm.passenger.id, cascade: deleteCascade }); } }} title="Delete Passenger" message={`Are you sure you want to delete ${deleteConfirm.passenger?.fullName}?`} confirmText="Delete" isDanger 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} + 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 */} diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 43592c4f7..2524c20b0 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -171,7 +171,7 @@ export default function RoutesPage() { const [originStationId, setOriginStationId] = useState(''); const [destinationStationId, setDestinationStationId] = useState(''); const [destinationDistance, setDestinationDistance] = useState(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 queryClient = useQueryClient(); @@ -207,13 +207,18 @@ export default function RoutesPage() { }); const deleteMutation = useMutation({ - mutationFn: routesApi.delete, + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => routesApi.delete(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routes'] }); }, onError: (e: any) => { const msg = e?.response?.data?.message || e?.message || 'Failed to delete route'; - setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + const 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 () => { if (!deleteConfirm.route) return; try { - await deleteMutation.mutateAsync(deleteConfirm.route.id); + await deleteMutation.mutateAsync({ id: deleteConfirm.route.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); setDeleteConfirm({ isOpen: false, route: null }); } catch { // error is set by onError handler @@ -471,7 +476,10 @@ export default function RoutesPage() { isDanger={true} isLoading={deleteMutation.isPending} 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 }))} /> ; + isPackageOnly?: boolean; } interface Train { @@ -51,7 +52,7 @@ export default function SchedulesPage() { const [showEditModal, setShowEditModal] = useState(false); const [editingSchedule, setEditingSchedule] = useState(null); const [selectedSchedules, setSelectedSchedules] = useState>(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 } ); const [error, setError] = useState(null); @@ -105,6 +106,7 @@ export default function SchedulesPage() { arrivalAt: '', status: 'SCHEDULED', coachIds: [] as string[], + isPackageOnly: false, }); const [filters, setFilters] = useState({ @@ -194,13 +196,18 @@ export default function SchedulesPage() { }); 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: () => { queryClient.invalidateQueries({ queryKey: ['schedules'] }); }, onError: (err: any) => { 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(), arrivalAt: arrLocal.toISOString(), status: editForm.status, + isPackageOnly: editForm.isPackageOnly, coaches: editForm.coachIds.map((coachId: string, idx: number) => ({ coachId, positionNumber: idx + 1, @@ -307,7 +315,7 @@ export default function SchedulesPage() { const ids = deleteConfirm.item as string[]; await bulkDeleteMutation.mutateAsync(ids); } 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 }); } catch { @@ -336,6 +344,7 @@ export default function SchedulesPage() { arrivalAt: arrStr, status: schedule.status, coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [], + isPackageOnly: schedule.isPackageOnly ?? false, }); setError(null); setShowEditModal(true); @@ -455,9 +464,14 @@ export default function SchedulesPage() { key: 'status', label: 'Status', render: (schedule: Schedule) => ( - - {schedule.status} - +
+ + {schedule.status} + + {schedule.isPackageOnly && ( + PKG + )} +
), }, ] as any; @@ -649,7 +663,10 @@ export default function SchedulesPage() { isDanger={true} isLoading={deleteScheduleMutation.isPending || bulkDeleteMutation.isPending} 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 }))} />
+
+ setEditForm({ ...editForm, isPackageOnly: e.target.checked })} + className="w-4 h-4 rounded" + /> + +
+
diff --git a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx index aef1ece25..063f4cab8 100644 --- a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx @@ -14,7 +14,7 @@ export default function StationsPage() { const [filters, setFilters] = useState({ search: '', country: '', operational: '' }); const [showModal, setShowModal] = useState(false); const [editingStation, setEditingStation] = useState(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(null); const queryClient = useQueryClient(); @@ -46,13 +46,18 @@ export default function StationsPage() { }); const deleteMutation = useMutation({ - mutationFn: stationsApi.delete, + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => stationsApi.delete(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['stations'] }); }, onError: (e: any) => { const msg = e?.response?.data?.message || e?.message || 'Failed to delete station'; - setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + const 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 () => { if (!deleteConfirm.station) return; try { - await deleteMutation.mutateAsync(deleteConfirm.station.id); + await deleteMutation.mutateAsync({ id: deleteConfirm.station.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); setDeleteConfirm({ isOpen: false, station: null }); } catch { // error is set by onError handler @@ -244,7 +249,10 @@ export default function StationsPage() { isDanger={true} isLoading={deleteMutation.isPending} error={deleteConfirm.error} - warning="This station may be referenced by routes, schedules, and bookings. Deleting it may impact these systems." + 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 */} diff --git a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx index b54615f3e..9dd17dfb8 100644 --- a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx @@ -16,7 +16,7 @@ export default function TrainsPage() { const [showModal, setShowModal] = useState(false); const [editingTrain, setEditingTrain] = useState(null); 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(); @@ -50,13 +50,18 @@ export default function TrainsPage() { }); const deleteTrainMutation = useMutation({ - mutationFn: (id: string) => fleetApi.deleteTrain(id), + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => fleetApi.deleteTrain(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['trains'] }); }, onError: (error: any) => { const msg = error?.response?.data?.message || error?.message || 'Failed to delete train'; - setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + const 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 () => { if (!deleteConfirm.train) return; try { - await deleteTrainMutation.mutateAsync(deleteConfirm.train.id); + await deleteTrainMutation.mutateAsync({ id: deleteConfirm.train.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); setDeleteConfirm({ isOpen: false, train: null }); } catch { // error is set by onError handler @@ -237,7 +242,10 @@ export default function TrainsPage() { isDanger={true} isLoading={deleteTrainMutation.isPending} error={deleteConfirm.error} - warning="This train may be assigned to schedules and trips. Deleting it may impact these systems and associated bookings." + 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 */} diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx index 39c68b397..c25863d60 100644 --- a/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx @@ -17,6 +17,9 @@ interface ConfirmDialogProps { isDanger?: boolean; warning?: string; error?: string; + cascadeWarning?: string; + onCascadeChange?: (checked: boolean) => void; + cascadeChecked?: boolean; } export default function ConfirmDialog({ @@ -31,6 +34,9 @@ export default function ConfirmDialog({ isDanger = false, warning, error, + cascadeWarning, + onCascadeChange, + cascadeChecked = false, }: ConfirmDialogProps) { useEffect(() => { if (!isOpen) return; @@ -129,6 +135,26 @@ export default function ConfirmDialog({
); })()} + + {cascadeWarning && ( +
+
+ +

{cascadeWarning}

+
+ +
+ )}
{/* Footer */} @@ -140,6 +166,7 @@ export default function ConfirmDialog({ variant={isDanger ? 'danger' : 'primary'} onClick={onConfirm} loading={isLoading} + disabled={!!cascadeWarning && !cascadeChecked} > {confirmText} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index b44e33f74..f611d3083 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -61,6 +61,7 @@ export const passengersApi = { }, getById: (id: string) => apiClient.get(`/passengers/${id}`), verify: (nationalId: string) => apiClient.post('/passengers/verify-fayda', { nationalId }), + delete: (id: string, cascade?: boolean) => apiClient.delete(`/passengers/${id}${cascade ? '?cascade=true' : ''}`), }; // Stations API export const stationsApi = { @@ -79,7 +80,7 @@ export const stationsApi = { getById: (id: string) => apiClient.get(`/stations/${id}`), create: (data: any) => apiClient.post('/stations', data), update: (id: string, data: any) => apiClient.patch(`/stations/${id}`, data), - delete: (id: string) => apiClient.delete(`/stations/${id}`), + delete: (id: string, cascade?: boolean) => apiClient.delete(`/stations/${id}${cascade ? '?cascade=true' : ''}`), }; // Fleet API @@ -108,11 +109,11 @@ export const fleetApi = { }, createTrain: (data: any) => apiClient.post('/fleet/trains', data), updateTrain: (id: string, data: any) => apiClient.patch(`/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(`/fleet/trains/${id}/restore`, {}), createCoach: (data: any) => apiClient.post('/fleet/coaches', data), updateCoach: (id: string, data: any) => apiClient.patch(`/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('/fleet/seatmap/generate', data), }; @@ -362,7 +363,7 @@ export const seatClassesApi = { getById: (id: string) => apiClient.get(`/fleet/classes/${id}`), create: (data: any) => apiClient.post('/fleet/classes', data), update: (id: string, data: any) => apiClient.patch(`/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 @@ -409,7 +410,7 @@ export const packagesApi = { update: (id: string, data: any) => apiClient.patch(`/packages/${id}`, data), activate: (id: string) => apiClient.patch(`/packages/${id}/activate`, {}), deactivate: (id: string) => apiClient.patch(`/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) => { const cleanParams = Object.fromEntries( Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null) diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/routes.ts b/apps/edr-passenger-web/backoffice/src/lib/api/routes.ts index 80c30e08b..638063b84 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/routes.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/routes.ts @@ -30,8 +30,8 @@ export const routesApi = { return apiClient.patch(`/routes/${id}`, data); }, - delete: (id: string) => { - return apiClient.delete(`/routes/${id}`); + delete: (id: string, cascade?: boolean) => { + return apiClient.delete(`/routes/${id}${cascade ? '?cascade=true' : ''}`); }, getFareRules: (routeId: string) => { diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 6ed06039c..af5748ed7 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -10,7 +10,7 @@ import { apiClient } from '@/lib/api-client'; import { useEffect, useState, useRef } from 'react'; import { CheckCircle, Copy, Train, FileText } from 'lucide-react'; import { format } from 'date-fns'; -import { isChild, calculatePassengerFare } from '@/utils/fare-utils'; +import { isChild, isFirstChild, calculatePassengerFare } from '@/utils/fare-utils'; type BookingWithTicket = { id: string; @@ -27,7 +27,7 @@ type BookingWithTicket = { export default function ConfirmationPage() { 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, packageTierPriceMinor, packageId } = useBookingStore(); // 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). const { selectedCurrency: paidCurrency, paidAmountMinor } = usePaymentStore(); @@ -338,9 +338,11 @@ export default function ConfirmationPage() { 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 adultCount = passengers.filter(p => !isChild(p)).length; + const childCount = passengers.filter(p => isChild(p)).length; + const pkgPaidChildrenCount = Math.max(0, childCount - adultCount); const fallback = isPackage - ? passengers.reduce((sum, p) => sum + (isChild(p) ? pkgChildFare : pkgAdultFare), 0) + ? adultCount * pkgAdultFare + pkgPaidChildrenCount * pkgAdultFare : isRoundTrip ? passengers.reduce((sum, p, i) => { const outFare = (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0); @@ -393,20 +395,27 @@ export default function ConfirmationPage() {

Seat(s)

- {isRoundTrip ? ( -
+ {(() => { + 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

; + return isRoundTrip ? ( +
+

+ Outbound: {(passenger as any).outboundCoachNumber && {(passenger as any).outboundCoachNumber}} — {(passenger as any).outboundSeatNumber || 'Auto-assigned at boarding'} +

+

+ Return: {(passenger as any).inboundCoachNumber && {(passenger as any).inboundCoachNumber}} — {(passenger as any).inboundSeatNumber || 'Auto-assigned at boarding'} +

+
+ ) : (

- Outbound: {(passenger as any).outboundCoachNumber && {(passenger as any).outboundCoachNumber}} — {(passenger as any).outboundSeatNumber || 'Auto-assigned at boarding'} + {passenger.coachNumber && (Coach {passenger.coachNumber})} — {passenger.seatNumber || 'Auto-assigned at boarding'}

-

- Return: {(passenger as any).inboundCoachNumber && {(passenger as any).inboundCoachNumber}} — {(passenger as any).inboundSeatNumber || 'Auto-assigned at boarding'} -

-
- ) : ( -

- {passenger.coachNumber && (Coach {passenger.coachNumber})} — {passenger.seatNumber || 'Auto-assigned at boarding'} -

- )} + ); + })()}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 8e36d79b6..52ce64a2c 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -37,9 +37,6 @@ export default function PaymentPage() { const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; 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 displayCurrency = 'ETB' as const; @@ -65,12 +62,16 @@ export default function PaymentPage() { }); // Per-leg totals across all passengers. - // 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). + // First child per adult = FREE (no seat); additional children = full adult fare. const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; const childCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length; + const pkgPaidChildrenCount = Math.max(0, childCount - adultCount); + const pkgRoundTripMultiplier = isPackage && isRoundTrip ? 2 : 1; + const pkgAdultFare = isPackage ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0; + const pkgChildFare = pkgAdultFare; // paid children pay full adult fare const pkgPerLegAdultFare = isPackage ? packageTierPriceMinor! : 0; - const pkgPerLegChildFare = isPackage ? Math.round(pkgPerLegAdultFare * 0.1) : 0; - const pkgPerLegTotal = isPackage ? adultCount * pkgPerLegAdultFare + childCount * pkgPerLegChildFare : 0; + const pkgPerLegChildFare = pkgPerLegAdultFare; // paid children pay full adult fare per leg + const pkgPerLegTotal = isPackage ? adultCount * pkgPerLegAdultFare + pkgPaidChildrenCount * pkgPerLegChildFare : 0; // Prefer each passenger's own seat fare (set during seat selection) over the schedule's // flat baseFareAdult — bed coaches price Upper/Middle/Lower berths differently, so a @@ -90,7 +91,7 @@ export default function PaymentPage() { }, 0) : 0); const baseFare = isPackage - ? adultCount * pkgAdultFare + childCount * pkgChildFare + ? adultCount * pkgAdultFare + pkgPaidChildrenCount * 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; @@ -294,11 +295,15 @@ export default function PaymentPage() {

Fare breakdown

{passengers.map((p, i) => { const isChildPassenger = isChild(p); + // For package bookings: children ordered after adults; first adultCount children are free + const childIndex = i - adultCount; + const isPkgFreeChild = isPackage && isChildPassenger && childIndex >= 0 && childIndex < adultCount; let passengerTotal: number; let isFreeChild = false; if (isPackage) { - passengerTotal = isChildPassenger ? pkgChildFare : pkgAdultFare; + isFreeChild = isPkgFreeChild; + passengerTotal = isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare); } else { // Prefer this passenger's actual seat fare (varies by berth for bed coaches) // over the schedule's flat baseFareAdult. @@ -319,9 +324,9 @@ export default function PaymentPage() { {p.name || `Passenger ${i + 1}`} {isChildPassenger && ( - ({isPackage ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'}) + ({isFreeChild ? 'CHILD - FREE' : 'CHILD - FULL FARE'}) )} @@ -332,19 +337,19 @@ export default function PaymentPage() { {isRoundTrip && (
- Outbound {!isPackage && isFreeChild ? '(Free)' : ''} + Outbound {isFreeChild ? '(Free)' : ''} {formatFare( isPackage - ? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare) + ? (isFreeChild ? 0 : (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)) : calculatePassengerFare(passengers, i, (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0)), displayCurrency )}
- Return {!isPackage && isFreeChild ? '(Free)' : ''} + Return {isFreeChild ? '(Free)' : ''} {formatFare( isPackage - ? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare) + ? (isFreeChild ? 0 : (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)) : calculatePassengerFare(passengers, i, (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0)), displayCurrency )} diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 359da2236..64afeb82b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -266,6 +266,16 @@ export default function ReviewPage() { } // 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 = { passengerId: passengerId, scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id, @@ -275,11 +285,12 @@ export default function ReviewPage() { seatClassId: seatClassId, bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', displayCurrency: displayCurrency, - passengers: passengers.map((p) => { + passengers: bookingPassengers.map((p) => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; + const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId; return { - seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), - ...(isRoundTrip && { returnSeatId: (p as any).inboundSeatId || '' }), + ...(seatId ? { seatId } : {}), + ...(isRoundTrip && (p as any).inboundSeatId ? { returnSeatId: (p as any).inboundSeatId } : {}), passengerName: p.name, dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', @@ -310,6 +321,16 @@ export default function ReviewPage() { if (priceTierId) bookingData.priceTierId = priceTierId; } else { // 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 = { scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id, holdId: seatHold?.holdId || '', @@ -318,11 +339,12 @@ export default function ReviewPage() { seatClassId: seatClassId, bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', displayCurrency: displayCurrency, - passengers: passengers.map(p => { + passengers: guestBookingPassengers.map(p => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; + const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId; return { - seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), - ...(isRoundTrip && { returnSeatId: (p as any).inboundSeatId || '' }), + ...(seatId ? { seatId } : {}), + ...(isRoundTrip && (p as any).inboundSeatId ? { returnSeatId: (p as any).inboundSeatId } : {}), passengerName: p.name, dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', @@ -447,12 +469,20 @@ export default function ReviewPage() { const isPackageBooking = packageTierPriceMinor !== null; // packageTierPriceMinor is the per-adult fare for ONE leg. - // Round-trip packages multiply by 2; children pay 10% of the adult fare. - const pkgRoundTripMultiplier = isPackageBooking && isRoundTrip ? 2 : 1; - const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0; - const pkgChildFare = isPackageBooking ? Math.round(pkgAdultFare * 0.1) : 0; + // Round-trip packages multiply by 2. + // First child per adult travels FREE; additional children pay full adult fare. const adultPassengerCount = searchCriteria?.adultCount ?? 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 // the schedule's own coachTypes/classes) is guaranteed correct for berths, unlike the @@ -467,7 +497,7 @@ export default function ReviewPage() { }; const total = isPackageBooking - ? adultPassengerCount * pkgAdultFare + childPassengerCount * pkgChildFare + ? adultPassengerCount * pkgAdultFare + pkgPaidChildrenCount * pkgChildFare : passengers.reduce((sum, p, i) => { const isChildPassenger = isChild(p); const line = fareBreakdown?.passengers?.[i]; @@ -477,6 +507,16 @@ export default function ReviewPage() { return sum + (seatFare ?? line?.fareMinor ?? 0); }, 0); + // 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) const FareSidebar = () => (
@@ -485,11 +525,13 @@ export default function ReviewPage() { {passengers.map((p, i) => { const line = fareBreakdown?.passengers?.[i]; - const isChildPassenger = isChild(p); - const isFreeChild = !isPackageBooking && (line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i))); + const isChildPassenger = isPackageChild(i); + const isFreeChild = isPackageBooking + ? isPkgFreeChild(i) + : (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i))); const seatFare = getPassengerSeatFare(p); const passengerTotal = isPackageBooking - ? (isChildPassenger ? pkgChildFare : pkgAdultFare) + ? (isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare)) : (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0)); return ( @@ -499,9 +541,9 @@ export default function ReviewPage() { {p.name || `Passenger ${i + 1}`} {isChildPassenger && ( - ({isPackageBooking ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'}) + ({isFreeChild ? 'CHILD - FREE' : 'CHILD - FULL FARE'}) )} @@ -810,7 +852,7 @@ export default function ReviewPage() {

Outbound Seat

{(p as any).outboundCoachNumber && {(p as any).outboundCoachNumber} — } - {(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 as any).outboundSeatId && (

{formatSeatClass(outboundSchedule)}

@@ -820,7 +862,7 @@ export default function ReviewPage() {

Return Seat

{(p as any).inboundCoachNumber && {(p as any).inboundCoachNumber} —} - {(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 as any).inboundSeatId && (

{formatSeatClass(inboundSchedule)}

@@ -832,7 +874,7 @@ export default function ReviewPage() {

Seat

{p.coachNumber && {p.coachNumber} — } - {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.seatId && (

{formatSeatClass(selectedSchedule)}

diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index b1987f588..b0a7e8fd8 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -233,22 +233,26 @@ export default function SeatsPage() { ? (currentJourneyType === "inbound" ? originalFaresRef.current.inbound : originalFaresRef.current.outbound) : originalFaresRef.current.oneWay; - // Child seat allocation rule: let A = adults, C = children (isChild = under 5). - // If C > A, only A - 1 children get their own seat and the rest share with an adult. - // If C <= A, no child gets a separate seat — all of them share with an adult. - // Adults always need their own seat. + // Seat eligibility: + // - All adults always need their own seat. + // - For package bookings: first child per adult travels free with no 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 adultIndices = 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 childCount = childIndices.length; - const eligibleChildCount = childCount > adultCount ? Math.max(adultCount - 1, 0) : 0; - const eligibleChildIndices = childIndices.slice(0, eligibleChildCount); - const eligibleSet = new Set([...adultIndices, ...eligibleChildIndices]); + // First `adultCount` children are free (no seat); the rest are paid (need a seat). + const freeChildIndices = new Set(childIndices.slice(0, adultCount)); + const paidChildIndices = childIndices.slice(adultCount); + const eligibleSet = new Set([...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(); - childIndices.slice(eligibleChildCount).forEach((childIdx, offset) => { + Array.from(freeChildIndices).forEach((childIdx, offset) => { const adultIdx = adultIndices[offset % Math.max(adultIndices.length, 1)]; if (adultIdx != null) { sharingWithAdult.set(childIdx, passengers[adultIdx]?.name || `Adult ${adultIdx + 1}`); diff --git a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx index a38515e03..3e4dc04df 100644 --- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx @@ -21,6 +21,9 @@ import { Tag, Shield, X, + Star, + Bed, + Armchair, } from "lucide-react"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -58,6 +61,13 @@ interface Schedule { routeStops?: RouteStop[]; } +interface CoachTypeInfo { + id: string; + name: string; + code: string; + type: string; // 'passenger' | 'sleeper' | 'dining' | 'baggage' +} + interface PriceTier { id: string; packageId: string; @@ -67,6 +77,7 @@ interface PriceTier { currency: string; availableSeats: number; bookedSeats: number; + seatClass?: { coachType?: CoachTypeInfo }; } 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(); + 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({ tiers, - selectedTierId, - onSelect, onBookNow, isRoundTrip, }: { tiers: PriceTier[]; - selectedTierId: string | null; - onSelect: (id: string) => void; - onBookNow: () => void; + onBookNow: (coachTypeId: string) => void; isRoundTrip: boolean; }) { + const [selectedId, setSelectedId] = useState(null); const priceMultiplier = isRoundTrip ? 2 : 1; + const groups = groupTiersByCoachType(tiers ?? []); + + if (!tiers?.length) { + return ( +
+

No price tiers available

+
+ ); + } + return ( -
-

- Select Seat Type -

- - {!tiers?.length ? ( -

- No price tiers available -

- ) : ( -
- {tiers.map((tier) => { - const soldOut = tier.availableSeats === 0; - const selected = tier.id === selectedTierId; - return ( -
!soldOut && onSelect(tier.id)} - className={`rounded-xl border-2 p-3.5 transition-all ${ - soldOut - ? "border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed" - : selected - ? "border-primary bg-primary/5" - : "border-gray-200 dark:border-gray-700 hover:border-primary/50 hover:shadow-sm cursor-pointer" - }`} - > - {/* Row 1: radio + full label */} -
-
- {selected &&
} -
-

- {tier.label.trim()} -

-
- - {/* Row 2: seatType badge + seats + price */} -
-
- - {tier.seatType.trim()} - - {soldOut ? ( - - SOLD OUT - - ) : ( - - {tier.availableSeats} left - - )} -
-

- {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} -

-
- - {/* Book Now — shown only when selected */} - {selected && ( - - )} +
+

Select Coach Type

+ {groups.map((group) => { + const CoachIcon = getCoachIcon(group.coachTypeType); + const allSoldOut = group.tiers.every((t) => t.availableSeats === 0); + const isSelected = selectedId === group.coachTypeId; + return ( +
!allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)} + > + {/* Coach type header */} +
+
+
- ); - })} -
- )} +
+

+ {formatCoachTypeLabel(group.coachTypeType)} +

+

+ From {formatPrice(group.minPrice * priceMultiplier, group.currency)} + {allSoldOut && · Sold out} +

+
+ {!allSoldOut && ( +
+ {isSelected && } +
+ )} +
+ + {/* All available classes for this coach type */} +
+ {group.tiers.map((tier) => { + const soldOut = tier.availableSeats === 0; + return ( +
+
+
+

{tier.seatType.trim()}

+

+ {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} + {soldOut ? ( + Sold out + ) : ( + {tier.availableSeats} left + )} +

+
+
+ ); + })} +
+ + {/* Book Now — only when this group is selected */} + {isSelected && !allSoldOut && ( +
+ +
+ )} +
+ ); + })}
); } @@ -294,8 +361,7 @@ function PriceTiersPanel({ // ─── Passenger count picker ────────────────────────────────────────────────── const PKG_MAX_ADULTS = 5; -const PKG_MAX_CHILDREN = 2; -const PKG_CHILD_FARE_RATIO = 0.1; +const PKG_CHILDREN_PER_ADULT = 5; function PassengerCountModal({ tier, @@ -319,8 +385,11 @@ function PassengerCountModal({ const [departureStationId, setDepartureStationId] = useState(''); const [showStationError, setShowStationError] = useState(false); const remaining = tier.availableSeats - tier.bookedSeats; - const childFareMinor = Math.round(tier.priceMinor * PKG_CHILD_FARE_RATIO); - const totalMinor = (adultCount * tier.priceMinor + childCount * childFareMinor) * priceMultiplier; + // First child per adult travels free (no seat); additional children pay full adult fare + 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 ( <> @@ -335,15 +404,15 @@ function PassengerCountModal({
-

Selected tier

-

{tier.label.trim()}

-

{remaining} seats remaining · {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult

+

Coach type

+

{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}

+

{remaining} seats remaining · prices from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)

{[ - { label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: setAdultCount }, - { 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: "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_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 }) => (
@@ -366,6 +435,12 @@ function PassengerCountModal({
))} + {freeChildren > 0 && ( +
+ {freeChildren} child{freeChildren > 1 ? 'ren' : ''} travel free (no seat) + ETB 0.00 +
+ )}
Total{priceMultiplier === 2 ? ' (round-trip)' : ''} {formatPrice(totalMinor, tier.currency)} @@ -426,7 +501,7 @@ export default function PackageDetailPage() { const id = params?.id as string; const { clearBooking, setSearchCriteria, setSelectedSchedule, setOutboundSchedule, setInboundSchedule, setPassengers, setPackageContext } = useBookingStore(); - const [selectedTierId, setSelectedTierId] = useState(null); + const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(null); const [passengerModalOpen, setPassengerModalOpen] = useState(false); const [bookingContextLoading, setBookingContextLoading] = useState(false); const [bookingContextError, setBookingContextError] = useState(null); @@ -443,17 +518,21 @@ export default function PackageDetailPage() { ? 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 handleBookNow = async (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => { - if (!selectedTier || !pkg) return; + if (!representativeTier || !pkg) return; setBookingContextLoading(true); setBookingContextError(null); try { 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(); @@ -473,7 +552,7 @@ export default function PackageDetailPage() { duration: s.durationMinutes ? `${Math.floor(s.durationMinutes / 60)}h ${s.durationMinutes % 60}m` : "", baseFareAdult: Math.round(ctx.totalMinor / passengerCount), baseFareChild: 0, - displayCurrency: selectedTier.currency, + displayCurrency: representativeTier.currency, selectedSeatClass: ctx.seatClassId, selectedSeatClassName: ctx.seatClassName ?? "", seatClassName: ctx.seatClassName ?? "", @@ -501,16 +580,25 @@ export default function PackageDetailPage() { } setPassengers( - Array.from({ length: passengerCount }, (_, i) => ({ - name: "", - dateOfBirth: "", - nationality: "ETHIOPIAN", - isPrimaryPassenger: i === 0, - })), + Array.from({ length: passengerCount }, (_, i) => { + // First `adultCount` entries are adults; remaining are children. + // Among children, the first `adultCount` are free (one per adult, no seat). + const isChildPassenger = i >= adultCount; + 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 - setPackageContext(id, selectedTier.id, selectedTier.priceMinor, pkg.name, departureStationId, departureStationName); + setPackageContext(id, representativeTier.id, representativeTier.priceMinor, pkg.name, departureStationId, departureStationName); router.push("/booking/passengers"); } catch (err: any) { @@ -557,9 +645,9 @@ export default function PackageDetailPage() { return (
{/* Passenger count modal */} - {passengerModalOpen && selectedTier && ( + {passengerModalOpen && representativeTier && ( { setPassengerModalOpen(false); setBookingContextError(null); }} onConfirm={handleBookNow} loading={bookingContextLoading} @@ -751,9 +839,7 @@ export default function PackageDetailPage() {
setPassengerModalOpen(true)} + onBookNow={(coachTypeId) => { setSelectedCoachTypeId(coachTypeId); setPassengerModalOpen(true); }} isRoundTrip={isRoundTripPkg} />
@@ -764,9 +850,7 @@ export default function PackageDetailPage() {
setPassengerModalOpen(true)} + onBookNow={(coachTypeId) => { setSelectedCoachTypeId(coachTypeId); setPassengerModalOpen(true); }} isRoundTrip={isRoundTripPkg} />