mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #185 from Tria-plc/alpha
Merge request for booking endpoint and other enhancements
This commit is contained in:
@@ -494,38 +494,44 @@ model FareRule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Booking {
|
model Booking {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
bookingRef String @unique
|
bookingRef String @unique
|
||||||
passengerId String
|
passengerId String
|
||||||
scheduleId String
|
scheduleId String
|
||||||
status BookingStatus @default(DRAFT)
|
bookingType String @default("ONE_WAY")
|
||||||
currency String @default("ETB")
|
status BookingStatus @default(DRAFT)
|
||||||
totalMinor Int
|
currency String @default("ETB")
|
||||||
adultCount Int @default(1)
|
totalMinor Int
|
||||||
childCount Int @default(0)
|
adultCount Int @default(1)
|
||||||
displayCurrency Currency?
|
childCount Int @default(0)
|
||||||
displayTotalMinor Int?
|
displayCurrency Currency?
|
||||||
bookingType String @default("ONE_WAY")
|
displayTotalMinor Int?
|
||||||
contactEmail String?
|
returnScheduleId String?
|
||||||
contactPhone String?
|
returnOriginStationId String?
|
||||||
userAgent String?
|
returnDestinationStationId String?
|
||||||
source String @default("WEB")
|
returnHoldId String?
|
||||||
promoCode String?
|
returnSeatClassId String?
|
||||||
paidAt DateTime?
|
contactEmail String?
|
||||||
createdAt DateTime @default(now())
|
contactPhone String?
|
||||||
updatedAt DateTime @updatedAt
|
userAgent String?
|
||||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
source String @default("WEB")
|
||||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
promoCode String?
|
||||||
seats BookingSeat[]
|
paidAt DateTime?
|
||||||
paymentIntent PaymentIntent?
|
createdAt DateTime @default(now())
|
||||||
ticket Ticket?
|
updatedAt DateTime @updatedAt
|
||||||
foodOrders FoodOrder[]
|
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||||
agentBooking AgentBooking?
|
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||||
modifications BookingModification[]
|
seats BookingSeat[]
|
||||||
cancellation BookingCancellation?
|
paymentIntent PaymentIntent?
|
||||||
baggage BaggageBooking[]
|
ticket Ticket?
|
||||||
|
foodOrders FoodOrder[]
|
||||||
|
agentBooking AgentBooking?
|
||||||
|
modifications BookingModification[]
|
||||||
|
cancellation BookingCancellation?
|
||||||
|
baggage BaggageBooking[]
|
||||||
|
|
||||||
@@index([passengerId, status])
|
@@index([passengerId, status])
|
||||||
|
@@index([bookingType])
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,9 +144,20 @@ export class BookingsController {
|
|||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Create booking (requires login)',
|
summary: 'Create booking (one-way or round-trip)',
|
||||||
description: `Creates a booking for logged-in users with saved passenger profiles.
|
description: `Creates a one-way or round-trip booking for logged-in users.
|
||||||
Use POST /bookings/guest for guest checkout without login.`
|
|
||||||
|
ONE_WAY booking:
|
||||||
|
- scheduleId, holdId, originStationId, destinationStationId
|
||||||
|
- passengers: array of PassengerInputDto with seatId
|
||||||
|
- Single PNR, single payment
|
||||||
|
|
||||||
|
ROUND_TRIP booking:
|
||||||
|
- Outbound: scheduleId, holdId, originStationId, destinationStationId, seatClassId
|
||||||
|
- Return: returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId
|
||||||
|
- passengers: array of RoundTripPassengerDto with outboundSeatId and returnSeatId
|
||||||
|
- Combined PNR, single payment for both legs
|
||||||
|
- Fare = outbound_fare + return_fare, single total, single promo, single loyalty deduction`
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 201, description: 'Booking created with fare breakdown' })
|
@ApiResponse({ status: 201, description: 'Booking created with fare breakdown' })
|
||||||
@ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' })
|
@ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' })
|
||||||
|
|||||||
@@ -15,53 +15,130 @@ export class PassengerInputDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class RoundTripPassengerDto {
|
export class RoundTripPassengerDto {
|
||||||
@ApiProperty({ description: 'Outbound segment seat ID' }) @IsString() outboundSeatId: string;
|
@ApiProperty({
|
||||||
@ApiProperty({ description: 'Return segment seat ID' }) @IsString() returnSeatId: string;
|
description: 'Outbound journey seat ID',
|
||||||
@ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string;
|
example: 'seat-uuid-outbound'
|
||||||
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD)' }) @IsDateString() dateOfBirth: string;
|
})
|
||||||
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
|
@IsString() outboundSeatId: string;
|
||||||
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
|
|
||||||
@ApiPropertyOptional() @IsOptional() @IsString() passportNumber?: string;
|
@ApiProperty({
|
||||||
@ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string;
|
description: 'Return journey seat ID',
|
||||||
@ApiPropertyOptional() @IsOptional() @IsString() nationality?: string;
|
example: 'seat-uuid-return'
|
||||||
|
})
|
||||||
|
@IsString() returnSeatId: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'Abebe Kebede',
|
||||||
|
description: 'Full passenger name (will be verified via Verifayda for Ethiopian nationals)'
|
||||||
|
})
|
||||||
|
@IsString() passengerName: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: '1990-05-15',
|
||||||
|
description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first child FREE), Age ≥5 = ADULT (full fare for both legs)'
|
||||||
|
})
|
||||||
|
@IsDateString() dateOfBirth: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'NATIONAL_ID',
|
||||||
|
enum: IdDocumentType,
|
||||||
|
description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others'
|
||||||
|
})
|
||||||
|
@IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'ET123456789',
|
||||||
|
description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)'
|
||||||
|
})
|
||||||
|
@IsOptional() @IsString() idDocumentNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'P1234567',
|
||||||
|
description: 'Passport number for non-Ethiopian passengers (no verification)'
|
||||||
|
})
|
||||||
|
@IsOptional() @IsString() passportNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'Djibouti',
|
||||||
|
description: 'Passport issuing country for non-Ethiopians'
|
||||||
|
})
|
||||||
|
@IsOptional() @IsString() passportCountry?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'Ethiopian',
|
||||||
|
description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)'
|
||||||
|
})
|
||||||
|
@IsOptional() @IsString() nationality?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateBookingDto {
|
export class CreateBookingDto {
|
||||||
@ApiProperty() @IsString() passengerId: string;
|
@ApiProperty({ description: 'Passenger ID' })
|
||||||
@ApiProperty() @IsString() scheduleId: string;
|
@IsString() passengerId: string;
|
||||||
@ApiProperty() @IsString() holdId: string;
|
|
||||||
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID for this leg (must match the hold)' }) @IsString() originStationId: string;
|
@ApiProperty({ description: 'Outbound schedule ID' })
|
||||||
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg (must match the hold)' }) @IsString() destinationStationId: string;
|
@IsString() scheduleId: string;
|
||||||
@ApiProperty({ type: [PassengerInputDto], description: 'Array of passengers with age-based categorization. First child (<5 years) travels FREE.' }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[];
|
|
||||||
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID (Economy Regular, Economy Bed, VIP Bed)' })
|
@ApiProperty({ description: 'Outbound seat hold ID' })
|
||||||
|
@IsString() holdId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID (must match the hold)' })
|
||||||
|
@IsString() originStationId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID (must match the hold)' })
|
||||||
|
@IsString() destinationStationId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID (Economy Regular, Economy Bed, VIP Bed)' })
|
||||||
@IsString() seatClassId: string;
|
@IsString() seatClassId: string;
|
||||||
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
|
|
||||||
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
|
|
||||||
@ApiPropertyOptional({ example: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string;
|
|
||||||
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CreateRoundTripBookingDto {
|
@ApiProperty({
|
||||||
@ApiProperty({ description: 'Passenger ID' }) @IsString() passengerId: string;
|
example: 'ONE_WAY',
|
||||||
|
enum: ['ONE_WAY', 'ROUND_TRIP'],
|
||||||
|
description: `Booking type:\n\n**ONE_WAY:**\n- Single journey from origin to destination\n- Uses: scheduleId, holdId, originStationId, destinationStationId, seatClassId\n- passengers: PassengerInputDto[] with seatId\n\n**ROUND_TRIP:**\n- Outbound + return journey with single PNR\n- Uses all outbound fields PLUS return fields\n- passengers: RoundTripPassengerDto[] with outboundSeatId and returnSeatId\n- Combined fare calculation with single payment`,
|
||||||
|
default: 'ONE_WAY'
|
||||||
|
})
|
||||||
|
@IsOptional() @IsString() bookingType?: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Outbound schedule ID' }) @IsString() outboundScheduleId: string;
|
@ApiProperty({
|
||||||
@ApiProperty({ description: 'Outbound origin station ID' }) @IsString() outboundOriginStationId: string;
|
type: [PassengerInputDto],
|
||||||
@ApiProperty({ description: 'Outbound destination station ID' }) @IsString() outboundDestinationStationId: string;
|
description: `Passenger array - type depends on bookingType:\n\n**For ONE_WAY:** PassengerInputDto[]\n- Each passenger has: seatId, passengerName, dateOfBirth, etc.\n\n**For ROUND_TRIP:** RoundTripPassengerDto[]\n- Each passenger has: outboundSeatId, returnSeatId, passengerName, dateOfBirth, etc.\n\n**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare`
|
||||||
@ApiProperty({ description: 'Outbound seat hold ID' }) @IsString() outboundHoldId: string;
|
})
|
||||||
|
@IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto)
|
||||||
|
passengers: PassengerInputDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
|
||||||
|
@IsOptional() @IsString() promoCode?: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Return schedule ID' }) @IsString() returnScheduleId: string;
|
@ApiPropertyOptional({ description: 'Loyalty points to redeem (applies to combined fare for round-trip)' })
|
||||||
@ApiProperty({ description: 'Return origin station ID (usually same as outbound destination)' }) @IsString() returnOriginStationId: string;
|
@IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
|
||||||
@ApiProperty({ description: 'Return destination station ID (usually same as outbound origin)' }) @IsString() returnDestinationStationId: string;
|
|
||||||
@ApiProperty({ description: 'Return seat hold ID' }) @IsString() returnHoldId: string;
|
|
||||||
|
|
||||||
@ApiProperty({ type: [RoundTripPassengerDto], description: 'Array of passengers with seats for both outbound and return legs' })
|
@ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' })
|
||||||
@IsArray() @ValidateNested({ each: true }) @Type(() => RoundTripPassengerDto) passengers: RoundTripPassengerDto[];
|
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||||
|
|
||||||
|
// Round-trip specific fields
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '**ROUND_TRIP ONLY:** Return schedule ID (required when bookingType=ROUND_TRIP)'
|
||||||
|
})
|
||||||
|
@IsOptional() @IsString() returnScheduleId?: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Seat class ID' }) @IsString() seatClassId: string;
|
@ApiPropertyOptional({
|
||||||
|
description: '**ROUND_TRIP ONLY:** Return origin station ID (usually same as outbound destination)'
|
||||||
|
})
|
||||||
|
@IsOptional() @IsString() returnOriginStationId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
|
@ApiPropertyOptional({
|
||||||
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
|
description: '**ROUND_TRIP ONLY:** Return destination station ID (usually same as outbound origin)'
|
||||||
@ApiPropertyOptional({ example: 'DJF', enum: Currency }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
})
|
||||||
|
@IsOptional() @IsString() returnDestinationStationId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '**ROUND_TRIP ONLY:** Return seat hold ID (required when bookingType=ROUND_TRIP)'
|
||||||
|
})
|
||||||
|
@IsOptional() @IsString() returnHoldId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '**ROUND_TRIP ONLY:** Return seat class ID (optional, defaults to outbound seatClassId if not provided)'
|
||||||
|
})
|
||||||
|
@IsOptional() @IsString() returnSeatClassId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ModifyBookingDto {
|
export class ModifyBookingDto {
|
||||||
|
|||||||
@@ -246,15 +246,19 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateBookingDto) {
|
async create(dto: CreateBookingDto) {
|
||||||
|
if (dto.bookingType === 'ROUND_TRIP') {
|
||||||
|
return this.createRoundTripBooking(dto);
|
||||||
|
}
|
||||||
|
return this.createOneWayBooking(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createOneWayBooking(dto: CreateBookingDto) {
|
||||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
||||||
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
|
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
|
||||||
|
|
||||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
where: { id: dto.scheduleId },
|
where: { id: dto.scheduleId },
|
||||||
include: {
|
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
|
||||||
originStation: true,
|
|
||||||
destinationStation: true,
|
|
||||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
|
||||||
@@ -262,18 +266,182 @@ export class BookingsService {
|
|||||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||||
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
|
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
|
||||||
|
|
||||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
const passengersData = await this.processPassengers(dto.passengers as any[]);
|
||||||
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
const { adultCount, childCount } = this.countPassengers(passengersData);
|
||||||
|
const fareCalculation = await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
|
||||||
|
|
||||||
|
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||||
|
let displayTotalMinor = fareCalculation.totalMinor;
|
||||||
|
if (displayCurrency !== Currency.ETB) {
|
||||||
|
displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency);
|
||||||
|
}
|
||||||
|
|
||||||
const seatIds = dto.passengers.map((p) => p.seatId);
|
const booking = await this.prisma.booking.create({
|
||||||
const passengersData = [];
|
data: {
|
||||||
let adultCount = 0, childCount = 0;
|
bookingRef: generateRef(),
|
||||||
|
passengerId: dto.passengerId,
|
||||||
|
scheduleId: dto.scheduleId,
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
bookingType: 'ONE_WAY',
|
||||||
|
totalMinor: fareCalculation.totalMinor,
|
||||||
|
adultCount,
|
||||||
|
childCount,
|
||||||
|
displayCurrency,
|
||||||
|
displayTotalMinor,
|
||||||
|
seats: {
|
||||||
|
create: passengersData.map(p => ({
|
||||||
|
seat: { connect: { id: p.seatId } },
|
||||||
|
passengerName: p.passengerName,
|
||||||
|
dateOfBirth: p.dateOfBirth,
|
||||||
|
passengerCategory: p.category,
|
||||||
|
idDocumentType: p.idDocumentType,
|
||||||
|
passportNumber: p.passportNumber,
|
||||||
|
passportCountry: p.passportCountry,
|
||||||
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
|
verifaydaData: p.verifaydaData,
|
||||||
|
fareMinor: p.category === PassengerCategory.ADULT ? fareCalculation.baseFareMinor : (fareCalculation.paidChildrenCount > 0 ? fareCalculation.baseFareMinor : 0),
|
||||||
|
displayCurrency
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
|
||||||
|
});
|
||||||
|
|
||||||
for (const passenger of dto.passengers) {
|
await this.seatsService.confirmSeats(passengersData.map(p => p.seatId));
|
||||||
|
this.eventEmitter.emit('booking.created', { booking });
|
||||||
|
return { ...booking, fareBreakdown: fareCalculation };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createRoundTripBooking(dto: CreateBookingDto) {
|
||||||
|
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
|
||||||
|
throw new BadRequestException('Return trip details required for round-trip booking');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [outboundHold, returnHold] = await Promise.all([
|
||||||
|
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
|
||||||
|
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } })
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired');
|
||||||
|
if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired');
|
||||||
|
|
||||||
|
const [outboundSchedule, returnSchedule] = await Promise.all([
|
||||||
|
this.prisma.trainSchedule.findUnique({
|
||||||
|
where: { id: dto.scheduleId },
|
||||||
|
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
|
||||||
|
}),
|
||||||
|
this.prisma.trainSchedule.findUnique({
|
||||||
|
where: { id: dto.returnScheduleId },
|
||||||
|
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!outboundSchedule || !returnSchedule) throw new NotFoundException('Schedule not found');
|
||||||
|
|
||||||
|
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||||
|
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||||
|
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
|
||||||
|
const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId);
|
||||||
|
|
||||||
|
if (!outboundOriginStop || !outboundDestStop || !returnOriginStop || !returnDestStop) {
|
||||||
|
throw new NotFoundException('Origin or destination stops not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
|
||||||
|
const { adultCount, childCount } = this.countPassengers(passengersData);
|
||||||
|
|
||||||
|
const [outboundFare, returnFare] = await Promise.all([
|
||||||
|
this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount),
|
||||||
|
this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor;
|
||||||
|
let discountMinor = 0;
|
||||||
|
if (dto.promoCode) {
|
||||||
|
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
|
||||||
|
if (promo?.active && promo.validUntil > new Date()) {
|
||||||
|
discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||||
|
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
|
||||||
|
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
||||||
|
|
||||||
|
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||||
|
let displayTotalMinor = totalMinor;
|
||||||
|
if (displayCurrency !== Currency.ETB) {
|
||||||
|
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
const booking = await this.prisma.booking.create({
|
||||||
|
data: {
|
||||||
|
bookingRef: generateRef(),
|
||||||
|
passengerId: dto.passengerId,
|
||||||
|
scheduleId: dto.scheduleId,
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
bookingType: 'ROUND_TRIP',
|
||||||
|
totalMinor,
|
||||||
|
adultCount,
|
||||||
|
childCount,
|
||||||
|
displayCurrency,
|
||||||
|
displayTotalMinor,
|
||||||
|
returnScheduleId: dto.returnScheduleId,
|
||||||
|
returnOriginStationId: dto.returnOriginStationId,
|
||||||
|
returnDestinationStationId: dto.returnDestinationStationId,
|
||||||
|
returnHoldId: dto.returnHoldId,
|
||||||
|
returnSeatClassId: dto.returnSeatClassId,
|
||||||
|
seats: {
|
||||||
|
create: passengersData.map(p => ({
|
||||||
|
seat: { connect: { id: p.outboundSeatId } },
|
||||||
|
passengerName: p.passengerName,
|
||||||
|
dateOfBirth: p.dateOfBirth,
|
||||||
|
passengerCategory: p.category,
|
||||||
|
idDocumentType: p.idDocumentType,
|
||||||
|
passportNumber: p.passportNumber,
|
||||||
|
passportCountry: p.passportCountry,
|
||||||
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
|
verifaydaData: p.verifaydaData,
|
||||||
|
fareMinor: p.category === PassengerCategory.ADULT ? (outboundFare.baseFareMinor + returnFare.baseFareMinor) : 0,
|
||||||
|
displayCurrency
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
|
||||||
|
});
|
||||||
|
|
||||||
|
const outboundSeatIds = passengersData.map(p => p.outboundSeatId);
|
||||||
|
const returnSeatIds = passengersData.map(p => p.returnSeatId);
|
||||||
|
await Promise.all([
|
||||||
|
this.seatsService.confirmSeats(outboundSeatIds),
|
||||||
|
this.seatsService.confirmSeats(returnSeatIds)
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.eventEmitter.emit('booking.created', { booking });
|
||||||
|
|
||||||
|
return {
|
||||||
|
...booking,
|
||||||
|
fareBreakdown: {
|
||||||
|
outboundFare: outboundFare.baseFareMinor,
|
||||||
|
returnFare: returnFare.baseFareMinor,
|
||||||
|
combinedBaseFareMinor,
|
||||||
|
discountMinor,
|
||||||
|
loyaltyRedemptionMinor: loyaltyMinor,
|
||||||
|
taxesFeesMinor: taxesMinor,
|
||||||
|
totalMinor,
|
||||||
|
currency: 'ETB',
|
||||||
|
displayCurrency,
|
||||||
|
displayTotalMinor
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processPassengers(passengers: any[]) {
|
||||||
|
const processedPassengers = [];
|
||||||
|
for (const passenger of passengers) {
|
||||||
const dateOfBirth = new Date(passenger.dateOfBirth);
|
const dateOfBirth = new Date(passenger.dateOfBirth);
|
||||||
const age = calculateAge(dateOfBirth);
|
const age = calculateAge(dateOfBirth);
|
||||||
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||||||
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
|
|
||||||
|
|
||||||
let passengerName = passenger.passengerName;
|
let passengerName = passenger.passengerName;
|
||||||
let verifaydaVerified = false;
|
let verifaydaVerified = false;
|
||||||
@@ -292,68 +460,93 @@ export class BookingsService {
|
|||||||
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
||||||
}
|
}
|
||||||
|
|
||||||
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||||||
}
|
}
|
||||||
|
return processedPassengers;
|
||||||
|
}
|
||||||
|
|
||||||
const primaryNationality = passengersData[0]?.nationality;
|
private async processRoundTripPassengers(passengers: any[]) {
|
||||||
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality, originStop.sequence, destStop.sequence);
|
const processedPassengers = [];
|
||||||
|
for (const passenger of passengers) {
|
||||||
|
const dateOfBirth = new Date(passenger.dateOfBirth);
|
||||||
|
const age = calculateAge(dateOfBirth);
|
||||||
|
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||||||
|
|
||||||
|
let passengerName = passenger.passengerName;
|
||||||
|
let verifaydaVerified = false;
|
||||||
|
let verifaydaData: Record<string, any> | undefined;
|
||||||
|
let nationality = passenger.nationality;
|
||||||
|
|
||||||
|
if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
|
||||||
|
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
|
||||||
|
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
|
||||||
|
passengerName = verification.passengerData?.fullName || passengerName;
|
||||||
|
verifaydaVerified = true;
|
||||||
|
verifaydaData = verification.passengerData?.profileData;
|
||||||
|
nationality = nationality || 'Ethiopian';
|
||||||
|
} else if (passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||||||
|
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
|
||||||
|
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
||||||
|
}
|
||||||
|
|
||||||
|
processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||||||
|
}
|
||||||
|
return processedPassengers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private countPassengers(passengersData: any[]) {
|
||||||
|
let adultCount = 0, childCount = 0;
|
||||||
|
for (const passenger of passengersData) {
|
||||||
|
if (passenger.category === PassengerCategory.ADULT) adultCount++;
|
||||||
|
else childCount++;
|
||||||
|
}
|
||||||
|
return { adultCount, childCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async calculateFare(
|
||||||
|
scheduleId: string,
|
||||||
|
seatClassId: string,
|
||||||
|
originStop: any,
|
||||||
|
destStop: any,
|
||||||
|
nationality?: string,
|
||||||
|
adultCount = 1,
|
||||||
|
childCount = 0,
|
||||||
|
promoCode?: string,
|
||||||
|
loyaltyRedemptionPoints?: number
|
||||||
|
) {
|
||||||
|
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||||
|
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence);
|
||||||
|
|
||||||
const adultFareMinor = baseFareMinor * adultCount;
|
const adultFareMinor = baseFareMinor * adultCount;
|
||||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||||
const childFareMinor = baseFareMinor * paidChildrenCount;
|
const childFareMinor = baseFareMinor * paidChildrenCount;
|
||||||
const totalBaseFareMinor = adultFareMinor + childFareMinor;
|
const totalBaseFareMinor = adultFareMinor + childFareMinor;
|
||||||
|
|
||||||
let discountMinor = 0;
|
let discountMinor = 0;
|
||||||
if (dto.promoCode) {
|
if (promoCode) {
|
||||||
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
|
const promo = await this.prisma.promotion.findUnique({ where: { code: promoCode } });
|
||||||
if (promo?.active && promo.validUntil > new Date()) {
|
if (promo?.active && promo.validUntil > new Date()) {
|
||||||
discountMinor = promo.percentOff ? Math.round(totalBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
discountMinor = promo.percentOff ? Math.round(totalBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10;
|
||||||
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
|
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
|
||||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
||||||
|
|
||||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
|
||||||
let displayTotalMinor = totalMinor;
|
|
||||||
if (displayCurrency !== Currency.ETB) {
|
|
||||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
|
||||||
}
|
|
||||||
|
|
||||||
const booking = await this.prisma.booking.create({
|
|
||||||
data: {
|
|
||||||
bookingRef: generateRef(),
|
|
||||||
passengerId: dto.passengerId,
|
|
||||||
scheduleId: dto.scheduleId,
|
|
||||||
status: 'PENDING_PAYMENT',
|
|
||||||
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
|
|
||||||
bookingType: dto.bookingType ?? 'ONE_WAY',
|
|
||||||
seats: {
|
|
||||||
create: passengersData.map((p) => ({
|
|
||||||
seat: { connect: { id: p.seatId } },
|
|
||||||
passengerName: p.passengerName,
|
|
||||||
dateOfBirth: p.dateOfBirth,
|
|
||||||
passengerCategory: p.category,
|
|
||||||
idDocumentType: p.idDocumentType,
|
|
||||||
idDocumentNumber: p.idDocumentType === IdDocumentType.NATIONAL_ID ? undefined : p.idDocumentNumber,
|
|
||||||
passportNumber: p.passportNumber,
|
|
||||||
passportCountry: p.passportCountry,
|
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
|
||||||
verifaydaData: p.verifaydaData || undefined,
|
|
||||||
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0),
|
|
||||||
displayCurrency,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.seatsService.confirmSeats(seatIds);
|
|
||||||
this.eventEmitter.emit('booking.created', { booking });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...booking,
|
baseFareMinor,
|
||||||
fareBreakdown: { baseFareMinor, adultCount, adultFareMinor, childCount, freeChildrenCount: Math.min(childCount, 1), paidChildrenCount, childFareMinor, totalBaseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor, currency: 'ETB', displayCurrency, displayTotalMinor },
|
adultCount,
|
||||||
|
adultFareMinor,
|
||||||
|
childCount,
|
||||||
|
freeChildrenCount: Math.min(childCount, 1),
|
||||||
|
paidChildrenCount,
|
||||||
|
childFareMinor,
|
||||||
|
totalBaseFareMinor,
|
||||||
|
discountMinor,
|
||||||
|
loyaltyRedemptionMinor: loyaltyMinor,
|
||||||
|
taxesFeesMinor: taxesMinor,
|
||||||
|
totalMinor
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,9 +134,28 @@ export class FleetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteCoachType(id: string) {
|
async deleteCoachType(id: string) {
|
||||||
const coachType = await this.prisma.coachType.findUnique({ where: { id } });
|
const coachType = await this.prisma.coachType.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
coaches: true,
|
||||||
|
seatClasses: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
if (!coachType) throw new NotFoundException('Coach type not found');
|
if (!coachType) throw new NotFoundException('Coach type not found');
|
||||||
|
|
||||||
|
// Check for related records
|
||||||
|
if (coachType.coaches.length > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot delete coach type. ${coachType.coaches.length} coach(es) are still using this coach type. Please reassign or delete the coaches first.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coachType.seatClasses.length > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot delete coach type. ${coachType.seatClasses.length} seat class(es) are still using this coach type. Please reassign or delete the seat classes first.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.prisma.coachType.delete({ where: { id } });
|
return this.prisma.coachType.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,9 +202,29 @@ export class FleetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteClass(id: string) {
|
async deleteClass(id: string) {
|
||||||
const seatClass = await this.prisma.seatClass.findUnique({ where: { id } });
|
const seatClass = await this.prisma.seatClass.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
fareRules: true,
|
||||||
|
routeFareRules: true,
|
||||||
|
segmentFares: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||||
|
|
||||||
|
// Check for related records
|
||||||
|
const relatedRecords = [
|
||||||
|
...seatClass.fareRules,
|
||||||
|
...seatClass.routeFareRules,
|
||||||
|
...seatClass.segmentFares,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (relatedRecords.length > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot delete seat class. ${relatedRecords.length} fare rule(s) are still using this seat class. Please delete the fare rules first.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.prisma.seatClass.delete({ where: { id } });
|
return this.prisma.seatClass.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,8 +259,21 @@ export class FleetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteTrain(id: string) {
|
async deleteTrain(id: string) {
|
||||||
const train = await this.prisma.train.findUnique({ where: { id } });
|
const train = await this.prisma.train.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
schedules: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
if (!train) throw new NotFoundException('Train not found');
|
if (!train) throw new NotFoundException('Train not found');
|
||||||
|
|
||||||
|
// Check for active schedules
|
||||||
|
if (train.schedules.length > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot delete train. This train has ${train.schedules.length} schedule(s). Please delete the schedules first.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.prisma.train.delete({ where: { id } });
|
return this.prisma.train.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,10 +357,53 @@ export class FleetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteCoach(id: string) {
|
async deleteCoach(id: string) {
|
||||||
const coach = await this.prisma.coach.findUnique({ where: { id } });
|
const coach = await this.prisma.coach.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
assignments: true,
|
||||||
|
seats: {
|
||||||
|
include: {
|
||||||
|
bookingSeats: true,
|
||||||
|
blocks: true,
|
||||||
|
ticketSeats: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
if (!coach) throw new NotFoundException('Coach not found');
|
if (!coach) throw new NotFoundException('Coach not found');
|
||||||
|
|
||||||
// Delete related seats first
|
// Check for active assignments
|
||||||
|
if (coach.assignments.length > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot delete coach. This coach is assigned to ${coach.assignments.length} schedule(s). Please remove the assignments first.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for booked seats
|
||||||
|
const bookedSeats = coach.seats.filter(seat => seat.bookingSeats.length > 0);
|
||||||
|
if (bookedSeats.length > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot delete coach. ${bookedSeats.length} seat(s) have active bookings. Please wait for bookings to complete or cancel them first.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for blocked seats
|
||||||
|
const blockedSeats = coach.seats.filter(seat => seat.blocks.length > 0);
|
||||||
|
if (blockedSeats.length > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot delete coach. ${blockedSeats.length} seat(s) are blocked. Please unblock them first.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for tickets
|
||||||
|
const seatsWithTickets = coach.seats.filter(seat => seat.ticketSeats.length > 0);
|
||||||
|
if (seatsWithTickets.length > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot delete coach. ${seatsWithTickets.length} seat(s) have issued tickets. Please wait for travel completion.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete related seats first (now safe to do)
|
||||||
await this.prisma.seat.deleteMany({ where: { coachId: id } });
|
await this.prisma.seat.deleteMany({ where: { coachId: id } });
|
||||||
|
|
||||||
return this.prisma.coach.delete({ where: { id } });
|
return this.prisma.coach.delete({ where: { id } });
|
||||||
|
|||||||
@@ -545,8 +545,13 @@ export class SchedulesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.coaches && dto.coaches.length > 0) {
|
if (dto.coaches !== undefined) {
|
||||||
await this.assignCoaches(id, dto.coaches);
|
if (dto.coaches.length > 0) {
|
||||||
|
await this.assignCoaches(id, dto.coaches);
|
||||||
|
} else {
|
||||||
|
// Remove all coach assignments when empty array is sent
|
||||||
|
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.getSchedule(id);
|
return this.getSchedule(id);
|
||||||
|
|||||||
@@ -204,14 +204,11 @@ export default function SchedulesPage() {
|
|||||||
departureAt: editForm.departureAt,
|
departureAt: editForm.departureAt,
|
||||||
arrivalAt: editForm.arrivalAt,
|
arrivalAt: editForm.arrivalAt,
|
||||||
status: editForm.status,
|
status: editForm.status,
|
||||||
};
|
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
|
||||||
|
|
||||||
if (editForm.coachIds.length > 0) {
|
|
||||||
payload.coaches = editForm.coachIds.map((coachId: string, idx: number) => ({
|
|
||||||
coachId,
|
coachId,
|
||||||
positionNumber: idx + 1,
|
positionNumber: idx + 1,
|
||||||
}));
|
})),
|
||||||
}
|
};
|
||||||
|
|
||||||
await updateScheduleMutation.mutateAsync({
|
await updateScheduleMutation.mutateAsync({
|
||||||
id: editingSchedule.id,
|
id: editingSchedule.id,
|
||||||
@@ -327,14 +324,20 @@ export default function SchedulesPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'originStation.name',
|
key: 'route',
|
||||||
label: 'From',
|
label: 'Route',
|
||||||
render: (schedule: Schedule) => <span>{schedule.originStation?.name}</span>,
|
sortable: true,
|
||||||
},
|
render: (schedule: Schedule) => (
|
||||||
{
|
<div className="flex items-center gap-2">
|
||||||
key: 'destinationStation.name',
|
<span className="text-sm font-medium">
|
||||||
label: 'To',
|
{schedule.originStation?.name || 'Unknown'}
|
||||||
render: (schedule: Schedule) => <span>{schedule.destinationStation?.name}</span>,
|
</span>
|
||||||
|
<span className="text-muted-foreground">→</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{schedule.destinationStation?.name || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'departureAt',
|
key: 'departureAt',
|
||||||
|
|||||||
@@ -420,7 +420,12 @@ export default function SeatsPage() {
|
|||||||
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
||||||
return seats.length > 0;
|
return seats.length > 0;
|
||||||
})
|
})
|
||||||
.sort((a: any, b: any) => (a.sequence || 0) - (b.sequence || 0));
|
.sort((a: any, b: any) => {
|
||||||
|
// Try multiple sequence field possibilities
|
||||||
|
const seqA = a.positionNumber ?? a.sequence ?? a.coach?.sequence ?? 999;
|
||||||
|
const seqB = b.positionNumber ?? b.sequence ?? b.coach?.sequence ?? 999;
|
||||||
|
return seqA - seqB;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -460,8 +465,30 @@ export default function SeatsPage() {
|
|||||||
<p className="text-muted-foreground mt-3">Loading seats...</p>
|
<p className="text-muted-foreground mt-3">Loading seats...</p>
|
||||||
</div>
|
</div>
|
||||||
) : coachesWithSeats.length === 0 ? (
|
) : coachesWithSeats.length === 0 ? (
|
||||||
<div className="card text-center py-12 text-muted-foreground">
|
<div className="space-y-6">
|
||||||
<p>No coaches with seats found for this schedule</p>
|
<div className="card">
|
||||||
|
<label className="label">Select Schedule</label>
|
||||||
|
<select
|
||||||
|
value={selectedSchedule}
|
||||||
|
onChange={(e) => setSelectedSchedule(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
>
|
||||||
|
<option value="">Select a schedule...</option>
|
||||||
|
{schedules.map((schedule: any) => {
|
||||||
|
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
||||||
|
const routeName = schedule.route?.name || 'N/A';
|
||||||
|
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
||||||
|
return (
|
||||||
|
<option key={schedule.id} value={schedule.id}>
|
||||||
|
{trainNumber} - {routeName} - {date}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="card text-center py-12 text-muted-foreground">
|
||||||
|
<p>No coaches with seats found for this schedule</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
@@ -526,7 +553,7 @@ export default function SeatsPage() {
|
|||||||
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
||||||
const isExpanded = expandedCoaches.has(coach.id);
|
const isExpanded = expandedCoaches.has(coach.id);
|
||||||
const seatOrBedLabel = isBedCoach ? 'beds' : 'seats';
|
const seatOrBedLabel = isBedCoach ? 'beds' : 'seats';
|
||||||
const sequence = coachData?.sequence ?? coach?.sequence ?? index + 1;
|
const sequence = coach.positionNumber ?? coach.sequence ?? coachData?.sequence ?? index + 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={coach.id} className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800/50 shadow-md hover:shadow-lg transition-shadow">
|
<div key={coach.id} className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800/50 shadow-md hover:shadow-lg transition-shadow">
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ export default function StationsPage() {
|
|||||||
lat: parseFloat(formData.get('lat') as string) || null,
|
lat: parseFloat(formData.get('lat') as string) || null,
|
||||||
lng: parseFloat(formData.get('lng') as string) || null,
|
lng: parseFloat(formData.get('lng') as string) || null,
|
||||||
timezone: formData.get('timezone') as string,
|
timezone: formData.get('timezone') as string,
|
||||||
distance: parseFloat(formData.get('distance') as string) || 0,
|
|
||||||
sequence,
|
sequence,
|
||||||
isOperational: formData.get('isOperational') === 'true',
|
isOperational: formData.get('isOperational') === 'true',
|
||||||
};
|
};
|
||||||
@@ -136,13 +135,7 @@ export default function StationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'distance',
|
|
||||||
label: 'Distance (km)',
|
|
||||||
render: (station: any) => (
|
|
||||||
<span className="font-mono text-sm">{station.distance ? `${station.distance}` : '0'}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'isOperational',
|
key: 'isOperational',
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
@@ -346,18 +339,6 @@ export default function StationsPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="label">Distance from Previous (km)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name="distance"
|
|
||||||
className="input"
|
|
||||||
defaultValue={editingStation?.distance || 0}
|
|
||||||
min="0"
|
|
||||||
step="0.1"
|
|
||||||
placeholder="e.g., 150.5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Sequence Number *</label>
|
<label className="label">Sequence Number *</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
Reference in New Issue
Block a user