Package booking new rules update, cascade delete option updates

This commit is contained in:
Stephanos A
2026-07-06 22:12:04 +03:00
parent f52d7442ac
commit 36055d0d82
31 changed files with 777 additions and 339 deletions

View File

@@ -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')

View File

@@ -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 };
}

View File

@@ -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')

View File

@@ -273,7 +273,7 @@ export class FleetService {
});
}
async deleteClass(id: string) {
async deleteClass(id: string, cascade = false) {
const seatClass = await this.prisma.seatClass.findUnique({
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 } });

View File

@@ -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')

View File

@@ -8,9 +8,12 @@ import { GuestBookingService } from '../bookings/guest-booking.service';
/** Package-specific fare rules */
const PKG_MAX_ADULTS = 5;
const PKG_CHILDREN_PER_ADULT = 2; // 2 children allowed per adult
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 {
@@ -71,16 +75,18 @@ export class PackagesService {
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 = tier.seatClassId ?? null;
let seatClassName: string | null = null;
@@ -113,8 +119,10 @@ export class PackagesService {
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: adultCount * PKG_CHILDREN_PER_ADULT,
totalMinor,
@@ -317,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 };
}
@@ -379,14 +393,16 @@ export class PackagesService {
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
@@ -435,7 +451,7 @@ export class PackagesService {
}),
this.prisma.packagePriceTier.update({
where: { id: dto.priceTierId },
data: { bookedSeats: { increment: passengerCount } },
data: { bookedSeats: { increment: seatsNeeded } },
}),
]);
@@ -446,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,

View File

@@ -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')

View File

@@ -432,7 +432,7 @@ export class PassengersService {
};
}
async deletePassenger(id: string) {
async deletePassenger(id: string, cascade = false) {
// id may be a TravelerProfile.id (from the list endpoint) or a Passenger.id
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 };
}

View File

@@ -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 ────────────────────────────────────────────────────────────

View File

@@ -110,7 +110,7 @@ export class RoutesService {
});
}
async deleteRoute(id: string) {
async deleteRoute(id: string, cascade = false) {
const route = await this.prisma.route.findUnique({
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 };
}

View File

@@ -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()

View File

@@ -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 } });
}

View File

@@ -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');
}
}

View File

@@ -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 } });

View File

@@ -38,6 +38,8 @@ function BookingsPageContent() {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(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() {
<ConfirmDialog
isOpen={deleteConfirmOpen}
onClose={() => { 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)}
/>
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md">

View File

@@ -15,7 +15,7 @@ export default function ClassesPage() {
const [filters, setFilters] = useState({ search: '' });
const [showModal, setShowModal] = useState(false);
const [editingClass, setEditingClass] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null; error?: string }>({ isOpen: false, class: null });
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, class: null });
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
const 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 */}

View File

@@ -145,7 +145,7 @@ export default function CoachesPage() {
const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, item: null });
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
const [isBedCoach, setIsBedCoach] = useState(false);
const [exportUtilModalOpen, setExportUtilModalOpen] = useState(false);
@@ -217,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'] });
},
@@ -277,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 }));
}
}
};
@@ -689,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 */}

View File

@@ -47,6 +47,7 @@ export default function PackagesPage() {
const [tierError, setTierError] = useState<string | null>(null);
const [deletePackageConfirm, setDeletePackageConfirm] = useState<any>(null);
const [deletePackageError, setDeletePackageError] = useState<string | null>(null);
const [deletePackageCascade, setDeletePackageCascade] = useState(false);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
@@ -120,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'),
});
@@ -293,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); },
},
];
@@ -522,14 +524,17 @@ export default function PackagesPage() {
{/* Delete Package Confirmation */}
<ConfirmDialog
isOpen={!!deletePackageConfirm}
onClose={() => { 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 */}

View File

@@ -40,6 +40,7 @@ export default function PassengersPage() {
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
const [deleteError, setDeleteError] = useState<string | null>(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() {
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => { 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 */}

View File

@@ -171,7 +171,7 @@ export default function RoutesPage() {
const [originStationId, setOriginStationId] = useState('');
const [destinationStationId, setDestinationStationId] = useState('');
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; 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 }))}
/>
<Modal

View File

@@ -52,7 +52,7 @@ export default function SchedulesPage() {
const [showEditModal, setShowEditModal] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
const [selectedSchedules, setSelectedSchedules] = useState<Set<string>>(new Set());
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean; error?: string }>(
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean; error?: string; cascade?: boolean; cascadeChecked?: boolean }>(
{ isOpen: false, item: null }
);
const [error, setError] = useState<string | null>(null);
@@ -196,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 }));
}
},
});
@@ -310,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 {
@@ -658,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 }))}
/>
<Modal

View File

@@ -14,7 +14,7 @@ export default function StationsPage() {
const [filters, setFilters] = useState({ search: '', country: '', operational: '' });
const [showModal, setShowModal] = useState(false);
const [editingStation, setEditingStation] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; station: any | null; error?: string }>({ isOpen: false, station: null });
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; station: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, station: null });
const [formError, setFormError] = useState<string | null>(null);
const 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 */}

View File

@@ -16,7 +16,7 @@ export default function TrainsPage() {
const [showModal, setShowModal] = useState(false);
const [editingTrain, setEditingTrain] = useState<TrainType | null>(null);
const [search, setSearch] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null; 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 */}

View File

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

View File

@@ -61,6 +61,7 @@ export const passengersApi = {
},
getById: (id: string) => apiClient.get<any>(`/passengers/${id}`),
verify: (nationalId: string) => apiClient.post<any>('/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<any>(`/stations/${id}`),
create: (data: any) => apiClient.post<any>('/stations', data),
update: (id: string, data: any) => apiClient.patch<any>(`/stations/${id}`, data),
delete: (id: string) => apiClient.delete(`/stations/${id}`),
delete: (id: string, cascade?: boolean) => apiClient.delete(`/stations/${id}${cascade ? '?cascade=true' : ''}`),
};
// Fleet API
@@ -108,11 +109,11 @@ export const fleetApi = {
},
createTrain: (data: any) => apiClient.post<any>('/fleet/trains', data),
updateTrain: (id: string, data: any) => apiClient.patch<any>(`/fleet/trains/${id}`, data),
deleteTrain: (id: string) => apiClient.delete(`/fleet/trains/${id}`),
deleteTrain: (id: string, cascade?: boolean) => apiClient.delete(`/fleet/trains/${id}${cascade ? '?cascade=true' : ''}`),
restoreTrain: (id: string) => apiClient.patch<any>(`/fleet/trains/${id}/restore`, {}),
createCoach: (data: any) => apiClient.post<any>('/fleet/coaches', data),
updateCoach: (id: string, data: any) => apiClient.patch<any>(`/fleet/coaches/${id}`, data),
deleteCoach: (id: string) => apiClient.delete(`/fleet/coaches/${id}`),
deleteCoach: (id: string, cascade?: boolean) => apiClient.delete(`/fleet/coaches/${id}${cascade ? '?cascade=true' : ''}`),
generateSeatMap: (data: any) => apiClient.post<any>('/fleet/seatmap/generate', data),
};
@@ -362,7 +363,7 @@ export const seatClassesApi = {
getById: (id: string) => apiClient.get<any>(`/fleet/classes/${id}`),
create: (data: any) => apiClient.post<any>('/fleet/classes', data),
update: (id: string, data: any) => apiClient.patch<any>(`/fleet/classes/${id}`, data),
delete: (id: string) => apiClient.delete(`/fleet/classes/${id}`),
delete: (id: string, cascade?: boolean) => apiClient.delete(`/fleet/classes/${id}${cascade ? '?cascade=true' : ''}`),
};
// Food & Dining API
@@ -409,7 +410,7 @@ export const packagesApi = {
update: (id: string, data: any) => apiClient.patch<any>(`/packages/${id}`, data),
activate: (id: string) => apiClient.patch<any>(`/packages/${id}/activate`, {}),
deactivate: (id: string) => apiClient.patch<any>(`/packages/${id}/deactivate`, {}),
remove: (id: string) => apiClient.delete(`/packages/${id}`),
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)

View File

@@ -30,8 +30,8 @@ export const routesApi = {
return apiClient.patch<Route>(`/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) => {

View File

@@ -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() {
</div>
<div>
<p className="text-gray-600 dark:text-gray-400">Seat(s)</p>
{isRoundTrip ? (
<div className="space-y-0.5">
{(() => {
const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
const isFreeChild = packageId
? index >= adultCount && (index - adultCount) < adultCount
: isChild(passenger) && isFirstChild(passengers, index);
if (isFreeChild) return <p className="font-semibold text-gray-900 dark:text-gray-100"></p>;
return isRoundTrip ? (
<div className="space-y-0.5">
<p className="font-semibold text-gray-900 dark:text-gray-100">
Outbound: {(passenger as any).outboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>{(passenger as any).outboundCoachNumber}</span>} {(passenger as any).outboundSeatNumber || 'Auto-assigned at boarding'}
</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
Return: {(passenger as any).inboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>{(passenger as any).inboundCoachNumber}</span>} {(passenger as any).inboundSeatNumber || 'Auto-assigned at boarding'}
</p>
</div>
) : (
<p className="font-semibold text-gray-900 dark:text-gray-100">
Outbound: {(passenger as any).outboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>{(passenger as any).outboundCoachNumber}</span>} {(passenger as any).outboundSeatNumber || 'Auto-assigned at boarding'}
{passenger.coachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>(Coach {passenger.coachNumber})</span>} {passenger.seatNumber || 'Auto-assigned at boarding'}
</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
Return: {(passenger as any).inboundCoachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>{(passenger as any).inboundCoachNumber}</span>} {(passenger as any).inboundSeatNumber || 'Auto-assigned at boarding'}
</p>
</div>
) : (
<p className="font-semibold text-gray-900 dark:text-gray-100">
{passenger.coachNumber && <span className='text-xs text-gray-500 dark:text-gray-400 ml-1'>(Coach {passenger.coachNumber})</span>} {passenger.seatNumber || 'Auto-assigned at boarding'}
</p>
)}
);
})()}
</div>
</div>
</div>

View File

@@ -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() {
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">Fare breakdown</h3>
{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 && (
<span className={`text-xs font-semibold ml-1 ${
isPackage ? 'text-blue-600' : isFreeChild ? 'text-green-600' : 'text-blue-600'
isFreeChild ? 'text-green-600' : 'text-blue-600'
}`}>
({isPackage ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'})
({isFreeChild ? 'CHILD - FREE' : 'CHILD - FULL FARE'})
</span>
)}
</span>
@@ -332,19 +337,19 @@ export default function PaymentPage() {
{isRoundTrip && (
<div className="pl-3 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
<div className="flex justify-between">
<span>Outbound {!isPackage && isFreeChild ? '(Free)' : ''}</span>
<span>Outbound {isFreeChild ? '(Free)' : ''}</span>
<span>{formatFare(
isPackage
? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)
? (isFreeChild ? 0 : (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare))
: calculatePassengerFare(passengers, i, (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0)),
displayCurrency
)}</span>
</div>
<div className="flex justify-between">
<span>Return {!isPackage && isFreeChild ? '(Free)' : ''}</span>
<span>Return {isFreeChild ? '(Free)' : ''}</span>
<span>{formatFare(
isPackage
? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)
? (isFreeChild ? 0 : (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare))
: calculatePassengerFare(passengers, i, (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0)),
displayCurrency
)}</span>

View File

@@ -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,7 +285,7 @@ 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 {
@@ -311,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 || '',
@@ -319,7 +339,7 @@ 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 {
@@ -449,12 +469,15 @@ 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.
@@ -474,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];
@@ -484,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 = () => (
<div className="card space-y-3">
@@ -493,10 +526,12 @@ export default function ReviewPage() {
{passengers.map((p, i) => {
const line = fareBreakdown?.passengers?.[i];
const isChildPassenger = isPackageChild(i);
const isFreeChild = !isPackageBooking && (line?.isFree ?? (isChild(p) && isFirstChild(passengers, 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 (
@@ -506,9 +541,9 @@ export default function ReviewPage() {
{p.name || `Passenger ${i + 1}`}
{isChildPassenger && (
<span className={`text-xs font-semibold ml-1 ${
isPackageBooking ? 'text-blue-600' : isFreeChild ? 'text-green-600' : 'text-blue-600'
isFreeChild ? 'text-green-600' : 'text-blue-600'
}`}>
({isPackageBooking ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'})
({isFreeChild ? 'CHILD - FREE' : 'CHILD - FULL FARE'})
</span>
)}
</span>
@@ -817,7 +852,7 @@ export default function ReviewPage() {
<p className="text-xs text-gray-500 dark:text-gray-400">Outbound Seat</p>
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
{(p as any).outboundCoachNumber && <span className="text-gray-500 dark:text-gray-400 ml-1">{(p as any).outboundCoachNumber} </span>}
{(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : 'Auto-assign'}
{(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}
</p>
{(p as any).outboundSeatId && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(outboundSchedule)}</p>
@@ -827,7 +862,7 @@ export default function ReviewPage() {
<p className="text-xs text-gray-500 dark:text-gray-400">Return Seat</p>
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
{(p as any).inboundCoachNumber && <span className="text-gray-500 dark:text-gray-400 ml-1">{(p as any).inboundCoachNumber} </span>}
{(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : 'Auto-assign'}
{(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}
</p>
{(p as any).inboundSeatId && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(inboundSchedule)}</p>
@@ -839,7 +874,7 @@ export default function ReviewPage() {
<p className="text-sm text-gray-600 dark:text-gray-400">Seat</p>
<p className="font-medium text-gray-900 dark:text-gray-100">
{p.coachNumber && <span className="text-gray-500 dark:text-gray-400 ml-1">{p.coachNumber} </span>}
{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'}
{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}
</p>
{p.seatId && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(selectedSchedule)}</p>

View File

@@ -233,22 +233,26 @@ export default function SeatsPage() {
? (currentJourneyType === "inbound" ? originalFaresRef.current.inbound : originalFaresRef.current.outbound)
: 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<number>([...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<number>([...adultIndices, ...paidChildIndices]);
// Children who don't get their own seat share with an adult (round-robin, for display).
// Free children share with an adult (round-robin, for display).
const sharingWithAdult = new Map<number, string>();
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}`);

View File

@@ -361,8 +361,7 @@ function PriceTiersPanel({
// ─── Passenger count picker ──────────────────────────────────────────────────
const PKG_MAX_ADULTS = 5;
const PKG_CHILDREN_PER_ADULT = 2;
const PKG_CHILD_FARE_RATIO = 0.1;
const PKG_CHILDREN_PER_ADULT = 5;
function PassengerCountModal({
tier,
@@ -386,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 (
<>
@@ -404,13 +406,13 @@ function PassengerCountModal({
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10">
<p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">Coach type</p>
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}</p>
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · prices from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · actual class chosen on seat map</p>
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · prices from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)</p>
</div>
<div className="px-6 py-5 space-y-4">
{[
{ label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: setAdultCount },
{ label: "Children", sub: `Under 5 · max ${PKG_CHILDREN_PER_ADULT} per adult · 10% of adult fare`, value: childCount, min: 0, max: Math.min(adultCount * PKG_CHILDREN_PER_ADULT, 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 }) => (
<div key={label} className="flex items-center justify-between">
<div>
@@ -433,6 +435,12 @@ function PassengerCountModal({
</div>
))}
{freeChildren > 0 && (
<div className="flex items-center justify-between text-xs text-green-600 dark:text-green-400">
<span>{freeChildren} child{freeChildren > 1 ? 'ren' : ''} travel free (no seat)</span>
<span>ETB 0.00</span>
</div>
)}
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
<span className="text-sm text-gray-500">Total{priceMultiplier === 2 ? ' (round-trip)' : ''}</span>
<span className="text-base font-extrabold text-primary">{formatPrice(totalMinor, tier.currency)}</span>
@@ -572,12 +580,21 @@ 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