diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index f29815996..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 = []; 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 ca10cc911..0e42cb5f4 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -44,6 +44,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() { @@ -291,6 +312,7 @@ export default function ClassesPage() { onClick={() => { setShowModal(false); setEditingClass(null); + setSelectedCoachTypeId(''); }} > Cancel diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index 6fe6e9286..91856f800 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -510,7 +510,7 @@ export default function CoachesPage() { className="input" defaultValue={editingItem?.number || editingItem?.coachNumber || ''} required - placeholder="e.g., A-001" + placeholder="e.g., HSC-0001" /> diff --git a/apps/edr-passenger-web/backoffice/src/app/promos/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/promos/layout.tsx new file mode 100644 index 000000000..df04c3b54 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/promos/layout.tsx @@ -0,0 +1,7 @@ +'use client'; + +import DashboardLayout from '../dashboard/layout'; + +export default function PromosLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/promos/page.tsx b/apps/edr-passenger-web/backoffice/src/app/promos/page.tsx new file mode 100644 index 000000000..a578593e2 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/promos/page.tsx @@ -0,0 +1,409 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Edit, Trash2, Copy, Check } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { promosApi, PromoCode } from '@/lib/api/promos'; + +export default function PromosPage() { + const [filters, setFilters] = useState({ search: '', active: '', page: 1, pageSize: 10 }); + const [showModal, setShowModal] = useState(false); + const [editingPromo, setEditingPromo] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; promo: any | null }>({ isOpen: false, promo: null }); + const [copiedCode, setCopiedCode] = useState(null); + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ['promos', filters], + queryFn: () => promosApi.getAll(filters), + }); + + const createMutation = useMutation({ + mutationFn: promosApi.create, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['promos'] }); + setShowModal(false); + setEditingPromo(null); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => promosApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['promos'] }); + setShowModal(false); + setEditingPromo(null); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: promosApi.delete, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['promos'] }); + }, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + + const promoData = { + code: formData.get('code') as string, + title: formData.get('title') as string, + discountType: formData.get('discountType') as 'PERCENTAGE' | 'FIXED', + discountValue: parseFloat(formData.get('discountValue') as string), + maxDiscount: formData.get('maxDiscount') ? parseFloat(formData.get('maxDiscount') as string) : undefined, + minBookingAmount: formData.get('minBookingAmount') ? parseFloat(formData.get('minBookingAmount') as string) : undefined, + maxUsagePerUser: formData.get('maxUsagePerUser') ? parseInt(formData.get('maxUsagePerUser') as string) : undefined, + totalUsageLimit: formData.get('totalUsageLimit') ? parseInt(formData.get('totalUsageLimit') as string) : undefined, + validFrom: formData.get('validFrom') as string, + validUntil: formData.get('validUntil') as string, + isActive: formData.get('isActive') === 'true', + }; + + if (editingPromo) { + await updateMutation.mutateAsync({ id: editingPromo.id, data: promoData }); + } else { + await createMutation.mutateAsync(promoData); + } + }; + + const handleDelete = (promo: any) => { + setDeleteConfirm({ isOpen: true, promo }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.promo) { + await deleteMutation.mutateAsync(deleteConfirm.promo.id); + setDeleteConfirm({ isOpen: false, promo: null }); + } + }; + + const copyToClipboard = (code: string) => { + navigator.clipboard.writeText(code); + setCopiedCode(code); + setTimeout(() => setCopiedCode(null), 2000); + }; + + const columns = [ + { + key: 'code', + label: 'Promo Code', + render: (promo: PromoCode) => ( +
+ {promo.code} + +
+ ), + }, + { + key: 'title', + label: 'Title', + render: (promo: PromoCode) => ( + {promo.title || '-'} + ), + }, + { + key: 'discount', + label: 'Discount', + render: (promo: PromoCode) => ( + + {promo.discountType === 'PERCENTAGE' + ? `${promo.discountValue}%` + : `ETB ${promo.discountValue}`} + + ), + }, + { + key: 'validity', + label: 'Valid Period', + render: (promo: PromoCode) => ( +
+
{new Date(promo.validFrom).toLocaleDateString()}
+
{new Date(promo.validUntil).toLocaleDateString()}
+
+ ), + }, + { + key: 'usage', + label: 'Usage', + render: (promo: PromoCode) => ( +
+
{promo.usageCount} used
+ {promo.totalUsageLimit && ( +
/ {promo.totalUsageLimit} limit
+ )} +
+ ), + }, + { + key: 'status', + label: 'Status', + render: (promo: PromoCode) => ( + + {promo.isActive ? 'Active' : 'Inactive'} + + ), + }, + ]; + + const actions = [ + { + label: 'Edit', + onClick: (promo: PromoCode) => { + setEditingPromo(promo); + setShowModal(true); + }, + variant: 'secondary' as const, + icon: Edit, + }, + { + label: 'Delete', + onClick: handleDelete, + variant: 'danger' as const, + icon: Trash2, + }, + ]; + + return ( +
+
+
+

Promo Codes

+

Manage promotional codes and discounts

+
+ { + setEditingPromo(null); + setShowModal(true); + }} + > + Add Promo Code + +
+ + {/* Filters */} +
+
+
+ setFilters({ ...filters, search: e.target.value, page: 1 })} + /> +
+
+ +
+
+
+ + {/* Promos Table */} + + + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, promo: null })} + onConfirm={confirmDelete} + title="Delete Promo Code" + message={`Are you sure you want to delete promo code "${deleteConfirm.promo?.code}"?`} + confirmText="Delete" + isDanger={true} + /> + + {/* Add/Edit Modal */} + { + setShowModal(false); + setEditingPromo(null); + }} + title={`${editingPromo ? 'Edit' : 'Create'} Promo Code`} + size="lg" + > +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ { + setShowModal(false); + setEditingPromo(null); + }} + > + Cancel + + + {editingPromo ? 'Update' : 'Create'} Promo Code + +
+
+
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 31aba6aeb..943b4fb5a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -82,10 +82,8 @@ export default function RoutesPage() { return; } - // Sort middle stops by distance from origin - const sortedMiddleStops = [...stops].sort((a, b) => - (a.distanceFromOrigin || 0) - (b.distanceFromOrigin || 0) - ); + // Keep current stop order (already rearranged by user) + const sortedMiddleStops = stops; // Calculate distanceKm (distance from previous stop) const stopsArray = [ @@ -137,6 +135,30 @@ export default function RoutesPage() { setStops(updated); }; + const handleDragStart = (e: React.DragEvent, index: number) => { + e.dataTransfer.setData('text/plain', index.toString()); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + (e.currentTarget as HTMLElement).style.opacity = '0.5'; + }; + + const handleDragLeave = (e: React.DragEvent) => { + (e.currentTarget as HTMLElement).style.opacity = '1'; + }; + + const handleDrop = (e: React.DragEvent, targetIndex: number) => { + e.preventDefault(); + (e.currentTarget as HTMLElement).style.opacity = '1'; + const sourceIndex = parseInt(e.dataTransfer.getData('text/plain')); + if (sourceIndex === targetIndex) return; + const newStops = [...stops]; + const [draggedStop] = newStops.splice(sourceIndex, 1); + newStops.splice(targetIndex, 0, draggedStop); + setStops(newStops); + }; + const generateRouteCode = (originId: string, destId: string) => { if (!originId || !destId) return ''; const origin = stations?.items?.find((s: any) => s.id === originId); @@ -277,7 +299,6 @@ export default function RoutesPage() { emptyMessage={search ? "No routes match your search" : "No routes found"} /> - {/* Delete Confirmation */} setDeleteConfirm({ isOpen: false, route: null })} @@ -289,7 +310,6 @@ export default function RoutesPage() { warning="This route may be referenced by schedules and bookings. Deleting it may impact these systems." /> - {/* Add/Edit Modal */} { @@ -383,7 +403,7 @@ export default function RoutesPage() { className="input" rows={2} defaultValue={editingRoute?.description} - placeholder="Main corridor via Dire Dawa" + placeholder="Outbound local route from [Origin] to [Destination]" /> @@ -412,10 +432,10 @@ export default function RoutesPage() {
+ Drag to rearrange intermediate stops
- {/* Origin Stop */}
1 @@ -435,9 +455,16 @@ export default function RoutesPage() {
- {/* Intermediate Stops */} {stops.map((stop, index) => ( -
+
handleDragStart(e, index)} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, index)} + className="flex gap-2 items-center p-3 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors" + >
{index + 2}
@@ -482,7 +509,6 @@ export default function RoutesPage() {
))} - {/* Add Intermediate Stop Button */} {originStationId && destinationStationId && (
)} - {/* Destination Stop */}
{stops.length + 2} diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index d68b5644b..a40fbbc02 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -48,10 +48,9 @@ export default function SchedulesPage() { const [showEditModal, setShowEditModal] = useState(false); const [editingSchedule, setEditingSchedule] = useState(null); const [selectedSchedules, setSelectedSchedules] = useState>(new Set()); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean }>({ - isOpen: false, - item: null, - }); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean }>( + { isOpen: false, item: null } + ); const [error, setError] = useState(null); const queryClient = useQueryClient(); @@ -179,6 +178,10 @@ export default function SchedulesPage() { forNextDays: parseInt(bulkForm.forNextDays), }; + if (bulkForm.coachIds.length > 0) { + payload.coachIds = bulkForm.coachIds; + } + await bulkGenerateMutation.mutateAsync(payload); }; @@ -236,13 +239,13 @@ export default function SchedulesPage() { const handleEditClick = (schedule: Schedule) => { setEditingSchedule(schedule); - + const dep = new Date(schedule.departureAt); const arr = new Date(schedule.arrivalAt); - + const depLocal = new Date(dep.getTime() - dep.getTimezoneOffset() * 60000).toISOString().slice(0, 16); const arrLocal = new Date(arr.getTime() - arr.getTimezoneOffset() * 60000).toISOString().slice(0, 16); - + setEditForm({ departureAt: depLocal, arrivalAt: arrLocal, diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index 2774dc8bb..62056e21a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -5,7 +5,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { seatsApi, schedulesApi } from '@/lib/api'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton' -import Badge from '@/components/ui/Badge'; import { Armchair, Lock, Unlock, Bed, X, RotateCcw } from 'lucide-react'; export default function SeatsPage() { @@ -144,7 +143,10 @@ export default function SeatsPage() { const seatsPerRow = arrangement[0] + (arrangement[1] || 0); const allSeatsForLayout = [...validSeats, ...removedSeats]; const rows = []; - + const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || ''); + const isVipBed = seatClassStr.toLowerCase().includes('vip'); + const bedWidth = isVipBed ? 'w-24' : 'w-16'; + for (let i = 0; i < allSeatsForLayout.length; i += seatsPerRow) { rows.push(allSeatsForLayout.slice(i, i + seatsPerRow)); } @@ -154,14 +156,15 @@ export default function SeatsPage() { {rows.map((rowSeats: any[], idx: number) => { const rowNumber = rowSeats[0]?.row || (idx + 1); const shouldFlipIcon = rowNumber % 2 === 0; + const shouldFlipRow = rowNumber % 2 === 1; const showSpacing = idx % 2 === 1; - + return (
{shouldFlipIcon && (
{rowSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
))} @@ -188,7 +191,7 @@ export default function SeatsPage() { {!shouldFlipIcon && (
{rowSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
))} @@ -228,6 +231,7 @@ export default function SeatsPage() { const rightSeats = rowSeats.slice(leftCount); const rowNumber = rowSeats[0]?.row || 1; const shouldFlipArmchair = rowNumber % 2 === 0; + const shouldFlipRow = rowNumber % 2 === 0; const showSpacing = rowIdx % 2 === 1; return ( @@ -236,7 +240,7 @@ export default function SeatsPage() {
{leftSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))} @@ -245,7 +249,7 @@ export default function SeatsPage() { {rightSeats.length > 0 && (
{rightSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))} @@ -299,7 +303,7 @@ export default function SeatsPage() {
{leftSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))} @@ -308,7 +312,7 @@ export default function SeatsPage() { {rightSeats.length > 0 && (
{rightSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))} @@ -402,17 +406,17 @@ export default function SeatsPage() {
{coachesWithSeats.map((coach: any) => { - const isBedCoach = (coach.seatClass && coach.seatClass.toLowerCase().includes('bed')) || - (coach.mode && coach.mode.toLowerCase().includes('bed')); + const isBedCoach = (coach.seatClass && coach.seatClass.toLowerCase().includes('bed')) || + (coach.mode && coach.mode.toLowerCase().includes('bed')); const seats = (coach.seats || []).filter((s: any) => s.seatNumber); return ( -
+

Coach {coach.coachNumber}

-
+
{renderCoachSeats(coach, isBedCoach)}
@@ -542,7 +546,11 @@ function SeatIcon({ handleUndoRemove, }: SeatIconProps) { const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-'); - + const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || ''); + const isVipBed = isBedCoach && seatClassStr.toLowerCase().includes('vip'); + const bedWidth = isVipBed ? 'w-24' : 'w-16'; + const width = isBedCoach ? bedWidth : 'w-10'; + if (!seat.seatNumber) { return
; } @@ -580,17 +588,19 @@ function SeatIcon({ {isBedCoach ? (
- +
) : (
- +
)} diff --git a/apps/edr-passenger-web/backoffice/src/app/settings/users/page.tsx b/apps/edr-passenger-web/backoffice/src/app/settings/users/page.tsx index ca24d9472..05120eb15 100644 --- a/apps/edr-passenger-web/backoffice/src/app/settings/users/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/settings/users/page.tsx @@ -2,57 +2,388 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Search, Edit, Trash2 } from 'lucide-react'; +import { Plus, Edit, Trash2, RefreshCw } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { usersApi, BackofficeUser } from '@/lib/api/users'; export default function UserManagementPage() { - const [searchTerm, setSearchTerm] = useState(''); + const [filters, setFilters] = useState({ search: '', role: '', status: '', page: 1, pageSize: 10 }); + const [showModal, setShowModal] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; user: any | null }>({ isOpen: false, user: null }); + const [resetPasswordModal, setResetPasswordModal] = useState<{ isOpen: boolean; user: any | null }>({ isOpen: false, user: null }); + const [newPassword, setNewPassword] = useState(''); + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ['users', filters], + queryFn: () => usersApi.getAll(filters), + }); + + const createMutation = useMutation({ + mutationFn: usersApi.create, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + setShowModal(false); + setEditingUser(null); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => usersApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + setShowModal(false); + setEditingUser(null); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: usersApi.delete, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + }, + }); + + const resetPasswordMutation = useMutation({ + mutationFn: ({ id, tempPassword }: { id: string; tempPassword: string }) => + usersApi.resetPassword(id, tempPassword), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + setResetPasswordModal({ isOpen: false, user: null }); + setNewPassword(''); + }, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + + const userData = { + email: formData.get('email') as string, + fullName: formData.get('fullName') as string, + role: formData.get('role') as string, + status: formData.get('status') as 'ACTIVE' | 'INACTIVE', + } as any; + + if (!editingUser) { + userData.password = formData.get('password') as string; + } + + if (editingUser) { + await updateMutation.mutateAsync({ id: editingUser.id, data: userData }); + } else { + await createMutation.mutateAsync(userData); + } + }; + + const handleDelete = (user: any) => { + setDeleteConfirm({ isOpen: true, user }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.user) { + await deleteMutation.mutateAsync(deleteConfirm.user.id); + setDeleteConfirm({ isOpen: false, user: null }); + } + }; + + const handleResetPassword = async () => { + if (resetPasswordModal.user && newPassword) { + await resetPasswordMutation.mutateAsync({ + id: resetPasswordModal.user.id, + tempPassword: newPassword, + }); + } + }; + + const columns = [ + { + key: 'fullName', + label: 'Full Name', + sortable: true, + render: (user: BackofficeUser) => ( +
+
{user.fullName}
+
{user.email}
+
+ ), + }, + { + key: 'role', + label: 'Role', + render: (user: BackofficeUser) => ( + + {user.role} + + ), + }, + { + key: 'status', + label: 'Status', + render: (user: BackofficeUser) => ( + + {user.status} + + ), + }, + { + key: 'lastLogin', + label: 'Last Login', + render: (user: BackofficeUser) => ( + + {user.lastLogin ? new Date(user.lastLogin).toLocaleString() : 'Never'} + + ), + }, + ]; + + const actions = [ + { + label: 'Edit', + onClick: (user: BackofficeUser) => { + setEditingUser(user); + setShowModal(true); + }, + variant: 'secondary' as const, + icon: Edit, + }, + { + label: 'Reset Password', + onClick: (user: BackofficeUser) => { + setResetPasswordModal({ isOpen: true, user }); + setNewPassword(''); + }, + variant: 'secondary' as const, + icon: RefreshCw, + }, + { + label: 'Delete', + onClick: handleDelete, + variant: 'danger' as const, + icon: Trash2, + }, + ]; return (
-

User Management

-

Manage system users and permissions

+

User Management

+

Manage backoffice users and their permissions

+ { + setEditingUser(null); + setShowModal(true); + }} + > + Add User +
+ {/* Filters */}
-
-
- +
+
setSearchTerm(e.target.value)} - className="input pl-10" + placeholder="Search users..." + className="input" + value={filters.search} + onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
+
+ +
+
+ +
-
-
- - - - - - - - - - - - - - -
NameEmailRoleStatus
- User management coming soon -
+ {/* Users Table */} + + + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, user: null })} + onConfirm={confirmDelete} + title="Delete User" + message={`Are you sure you want to delete ${deleteConfirm.user?.fullName}? This action cannot be undone.`} + confirmText="Delete" + isDanger={true} + /> + + {/* Reset Password Modal */} + setResetPasswordModal({ isOpen: false, user: null })} + title="Reset User Password" + > +
+
+

Temporary Password

+

+ Set a temporary password for {resetPasswordModal.user?.fullName}. They will need to change it on first login. +

+
+
+ + setNewPassword(e.target.value)} + placeholder="Enter temporary password" + required + /> +
+
+ setResetPasswordModal({ isOpen: false, user: null })} + > + Cancel + + + Reset Password + +
-
+ + + {/* Add/Edit Modal */} + { + setShowModal(false); + setEditingUser(null); + }} + title={`${editingUser ? 'Edit' : 'Add'} User`} + size="lg" + > +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {!editingUser && ( +
+ + +
+ )} +
+ +
+ { + setShowModal(false); + setEditingUser(null); + }} + > + Cancel + + + {editingUser ? 'Update' : 'Create'} User + +
+
+
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx index 021150676..b966d9a9b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx @@ -279,7 +279,7 @@ export default function StationsPage() { className="input" defaultValue={editingStation?.name} required - placeholder="e.g., Addis Ababa" + placeholder="e.g., Lebu" />
diff --git a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx index dc02a8eab..9cf98183e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx @@ -250,7 +250,7 @@ export default function TrainsPage() { className="input" defaultValue={editingTrain?.number} required - placeholder="e.g., EDR-001" + placeholder="e.g., EDR-101" />
diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index a97f38347..f7d4601e1 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -70,7 +70,7 @@ const navigationSections = [ items: [ { name: 'Pricing & Fares', href: '/pricing', icon: DollarSign }, { name: 'Payments', href: '/payments', icon: CreditCard }, - { name: 'Promotions', href: '/promotions', icon: Gift }, + { name: 'Promo Codes', href: '/promos', icon: Gift }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 10f5b8a32..1032e4690 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -250,6 +250,9 @@ export const promotionsApi = { delete: (id: string) => apiClient.delete(`/promos/${id}`), }; +export { promosApi } from './promos'; +export { usersApi } from './users'; + // Support API export const supportApi = { getConversations: async (params?: any) => { diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/promos.ts b/apps/edr-passenger-web/backoffice/src/lib/api/promos.ts new file mode 100644 index 000000000..cd461b114 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/api/promos.ts @@ -0,0 +1,54 @@ +import { apiClient } from '@/lib/api-client'; + +export interface PromoCode { + id: string; + code: string; + title: string; + discountType: 'PERCENTAGE' | 'FIXED'; + discountValue: number; + maxDiscount?: number; + minBookingAmount?: number; + maxUsagePerUser?: number; + totalUsageLimit?: number; + usageCount: number; + validFrom: string; + validUntil: string; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export const promosApi = { + getAll: (filters?: { search?: string; active?: string; page?: number; pageSize?: number }) => { + const params = new URLSearchParams(); + if (filters?.search) params.append('search', filters.search); + if (filters?.active) params.append('active', filters.active); + if (filters?.page) params.append('page', filters.page.toString()); + if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString()); + + return apiClient.get<{ items: PromoCode[]; total: number; page: number; pageSize: number }>(`/promos/all?${params.toString()}`); + }, + + getById: (id: string) => { + return apiClient.get(`/promos/${id}`); + }, + + create: (data: Omit) => { + return apiClient.post('/promos', data); + }, + + update: (id: string, data: Partial) => { + return apiClient.patch(`/promos/${id}`, data); + }, + + delete: (id: string) => { + return apiClient.delete(`/promos/${id}`); + }, + + validate: (code: string, bookingAmount?: number) => { + return apiClient.post<{ valid: boolean; message?: string; discount?: number }>('/promos/validate', { + code, + bookingAmount, + }); + }, +}; diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/users.ts b/apps/edr-passenger-web/backoffice/src/lib/api/users.ts new file mode 100644 index 000000000..9355e68b5 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/api/users.ts @@ -0,0 +1,59 @@ +import { apiClient } from '@/lib/api-client'; + +export interface BackofficeUser { + id: string; + email: string; + fullName: string; + role: 'ADMIN' | 'SUPERVISOR' | 'STAFF' | 'AGENT'; + status: 'ACTIVE' | 'INACTIVE'; + lastLogin?: string; + createdAt: string; + updatedAt: string; +} + +export const usersApi = { + getAll: async (filters?: { search?: string; role?: string; status?: string; page?: number; pageSize?: number }) => { + const params = new URLSearchParams(); + if (filters?.search) params.append('search', filters.search); + if (filters?.role) params.append('role', filters.role); + if (filters?.status) params.append('status', filters.status); + if (filters?.page) params.append('page', filters.page.toString()); + if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString()); + + const response = await apiClient.get(`/auth/users?${params.toString()}`); + + // Handle different response formats + if (response && typeof response === 'object') { + if ('items' in response) { + return response as { items: BackofficeUser[]; total: number }; + } + if (Array.isArray(response)) { + return { items: response as BackofficeUser[], total: response.length }; + } + } + + return { items: Array.isArray(response) ? response : [], total: 0 }; + }, + + getById: (id: string) => { + return apiClient.get(`/auth/users/${id}`); + }, + + create: (data: { email: string; fullName: string; role: string; password: string }) => { + return apiClient.post('/auth/users', data); + }, + + update: (id: string, data: Partial) => { + return apiClient.patch(`/auth/users/${id}`, data); + }, + + delete: (id: string) => { + return apiClient.delete(`/auth/users/${id}`); + }, + + resetPassword: (id: string, tempPassword: string) => { + return apiClient.post<{ success: boolean; message: string }>(`/auth/users/${id}/reset-password`, { + tempPassword, + }); + }, +}; diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index a1cbcac0c..0ef0e7abc 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -5,9 +5,9 @@ import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { useBookingStore } from '@/lib/booking-store'; import { Schedule } from '@/types'; -import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Loader2, Check, ChevronDown, ChevronUp, MapPin } from 'lucide-react'; +import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Loader2, Check, ChevronDown, ChevronUp, MapPin, Gift } from 'lucide-react'; import { format } from 'date-fns'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; export default function ResultsPage() { const router = useRouter(); @@ -15,6 +15,7 @@ export default function ResultsPage() { const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule); const [selectedClasses, setSelectedClasses] = useState>({}); const [expandedSchedules, setExpandedSchedules] = useState>({}); + const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null); const searchData = { originStationId: searchParams.get('origin') || '', @@ -23,8 +24,28 @@ export default function ResultsPage() { adultCount: parseInt(searchParams.get('adults') || '1'), childCount: parseInt(searchParams.get('children') || '0'), nationality: searchParams.get('nationality') || 'ETHIOPIAN', + promoCode: searchParams.get('promoCode') || '', }; + useEffect(() => { + if (searchData.promoCode) { + apiClient + .post('/promos/validate', { code: searchData.promoCode }) + .then((response: any) => { + if (response.applicable || response.valid) { + setPromoData({ + code: searchData.promoCode, + discount: response.message || 'Discount applied', + message: response.message || 'Promo code applied successfully!', + }); + } + }) + .catch((err) => { + console.error('Promo validation failed:', err); + }); + } + }, [searchData.promoCode]); + const buildSearchUrl = () => { const params = new URLSearchParams({ origin: searchData.originStationId, @@ -44,6 +65,9 @@ export default function ResultsPage() { const response = await apiClient.post('/search', searchData) as Schedule[]; console.log('Search results:', response); console.log('Number of results:', response?.length || 0); + if (response?.length > 0) { + console.log('First schedule availabilityByClass:', response[0].availabilityByClass); + } return response; }, enabled: !!searchData.originStationId && !!searchData.destinationStationId, @@ -156,6 +180,21 @@ export default function ResultsPage() {
+ {/* Promo Notification */} + {promoData && ( +
+
+ +
+
+

Promo code applied!

+

+ {promoData.code} - {promoData.message} +

+
+
+ )} +
+ {searchData.promoCode && ( +
+ + {searchData.promoCode} +
+ )}
@@ -287,6 +332,7 @@ export default function ResultsPage() { const isSelected = selectedClass === fareClass.seatClassName; const availableSeats = schedule.availabilityByClass?.[fareClass.seatClassName] || 0; const isAvailable = availableSeats > 0; + const isBedClass = fareClass.seatClassName.toLowerCase().includes('bed'); return (
- {/* Second Row: Passengers, Nationality, Promo Code */} + {/* Second Row: Passengers, Nationality */}
{/* Passengers Dropdown */}
@@ -321,14 +355,39 @@ export default function SearchPage() {
- {/* Promo Code */} + {/* Promo Code with Validation */}
- +
+
+ + { + setPromoCode(e.target.value.toUpperCase()); + if (promoValidation) setPromoValidation(null); + }} + placeholder="Enter code" + className="w-full pl-10 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400" + onKeyPress={(e) => e.key === 'Enter' && handleValidatePromo()} + /> +
+ +
+ {promoValidation && ( +
+ {promoValidation.valid && } + {promoValidation.message} +
+ )}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index 7b1d83197..adeae8102 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -11,18 +11,17 @@ import { Armchair, Bed, ChevronLeft } from 'lucide-react'; import CustomModal from '@/components/CustomModal'; -const SeatButton = memo(({ seat, isSelected, onToggle, isBedCoach, bedLabel }: any) => { +const SeatButton = memo(({ seat, isSelected, onToggle, isBedCoach, bedLabel, coachSeatClass }: any) => { const seatLabel = seat.number || seat.label || seat.seatNumber || '?'; + const bedWidth = 'w-24'; + const width = isBedCoach ? bedWidth : 'w-10'; return (
- - {seatLabel}{bedLabel} -
@@ -85,14 +85,13 @@ export default function SeatsPage() { }, }); - // Mutation to book seats permanently (called after payment) const bookSeatsMutation = useMutation({ mutationFn: async (seatIds: string[]) => { return Promise.all( seatIds.map((seatId) => apiClient.patch(`/seats/${seatId}`, { status: 'BOOKED', - }).catch(() => null) // Ignore errors, seats are already booked via booking system + }).catch(() => null) ) ); }, @@ -101,35 +100,59 @@ export default function SeatsPage() { const coaches = useMemo(() => (seatMapData as any)?.coaches || [], [seatMapData]); const filteredCoaches = useMemo(() => { - let filtered = selectedSchedule?.selectedSeatClass - ? coaches.filter((c: any) => { - const seatClassName = typeof c.seatClass === 'string' ? c.seatClass : (c.seatClass?.name || c.coachClass || ''); - return seatClassName === selectedSchedule.selectedSeatClass || - seatClassName.replace(/_/g, ' ').toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase() || - seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase(); - }) - : coaches; + if (!selectedSchedule?.selectedSeatClass) { + return coaches.filter((c: any) => c.seats && c.seats.length > 0); + } + + let filtered = coaches.filter((c: any) => { + const seatClasses = c.seatClasses || [c.seatClass] || []; + return seatClasses.some((seatClassName: string) => + seatClassName === selectedSchedule.selectedSeatClass || + seatClassName.replace(/_/g, ' ').toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase() || + seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase() + ); + }); return filtered.filter((c: any) => c.seats && c.seats.length > 0); }, [coaches, selectedSchedule?.selectedSeatClass]); - - const selectedCoachData = useMemo(() => filteredCoaches.find((c: any) => c.id === selectedCoach), [filteredCoaches, selectedCoach]); - const allSeats = useMemo(() => selectedCoachData?.seats || [], [selectedCoachData]); - const validSeats = useMemo(() => allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')), [allSeats]); useEffect(() => { - if (filteredCoaches && filteredCoaches.length > 0 && !selectedCoach) { + if (filteredCoaches.length > 0 && !selectedCoach) { setSelectedCoach(filteredCoaches[0].id); } }, [filteredCoaches, selectedCoach]); - const toggleSeat = useCallback((seatId: string) => { - setSelectedSeats(prev => { - if (prev.includes(seatId)) { - return prev.filter(id => id !== seatId); - } else if (prev.length < passengers.length) { - return [...prev, seatId]; + const selectedCoachData = useMemo(() => filteredCoaches.find((c: any) => c.id === selectedCoach), [filteredCoaches, selectedCoach]); + const allSeats = useMemo(() => selectedCoachData?.seats || [], [selectedCoachData]); + + const getBedPosition = (selectedClass: string): string | null => { + const lowerClass = selectedClass.toLowerCase(); + if (lowerClass.includes('upper')) return 'upper'; + if (lowerClass.includes('middle')) return 'middle'; + if (lowerClass.includes('lower')) return 'lower'; + return null; + }; + + const validSeats = useMemo(() => { + let seats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')); + const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed'); + + if (isBedCoach && selectedSchedule?.selectedSeatClass) { + const selectedBedPosition = getBedPosition(selectedSchedule.selectedSeatClass); + if (selectedBedPosition) { + seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition); + } + } + + return seats; + }, [allSeats, selectedCoachData, selectedSchedule?.selectedSeatClass]); + + const handleSeatClick = useCallback((seatId: string) => { + setSelectedSeats(prev => { + if (prev.length < passengers.length) { + return [...prev, seatId]; + } else { + return [seatId]; } - return prev; }); }, [passengers.length]); @@ -197,7 +220,6 @@ export default function SeatsPage() { } }, [selectedSchedule, passengers.length, router]); - // Auto-book seats when booking is confirmed (after payment) useEffect(() => { if (bookingId && selectedSeats.length > 0) { bookSeatsMutation.mutate(selectedSeats); @@ -221,11 +243,55 @@ export default function SeatsPage() { const arrangement = parseSeatArrangement(coach.seatArrangement); const leftCount = arrangement[0]; + if (validSeats.length === 0) { + return
No seats
; + } + + const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); + const seatClassStr = typeof selectedCoachData?.seatClass === 'string' ? selectedCoachData.seatClass : (selectedCoachData?.seatClass?.name || ''); + + if (isBedCoach && hasBedPositionData) { + return ( +
+ {validSeats.map((seat: any) => { + const rowNumber = seat.row || 1; + const shouldFlipIcon = rowNumber % 2 === 0; + + return ( +
+ {shouldFlipIcon && ( +
+ {seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''} +
+ )} +
+ +
+ {!shouldFlipIcon && ( +
+ {seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''} +
+ )} +
+ ); + })} +
+ ); + } + const rows = []; const processedRows = new Set(); - for (const seat of allSeats) { + for (const seat of validSeats) { if (!processedRows.has(seat.row)) { - rows.push(allSeats.filter((s: any) => s.row === seat.row).sort((a: any, b: any) => { + rows.push(validSeats.filter((s: any) => s.row === seat.row).sort((a: any, b: any) => { const colA = a.col.charCodeAt(0); const colB = b.col.charCodeAt(0); return colA - colB; @@ -240,17 +306,17 @@ export default function SeatsPage() { const leftSeats = rowSeats.slice(0, leftCount); const rightSeats = rowSeats.slice(leftCount); const rowNumber = rowSeats[0]?.row || 1; - const shouldFlipIcon = rowNumber % 2 === 0; + const shouldFlipArmchair = rowNumber % 2 === 0; const showSpacing = rowIdx % 2 === 1; return (
- {shouldFlipIcon && ( -
+ {shouldFlipArmchair && ( +
{leftSeats.map((seat: any) => ( -
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''} +
+ {seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))}
@@ -258,58 +324,50 @@ export default function SeatsPage() { {rightSeats.length > 0 && (
{rightSeats.map((seat: any) => ( -
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''} +
+ {seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))}
)}
)} -
+
{leftSeats.map((seat: any) => ( - seat.seatNumber && !seat.seatNumber.startsWith('-') ? ( - - ) : ( -
- ) + ))}
{rightSeats.length > 0 &&
} {rightSeats.length > 0 && (
{rightSeats.map((seat: any) => ( - seat.seatNumber && !seat.seatNumber.startsWith('-') ? ( - - ) : ( -
- ) + ))}
)}
- {!shouldFlipIcon && ( -
+ {!shouldFlipArmchair && ( +
{leftSeats.map((seat: any) => ( -
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''} +
+ {seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))}
@@ -317,8 +375,8 @@ export default function SeatsPage() { {rightSeats.length > 0 && (
{rightSeats.map((seat: any) => ( -
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''} +
+ {seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))}
@@ -334,8 +392,6 @@ export default function SeatsPage() { ); }; - const isBedCoach = selectedCoachData && (selectedCoachData.seatClass?.toLowerCase().includes('bed') || selectedCoachData.mode?.toLowerCase().includes('bed')); - if (!selectedSchedule || !passengers.length) return null; return ( @@ -362,64 +418,74 @@ export default function SeatsPage() {

Select seats

-
+
-
-

Select coach

- {selectedSchedule?.selectedSeatClassName && ( -
- Showing coaches for: {selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')} -
- )} -
- {filteredCoaches?.map((coach: any) => { - const availableCount = coach.seats?.filter((s: any) => s.status === 'AVAILABLE').length || 0; - const seatClassName = typeof coach.seatClass === 'string' ? coach.seatClass : (coach.seatClass?.name || coach.coachClass || ''); - return ( - - ); - })} -
-
- -
-

- Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber} -

- {selectedCoachData && ( -

- Arrangement: {selectedCoachData.seatArrangement} • Total: {selectedCoachData.totalSeats} seats -

- )} - - {isLoading ? ( + {isLoading ? ( +

Loading seats...

- ) : error ? ( +
+ ) : error ? ( +

Error loading seats

{error?.message || 'Please try again'}

- ) : validSeats.length === 0 ? ( +
+ ) : filteredCoaches.length === 0 ? ( +
-

No seats available in this coach

-

Please select a different coach

+

No coaches available for {selectedSchedule?.selectedSeatClass}

+

Please select a different seat class

- ) : ( - <> +
+ ) : ( +
+
+

Select coach

+
+ {filteredCoaches?.map((coach: any) => { + const coachSeats = coach.seats?.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')) || []; + const isBedCoach = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed'); + let filteredSeats = coachSeats; + if (isBedCoach && selectedSchedule?.selectedSeatClass) { + const bedPos = getBedPosition(selectedSchedule.selectedSeatClass); + if (bedPos) { + filteredSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos); + } + } + const availableCount = filteredSeats.filter((s: any) => s.status === 'AVAILABLE').length || 0; + const seatClassName = selectedSchedule?.selectedSeatClass || (typeof coach.seatClass === 'string' ? coach.seatClass : (coach.seatClass?.name || coach.coachClass || '')); + return ( + + ); + })} +
+
+ +
+

+ Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber} +

+ {selectedCoachData && ( +

+ Arrangement: {selectedCoachData.seatArrangement} • Total: {selectedCoachData.totalSeats} seats +

+ )} +
@@ -439,16 +505,23 @@ export default function SeatsPage() {
-
- {renderCoachSeats(selectedCoachData, isBedCoach)} +
+ {validSeats.length === 0 ? ( +
+

No seats available in this coach

+

Please select a different coach

+
+ ) : ( + renderCoachSeats(selectedCoachData, (selectedCoachData.seatClass?.toLowerCase().includes('bed') || selectedCoachData.mode?.toLowerCase().includes('bed'))) + )}
- - )} -
+
+
+ )}
-
-
+
+

Selection summary

Select {passengers.length} seat(s) for your passengers diff --git a/apps/edr-passenger-web/portal/src/app/profile/page.tsx b/apps/edr-passenger-web/portal/src/app/profile/page.tsx index 91daf1d16..dbc64d4a9 100644 --- a/apps/edr-passenger-web/portal/src/app/profile/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/profile/page.tsx @@ -526,7 +526,7 @@ export default function ProfilePage() { value={settings.preferredOrigin} onChange={(e) => setSettings({ ...settings, preferredOrigin: e.target.value })} className="input-field" - placeholder="e.g., Addis Ababa" + placeholder="e.g., Lebu" />