diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts
index 9aff71529..99db0202b 100644
--- a/apps/edr-passenger-api/prisma/seed.ts
+++ b/apps/edr-passenger-api/prisma/seed.ts
@@ -3,8 +3,8 @@ import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
-const EDR_ROUTE_ID = 'route-edr-main';
-const TRAIN_ID = 'train-001';
+const EDR_ROUTE_ID = 'route-edr-101';
+const TRAIN_ID = 'EDR-101';
async function seedSystemUsers() {
console.log('š„ Seeding system users...');
@@ -140,9 +140,9 @@ async function seedStations() {
async function seedCoachTypesAndClasses() {
console.log('\nš Seeding coach types and seat classes...');
const coachTypes = [
- { code: 'ECO', name: 'Economy', type: 'passenger' },
- { code: 'ECO_BED', name: 'Economy Bed', type: 'sleeper' },
- { code: 'VIP_BED', name: 'VIP Bed', type: 'sleeper' },
+ { code: 'HSC', name: 'Hard Seat Coach', type: 'Economy Regular' },
+ { code: 'HBC', name: 'Hard Bed Coach', type: 'Economy Bed' },
+ { code: 'SBC', name: 'Soft Bed Coach', type: 'VIP Bed' },
];
for (const ct of coachTypes) {
@@ -154,10 +154,12 @@ async function seedCoachTypesAndClasses() {
}
const seatClasses = [
- { name: 'ECONOMY_REGULAR', coachCode: 'ECO', baseFareMinor: 35000 },
- { name: 'ECONOMY_WINDOW', coachCode: 'ECO', baseFareMinor: 37000 },
- { name: 'ECONOMY_BED', coachCode: 'ECO_BED', baseFareMinor: 55000 },
- { name: 'VIP_BED', coachCode: 'VIP_BED', baseFareMinor: 85000 },
+ { name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900 },
+ { name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800 },
+ { name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600 },
+ { name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550 },
+ { name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500 },
+ { name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250 },
];
for (const sc of seatClasses) {
@@ -177,20 +179,19 @@ async function seedRoute() {
const lastStation = await prisma.station.findUnique({ where: { code: 'NAG' } });
const route = await prisma.route.upsert({
- where: { code: 'EDR-MAIN' },
+ where: { code: 'EDR-101' },
update: {},
create: {
- id: EDR_ROUTE_ID,
- code: 'EDR-MAIN',
- name: 'Ethio-Djibouti Railway Main Route',
- description: 'Main route connecting Sebeta to Nagad',
- effectiveFrom: new Date('2024-01-01'),
+ code: 'EDR-101',
+ name: 'Sebeta - Dire Dawa',
+ description: 'Outbound local route from Sebeta to Dire Dawa',
+ effectiveFrom: new Date('2026-01-01'),
effectiveUntil: new Date('2034-12-31'),
active: true,
},
});
- const stationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE', 'ADG', 'AYS', 'DAW', 'ALS', 'HOL', 'NAG'];
+ const stationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE'];
for (let i = 0; i < stationCodes.length; i++) {
const station = await prisma.station.findUnique({ where: { code: stationCodes[i] } });
await prisma.routeStop.upsert({
@@ -204,17 +205,14 @@ async function seedRoute() {
async function seedCoaches() {
console.log('\nš Seeding coaches and seats...');
- const ecoCoachType = await prisma.coachType.findUnique({ where: { id: 'ECO' } });
- const ecoBedCoachType = await prisma.coachType.findUnique({ where: { id: 'ECO_BED' } });
- const vipBedCoachType = await prisma.coachType.findUnique({ where: { id: 'VIP_BED' } });
+ const ecoCoachType = await prisma.coachType.findUnique({ where: { id: 'HSC' } });
+ const ecoBedCoachType = await prisma.coachType.findUnique({ where: { id: 'HBC' } });
+ const vipBedCoachType = await prisma.coachType.findUnique({ where: { id: 'SBC' } });
const coaches = [
- { number: 'C-001', coachTypeId: ecoCoachType!.id, arrangement: '2+2', capacity: 48 },
- { number: 'C-002', coachTypeId: ecoCoachType!.id, arrangement: '2+2', capacity: 48 },
- { number: 'C-003', coachTypeId: ecoCoachType!.id, arrangement: '2+2', capacity: 48 },
- { number: 'C-004', coachTypeId: ecoBedCoachType!.id, arrangement: '2+2', capacity: 32 },
- { number: 'C-005', coachTypeId: ecoBedCoachType!.id, arrangement: '2+2', capacity: 32 },
- { number: 'C-006', coachTypeId: vipBedCoachType!.id, arrangement: '1+1', capacity: 16 },
+ { number: 'HSC-0001', coachTypeId: ecoCoachType!.id, arrangement: '3+2', capacity: 40 },
+ { number: 'HBC-0001', coachTypeId: ecoBedCoachType!.id, arrangement: '3+0', capacity: 66 },
+ { number: 'SBC-0001', coachTypeId: vipBedCoachType!.id, arrangement: '2+0', capacity: 120 },
];
let totalSeats = 0;
@@ -229,9 +227,16 @@ async function seedCoaches() {
for (let row = 1; row <= Math.ceil(coach.capacity / 2); row++) {
for (const col of ['A', 'B', 'C', 'D']) {
if (seatIndex <= coach.capacity) {
+ let bedPosition: string | null = null;
+ if (c.coachTypeId === ecoBedCoachType!.id || c.coachTypeId === vipBedCoachType!.id) {
+ if (row % 3 === 1) bedPosition = 'upper';
+ else if (row % 3 === 2) bedPosition = 'middle';
+ else bedPosition = 'lower';
+ }
+
await prisma.seat.upsert({
where: { coachId_seatNumber: { coachId: c.id, seatNumber: seatIndex.toString() } },
- update: {},
+ update: { bedPosition },
create: {
coachId: c.id,
seatNumber: seatIndex.toString(),
@@ -239,6 +244,7 @@ async function seedCoaches() {
col,
isWindow: col === 'A' || col === 'D',
isAisle: col === 'B' || col === 'C',
+ bedPosition,
},
});
seatIndex++;
@@ -258,15 +264,14 @@ async function seedTrips() {
create: { id: TRAIN_ID, number: 'EDR-001', name: 'Djibouti Express' },
});
- const route = await prisma.route.findUnique({ where: { code: 'EDR-MAIN' } });
+ const route = await prisma.route.findUnique({ where: { code: 'EDR-101' } });
const firstStation = await prisma.station.findUnique({ where: { code: 'SBT' } });
- const lastStation = await prisma.station.findUnique({ where: { code: 'NAG' } });
+ const lastStation = await prisma.station.findUnique({ where: { code: 'DIR' } });
const coaches = await prisma.coach.findMany();
const now = new Date();
const schedules = [];
- // Bulk prepare schedule data
for (let d = 0; d < 30; d++) {
const tripDate = new Date(now);
tripDate.setDate(tripDate.getDate() + d);
@@ -287,12 +292,10 @@ async function seedTrips() {
});
}
- // Bulk create schedules
const createdSchedules = await Promise.all(
schedules.map(s => prisma.trainSchedule.create({ data: s }))
);
- // Bulk create coach assignments and live status
const coachAssignments = [];
const liveStatuses = [];
@@ -311,31 +314,12 @@ async function seedTrips() {
});
}
- // Stop times: one TripStopTime per station per schedule
- const stationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE', 'ADG', 'AYS', 'DAW', 'ALS', 'HOL', 'NAG'];
- const stopTimeRows = [];
- for (const schedule of createdSchedules) {
- const dep = new Date(schedule.departureAt);
- for (let i = 0; i < stationCodes.length; i++) {
- const stationId = `station-${stationCodes[i].toLowerCase()}`;
- const offsetMs = i * 60 * 60 * 1000; // ~1 hour between stops
- stopTimeRows.push({
- scheduleId: schedule.id,
- stationId,
- sequence: i + 1,
- plannedArrivalAt: i === 0 ? null : new Date(dep.getTime() + offsetMs),
- plannedDepartureAt: i === stationCodes.length - 1 ? null : new Date(dep.getTime() + offsetMs),
- });
- }
- }
-
await Promise.all([
...coachAssignments.map(ca => prisma.coachAssignment.create({ data: ca })),
...liveStatuses.map(ls => prisma.tripLiveStatus.create({ data: ls })),
- prisma.tripStopTime.createMany({ data: stopTimeRows }),
]);
-
- console.log(` ā
Train with ${createdSchedules.length} upcoming trips and stop times created`);
+
+ console.log(` ā
Train with ${createdSchedules.length} upcoming trips created`);
}
async function seedFareRules() {
diff --git a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts
index 33976f22f..0565faf8f 100644
--- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts
+++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts
@@ -1,8 +1,11 @@
-import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException } from '@nestjs/common';
+import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException, Param, Patch, Delete, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
import { JwtGuard } from '../../common/jwt.guard';
+import { RolesGuard } from '../../common/roles.guard';
+import { Roles } from '../../common/roles.decorator';
+import { UserRole } from '@prisma/client';
@ApiTags('Auth')
@Controller('auth')
@@ -240,4 +243,61 @@ export class AuthController {
}
return this.service.getProfile(req.user.userId);
}
+
+ @Get('users')
+ @UseGuards(JwtGuard, RolesGuard)
+ @Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Get all backoffice users (admin/supervisor only)' })
+ getUsers(
+ @Query('search') search?: string,
+ @Query('role') role?: string,
+ @Query('status') status?: string,
+ @Query('page') page?: string,
+ @Query('pageSize') pageSize?: string,
+ ) {
+ return this.service.getUsers({
+ search,
+ role,
+ status,
+ page: page ? parseInt(page) : 1,
+ pageSize: pageSize ? parseInt(pageSize) : 10,
+ });
+ }
+
+ @Post('users')
+ @UseGuards(JwtGuard, RolesGuard)
+ @Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Create new backoffice user (admin/supervisor only)' })
+ createUser(@Body() dto: any) {
+ return this.service.createUser(dto);
+ }
+
+ @Patch('users/:id')
+ @UseGuards(JwtGuard, RolesGuard)
+ @Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Update backoffice user (admin/supervisor only)' })
+ updateUser(@Param('id') id: string, @Body() dto: any) {
+ return this.service.updateUser(id, dto);
+ }
+
+ @Delete('users/:id')
+ @UseGuards(JwtGuard, RolesGuard)
+ @Roles(UserRole.ADMIN)
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Delete backoffice user (admin only)' })
+ deleteUser(@Param('id') id: string) {
+ return this.service.deleteUser(id);
+ }
+
+ @Post('users/:id/reset-password')
+ @UseGuards(JwtGuard, RolesGuard)
+ @Roles(UserRole.ADMIN, UserRole.SUPERVISOR)
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Reset user password with temporary password (admin/supervisor only)' })
+ resetUserPassword(@Param('id') id: string, @Body() dto: { tempPassword: string }) {
+ return this.service.resetUserPassword(id, dto.tempPassword);
+ }
}
diff --git a/apps/edr-passenger-api/src/modules/auth/auth.service.ts b/apps/edr-passenger-api/src/modules/auth/auth.service.ts
index 931db07b5..e937a106b 100644
--- a/apps/edr-passenger-api/src/modules/auth/auth.service.ts
+++ b/apps/edr-passenger-api/src/modules/auth/auth.service.ts
@@ -1,4 +1,4 @@
-import { Injectable, UnauthorizedException, ConflictException, BadRequestException } from '@nestjs/common';
+import { Injectable, UnauthorizedException, ConflictException, BadRequestException, NotFoundException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../../common/prisma.service';
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
@@ -58,7 +58,7 @@ export class AuthService {
await this.prisma.user.update({
where: { id: user.id },
- data: { failedLoginAttempts: 0, lockedUntil: null }
+ data: { failedLoginAttempts: 0, lockedUntil: null, lastLoginAt: new Date() }
});
await this.createAuditLog(user.id, 'USER_LOGIN', 'User', user.id, null, null);
@@ -131,6 +131,174 @@ export class AuthService {
return { reset: true };
}
+ async getUsers(filters: { search?: string; role?: string; status?: string; page?: number; pageSize?: number }) {
+ const { search, role, status, page = 1, pageSize = 10 } = filters;
+ const skip = (page - 1) * pageSize;
+
+ const where: any = {
+ role: { not: 'PASSENGER' }, // Exclude passenger accounts
+ };
+
+ if (search) {
+ where.OR = [
+ { email: { contains: search, mode: 'insensitive' } },
+ { fullName: { contains: search, mode: 'insensitive' } },
+ ];
+ }
+
+ if (role) {
+ where.role = role;
+ }
+
+ // For status filtering, we check if user is active (no lock/block) or inactive
+ if (status === 'ACTIVE') {
+ where.AND = [
+ { blockedUntil: { lte: new Date() } },
+ { lockedUntil: { lte: new Date() } }
+ ];
+ } else if (status === 'INACTIVE') {
+ where.OR = [
+ { blockedUntil: { gt: new Date() } },
+ { lockedUntil: { gt: new Date() } }
+ ];
+ }
+
+ const [items, total] = await Promise.all([
+ this.prisma.user.findMany({
+ where,
+ select: {
+ id: true,
+ email: true,
+ fullName: true,
+ role: true,
+ lastLoginAt: true,
+ createdAt: true,
+ blockedUntil: true,
+ lockedUntil: true,
+ },
+ skip,
+ take: pageSize,
+ orderBy: { createdAt: 'desc' },
+ }),
+ this.prisma.user.count({ where }),
+ ]);
+
+ return {
+ items: items.map(user => ({
+ id: user.id,
+ email: user.email,
+ fullName: user.fullName,
+ role: user.role,
+ lastLogin: user.lastLoginAt,
+ status: (!user.blockedUntil || user.blockedUntil <= new Date()) &&
+ (!user.lockedUntil || user.lockedUntil <= new Date())
+ ? 'ACTIVE'
+ : 'INACTIVE',
+ })),
+ total,
+ page,
+ pageSize,
+ };
+ }
+
+ async createUser(dto: { email: string; fullName: string; role: string; status?: string; password?: string }) {
+ const exists = await this.prisma.user.findFirst({
+ where: { OR: [{ email: dto.email }] },
+ });
+ if (exists) throw new ConflictException('Email already registered');
+
+ const passwordHash = await bcrypt.hash(dto.password || 'TempPassword123!', 10);
+
+ const user = await this.prisma.user.create({
+ data: {
+ email: dto.email,
+ fullName: dto.fullName,
+ role: dto.role as any,
+ phone: dto.email, // Use email as phone temporarily for unique constraint
+ passwordHash,
+ blockedUntil: dto.status === 'INACTIVE' ? new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) : undefined,
+ },
+ select: {
+ id: true,
+ email: true,
+ fullName: true,
+ role: true,
+ lastLoginAt: true,
+ createdAt: true,
+ },
+ });
+
+ await this.createAuditLog(user.id, 'USER_CREATED', 'User', user.id, null, { email: user.email, role: dto.role });
+
+ return user;
+ }
+
+ async updateUser(id: string, dto: Partial<{ email: string; fullName: string; role: string; status: string }>) {
+ const user = await this.prisma.user.findUnique({ where: { id } });
+ if (!user) throw new NotFoundException('User not found');
+
+ const updateData: any = {};
+ if (dto.fullName) updateData.fullName = dto.fullName;
+ if (dto.role) updateData.role = dto.role;
+ if (dto.status === 'ACTIVE') {
+ updateData.blockedUntil = null;
+ updateData.lockedUntil = null;
+ } else if (dto.status === 'INACTIVE') {
+ updateData.blockedUntil = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
+ }
+
+ const updated = await this.prisma.user.update({
+ where: { id },
+ data: updateData,
+ select: {
+ id: true,
+ email: true,
+ fullName: true,
+ role: true,
+ lastLoginAt: true,
+ createdAt: true,
+ },
+ });
+
+ await this.createAuditLog(id, 'USER_UPDATED', 'User', id, { oldData: user }, { newData: updateData });
+
+ return updated;
+ }
+
+ async deleteUser(id: string) {
+ const user = await this.prisma.user.findUnique({ where: { id } });
+ if (!user) throw new NotFoundException('User not found');
+
+ // Don't actually delete, just deactivate
+ await this.prisma.user.update({
+ where: { id },
+ data: { blockedUntil: new Date(), lockedUntil: new Date() },
+ });
+
+ await this.createAuditLog(id, 'USER_DELETED', 'User', id, { email: user.email }, null);
+
+ return { deleted: true };
+ }
+
+ async resetUserPassword(id: string, tempPassword: string) {
+ const user = await this.prisma.user.findUnique({ where: { id } });
+ if (!user) throw new NotFoundException('User not found');
+
+ const passwordHash = await bcrypt.hash(tempPassword, 10);
+ await this.prisma.user.update({
+ where: { id },
+ data: {
+ passwordHash,
+ failedLoginAttempts: 0,
+ lockedUntil: null,
+ },
+ });
+
+ await this.createAuditLog(id, 'PASSWORD_RESET_ADMIN', 'User', id, null, { resetBy: 'admin' });
+
+ return { reset: true, tempPassword };
+ }
+
private async signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) {
// Get the full user data to include fullName
const user = await this.prisma.user.findUnique({
diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts
index 19f544db5..d0875d79e 100644
--- a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts
+++ b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts
@@ -54,4 +54,13 @@ export class CreateClassDto {
@ApiProperty({ example: 5000 }) @IsInt() baseFareMinor: number;
}
-export class UpdateClassDto extends PartialType(OmitType(CreateClassDto, ['coachTypeId'] as const)) {}
+export class UpdateClassDto {
+ @ApiPropertyOptional({ example: 'coach-type-uuid' }) @IsOptional() @IsString() coachTypeId?: string;
+ @ApiPropertyOptional({ example: 'Economy' }) @IsOptional() @IsString() name?: string;
+ @ApiPropertyOptional() @IsOptional() @IsString() description?: string;
+ @ApiPropertyOptional({ example: 5000 }) @IsOptional() @IsInt() baseFareMinor?: number;
+ @ApiPropertyOptional({ example: true })
+ @IsOptional()
+ @IsBoolean()
+ isActive?: boolean;
+}
diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts
index 5ea4104a1..043a3531b 100644
--- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts
+++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts
@@ -167,13 +167,21 @@ export class FleetService {
const seatClass = await this.prisma.seatClass.findUnique({ where: { id } });
if (!seatClass) throw new NotFoundException('Seat class not found');
+ const updateData: any = {
+ coachTypeId: dto.coachTypeId,
+ name: dto.name,
+ description: dto.description,
+ baseFareMinor: dto.baseFareMinor,
+ };
+
+ if (dto.isActive !== undefined) {
+ updateData.isActive = dto.isActive;
+ }
+
return this.prisma.seatClass.update({
where: { id },
- data: {
- name: dto.name,
- description: dto.description,
- baseFareMinor: dto.baseFareMinor,
- },
+ data: updateData,
+ include: { coachType: true },
});
}
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
index 49b570ab1..93fd901df 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
@@ -12,28 +12,37 @@ import { UserRole } from '@prisma/client';
@Controller('payments')
export class PaymentsController {
constructor(private service: PaymentsService) {}
+
+ @Get('all')
+ @UseGuards(JwtGuard, RolesGuard)
+ @Roles(UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.STAFF)
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Get all payments with filters (staff/admin only)' })
+ @ApiQuery({ name: 'search', required: false })
+ @ApiQuery({ name: 'status', required: false })
+ @ApiQuery({ name: 'method', required: false })
+ @ApiQuery({ name: 'page', required: false })
+ @ApiQuery({ name: 'pageSize', required: false })
+ async getAll(
+ @Query('search') search?: string,
+ @Query('status') status?: string,
+ @Query('method') method?: string,
+ @Query('page') page?: string,
+ @Query('pageSize') pageSize?: string,
+ ) {
+ return this.service.getAll({
+ search,
+ status,
+ method,
+ page: page ? parseInt(page) : 1,
+ pageSize: pageSize ? parseInt(pageSize) : 10,
+ });
+ }
@Post('initiate')
@ApiOperation({
summary: 'Initiate payment with nationality-based payment methods',
- description: `Initiates payment for a booking with support for multiple payment providers:
-
-**Ethiopian Payment Methods:**
-- TELEBIRR - Ethiopia's leading mobile money
-- CBE_BIRR - Commercial Bank of Ethiopia
-- EBIRR - Electronic payment gateway
-
-**Djiboutian Payment Methods:**
-- WAAFI - Djibouti's mobile money service
-
-**International Payment Methods:**
-- CARD - Visa, Mastercard
-- WALLET - Internal wallet balance
-
-**Multi-Currency:**
-- All transactions processed in ETB
-- Display amounts in ETB, DJF, or USD
-- Real-time exchange rate conversion`
+ description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- Real-time exchange rate conversion`
})
initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); }
@@ -102,7 +111,7 @@ export class PaymentsController {
}
private buildRedirectHtml(url: string): string {
- const escaped = url.replace(/"/g, '"');
+ const escaped = url.replace(/\"/g, '"');
return `
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
index 7ec215a11..766ae6abe 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
@@ -49,6 +49,54 @@ export class PaymentsService {
]);
}
+ async getAll(filters: { search?: string; status?: string; method?: string; page?: number; pageSize?: number }) {
+ const { search, status, method, page = 1, pageSize = 10 } = filters;
+ const skip = (page - 1) * pageSize;
+
+ const where: any = {};
+ if (search) {
+ where.OR = [
+ { id: { contains: search, mode: 'insensitive' } },
+ { booking: { bookingRef: { contains: search, mode: 'insensitive' } } },
+ ];
+ }
+ if (status) {
+ where.status = status;
+ }
+ if (method) {
+ where.method = method;
+ }
+
+ const [items, total] = await Promise.all([
+ this.prisma.paymentIntent.findMany({
+ where,
+ include: { booking: true },
+ skip,
+ take: pageSize,
+ orderBy: { createdAt: 'desc' },
+ }),
+ this.prisma.paymentIntent.count({ where }),
+ ]);
+
+ return {
+ items: items.map(item => ({
+ id: item.id,
+ reference: item.id.substring(0, 8),
+ bookingId: item.bookingId,
+ booking: { bookingRef: item.booking?.bookingRef },
+ amountMinor: item.amountMinor,
+ currency: item.currency,
+ method: item.method,
+ status: item.status,
+ createdAt: item.createdAt,
+ paidAt: item.paidAt,
+ })),
+ total,
+ page,
+ pageSize,
+ };
+ }
+
async initiatePayment(dto: InitiatePaymentDto): Promise {
const booking = await this.prisma.booking.findUnique({
where: { id: dto.bookingId },
diff --git a/apps/edr-passenger-api/src/modules/promos/promos.controller.ts b/apps/edr-passenger-api/src/modules/promos/promos.controller.ts
index ccb76e051..8bdf8f868 100644
--- a/apps/edr-passenger-api/src/modules/promos/promos.controller.ts
+++ b/apps/edr-passenger-api/src/modules/promos/promos.controller.ts
@@ -1,4 +1,4 @@
-import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
+import { Body, Controller, Get, Param, Post, UseGuards, Query, Patch, Delete } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PromosService } from './promos.service';
import { CreatePromotionDto } from './promos.dto';
@@ -8,7 +8,66 @@ import { JwtGuard } from '../../common/jwt.guard';
@Controller('promos')
export class PromosController {
constructor(private service: PromosService) {}
- @Get() @ApiOperation({ summary: 'Get active promotions' }) getActive() { return this.service.getActive(); }
- @Get('validate/:code') @ApiOperation({ summary: 'Validate a promo code' }) validate(@Param('code') code: string) { return this.service.validate(code); }
- @Post() @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create promotion (admin)' }) create(@Body() dto: CreatePromotionDto) { return this.service.create(dto); }
+
+ @Get()
+ @ApiOperation({ summary: 'Get active promotions' })
+ getActive() {
+ return this.service.getActive();
+ }
+
+ @Get('all')
+ @UseGuards(JwtGuard)
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Get all promos with filters (admin)' })
+ getAll(
+ @Query('search') search?: string,
+ @Query('active') active?: string,
+ @Query('page') page?: string,
+ @Query('pageSize') pageSize?: string,
+ ) {
+ return this.service.getAll({
+ search,
+ active: active === 'true' ? true : active === 'false' ? false : undefined,
+ page: page ? parseInt(page) : 1,
+ pageSize: pageSize ? parseInt(pageSize) : 10,
+ });
+ }
+
+ @Get(':id')
+ @UseGuards(JwtGuard)
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Get promo by ID' })
+ getById(@Param('id') id: string) {
+ return this.service.getById(id);
+ }
+
+ @Get('validate/:code')
+ @ApiOperation({ summary: 'Validate a promo code' })
+ validate(@Param('code') code: string) {
+ return this.service.validate(code);
+ }
+
+ @Post()
+ @UseGuards(JwtGuard)
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Create promotion (admin)' })
+ create(@Body() dto: CreatePromotionDto) {
+ return this.service.create(dto);
+ }
+
+ @Patch(':id')
+ @UseGuards(JwtGuard)
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Update promo (admin)' })
+ update(@Param('id') id: string, @Body() dto: Partial) {
+ return this.service.update(id, dto);
+ }
+
+ @Delete(':id')
+ @UseGuards(JwtGuard)
+ @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Delete promo (admin)' })
+ delete(@Param('id') id: string) {
+ return this.service.delete(id);
+ }
}
diff --git a/apps/edr-passenger-api/src/modules/promos/promos.dto.ts b/apps/edr-passenger-api/src/modules/promos/promos.dto.ts
index 421269f5c..759fe371e 100644
--- a/apps/edr-passenger-api/src/modules/promos/promos.dto.ts
+++ b/apps/edr-passenger-api/src/modules/promos/promos.dto.ts
@@ -1,13 +1,46 @@
-import { IsString, IsOptional, IsInt } from 'class-validator';
+import { IsString, IsOptional, IsInt, IsBoolean } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreatePromotionDto {
- @ApiProperty({ example: 'Weekend Special' }) @IsString() title: string;
- @ApiPropertyOptional({ example: '15% off all routes' }) @IsOptional() @IsString() subtitle?: string;
- @ApiProperty({ example: 'WEEKEND15' }) @IsString() code: string;
- @ApiPropertyOptional({ example: 15 }) @IsOptional() @IsInt() percentOff?: number;
- @ApiPropertyOptional({ example: 5000 }) @IsOptional() @IsInt() amountOffMinor?: number;
- @ApiProperty({ example: '2026-12-31T23:59:59Z' }) @IsString() validUntil: string;
- @ApiPropertyOptional({ example: 'Book Now' }) @IsOptional() @IsString() ctaLabel?: string;
- @ApiPropertyOptional({ example: 'edr://search' }) @IsOptional() @IsString() deepLink?: string;
+ @ApiProperty({ example: 'SUMMER2024' })
+ @IsString()
+ code: string;
+
+ @ApiProperty({ example: 'Summer Discount' })
+ @IsString()
+ title: string;
+
+ @ApiPropertyOptional({ example: 'Get 15% off' })
+ @IsOptional()
+ @IsString()
+ subtitle?: string;
+
+ @ApiPropertyOptional({ example: 15 })
+ @IsOptional()
+ @IsInt()
+ percentOff?: number;
+
+ @ApiPropertyOptional({ example: 5000 })
+ @IsOptional()
+ @IsInt()
+ amountOffMinor?: number;
+
+ @ApiProperty({ example: '2026-12-31T23:59:59Z' })
+ @IsString()
+ validUntil: string;
+
+ @ApiPropertyOptional({ example: 'Book Now' })
+ @IsOptional()
+ @IsString()
+ ctaLabel?: string;
+
+ @ApiPropertyOptional({ example: 'edr://search' })
+ @IsOptional()
+ @IsString()
+ deepLink?: string;
+
+ @ApiPropertyOptional({ example: true })
+ @IsOptional()
+ @IsBoolean()
+ active?: boolean;
}
diff --git a/apps/edr-passenger-api/src/modules/promos/promos.service.ts b/apps/edr-passenger-api/src/modules/promos/promos.service.ts
index 97a8ed275..141d80b57 100644
--- a/apps/edr-passenger-api/src/modules/promos/promos.service.ts
+++ b/apps/edr-passenger-api/src/modules/promos/promos.service.ts
@@ -1,20 +1,190 @@
-import { Injectable } from '@nestjs/common';
+import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreatePromotionDto } from './promos.dto';
+import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
@Injectable()
export class PromosService {
constructor(private prisma: PrismaService) {}
- getActive() { return this.prisma.promotion.findMany({ where: { active: true, validUntil: { gte: new Date() } }, orderBy: { createdAt: 'desc' } }); }
+ getActive() {
+ return this.prisma.promotion.findMany({
+ where: { active: true, validUntil: { gte: new Date() } },
+ orderBy: { createdAt: 'desc' },
+ });
+ }
+
+ async getAll(filters: { search?: string; active?: boolean; page?: number; pageSize?: number }) {
+ const { search, active, page = 1, pageSize = 10 } = filters;
+ const skip = (page - 1) * pageSize;
+
+ const where: any = {};
+ if (search) {
+ where.OR = [
+ { code: { contains: search, mode: 'insensitive' } },
+ { title: { contains: search, mode: 'insensitive' } },
+ ];
+ }
+ if (active !== undefined) {
+ where.active = active;
+ }
+
+ const [items, total] = await Promise.all([
+ this.prisma.promotion.findMany({
+ where,
+ skip,
+ take: pageSize,
+ orderBy: { createdAt: 'desc' },
+ }),
+ this.prisma.promotion.count({ where }),
+ ]);
+
+ return { items: this.formatItems(items), total, page, pageSize };
+ }
+
+ async getById(id: string) {
+ const promo = await this.prisma.promotion.findUnique({ where: { id } });
+ if (!promo) throw new NotFoundException('Promo not found');
+ return this.formatItem(promo);
+ }
async validate(code: string) {
const promo = await this.prisma.promotion.findUnique({ where: { code } });
- if (!promo || !promo.active || promo.validUntil < new Date()) return { applicable: false, message: 'Promo code invalid or expired' };
- return { code: promo.code, percentOff: promo.percentOff, amountOffMinor: promo.amountOffMinor, validUntil: promo.validUntil, applicable: true, message: promo.percentOff ? `${promo.percentOff}% off` : `ETB ${((promo.amountOffMinor ?? 0) / 100).toFixed(2)} off` };
+ if (!promo || !promo.active || promo.validUntil < new Date())
+ return { applicable: false, message: 'Promo code invalid or expired' };
+ return {
+ code: promo.code,
+ percentOff: promo.percentOff,
+ amountOffMinor: promo.amountOffMinor,
+ validUntil: promo.validUntil,
+ applicable: true,
+ message: promo.percentOff
+ ? `${promo.percentOff}% off`
+ : `ETB ${((promo.amountOffMinor ?? 0) / 100).toFixed(2)} off`,
+ };
}
- create(dto: CreatePromotionDto) {
- return this.prisma.promotion.create({ data: { ...dto, validUntil: new Date(dto.validUntil) } });
+ async create(dto: CreatePromotionDto & { discountType?: string; discountValue?: number }) {
+ try {
+ // Map frontend fields to database fields
+ let percentOff: number | undefined;
+ let amountOffMinor: number | undefined;
+
+ if (dto.discountType && dto.discountValue !== undefined) {
+ if (dto.discountType === 'PERCENTAGE') {
+ percentOff = dto.discountValue;
+ } else if (dto.discountType === 'FIXED') {
+ amountOffMinor = dto.discountValue;
+ }
+ } else {
+ // Fallback to direct fields
+ percentOff = dto.percentOff;
+ amountOffMinor = dto.amountOffMinor;
+ }
+
+ const promo = await this.prisma.promotion.create({
+ data: {
+ code: dto.code,
+ title: dto.title,
+ subtitle: dto.subtitle,
+ percentOff,
+ amountOffMinor,
+ validUntil: new Date(dto.validUntil),
+ ctaLabel: dto.ctaLabel,
+ deepLink: dto.deepLink,
+ active: dto.active ?? true,
+ },
+ });
+ return this.formatItem(promo);
+ } catch (error) {
+ if (error instanceof PrismaClientKnownRequestError) {
+ if (error.code === 'P2002') {
+ const field = (error.meta?.target as string[])?.[0];
+ throw new BadRequestException(
+ `A promo code with this ${field} already exists. Please use a different ${field}.`,
+ );
+ }
+ }
+ throw error;
+ }
+ }
+
+ async update(id: string, dto: Partial & { discountType?: string; discountValue?: number }) {
+ const promo = await this.prisma.promotion.findUnique({ where: { id } });
+ if (!promo) throw new NotFoundException('Promo not found');
+
+ const updateData: any = {};
+
+ // Map frontend fields to database fields
+ if (dto.discountType && dto.discountValue !== undefined) {
+ // Clear existing discount fields
+ updateData.percentOff = null;
+ updateData.amountOffMinor = null;
+
+ if (dto.discountType === 'PERCENTAGE') {
+ updateData.percentOff = dto.discountValue;
+ } else if (dto.discountType === 'FIXED') {
+ updateData.amountOffMinor = dto.discountValue;
+ }
+ } else {
+ // Only include fields that are explicitly provided
+ if (dto.percentOff !== undefined) updateData.percentOff = dto.percentOff;
+ if (dto.amountOffMinor !== undefined) updateData.amountOffMinor = dto.amountOffMinor;
+ }
+
+ if (dto.title !== undefined) updateData.title = dto.title;
+ if (dto.subtitle !== undefined) updateData.subtitle = dto.subtitle;
+ if (dto.ctaLabel !== undefined) updateData.ctaLabel = dto.ctaLabel;
+ if (dto.deepLink !== undefined) updateData.deepLink = dto.deepLink;
+ if (dto.active !== undefined) updateData.active = dto.active;
+ if (dto.validUntil !== undefined) updateData.validUntil = new Date(dto.validUntil);
+
+ // Don't allow updating code - it's immutable after creation
+
+ try {
+ const updated = await this.prisma.promotion.update({
+ where: { id },
+ data: updateData,
+ });
+ return this.formatItem(updated);
+ } catch (error) {
+ if (error instanceof PrismaClientKnownRequestError && error.code === 'P2002') {
+ const field = (error.meta?.target as string[])?.[0];
+ throw new BadRequestException(
+ `A promo code with this ${field} already exists. Please use a different ${field}.`,
+ );
+ }
+ throw error;
+ }
+ }
+
+ async delete(id: string) {
+ const promo = await this.prisma.promotion.findUnique({ where: { id } });
+ if (!promo) throw new NotFoundException('Promo not found');
+ return this.prisma.promotion.delete({ where: { id } });
+ }
+
+ private formatItem(promo: any) {
+ return {
+ id: promo.id,
+ code: promo.code,
+ title: promo.title,
+ discountType: promo.percentOff ? 'PERCENTAGE' : 'FIXED',
+ discountValue: promo.percentOff || promo.amountOffMinor || 0,
+ maxDiscount: undefined,
+ minBookingAmount: undefined,
+ maxUsagePerUser: undefined,
+ totalUsageLimit: undefined,
+ usageCount: 0,
+ validFrom: promo.createdAt,
+ validUntil: promo.validUntil,
+ isActive: promo.active,
+ createdAt: promo.createdAt,
+ updatedAt: promo.createdAt,
+ };
+ }
+
+ private formatItems(promos: any[]) {
+ return promos.map((promo) => this.formatItem(promo));
}
}
diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts
index e6b6841f7..29254892d 100644
--- a/apps/edr-passenger-api/src/modules/search/search.service.ts
+++ b/apps/edr-passenger-api/src/modules/search/search.service.ts
@@ -50,31 +50,56 @@ export class SearchService {
const availabilityByClass: Record = {};
for (const assignment of schedule.coachAssignments) {
- // Get seat class names from coach type
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
-
- for (const seatClassName of seatClassNames) {
- if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
- }
+ const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);
- // Count available seats (skip blocked and removed seats)
- for (const seat of assignment.coach.seats) {
- // Skip blocked seats
- if (seat.status === 'BLOCKED') continue;
+ if (isBedCoach) {
+ const bedPositions = ['upper', 'middle', 'lower'];
+ for (const bedPosition of bedPositions) {
+ let count = 0;
+ for (const seat of assignment.coach.seats) {
+ if (seat.bedPosition !== bedPosition) continue;
+ if (seat.status === 'BLOCKED') continue;
+ if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
+
+ const free = await this.segmentsService.isSeatFreeForLeg(
+ schedule.id, seat.id,
+ originStop.sequence, destStop.sequence,
+ );
+ if (free) count++;
+ }
+
+ if (count > 0) {
+ const matchingClass = seatClassNames.find((className: string) => {
+ const classNameLower = className.toLowerCase();
+ return (
+ (bedPosition === 'upper' && classNameLower.includes('upper')) ||
+ (bedPosition === 'middle' && classNameLower.includes('middle')) ||
+ (bedPosition === 'lower' && classNameLower.includes('lower'))
+ );
+ });
+ if (matchingClass) {
+ if (!availabilityByClass[matchingClass]) availabilityByClass[matchingClass] = 0;
+ availabilityByClass[matchingClass] += count;
+ }
+ }
+ }
+ } else {
+ let availableSeatsInCoach = 0;
+ for (const seat of assignment.coach.seats) {
+ if (seat.status === 'BLOCKED') continue;
+ if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
+
+ const free = await this.segmentsService.isSeatFreeForLeg(
+ schedule.id, seat.id,
+ originStop.sequence, destStop.sequence,
+ );
+ if (free) availableSeatsInCoach++;
+ }
- // Skip removed seats (empty seatNumber)
- if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
-
- const free = await this.segmentsService.isSeatFreeForLeg(
- schedule.id, seat.id,
- originStop.sequence, destStop.sequence,
- );
-
- if (free) {
- // Group by seat class - use the first seat class for now
- // In a full implementation, seats would have a seatClassId
- const className = seatClassNames[0] || 'Standard';
- availabilityByClass[className]++;
+ for (const seatClassName of seatClassNames) {
+ if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
+ availabilityByClass[seatClassName] += availableSeatsInCoach;
}
}
}
@@ -225,7 +250,6 @@ export class SearchService {
destinationStationId: string,
nationality?: string,
): Promise> {
- // Get unique seat classes from all coaches assigned to this schedule via their coach types
const seatClassIds: string[] = Array.from(
new Set(
schedule.coachAssignments
diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
index 9e8d55364..88f9666bd 100644
--- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts
+++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
@@ -32,10 +32,8 @@ export class SeatsService {
const response = {
coaches: assignments.map((a) => {
- // Include all seats (both valid and removed with negative seatNumbers)
const allSeats = a.coach.seats;
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
- const seatClass = seatClassNames.length > 0 ? seatClassNames[0] : 'Standard';
return {
id: a.coach.id,
@@ -44,7 +42,8 @@ export class SeatsService {
label: a.coach.number,
mode: a.coach.status,
name: `Coach ${a.coach.number}`,
- seatClass,
+ seatClasses: seatClassNames,
+ seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard',
positionNumber: a.positionNumber,
seatArrangement: a.coach.arrangement,
totalSeats: a.coach.capacity,
diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
index bf547aa97..5939ab882 100644
--- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, Search } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
@@ -16,6 +16,7 @@ export default function ClassesPage() {
const [showModal, setShowModal] = useState(false);
const [editingClass, setEditingClass] = useState(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null }>({ isOpen: false, class: null });
+ const [selectedCoachTypeId, setSelectedCoachTypeId] = useState('');
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
@@ -28,12 +29,19 @@ export default function ClassesPage() {
queryFn: () => apiClient.get('/fleet/coach-types'),
});
+ useEffect(() => {
+ if (showModal && editingClass) {
+ setSelectedCoachTypeId(editingClass.coachTypeId || '');
+ }
+ }, [showModal, editingClass]);
+
const createMutation = useMutation({
mutationFn: seatClassesApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['classes'] });
setShowModal(false);
setEditingClass(null);
+ setSelectedCoachTypeId('');
},
});
@@ -43,6 +51,7 @@ export default function ClassesPage() {
queryClient.invalidateQueries({ queryKey: ['classes'] });
setShowModal(false);
setEditingClass(null);
+ setSelectedCoachTypeId('');
},
});
@@ -55,12 +64,19 @@ export default function ClassesPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
+
+ if (!selectedCoachTypeId) {
+ alert('Please select a coach type');
+ return;
+ }
+
const formData = new FormData(e.currentTarget);
const classData = {
- coachTypeId: formData.get('coachTypeId') as string,
+ coachTypeId: selectedCoachTypeId,
name: formData.get('name') as string,
description: formData.get('description') as string,
baseFareMinor: parseInt(formData.get('baseFareMinor') as string) || 0,
+ isActive: formData.get('isActive') === 'true',
};
if (editingClass) {
@@ -136,13 +152,19 @@ export default function ClassesPage() {
},
];
+ const handleOpenModal = (cls?: any) => {
+ if (cls) {
+ setEditingClass(cls);
+ } else {
+ setEditingClass(null);
+ }
+ setShowModal(true);
+ };
+
const actions = [
{
label: 'Edit',
- onClick: (cls: any) => {
- setEditingClass(cls);
- setShowModal(true);
- },
+ onClick: (cls: any) => handleOpenModal(cls),
variant: 'secondary' as const,
icon: Edit,
},
@@ -163,10 +185,7 @@ export default function ClassesPage() {
{
- setEditingClass(null);
- setShowModal(true);
- }}
+ onClick={() => handleOpenModal()}
>
Add Class
@@ -211,6 +230,7 @@ export default function ClassesPage() {
onClose={() => {
setShowModal(false);
setEditingClass(null);
+ setSelectedCoachTypeId('');
}}
title={`${editingClass ? 'Edit' : 'Add'} Class`}
size="lg"
@@ -222,7 +242,8 @@ export default function ClassesPage() {