mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Seatmap rendering and other updates
This commit is contained in:
@@ -3,8 +3,8 @@ import * as bcrypt from 'bcrypt';
|
|||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
const EDR_ROUTE_ID = 'route-edr-main';
|
const EDR_ROUTE_ID = 'route-edr-101';
|
||||||
const TRAIN_ID = 'train-001';
|
const TRAIN_ID = 'EDR-101';
|
||||||
|
|
||||||
async function seedSystemUsers() {
|
async function seedSystemUsers() {
|
||||||
console.log('👥 Seeding system users...');
|
console.log('👥 Seeding system users...');
|
||||||
@@ -140,9 +140,9 @@ async function seedStations() {
|
|||||||
async function seedCoachTypesAndClasses() {
|
async function seedCoachTypesAndClasses() {
|
||||||
console.log('\n🚂 Seeding coach types and seat classes...');
|
console.log('\n🚂 Seeding coach types and seat classes...');
|
||||||
const coachTypes = [
|
const coachTypes = [
|
||||||
{ code: 'ECO', name: 'Economy', type: 'passenger' },
|
{ code: 'HSC', name: 'Hard Seat Coach', type: 'Economy Regular' },
|
||||||
{ code: 'ECO_BED', name: 'Economy Bed', type: 'sleeper' },
|
{ code: 'HBC', name: 'Hard Bed Coach', type: 'Economy Bed' },
|
||||||
{ code: 'VIP_BED', name: 'VIP Bed', type: 'sleeper' },
|
{ code: 'SBC', name: 'Soft Bed Coach', type: 'VIP Bed' },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const ct of coachTypes) {
|
for (const ct of coachTypes) {
|
||||||
@@ -154,10 +154,12 @@ async function seedCoachTypesAndClasses() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const seatClasses = [
|
const seatClasses = [
|
||||||
{ name: 'ECONOMY_REGULAR', coachCode: 'ECO', baseFareMinor: 35000 },
|
{ name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900 },
|
||||||
{ name: 'ECONOMY_WINDOW', coachCode: 'ECO', baseFareMinor: 37000 },
|
{ name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800 },
|
||||||
{ name: 'ECONOMY_BED', coachCode: 'ECO_BED', baseFareMinor: 55000 },
|
{ name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600 },
|
||||||
{ name: 'VIP_BED', coachCode: 'VIP_BED', baseFareMinor: 85000 },
|
{ 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) {
|
for (const sc of seatClasses) {
|
||||||
@@ -177,20 +179,19 @@ async function seedRoute() {
|
|||||||
const lastStation = await prisma.station.findUnique({ where: { code: 'NAG' } });
|
const lastStation = await prisma.station.findUnique({ where: { code: 'NAG' } });
|
||||||
|
|
||||||
const route = await prisma.route.upsert({
|
const route = await prisma.route.upsert({
|
||||||
where: { code: 'EDR-MAIN' },
|
where: { code: 'EDR-101' },
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
id: EDR_ROUTE_ID,
|
code: 'EDR-101',
|
||||||
code: 'EDR-MAIN',
|
name: 'Sebeta - Dire Dawa',
|
||||||
name: 'Ethio-Djibouti Railway Main Route',
|
description: 'Outbound local route from Sebeta to Dire Dawa',
|
||||||
description: 'Main route connecting Sebeta to Nagad',
|
effectiveFrom: new Date('2026-01-01'),
|
||||||
effectiveFrom: new Date('2024-01-01'),
|
|
||||||
effectiveUntil: new Date('2034-12-31'),
|
effectiveUntil: new Date('2034-12-31'),
|
||||||
active: true,
|
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++) {
|
for (let i = 0; i < stationCodes.length; i++) {
|
||||||
const station = await prisma.station.findUnique({ where: { code: stationCodes[i] } });
|
const station = await prisma.station.findUnique({ where: { code: stationCodes[i] } });
|
||||||
await prisma.routeStop.upsert({
|
await prisma.routeStop.upsert({
|
||||||
@@ -204,17 +205,14 @@ async function seedRoute() {
|
|||||||
|
|
||||||
async function seedCoaches() {
|
async function seedCoaches() {
|
||||||
console.log('\n🚃 Seeding coaches and seats...');
|
console.log('\n🚃 Seeding coaches and seats...');
|
||||||
const ecoCoachType = await prisma.coachType.findUnique({ where: { id: 'ECO' } });
|
const ecoCoachType = await prisma.coachType.findUnique({ where: { id: 'HSC' } });
|
||||||
const ecoBedCoachType = await prisma.coachType.findUnique({ where: { id: 'ECO_BED' } });
|
const ecoBedCoachType = await prisma.coachType.findUnique({ where: { id: 'HBC' } });
|
||||||
const vipBedCoachType = await prisma.coachType.findUnique({ where: { id: 'VIP_BED' } });
|
const vipBedCoachType = await prisma.coachType.findUnique({ where: { id: 'SBC' } });
|
||||||
|
|
||||||
const coaches = [
|
const coaches = [
|
||||||
{ number: 'C-001', coachTypeId: ecoCoachType!.id, arrangement: '2+2', capacity: 48 },
|
{ number: 'HSC-0001', coachTypeId: ecoCoachType!.id, arrangement: '3+2', capacity: 40 },
|
||||||
{ number: 'C-002', coachTypeId: ecoCoachType!.id, arrangement: '2+2', capacity: 48 },
|
{ number: 'HBC-0001', coachTypeId: ecoBedCoachType!.id, arrangement: '3+0', capacity: 66 },
|
||||||
{ number: 'C-003', coachTypeId: ecoCoachType!.id, arrangement: '2+2', capacity: 48 },
|
{ number: 'SBC-0001', coachTypeId: vipBedCoachType!.id, arrangement: '2+0', capacity: 120 },
|
||||||
{ 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 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let totalSeats = 0;
|
let totalSeats = 0;
|
||||||
@@ -229,9 +227,16 @@ async function seedCoaches() {
|
|||||||
for (let row = 1; row <= Math.ceil(coach.capacity / 2); row++) {
|
for (let row = 1; row <= Math.ceil(coach.capacity / 2); row++) {
|
||||||
for (const col of ['A', 'B', 'C', 'D']) {
|
for (const col of ['A', 'B', 'C', 'D']) {
|
||||||
if (seatIndex <= coach.capacity) {
|
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({
|
await prisma.seat.upsert({
|
||||||
where: { coachId_seatNumber: { coachId: c.id, seatNumber: seatIndex.toString() } },
|
where: { coachId_seatNumber: { coachId: c.id, seatNumber: seatIndex.toString() } },
|
||||||
update: {},
|
update: { bedPosition },
|
||||||
create: {
|
create: {
|
||||||
coachId: c.id,
|
coachId: c.id,
|
||||||
seatNumber: seatIndex.toString(),
|
seatNumber: seatIndex.toString(),
|
||||||
@@ -239,6 +244,7 @@ async function seedCoaches() {
|
|||||||
col,
|
col,
|
||||||
isWindow: col === 'A' || col === 'D',
|
isWindow: col === 'A' || col === 'D',
|
||||||
isAisle: col === 'B' || col === 'C',
|
isAisle: col === 'B' || col === 'C',
|
||||||
|
bedPosition,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
seatIndex++;
|
seatIndex++;
|
||||||
@@ -258,15 +264,14 @@ async function seedTrips() {
|
|||||||
create: { id: TRAIN_ID, number: 'EDR-001', name: 'Djibouti Express' },
|
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 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 coaches = await prisma.coach.findMany();
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const schedules = [];
|
const schedules = [];
|
||||||
|
|
||||||
// Bulk prepare schedule data
|
|
||||||
for (let d = 0; d < 30; d++) {
|
for (let d = 0; d < 30; d++) {
|
||||||
const tripDate = new Date(now);
|
const tripDate = new Date(now);
|
||||||
tripDate.setDate(tripDate.getDate() + d);
|
tripDate.setDate(tripDate.getDate() + d);
|
||||||
@@ -287,12 +292,10 @@ async function seedTrips() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bulk create schedules
|
|
||||||
const createdSchedules = await Promise.all(
|
const createdSchedules = await Promise.all(
|
||||||
schedules.map(s => prisma.trainSchedule.create({ data: s }))
|
schedules.map(s => prisma.trainSchedule.create({ data: s }))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Bulk create coach assignments and live status
|
|
||||||
const coachAssignments = [];
|
const coachAssignments = [];
|
||||||
const liveStatuses = [];
|
const liveStatuses = [];
|
||||||
|
|
||||||
|
|||||||
@@ -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 { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
|
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
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')
|
@ApiTags('Auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@@ -240,4 +243,61 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
return this.service.getProfile(req.user.userId);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { JwtService } from '@nestjs/jwt';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
|
import { RegisterDto, LoginDto, RequestOtpDto, VerifyOtpDto, RequestPasswordResetDto, ResetPasswordDto } from './auth.dto';
|
||||||
@@ -58,7 +58,7 @@ export class AuthService {
|
|||||||
|
|
||||||
await this.prisma.user.update({
|
await this.prisma.user.update({
|
||||||
where: { id: user.id },
|
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);
|
await this.createAuditLog(user.id, 'USER_LOGIN', 'User', user.id, null, null);
|
||||||
@@ -131,6 +131,174 @@ export class AuthService {
|
|||||||
return { reset: true };
|
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) {
|
private async signToken(userId: string, email: string, role: string, passengerId?: string, agentId?: string) {
|
||||||
// Get the full user data to include fullName
|
// Get the full user data to include fullName
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
|
|||||||
@@ -54,4 +54,13 @@ export class CreateClassDto {
|
|||||||
@ApiProperty({ example: 5000 }) @IsInt() baseFareMinor: number;
|
@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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -167,13 +167,21 @@ export class FleetService {
|
|||||||
const seatClass = await this.prisma.seatClass.findUnique({ where: { id } });
|
const seatClass = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
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({
|
return this.prisma.seatClass.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: updateData,
|
||||||
name: dto.name,
|
include: { coachType: true },
|
||||||
description: dto.description,
|
|
||||||
baseFareMinor: dto.baseFareMinor,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,27 +13,36 @@ import { UserRole } from '@prisma/client';
|
|||||||
export class PaymentsController {
|
export class PaymentsController {
|
||||||
constructor(private service: PaymentsService) {}
|
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')
|
@Post('initiate')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Initiate payment with nationality-based payment methods',
|
summary: 'Initiate payment with nationality-based payment methods',
|
||||||
description: `Initiates payment for a booking with support for multiple payment providers:
|
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`
|
||||||
|
|
||||||
**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`
|
|
||||||
})
|
})
|
||||||
initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); }
|
initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); }
|
||||||
|
|
||||||
@@ -102,7 +111,7 @@ export class PaymentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildRedirectHtml(url: string): string {
|
private buildRedirectHtml(url: string): string {
|
||||||
const escaped = url.replace(/"/g, '"');
|
const escaped = url.replace(/\"/g, '"');
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
|||||||
@@ -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<InitiateResponseDto> {
|
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||||
const booking = await this.prisma.booking.findUnique({
|
const booking = await this.prisma.booking.findUnique({
|
||||||
where: { id: dto.bookingId },
|
where: { id: dto.bookingId },
|
||||||
|
|||||||
@@ -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 { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { PromosService } from './promos.service';
|
import { PromosService } from './promos.service';
|
||||||
import { CreatePromotionDto } from './promos.dto';
|
import { CreatePromotionDto } from './promos.dto';
|
||||||
@@ -8,7 +8,66 @@ import { JwtGuard } from '../../common/jwt.guard';
|
|||||||
@Controller('promos')
|
@Controller('promos')
|
||||||
export class PromosController {
|
export class PromosController {
|
||||||
constructor(private service: PromosService) {}
|
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); }
|
@Get()
|
||||||
@Post() @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create promotion (admin)' }) create(@Body() dto: CreatePromotionDto) { return this.service.create(dto); }
|
@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<CreatePromotionDto>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class CreatePromotionDto {
|
export class CreatePromotionDto {
|
||||||
@ApiProperty({ example: 'Weekend Special' }) @IsString() title: string;
|
@ApiProperty({ example: 'SUMMER2024' })
|
||||||
@ApiPropertyOptional({ example: '15% off all routes' }) @IsOptional() @IsString() subtitle?: string;
|
@IsString()
|
||||||
@ApiProperty({ example: 'WEEKEND15' }) @IsString() code: string;
|
code: string;
|
||||||
@ApiPropertyOptional({ example: 15 }) @IsOptional() @IsInt() percentOff?: number;
|
|
||||||
@ApiPropertyOptional({ example: 5000 }) @IsOptional() @IsInt() amountOffMinor?: number;
|
@ApiProperty({ example: 'Summer Discount' })
|
||||||
@ApiProperty({ example: '2026-12-31T23:59:59Z' }) @IsString() validUntil: string;
|
@IsString()
|
||||||
@ApiPropertyOptional({ example: 'Book Now' }) @IsOptional() @IsString() ctaLabel?: string;
|
title: string;
|
||||||
@ApiPropertyOptional({ example: 'edr://search' }) @IsOptional() @IsString() deepLink?: 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,190 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { CreatePromotionDto } from './promos.dto';
|
import { CreatePromotionDto } from './promos.dto';
|
||||||
|
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PromosService {
|
export class PromosService {
|
||||||
constructor(private prisma: PrismaService) {}
|
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) {
|
async validate(code: string) {
|
||||||
const promo = await this.prisma.promotion.findUnique({ where: { code } });
|
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' };
|
if (!promo || !promo.active || promo.validUntil < new Date())
|
||||||
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` };
|
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) {
|
async create(dto: CreatePromotionDto & { discountType?: string; discountValue?: number }) {
|
||||||
return this.prisma.promotion.create({ data: { ...dto, validUntil: new Date(dto.validUntil) } });
|
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<CreatePromotionDto> & { 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,31 +50,56 @@ export class SearchService {
|
|||||||
const availabilityByClass: Record<string, number> = {};
|
const availabilityByClass: Record<string, number> = {};
|
||||||
|
|
||||||
for (const assignment of schedule.coachAssignments) {
|
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'];
|
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
|
||||||
|
const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);
|
||||||
|
|
||||||
for (const seatClassName of seatClassNames) {
|
if (isBedCoach) {
|
||||||
if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
|
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;
|
||||||
|
|
||||||
// Count available seats (skip blocked and removed seats)
|
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||||
for (const seat of assignment.coach.seats) {
|
schedule.id, seat.id,
|
||||||
// Skip blocked seats
|
originStop.sequence, destStop.sequence,
|
||||||
if (seat.status === 'BLOCKED') continue;
|
);
|
||||||
|
if (free) count++;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip removed seats (empty seatNumber)
|
if (count > 0) {
|
||||||
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
|
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(
|
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||||
schedule.id, seat.id,
|
schedule.id, seat.id,
|
||||||
originStop.sequence, destStop.sequence,
|
originStop.sequence, destStop.sequence,
|
||||||
);
|
);
|
||||||
|
if (free) availableSeatsInCoach++;
|
||||||
|
}
|
||||||
|
|
||||||
if (free) {
|
for (const seatClassName of seatClassNames) {
|
||||||
// Group by seat class - use the first seat class for now
|
if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
|
||||||
// In a full implementation, seats would have a seatClassId
|
availabilityByClass[seatClassName] += availableSeatsInCoach;
|
||||||
const className = seatClassNames[0] || 'Standard';
|
|
||||||
availabilityByClass[className]++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,7 +250,6 @@ export class SearchService {
|
|||||||
destinationStationId: string,
|
destinationStationId: string,
|
||||||
nationality?: string,
|
nationality?: string,
|
||||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
|
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
|
||||||
// Get unique seat classes from all coaches assigned to this schedule via their coach types
|
|
||||||
const seatClassIds: string[] = Array.from(
|
const seatClassIds: string[] = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
schedule.coachAssignments
|
schedule.coachAssignments
|
||||||
|
|||||||
@@ -32,10 +32,8 @@ export class SeatsService {
|
|||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
coaches: assignments.map((a) => {
|
coaches: assignments.map((a) => {
|
||||||
// Include all seats (both valid and removed with negative seatNumbers)
|
|
||||||
const allSeats = a.coach.seats;
|
const allSeats = a.coach.seats;
|
||||||
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
|
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
|
||||||
const seatClass = seatClassNames.length > 0 ? seatClassNames[0] : 'Standard';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: a.coach.id,
|
id: a.coach.id,
|
||||||
@@ -44,7 +42,8 @@ export class SeatsService {
|
|||||||
label: a.coach.number,
|
label: a.coach.number,
|
||||||
mode: a.coach.status,
|
mode: a.coach.status,
|
||||||
name: `Coach ${a.coach.number}`,
|
name: `Coach ${a.coach.number}`,
|
||||||
seatClass,
|
seatClasses: seatClassNames,
|
||||||
|
seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard',
|
||||||
positionNumber: a.positionNumber,
|
positionNumber: a.positionNumber,
|
||||||
seatArrangement: a.coach.arrangement,
|
seatArrangement: a.coach.arrangement,
|
||||||
totalSeats: a.coach.capacity,
|
totalSeats: a.coach.capacity,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Edit, Trash2, Search } from 'lucide-react';
|
import { Plus, Edit, Trash2, Search } from 'lucide-react';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
@@ -16,6 +16,7 @@ export default function ClassesPage() {
|
|||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingClass, setEditingClass] = useState<any>(null);
|
const [editingClass, setEditingClass] = useState<any>(null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null }>({ isOpen: false, class: null });
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null }>({ isOpen: false, class: null });
|
||||||
|
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
@@ -28,12 +29,19 @@ export default function ClassesPage() {
|
|||||||
queryFn: () => apiClient.get('/fleet/coach-types'),
|
queryFn: () => apiClient.get('/fleet/coach-types'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showModal && editingClass) {
|
||||||
|
setSelectedCoachTypeId(editingClass.coachTypeId || '');
|
||||||
|
}
|
||||||
|
}, [showModal, editingClass]);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: seatClassesApi.create,
|
mutationFn: seatClassesApi.create,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['classes'] });
|
queryClient.invalidateQueries({ queryKey: ['classes'] });
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setEditingClass(null);
|
setEditingClass(null);
|
||||||
|
setSelectedCoachTypeId('');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,6 +51,7 @@ export default function ClassesPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['classes'] });
|
queryClient.invalidateQueries({ queryKey: ['classes'] });
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setEditingClass(null);
|
setEditingClass(null);
|
||||||
|
setSelectedCoachTypeId('');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,12 +64,19 @@ export default function ClassesPage() {
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!selectedCoachTypeId) {
|
||||||
|
alert('Please select a coach type');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
const classData = {
|
const classData = {
|
||||||
coachTypeId: formData.get('coachTypeId') as string,
|
coachTypeId: selectedCoachTypeId,
|
||||||
name: formData.get('name') as string,
|
name: formData.get('name') as string,
|
||||||
description: formData.get('description') as string,
|
description: formData.get('description') as string,
|
||||||
baseFareMinor: parseInt(formData.get('baseFareMinor') as string) || 0,
|
baseFareMinor: parseInt(formData.get('baseFareMinor') as string) || 0,
|
||||||
|
isActive: formData.get('isActive') === 'true',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingClass) {
|
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 = [
|
const actions = [
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
onClick: (cls: any) => {
|
onClick: (cls: any) => handleOpenModal(cls),
|
||||||
setEditingClass(cls);
|
|
||||||
setShowModal(true);
|
|
||||||
},
|
|
||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
icon: Edit,
|
icon: Edit,
|
||||||
},
|
},
|
||||||
@@ -163,10 +185,7 @@ export default function ClassesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={Plus}
|
icon={Plus}
|
||||||
onClick={() => {
|
onClick={() => handleOpenModal()}
|
||||||
setEditingClass(null);
|
|
||||||
setShowModal(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Add Class
|
Add Class
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
@@ -211,6 +230,7 @@ export default function ClassesPage() {
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setEditingClass(null);
|
setEditingClass(null);
|
||||||
|
setSelectedCoachTypeId('');
|
||||||
}}
|
}}
|
||||||
title={`${editingClass ? 'Edit' : 'Add'} Class`}
|
title={`${editingClass ? 'Edit' : 'Add'} Class`}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -222,7 +242,8 @@ export default function ClassesPage() {
|
|||||||
<select
|
<select
|
||||||
name="coachTypeId"
|
name="coachTypeId"
|
||||||
className="input"
|
className="input"
|
||||||
defaultValue={editingClass?.coachTypeId || ''}
|
value={selectedCoachTypeId}
|
||||||
|
onChange={(e) => setSelectedCoachTypeId(e.target.value)}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Select Coach Type</option>
|
<option value="">Select Coach Type</option>
|
||||||
@@ -276,7 +297,7 @@ export default function ClassesPage() {
|
|||||||
<select
|
<select
|
||||||
name="isActive"
|
name="isActive"
|
||||||
className="input"
|
className="input"
|
||||||
defaultValue={editingClass?.isActive?.toString() || 'true'}
|
defaultValue={editingClass?.isActive !== undefined ? editingClass.isActive.toString() : 'true'}
|
||||||
>
|
>
|
||||||
<option value="true">Active</option>
|
<option value="true">Active</option>
|
||||||
<option value="false">Inactive</option>
|
<option value="false">Inactive</option>
|
||||||
@@ -291,6 +312,7 @@ export default function ClassesPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setEditingClass(null);
|
setEditingClass(null);
|
||||||
|
setSelectedCoachTypeId('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -510,7 +510,7 @@ export default function CoachesPage() {
|
|||||||
className="input"
|
className="input"
|
||||||
defaultValue={editingItem?.number || editingItem?.coachNumber || ''}
|
defaultValue={editingItem?.number || editingItem?.coachNumber || ''}
|
||||||
required
|
required
|
||||||
placeholder="e.g., A-001"
|
placeholder="e.g., HSC-0001"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import DashboardLayout from '../dashboard/layout';
|
||||||
|
|
||||||
|
export default function PromosLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <DashboardLayout>{children}</DashboardLayout>;
|
||||||
|
}
|
||||||
409
apps/edr-passenger-web/backoffice/src/app/promos/page.tsx
Normal file
409
apps/edr-passenger-web/backoffice/src/app/promos/page.tsx
Normal file
@@ -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<any>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; promo: any | null }>({ isOpen: false, promo: null });
|
||||||
|
const [copiedCode, setCopiedCode] = useState<string | null>(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<HTMLFormElement>) => {
|
||||||
|
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) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono font-semibold text-lg">{promo.code}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(promo.code)}
|
||||||
|
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
|
||||||
|
title="Copy code"
|
||||||
|
>
|
||||||
|
{copiedCode === promo.code ? (
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
label: 'Title',
|
||||||
|
render: (promo: PromoCode) => (
|
||||||
|
<span className="text-sm font-medium">{promo.title || '-'}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'discount',
|
||||||
|
label: 'Discount',
|
||||||
|
render: (promo: PromoCode) => (
|
||||||
|
<span className="font-semibold">
|
||||||
|
{promo.discountType === 'PERCENTAGE'
|
||||||
|
? `${promo.discountValue}%`
|
||||||
|
: `ETB ${promo.discountValue}`}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'validity',
|
||||||
|
label: 'Valid Period',
|
||||||
|
render: (promo: PromoCode) => (
|
||||||
|
<div className="text-sm">
|
||||||
|
<div>{new Date(promo.validFrom).toLocaleDateString()}</div>
|
||||||
|
<div className="text-muted-foreground">{new Date(promo.validUntil).toLocaleDateString()}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'usage',
|
||||||
|
label: 'Usage',
|
||||||
|
render: (promo: PromoCode) => (
|
||||||
|
<div className="text-sm">
|
||||||
|
<div>{promo.usageCount} used</div>
|
||||||
|
{promo.totalUsageLimit && (
|
||||||
|
<div className="text-muted-foreground">/ {promo.totalUsageLimit} limit</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
render: (promo: PromoCode) => (
|
||||||
|
<Badge variant="status" status={promo.isActive ? 'CONFIRMED' : 'CANCELLED'}>
|
||||||
|
{promo.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Promo Codes</h1>
|
||||||
|
<p className="text-muted-foreground">Manage promotional codes and discounts</p>
|
||||||
|
</div>
|
||||||
|
<ActionButton
|
||||||
|
icon={Plus}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingPromo(null);
|
||||||
|
setShowModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Promo Code
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search promo codes..."
|
||||||
|
className="input"
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={filters.active}
|
||||||
|
onChange={(e) => setFilters({ ...filters, active: e.target.value, page: 1 })}
|
||||||
|
>
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="true">Active</option>
|
||||||
|
<option value="false">Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Promos Table */}
|
||||||
|
<DataTable
|
||||||
|
data={data?.items || []}
|
||||||
|
columns={columns}
|
||||||
|
actions={actions}
|
||||||
|
loading={isLoading}
|
||||||
|
emptyMessage="No promo codes found"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={deleteConfirm.isOpen}
|
||||||
|
onClose={() => 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 */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingPromo(null);
|
||||||
|
}}
|
||||||
|
title={`${editingPromo ? 'Edit' : 'Create'} Promo Code`}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Promo Code *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="code"
|
||||||
|
className="input uppercase"
|
||||||
|
defaultValue={editingPromo?.code}
|
||||||
|
required
|
||||||
|
placeholder="e.g., SUMMER2024"
|
||||||
|
maxLength={20}
|
||||||
|
disabled={!!editingPromo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Title *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingPromo?.title}
|
||||||
|
required
|
||||||
|
placeholder="e.g., Summer Discount"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Discount Type *</label>
|
||||||
|
<select
|
||||||
|
name="discountType"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingPromo?.discountType || 'PERCENTAGE'}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="PERCENTAGE">Percentage (%)</option>
|
||||||
|
<option value="FIXED">Fixed Amount (ETB)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Discount Value *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="discountValue"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingPromo?.discountValue}
|
||||||
|
required
|
||||||
|
placeholder="e.g., 15"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Max Discount (ETB)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="maxDiscount"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingPromo?.maxDiscount}
|
||||||
|
placeholder="e.g., 500"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Min Booking Amount (ETB)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="minBookingAmount"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingPromo?.minBookingAmount}
|
||||||
|
placeholder="e.g., 1000"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Max Usage Per User</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="maxUsagePerUser"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingPromo?.maxUsagePerUser}
|
||||||
|
placeholder="Unlimited if empty"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Total Usage Limit</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="totalUsageLimit"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingPromo?.totalUsageLimit}
|
||||||
|
placeholder="Unlimited if empty"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Valid From *</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="validFrom"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingPromo?.validFrom?.slice(0, 16)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Valid Until *</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="validUntil"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingPromo?.validUntil?.slice(0, 16)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Status</label>
|
||||||
|
<select
|
||||||
|
name="isActive"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingPromo?.isActive?.toString() || 'true'}
|
||||||
|
>
|
||||||
|
<option value="true">Active</option>
|
||||||
|
<option value="false">Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<ActionButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingPromo(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
type="submit"
|
||||||
|
loading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
{editingPromo ? 'Update' : 'Create'} Promo Code
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -82,10 +82,8 @@ export default function RoutesPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort middle stops by distance from origin
|
// Keep current stop order (already rearranged by user)
|
||||||
const sortedMiddleStops = [...stops].sort((a, b) =>
|
const sortedMiddleStops = stops;
|
||||||
(a.distanceFromOrigin || 0) - (b.distanceFromOrigin || 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate distanceKm (distance from previous stop)
|
// Calculate distanceKm (distance from previous stop)
|
||||||
const stopsArray = [
|
const stopsArray = [
|
||||||
@@ -137,6 +135,30 @@ export default function RoutesPage() {
|
|||||||
setStops(updated);
|
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) => {
|
const generateRouteCode = (originId: string, destId: string) => {
|
||||||
if (!originId || !destId) return '';
|
if (!originId || !destId) return '';
|
||||||
const origin = stations?.items?.find((s: any) => s.id === originId);
|
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"}
|
emptyMessage={search ? "No routes match your search" : "No routes found"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.isOpen}
|
isOpen={deleteConfirm.isOpen}
|
||||||
onClose={() => setDeleteConfirm({ isOpen: false, route: null })}
|
onClose={() => 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."
|
warning="This route may be referenced by schedules and bookings. Deleting it may impact these systems."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Add/Edit Modal */}
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -383,7 +403,7 @@ export default function RoutesPage() {
|
|||||||
className="input"
|
className="input"
|
||||||
rows={2}
|
rows={2}
|
||||||
defaultValue={editingRoute?.description}
|
defaultValue={editingRoute?.description}
|
||||||
placeholder="Main corridor via Dire Dawa"
|
placeholder="Outbound local route from [Origin] to [Destination]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -412,10 +432,10 @@ export default function RoutesPage() {
|
|||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<label className="label mb-0">Route Stops</label>
|
<label className="label mb-0">Route Stops</label>
|
||||||
|
<span className="text-xs text-muted-foreground">Drag to rearrange intermediate stops</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Origin Stop */}
|
|
||||||
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
|
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
|
||||||
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
||||||
1
|
1
|
||||||
@@ -435,9 +455,16 @@ export default function RoutesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Intermediate Stops */}
|
|
||||||
{stops.map((stop, index) => (
|
{stops.map((stop, index) => (
|
||||||
<div key={index} className="flex gap-2 items-center p-3 bg-muted/50 rounded">
|
<div
|
||||||
|
key={index}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => 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"
|
||||||
|
>
|
||||||
<div className="flex-shrink-0 w-8 h-8 bg-secondary text-secondary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
<div className="flex-shrink-0 w-8 h-8 bg-secondary text-secondary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
||||||
{index + 2}
|
{index + 2}
|
||||||
</div>
|
</div>
|
||||||
@@ -482,7 +509,6 @@ export default function RoutesPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Add Intermediate Stop Button */}
|
|
||||||
{originStationId && destinationStationId && (
|
{originStationId && destinationStationId && (
|
||||||
<div className="flex justify-center py-2">
|
<div className="flex justify-center py-2">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -497,7 +523,6 @@ export default function RoutesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Destination Stop */}
|
|
||||||
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
|
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
|
||||||
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
||||||
{stops.length + 2}
|
{stops.length + 2}
|
||||||
|
|||||||
@@ -48,10 +48,9 @@ export default function SchedulesPage() {
|
|||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
|
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
|
||||||
const [selectedSchedules, setSelectedSchedules] = useState<Set<string>>(new Set());
|
const [selectedSchedules, setSelectedSchedules] = useState<Set<string>>(new Set());
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean }>({
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean }>(
|
||||||
isOpen: false,
|
{ isOpen: false, item: null }
|
||||||
item: null,
|
);
|
||||||
});
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -179,6 +178,10 @@ export default function SchedulesPage() {
|
|||||||
forNextDays: parseInt(bulkForm.forNextDays),
|
forNextDays: parseInt(bulkForm.forNextDays),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (bulkForm.coachIds.length > 0) {
|
||||||
|
payload.coachIds = bulkForm.coachIds;
|
||||||
|
}
|
||||||
|
|
||||||
await bulkGenerateMutation.mutateAsync(payload);
|
await bulkGenerateMutation.mutateAsync(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { seatsApi, schedulesApi } from '@/lib/api';
|
import { seatsApi, schedulesApi } from '@/lib/api';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import ActionButton from '@/components/ui/ActionButton'
|
import ActionButton from '@/components/ui/ActionButton'
|
||||||
import Badge from '@/components/ui/Badge';
|
|
||||||
import { Armchair, Lock, Unlock, Bed, X, RotateCcw } from 'lucide-react';
|
import { Armchair, Lock, Unlock, Bed, X, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
export default function SeatsPage() {
|
export default function SeatsPage() {
|
||||||
@@ -144,6 +143,9 @@ export default function SeatsPage() {
|
|||||||
const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
|
const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
|
||||||
const allSeatsForLayout = [...validSeats, ...removedSeats];
|
const allSeatsForLayout = [...validSeats, ...removedSeats];
|
||||||
const rows = [];
|
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) {
|
for (let i = 0; i < allSeatsForLayout.length; i += seatsPerRow) {
|
||||||
rows.push(allSeatsForLayout.slice(i, i + seatsPerRow));
|
rows.push(allSeatsForLayout.slice(i, i + seatsPerRow));
|
||||||
@@ -154,6 +156,7 @@ export default function SeatsPage() {
|
|||||||
{rows.map((rowSeats: any[], idx: number) => {
|
{rows.map((rowSeats: any[], idx: number) => {
|
||||||
const rowNumber = rowSeats[0]?.row || (idx + 1);
|
const rowNumber = rowSeats[0]?.row || (idx + 1);
|
||||||
const shouldFlipIcon = rowNumber % 2 === 0;
|
const shouldFlipIcon = rowNumber % 2 === 0;
|
||||||
|
const shouldFlipRow = rowNumber % 2 === 1;
|
||||||
const showSpacing = idx % 2 === 1;
|
const showSpacing = idx % 2 === 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -161,7 +164,7 @@ export default function SeatsPage() {
|
|||||||
{shouldFlipIcon && (
|
{shouldFlipIcon && (
|
||||||
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
||||||
{rowSeats.map((seat: any) => (
|
{rowSeats.map((seat: any) => (
|
||||||
<div key={`num-before-${seat.id}`} className="w-12 h-4 flex items-center justify-center">
|
<div key={`num-before-${seat.id}`} className={`${bedWidth} h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground`}>
|
||||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -188,7 +191,7 @@ export default function SeatsPage() {
|
|||||||
{!shouldFlipIcon && (
|
{!shouldFlipIcon && (
|
||||||
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
||||||
{rowSeats.map((seat: any) => (
|
{rowSeats.map((seat: any) => (
|
||||||
<div key={`num-after-${seat.id}`} className="w-12 h-4 flex items-center justify-center">
|
<div key={`num-after-${seat.id}`} className={`${bedWidth} h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground`}>
|
||||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -228,6 +231,7 @@ export default function SeatsPage() {
|
|||||||
const rightSeats = rowSeats.slice(leftCount);
|
const rightSeats = rowSeats.slice(leftCount);
|
||||||
const rowNumber = rowSeats[0]?.row || 1;
|
const rowNumber = rowSeats[0]?.row || 1;
|
||||||
const shouldFlipArmchair = rowNumber % 2 === 0;
|
const shouldFlipArmchair = rowNumber % 2 === 0;
|
||||||
|
const shouldFlipRow = rowNumber % 2 === 0;
|
||||||
const showSpacing = rowIdx % 2 === 1;
|
const showSpacing = rowIdx % 2 === 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -236,7 +240,7 @@ export default function SeatsPage() {
|
|||||||
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
{leftSeats.map((seat: any) => (
|
{leftSeats.map((seat: any) => (
|
||||||
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
|
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
|
||||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -245,7 +249,7 @@ export default function SeatsPage() {
|
|||||||
{rightSeats.length > 0 && (
|
{rightSeats.length > 0 && (
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
{rightSeats.map((seat: any) => (
|
{rightSeats.map((seat: any) => (
|
||||||
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
|
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
|
||||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -299,7 +303,7 @@ export default function SeatsPage() {
|
|||||||
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
{leftSeats.map((seat: any) => (
|
{leftSeats.map((seat: any) => (
|
||||||
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
|
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
|
||||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -308,7 +312,7 @@ export default function SeatsPage() {
|
|||||||
{rightSeats.length > 0 && (
|
{rightSeats.length > 0 && (
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
{rightSeats.map((seat: any) => (
|
{rightSeats.map((seat: any) => (
|
||||||
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
|
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
|
||||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -403,16 +407,16 @@ export default function SeatsPage() {
|
|||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{coachesWithSeats.map((coach: any) => {
|
{coachesWithSeats.map((coach: any) => {
|
||||||
const isBedCoach = (coach.seatClass && coach.seatClass.toLowerCase().includes('bed')) ||
|
const isBedCoach = (coach.seatClass && coach.seatClass.toLowerCase().includes('bed')) ||
|
||||||
(coach.mode && coach.mode.toLowerCase().includes('bed'));
|
(coach.mode && coach.mode.toLowerCase().includes('bed'));
|
||||||
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={coach.id} className="border rounded-lg p-3 bg-white dark:bg-card">
|
<div key={coach.id} className="flex flex-col gap-4">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<h3 className="font-semibold text-sm">Coach {coach.coachNumber}</h3>
|
<h3 className="font-semibold text-sm">Coach {coach.coachNumber}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-900/30 py-2 rounded-lg">
|
<div className="bg-gray-50 dark:bg-gray-900/30 rounded-lg w-64 border border-gray-200 dark:border-gray-700 p-2">
|
||||||
{renderCoachSeats(coach, isBedCoach)}
|
{renderCoachSeats(coach, isBedCoach)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -542,6 +546,10 @@ function SeatIcon({
|
|||||||
handleUndoRemove,
|
handleUndoRemove,
|
||||||
}: SeatIconProps) {
|
}: SeatIconProps) {
|
||||||
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
|
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) {
|
if (!seat.seatNumber) {
|
||||||
return <div className="w-7 h-7" />;
|
return <div className="w-7 h-7" />;
|
||||||
@@ -580,17 +588,19 @@ function SeatIcon({
|
|||||||
|
|
||||||
{isBedCoach ? (
|
{isBedCoach ? (
|
||||||
<div
|
<div
|
||||||
className={`w-11 h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
className={`${width} h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
||||||
title={`${seat.seatNumber} - ${seat.bedPosition} - ${status}`}
|
title={`${seat.seatNumber} - ${seat.bedPosition} - ${status}`}
|
||||||
|
style={seat.row % 2 === 1 ? { transform: 'scaleY(-1)' } : undefined}
|
||||||
>
|
>
|
||||||
<Bed className="w-7 h-7 text-white" style={shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined} />
|
<Bed className="w-7 h-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={`w-11 h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
className={`w-11 h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
||||||
title={`${seat.seatNumber} - ${status}`}
|
title={`${seat.seatNumber} - ${status}`}
|
||||||
|
style={seat.row % 2 === 0 ? { transform: 'scaleY(-1)' } : undefined}
|
||||||
>
|
>
|
||||||
<Armchair className="w-7 h-7 text-white" style={shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined} />
|
<Armchair className="w-7 h-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,57 +2,388 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 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() {
|
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<any>(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<HTMLFormElement>) => {
|
||||||
|
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) => (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{user.fullName}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'role',
|
||||||
|
label: 'Role',
|
||||||
|
render: (user: BackofficeUser) => (
|
||||||
|
<Badge variant="status" status={user.role}>
|
||||||
|
{user.role}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
render: (user: BackofficeUser) => (
|
||||||
|
<Badge variant="status" status={user.status === 'ACTIVE' ? 'CONFIRMED' : 'CANCELLED'}>
|
||||||
|
{user.status}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastLogin',
|
||||||
|
label: 'Last Login',
|
||||||
|
render: (user: BackofficeUser) => (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{user.lastLogin ? new Date(user.lastLogin).toLocaleString() : 'Never'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">User Management</h1>
|
<h1 className="text-2xl font-bold text-foreground">User Management</h1>
|
||||||
<p className="text-muted-foreground mt-1">Manage system users and permissions</p>
|
<p className="text-muted-foreground">Manage backoffice users and their permissions</p>
|
||||||
</div>
|
</div>
|
||||||
|
<ActionButton
|
||||||
|
icon={Plus}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingUser(null);
|
||||||
|
setShowModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add User
|
||||||
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div className="relative flex-1">
|
<div>
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search users by name or email..."
|
placeholder="Search users..."
|
||||||
value={searchTerm}
|
className="input"
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
value={filters.search}
|
||||||
className="input pl-10"
|
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={filters.role}
|
||||||
|
onChange={(e) => setFilters({ ...filters, role: e.target.value, page: 1 })}
|
||||||
|
>
|
||||||
|
<option value="">All Roles</option>
|
||||||
|
<option value="ADMIN">Admin</option>
|
||||||
|
<option value="SUPERVISOR">Supervisor</option>
|
||||||
|
<option value="STAFF">Staff</option>
|
||||||
|
<option value="AGENT">Agent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(e) => setFilters({ ...filters, status: e.target.value, page: 1 })}
|
||||||
|
>
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="ACTIVE">Active</option>
|
||||||
|
<option value="INACTIVE">Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
{/* Users Table */}
|
||||||
<div className="overflow-x-auto">
|
<DataTable
|
||||||
<table className="w-full">
|
data={data?.items || []}
|
||||||
<thead>
|
columns={columns}
|
||||||
<tr className="border-b border-border">
|
actions={actions}
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Name</th>
|
loading={isLoading}
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Email</th>
|
emptyMessage="No users found"
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Role</th>
|
/>
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Status</th>
|
|
||||||
</tr>
|
{/* Delete Confirmation */}
|
||||||
</thead>
|
<ConfirmDialog
|
||||||
<tbody>
|
isOpen={deleteConfirm.isOpen}
|
||||||
<tr>
|
onClose={() => setDeleteConfirm({ isOpen: false, user: null })}
|
||||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">
|
onConfirm={confirmDelete}
|
||||||
User management coming soon
|
title="Delete User"
|
||||||
</td>
|
message={`Are you sure you want to delete ${deleteConfirm.user?.fullName}? This action cannot be undone.`}
|
||||||
</tr>
|
confirmText="Delete"
|
||||||
</tbody>
|
isDanger={true}
|
||||||
</table>
|
/>
|
||||||
|
|
||||||
|
{/* Reset Password Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={resetPasswordModal.isOpen}
|
||||||
|
onClose={() => setResetPasswordModal({ isOpen: false, user: null })}
|
||||||
|
title="Reset User Password"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded p-3 text-sm">
|
||||||
|
<p className="font-semibold text-blue-900 dark:text-blue-200">Temporary Password</p>
|
||||||
|
<p className="text-blue-800 dark:text-blue-300 mt-1">
|
||||||
|
Set a temporary password for {resetPasswordModal.user?.fullName}. They will need to change it on first login.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Temporary Password *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Enter temporary password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<ActionButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setResetPasswordModal({ isOpen: false, user: null })}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
onClick={handleResetPassword}
|
||||||
|
loading={resetPasswordMutation.isPending}
|
||||||
|
disabled={!newPassword}
|
||||||
|
>
|
||||||
|
Reset Password
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Add/Edit Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
}}
|
||||||
|
title={`${editingUser ? 'Edit' : 'Add'} User`}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Full Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="fullName"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingUser?.fullName}
|
||||||
|
required
|
||||||
|
placeholder="e.g., John Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Email *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingUser?.email}
|
||||||
|
required
|
||||||
|
placeholder="e.g., john@example.com"
|
||||||
|
disabled={!!editingUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Role *</label>
|
||||||
|
<select
|
||||||
|
name="role"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingUser?.role || 'STAFF'}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="ADMIN">Admin</option>
|
||||||
|
<option value="SUPERVISOR">Supervisor</option>
|
||||||
|
<option value="STAFF">Staff</option>
|
||||||
|
<option value="AGENT">Agent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Status</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingUser?.status || 'ACTIVE'}
|
||||||
|
>
|
||||||
|
<option value="ACTIVE">Active</option>
|
||||||
|
<option value="INACTIVE">Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{!editingUser && (
|
||||||
|
<div>
|
||||||
|
<label className="label">Password *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
className="input"
|
||||||
|
required
|
||||||
|
placeholder="Minimum 8 characters"
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<ActionButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
type="submit"
|
||||||
|
loading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
{editingUser ? 'Update' : 'Create'} User
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ export default function StationsPage() {
|
|||||||
className="input"
|
className="input"
|
||||||
defaultValue={editingStation?.name}
|
defaultValue={editingStation?.name}
|
||||||
required
|
required
|
||||||
placeholder="e.g., Addis Ababa"
|
placeholder="e.g., Lebu"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ export default function TrainsPage() {
|
|||||||
className="input"
|
className="input"
|
||||||
defaultValue={editingTrain?.number}
|
defaultValue={editingTrain?.number}
|
||||||
required
|
required
|
||||||
placeholder="e.g., EDR-001"
|
placeholder="e.g., EDR-101"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const navigationSections = [
|
|||||||
items: [
|
items: [
|
||||||
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign },
|
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign },
|
||||||
{ name: 'Payments', href: '/payments', icon: CreditCard },
|
{ name: 'Payments', href: '/payments', icon: CreditCard },
|
||||||
{ name: 'Promotions', href: '/promotions', icon: Gift },
|
{ name: 'Promo Codes', href: '/promos', icon: Gift },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -250,6 +250,9 @@ export const promotionsApi = {
|
|||||||
delete: (id: string) => apiClient.delete(`/promos/${id}`),
|
delete: (id: string) => apiClient.delete(`/promos/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { promosApi } from './promos';
|
||||||
|
export { usersApi } from './users';
|
||||||
|
|
||||||
// Support API
|
// Support API
|
||||||
export const supportApi = {
|
export const supportApi = {
|
||||||
getConversations: async (params?: any) => {
|
getConversations: async (params?: any) => {
|
||||||
|
|||||||
54
apps/edr-passenger-web/backoffice/src/lib/api/promos.ts
Normal file
54
apps/edr-passenger-web/backoffice/src/lib/api/promos.ts
Normal file
@@ -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<PromoCode>(`/promos/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
create: (data: Omit<PromoCode, 'id' | 'createdAt' | 'updatedAt' | 'usageCount'>) => {
|
||||||
|
return apiClient.post<PromoCode>('/promos', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<PromoCode>) => {
|
||||||
|
return apiClient.patch<PromoCode>(`/promos/${id}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: (id: string) => {
|
||||||
|
return apiClient.delete<void>(`/promos/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
validate: (code: string, bookingAmount?: number) => {
|
||||||
|
return apiClient.post<{ valid: boolean; message?: string; discount?: number }>('/promos/validate', {
|
||||||
|
code,
|
||||||
|
bookingAmount,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
59
apps/edr-passenger-web/backoffice/src/lib/api/users.ts
Normal file
59
apps/edr-passenger-web/backoffice/src/lib/api/users.ts
Normal file
@@ -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<any>(`/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<BackofficeUser>(`/auth/users/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
create: (data: { email: string; fullName: string; role: string; password: string }) => {
|
||||||
|
return apiClient.post<BackofficeUser>('/auth/users', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<BackofficeUser>) => {
|
||||||
|
return apiClient.patch<BackofficeUser>(`/auth/users/${id}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: (id: string) => {
|
||||||
|
return apiClient.delete<void>(`/auth/users/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetPassword: (id: string, tempPassword: string) => {
|
||||||
|
return apiClient.post<{ success: boolean; message: string }>(`/auth/users/${id}/reset-password`, {
|
||||||
|
tempPassword,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -5,9 +5,9 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useBookingStore } from '@/lib/booking-store';
|
import { useBookingStore } from '@/lib/booking-store';
|
||||||
import { Schedule } from '@/types';
|
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 { format } from 'date-fns';
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
export default function ResultsPage() {
|
export default function ResultsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -15,6 +15,7 @@ export default function ResultsPage() {
|
|||||||
const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule);
|
const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule);
|
||||||
const [selectedClasses, setSelectedClasses] = useState<Record<string, string>>({});
|
const [selectedClasses, setSelectedClasses] = useState<Record<string, string>>({});
|
||||||
const [expandedSchedules, setExpandedSchedules] = useState<Record<string, boolean>>({});
|
const [expandedSchedules, setExpandedSchedules] = useState<Record<string, boolean>>({});
|
||||||
|
const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
|
||||||
|
|
||||||
const searchData = {
|
const searchData = {
|
||||||
originStationId: searchParams.get('origin') || '',
|
originStationId: searchParams.get('origin') || '',
|
||||||
@@ -23,8 +24,28 @@ export default function ResultsPage() {
|
|||||||
adultCount: parseInt(searchParams.get('adults') || '1'),
|
adultCount: parseInt(searchParams.get('adults') || '1'),
|
||||||
childCount: parseInt(searchParams.get('children') || '0'),
|
childCount: parseInt(searchParams.get('children') || '0'),
|
||||||
nationality: searchParams.get('nationality') || 'ETHIOPIAN',
|
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 buildSearchUrl = () => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
origin: searchData.originStationId,
|
origin: searchData.originStationId,
|
||||||
@@ -44,6 +65,9 @@ export default function ResultsPage() {
|
|||||||
const response = await apiClient.post('/search', searchData) as Schedule[];
|
const response = await apiClient.post('/search', searchData) as Schedule[];
|
||||||
console.log('Search results:', response);
|
console.log('Search results:', response);
|
||||||
console.log('Number of results:', response?.length || 0);
|
console.log('Number of results:', response?.length || 0);
|
||||||
|
if (response?.length > 0) {
|
||||||
|
console.log('First schedule availabilityByClass:', response[0].availabilityByClass);
|
||||||
|
}
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
enabled: !!searchData.originStationId && !!searchData.destinationStationId,
|
enabled: !!searchData.originStationId && !!searchData.destinationStationId,
|
||||||
@@ -156,6 +180,21 @@ export default function ResultsPage() {
|
|||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
|
{/* Promo Notification */}
|
||||||
|
{promoData && (
|
||||||
|
<div className="mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex items-start gap-3">
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
|
<Check className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-green-900 dark:text-green-200">Promo code applied!</h3>
|
||||||
|
<p className="text-sm text-green-800 dark:text-green-300 mt-1">
|
||||||
|
<span className="font-mono font-bold">{promoData.code}</span> - {promoData.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push(buildSearchUrl())}
|
onClick={() => router.push(buildSearchUrl())}
|
||||||
@@ -174,6 +213,12 @@ export default function ResultsPage() {
|
|||||||
<Users className="w-4 h-4" />
|
<Users className="w-4 h-4" />
|
||||||
<span>{searchData.adultCount} adult(s), {searchData.childCount} child(ren)</span>
|
<span>{searchData.adultCount} adult(s), {searchData.childCount} child(ren)</span>
|
||||||
</div>
|
</div>
|
||||||
|
{searchData.promoCode && (
|
||||||
|
<div className="flex items-center gap-2 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 px-3 py-1 rounded-full text-sm">
|
||||||
|
<Gift className="w-4 h-4" />
|
||||||
|
<span>{searchData.promoCode}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -287,6 +332,7 @@ export default function ResultsPage() {
|
|||||||
const isSelected = selectedClass === fareClass.seatClassName;
|
const isSelected = selectedClass === fareClass.seatClassName;
|
||||||
const availableSeats = schedule.availabilityByClass?.[fareClass.seatClassName] || 0;
|
const availableSeats = schedule.availabilityByClass?.[fareClass.seatClassName] || 0;
|
||||||
const isAvailable = availableSeats > 0;
|
const isAvailable = availableSeats > 0;
|
||||||
|
const isBedClass = fareClass.seatClassName.toLowerCase().includes('bed');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -315,7 +361,7 @@ export default function ResultsPage() {
|
|||||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
{isAvailable ? (
|
{isAvailable ? (
|
||||||
<span className="text-green-600 dark:text-green-400 font-medium">
|
<span className="text-green-600 dark:text-green-400 font-medium">
|
||||||
{availableSeats} seat{availableSeats !== 1 ? 's' : ''} available
|
{availableSeats} {isBedClass ? 'bed' : 'seat'}{availableSeats !== 1 ? 's' : ''} available
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-red-600 dark:text-red-400 font-medium">Sold out</span>
|
<span className="text-red-600 dark:text-red-400 font-medium">Sold out</span>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useAuthStore } from '@/lib/auth-store';
|
|||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useBookingStore } from '@/lib/booking-store';
|
import { useBookingStore } from '@/lib/booking-store';
|
||||||
import { Station } from '@/types';
|
import { Station } from '@/types';
|
||||||
import { Train, MapPin, ArrowRight, Plus, Minus, Search, Users, ChevronDown } from 'lucide-react';
|
import { Train, MapPin, ArrowRight, Plus, Minus, Search, Users, ChevronDown, Gift, Check } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import ModernDatePicker from '@/components/ModernDatePicker';
|
import ModernDatePicker from '@/components/ModernDatePicker';
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ const searchSchema = z.object({
|
|||||||
adultCount: z.number().min(1).max(9),
|
adultCount: z.number().min(1).max(9),
|
||||||
childCount: z.number().min(0).max(9),
|
childCount: z.number().min(0).max(9),
|
||||||
nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']),
|
nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']),
|
||||||
|
promoCode: z.string().optional(),
|
||||||
}).refine((data) => data.originStationId !== data.destinationStationId, {
|
}).refine((data) => data.originStationId !== data.destinationStationId, {
|
||||||
message: 'Origin and destination must be different',
|
message: 'Origin and destination must be different',
|
||||||
path: ['destinationStationId'],
|
path: ['destinationStationId'],
|
||||||
@@ -33,6 +34,9 @@ export default function SearchPage() {
|
|||||||
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
|
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
|
||||||
const { user, isAuthenticated } = useAuthStore();
|
const { user, isAuthenticated } = useAuthStore();
|
||||||
const [isPassengerOpen, setIsPassengerOpen] = useState(false);
|
const [isPassengerOpen, setIsPassengerOpen] = useState(false);
|
||||||
|
const [promoCode, setPromoCode] = useState('');
|
||||||
|
const [promoValidation, setPromoValidation] = useState<{ valid: boolean; message: string; discount?: string } | null>(null);
|
||||||
|
const [promoLoading, setPromoLoading] = useState(false);
|
||||||
|
|
||||||
const { data: stations, isLoading, error } = useQuery<Station[]>({
|
const { data: stations, isLoading, error } = useQuery<Station[]>({
|
||||||
queryKey: ['stations'],
|
queryKey: ['stations'],
|
||||||
@@ -49,6 +53,7 @@ export default function SearchPage() {
|
|||||||
childCount: 0,
|
childCount: 0,
|
||||||
nationality: 'ETHIOPIAN',
|
nationality: 'ETHIOPIAN',
|
||||||
departureDate: new Date().toISOString().split('T')[0],
|
departureDate: new Date().toISOString().split('T')[0],
|
||||||
|
promoCode: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,6 +90,36 @@ export default function SearchPage() {
|
|||||||
const adultCount = watch('adultCount');
|
const adultCount = watch('adultCount');
|
||||||
const childCount = watch('childCount');
|
const childCount = watch('childCount');
|
||||||
|
|
||||||
|
const handleValidatePromo = async () => {
|
||||||
|
if (!promoCode.trim()) {
|
||||||
|
setPromoValidation(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPromoLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/promos/validate', { code: promoCode }) as any;
|
||||||
|
setPromoValidation({
|
||||||
|
valid: response.applicable || response.valid,
|
||||||
|
message: response.message || (response.applicable ? 'Promo code applied successfully!' : 'Invalid promo code'),
|
||||||
|
discount: response.message,
|
||||||
|
});
|
||||||
|
if (response.applicable || response.valid) {
|
||||||
|
setValue('promoCode', promoCode);
|
||||||
|
} else {
|
||||||
|
setPromoCode('');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setPromoValidation({
|
||||||
|
valid: false,
|
||||||
|
message: err?.response?.data?.message || 'Promo code is invalid or expired',
|
||||||
|
});
|
||||||
|
setPromoCode('');
|
||||||
|
} finally {
|
||||||
|
setPromoLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = (data: SearchForm) => {
|
const onSubmit = (data: SearchForm) => {
|
||||||
setSearchCriteria(data);
|
setSearchCriteria(data);
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -94,6 +129,7 @@ export default function SearchPage() {
|
|||||||
adults: data.adultCount.toString(),
|
adults: data.adultCount.toString(),
|
||||||
children: data.childCount.toString(),
|
children: data.childCount.toString(),
|
||||||
nationality: data.nationality,
|
nationality: data.nationality,
|
||||||
|
...(data.promoCode && { promoCode: data.promoCode }),
|
||||||
});
|
});
|
||||||
router.push(`/booking/results?${params}`);
|
router.push(`/booking/results?${params}`);
|
||||||
};
|
};
|
||||||
@@ -122,8 +158,6 @@ export default function SearchPage() {
|
|||||||
{ from: 'Diredawa', to: 'Nagad', duration: '4h' },
|
{ from: 'Diredawa', to: 'Nagad', duration: '4h' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
{/* Search Section */}
|
{/* Search Section */}
|
||||||
@@ -215,7 +249,7 @@ export default function SearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Second Row: Passengers, Nationality, Promo Code */}
|
{/* Second Row: Passengers, Nationality */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end mb-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end mb-4">
|
||||||
{/* Passengers Dropdown */}
|
{/* Passengers Dropdown */}
|
||||||
<div className="space-y-2 relative z-20">
|
<div className="space-y-2 relative z-20">
|
||||||
@@ -321,14 +355,39 @@ export default function SearchPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Promo Code */}
|
{/* Promo Code with Validation */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Promo Code (Optional)</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Promo Code (Optional)</label>
|
||||||
<input
|
<div className="flex gap-2">
|
||||||
type="text"
|
<div className="flex-1 relative">
|
||||||
placeholder="Enter promo code"
|
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
|
||||||
className="w-full px-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"
|
<input
|
||||||
/>
|
type="text"
|
||||||
|
value={promoCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
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()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleValidatePromo}
|
||||||
|
disabled={!promoCode || promoLoading}
|
||||||
|
className="px-4 py-3.5 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
||||||
|
>
|
||||||
|
{promoLoading ? '...' : 'Apply'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{promoValidation && (
|
||||||
|
<div className={`flex items-center gap-2 text-sm ${promoValidation.valid ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||||
|
{promoValidation.valid && <Check className="w-4 h-4" />}
|
||||||
|
<span>{promoValidation.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,18 +11,17 @@ import { Armchair, Bed, ChevronLeft } from 'lucide-react';
|
|||||||
|
|
||||||
import CustomModal from '@/components/CustomModal';
|
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 seatLabel = seat.number || seat.label || seat.seatNumber || '?';
|
||||||
|
const bedWidth = 'w-24';
|
||||||
|
const width = isBedCoach ? bedWidth : 'w-10';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className="text-xs font-bold mb-0.5 text-gray-900 dark:text-gray-100">
|
|
||||||
{seatLabel}{bedLabel}
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggle(seat.id)}
|
onClick={() => onToggle(seat.id)}
|
||||||
disabled={seat.status !== 'AVAILABLE'}
|
disabled={seat.status !== 'AVAILABLE'}
|
||||||
className={`w-11 h-11 rounded flex items-center justify-center transition-all ${
|
className={`${width} h-11 rounded flex items-center justify-center transition-all ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-[rgb(20_113_76)] text-white shadow-md scale-105'
|
? 'bg-[rgb(20_113_76)] text-white shadow-md scale-105'
|
||||||
: seat.status === 'AVAILABLE'
|
: seat.status === 'AVAILABLE'
|
||||||
@@ -31,12 +30,13 @@ const SeatButton = memo(({ seat, isSelected, onToggle, isBedCoach, bedLabel }: a
|
|||||||
? 'bg-yellow-500 text-white cursor-not-allowed opacity-75'
|
? 'bg-yellow-500 text-white cursor-not-allowed opacity-75'
|
||||||
: 'bg-gray-500 text-white cursor-not-allowed opacity-60'
|
: 'bg-gray-500 text-white cursor-not-allowed opacity-60'
|
||||||
}`}
|
}`}
|
||||||
title={`Seat ${seatLabel}${bedLabel} - ${seat.status}`}
|
title={`Seat ${seatLabel}${bedLabel} - ${seat.status} - ${coachSeatClass}`}
|
||||||
|
style={isBedCoach ? (seat.row % 2 === 1 ? { transform: 'scaleY(-1)' } : undefined) : (seat.row % 2 === 0 ? { transform: 'scaleY(-1)' } : undefined)}
|
||||||
>
|
>
|
||||||
{isBedCoach ? (
|
{isBedCoach ? (
|
||||||
<Bed className="w-7 h-7" style={seat.bedFlip ? { transform: 'scaleY(-1)' } : undefined} />
|
<Bed className="w-7 h-7" />
|
||||||
) : (
|
) : (
|
||||||
<Armchair className="w-7 h-7" style={seat.armchairFlip ? { transform: 'scaleY(-1)' } : undefined} />
|
<Armchair className="w-7 h-7" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,14 +85,13 @@ export default function SeatsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutation to book seats permanently (called after payment)
|
|
||||||
const bookSeatsMutation = useMutation({
|
const bookSeatsMutation = useMutation({
|
||||||
mutationFn: async (seatIds: string[]) => {
|
mutationFn: async (seatIds: string[]) => {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
seatIds.map((seatId) =>
|
seatIds.map((seatId) =>
|
||||||
apiClient.patch(`/seats/${seatId}`, {
|
apiClient.patch(`/seats/${seatId}`, {
|
||||||
status: 'BOOKED',
|
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 coaches = useMemo(() => (seatMapData as any)?.coaches || [], [seatMapData]);
|
||||||
|
|
||||||
const filteredCoaches = useMemo(() => {
|
const filteredCoaches = useMemo(() => {
|
||||||
let filtered = selectedSchedule?.selectedSeatClass
|
if (!selectedSchedule?.selectedSeatClass) {
|
||||||
? coaches.filter((c: any) => {
|
return coaches.filter((c: any) => c.seats && c.seats.length > 0);
|
||||||
const seatClassName = typeof c.seatClass === 'string' ? c.seatClass : (c.seatClass?.name || c.coachClass || '');
|
}
|
||||||
return seatClassName === selectedSchedule.selectedSeatClass ||
|
|
||||||
seatClassName.replace(/_/g, ' ').toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase() ||
|
let filtered = coaches.filter((c: any) => {
|
||||||
seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase();
|
const seatClasses = c.seatClasses || [c.seatClass] || [];
|
||||||
})
|
return seatClasses.some((seatClassName: string) =>
|
||||||
: coaches;
|
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);
|
return filtered.filter((c: any) => c.seats && c.seats.length > 0);
|
||||||
}, [coaches, selectedSchedule?.selectedSeatClass]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (filteredCoaches && filteredCoaches.length > 0 && !selectedCoach) {
|
if (filteredCoaches.length > 0 && !selectedCoach) {
|
||||||
setSelectedCoach(filteredCoaches[0].id);
|
setSelectedCoach(filteredCoaches[0].id);
|
||||||
}
|
}
|
||||||
}, [filteredCoaches, selectedCoach]);
|
}, [filteredCoaches, selectedCoach]);
|
||||||
|
|
||||||
const toggleSeat = useCallback((seatId: string) => {
|
const selectedCoachData = useMemo(() => filteredCoaches.find((c: any) => c.id === selectedCoach), [filteredCoaches, selectedCoach]);
|
||||||
setSelectedSeats(prev => {
|
const allSeats = useMemo(() => selectedCoachData?.seats || [], [selectedCoachData]);
|
||||||
if (prev.includes(seatId)) {
|
|
||||||
return prev.filter(id => id !== seatId);
|
const getBedPosition = (selectedClass: string): string | null => {
|
||||||
} else if (prev.length < passengers.length) {
|
const lowerClass = selectedClass.toLowerCase();
|
||||||
return [...prev, seatId];
|
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]);
|
}, [passengers.length]);
|
||||||
|
|
||||||
@@ -197,7 +220,6 @@ export default function SeatsPage() {
|
|||||||
}
|
}
|
||||||
}, [selectedSchedule, passengers.length, router]);
|
}, [selectedSchedule, passengers.length, router]);
|
||||||
|
|
||||||
// Auto-book seats when booking is confirmed (after payment)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bookingId && selectedSeats.length > 0) {
|
if (bookingId && selectedSeats.length > 0) {
|
||||||
bookSeatsMutation.mutate(selectedSeats);
|
bookSeatsMutation.mutate(selectedSeats);
|
||||||
@@ -221,11 +243,55 @@ export default function SeatsPage() {
|
|||||||
const arrangement = parseSeatArrangement(coach.seatArrangement);
|
const arrangement = parseSeatArrangement(coach.seatArrangement);
|
||||||
const leftCount = arrangement[0];
|
const leftCount = arrangement[0];
|
||||||
|
|
||||||
|
if (validSeats.length === 0) {
|
||||||
|
return <div className="text-xs text-muted-foreground">No seats</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
|
||||||
|
const seatClassStr = typeof selectedCoachData?.seatClass === 'string' ? selectedCoachData.seatClass : (selectedCoachData?.seatClass?.name || '');
|
||||||
|
|
||||||
|
if (isBedCoach && hasBedPositionData) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 w-40">
|
||||||
|
{validSeats.map((seat: any) => {
|
||||||
|
const rowNumber = seat.row || 1;
|
||||||
|
const shouldFlipIcon = rowNumber % 2 === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={seat.id}>
|
||||||
|
{shouldFlipIcon && (
|
||||||
|
<div className="w-24 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
||||||
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex">
|
||||||
|
<SeatButton
|
||||||
|
key={seat.id}
|
||||||
|
seat={seat}
|
||||||
|
isSelected={selectedSeats.includes(seat.id)}
|
||||||
|
onToggle={handleSeatClick}
|
||||||
|
isBedCoach={true}
|
||||||
|
bedLabel={getBedLabel(seat.bedPosition)}
|
||||||
|
coachSeatClass={seatClassStr}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!shouldFlipIcon && (
|
||||||
|
<div className="w-24 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
||||||
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const rows = [];
|
const rows = [];
|
||||||
const processedRows = new Set();
|
const processedRows = new Set();
|
||||||
for (const seat of allSeats) {
|
for (const seat of validSeats) {
|
||||||
if (!processedRows.has(seat.row)) {
|
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 colA = a.col.charCodeAt(0);
|
||||||
const colB = b.col.charCodeAt(0);
|
const colB = b.col.charCodeAt(0);
|
||||||
return colA - colB;
|
return colA - colB;
|
||||||
@@ -240,17 +306,17 @@ export default function SeatsPage() {
|
|||||||
const leftSeats = rowSeats.slice(0, leftCount);
|
const leftSeats = rowSeats.slice(0, leftCount);
|
||||||
const rightSeats = rowSeats.slice(leftCount);
|
const rightSeats = rowSeats.slice(leftCount);
|
||||||
const rowNumber = rowSeats[0]?.row || 1;
|
const rowNumber = rowSeats[0]?.row || 1;
|
||||||
const shouldFlipIcon = rowNumber % 2 === 0;
|
const shouldFlipArmchair = rowNumber % 2 === 0;
|
||||||
const showSpacing = rowIdx % 2 === 1;
|
const showSpacing = rowIdx % 2 === 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`row-${rowSeats[0]?.id}`}>
|
<div key={`row-${rowSeats[0]?.id}`}>
|
||||||
{shouldFlipIcon && (
|
{shouldFlipArmchair && (
|
||||||
<div className="flex gap-0.5 justify-center text-xs text-muted-foreground mb-1">
|
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
{leftSeats.map((seat: any) => (
|
{leftSeats.map((seat: any) => (
|
||||||
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
|
<div key={`num-before-left-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
||||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -258,58 +324,50 @@ export default function SeatsPage() {
|
|||||||
{rightSeats.length > 0 && (
|
{rightSeats.length > 0 && (
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
{rightSeats.map((seat: any) => (
|
{rightSeats.map((seat: any) => (
|
||||||
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
|
<div key={`num-before-right-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
||||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-0.5 justify-center">
|
<div className="flex gap-0.5 justify-start">
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
{leftSeats.map((seat: any) => (
|
{leftSeats.map((seat: any) => (
|
||||||
seat.seatNumber && !seat.seatNumber.startsWith('-') ? (
|
<SeatButton
|
||||||
<SeatButton
|
key={seat.id}
|
||||||
key={seat.id}
|
seat={seat}
|
||||||
seat={seat}
|
isSelected={selectedSeats.includes(seat.id)}
|
||||||
isSelected={selectedSeats.includes(seat.id)}
|
onToggle={handleSeatClick}
|
||||||
onToggle={toggleSeat}
|
isBedCoach={false}
|
||||||
isBedCoach={isBedCoach}
|
bedLabel=""
|
||||||
bedLabel={getBedLabel(seat.bedPosition)}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div key={seat.id} className="w-11 h-11" />
|
|
||||||
)
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{rightSeats.length > 0 && <div className="w-3" />}
|
{rightSeats.length > 0 && <div className="w-3" />}
|
||||||
{rightSeats.length > 0 && (
|
{rightSeats.length > 0 && (
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
{rightSeats.map((seat: any) => (
|
{rightSeats.map((seat: any) => (
|
||||||
seat.seatNumber && !seat.seatNumber.startsWith('-') ? (
|
<SeatButton
|
||||||
<SeatButton
|
key={seat.id}
|
||||||
key={seat.id}
|
seat={seat}
|
||||||
seat={seat}
|
isSelected={selectedSeats.includes(seat.id)}
|
||||||
isSelected={selectedSeats.includes(seat.id)}
|
onToggle={handleSeatClick}
|
||||||
onToggle={toggleSeat}
|
isBedCoach={false}
|
||||||
isBedCoach={isBedCoach}
|
bedLabel=""
|
||||||
bedLabel={getBedLabel(seat.bedPosition)}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div key={seat.id} className="w-11 h-11" />
|
|
||||||
)
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!shouldFlipIcon && (
|
{!shouldFlipArmchair && (
|
||||||
<div className="flex gap-0.5 justify-center text-xs text-muted-foreground mb-1">
|
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
{leftSeats.map((seat: any) => (
|
{leftSeats.map((seat: any) => (
|
||||||
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
|
<div key={`num-left-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
||||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -317,8 +375,8 @@ export default function SeatsPage() {
|
|||||||
{rightSeats.length > 0 && (
|
{rightSeats.length > 0 && (
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
{rightSeats.map((seat: any) => (
|
{rightSeats.map((seat: any) => (
|
||||||
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
|
<div key={`num-right-${seat.id}`} className="w-10 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
||||||
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -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;
|
if (!selectedSchedule || !passengers.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -362,64 +418,74 @@ export default function SeatsPage() {
|
|||||||
|
|
||||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Select seats</h1>
|
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Select seats</h1>
|
||||||
|
|
||||||
<div className="grid lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 mb-4">
|
{isLoading ? (
|
||||||
<h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select coach</h3>
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||||
{selectedSchedule?.selectedSeatClassName && (
|
|
||||||
<div className="mb-3 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Showing coaches for: <span className="font-semibold text-[rgb(20_113_76)]">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
|
||||||
{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 (
|
|
||||||
<button
|
|
||||||
key={coach.id}
|
|
||||||
onClick={() => setSelectedCoach(coach.id)}
|
|
||||||
className={`px-4 py-2 rounded whitespace-nowrap transition-all ${
|
|
||||||
selectedCoach === coach.id
|
|
||||||
? 'bg-[rgb(20_113_76)] text-white shadow-lg'
|
|
||||||
: 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="font-semibold">{coach.label || coach.name || coach.coachNumber}</div>
|
|
||||||
<div className="text-xs opacity-75">{seatClassName}</div>
|
|
||||||
<div className="text-xs opacity-75">{availableCount} available</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
|
||||||
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
|
||||||
Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}
|
|
||||||
</h3>
|
|
||||||
{selectedCoachData && (
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
|
||||||
Arrangement: {selectedCoachData.seatArrangement} • Total: {selectedCoachData.totalSeats} seats
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
<p>Loading seats...</p>
|
<p>Loading seats...</p>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||||
<div className="text-center py-8 text-red-500 dark:text-red-400">
|
<div className="text-center py-8 text-red-500 dark:text-red-400">
|
||||||
<p>Error loading seats</p>
|
<p>Error loading seats</p>
|
||||||
<p className="text-sm mt-2">{error?.message || 'Please try again'}</p>
|
<p className="text-sm mt-2">{error?.message || 'Please try again'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : validSeats.length === 0 ? (
|
</div>
|
||||||
|
) : filteredCoaches.length === 0 ? (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
<p>No seats available in this coach</p>
|
<p>No coaches available for {selectedSchedule?.selectedSeatClass}</p>
|
||||||
<p className="text-sm mt-2">Please select a different coach</p>
|
<p className="text-sm mt-2">Please select a different seat class</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<>
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||||
|
<h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select coach</h3>
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={coach.id}
|
||||||
|
onClick={() => setSelectedCoach(coach.id)}
|
||||||
|
className={`px-4 py-2 rounded transition-all text-left ${
|
||||||
|
selectedCoach === coach.id
|
||||||
|
? 'bg-[rgb(20_113_76)] text-white shadow-lg'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-semibold">{coach.label || coach.name || coach.coachNumber}</div>
|
||||||
|
<div className="text-xs opacity-75">{seatClassName}</div>
|
||||||
|
<div className="text-xs opacity-75">{availableCount} available</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||||
|
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
||||||
|
Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}
|
||||||
|
</h3>
|
||||||
|
{selectedCoachData && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
Arrangement: {selectedCoachData.seatArrangement} • Total: {selectedCoachData.totalSeats} seats
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 mb-6 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-sm">
|
<div className="flex flex-wrap gap-4 mb-6 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-4 h-4 bg-green-500 rounded"></div>
|
<div className="w-4 h-4 bg-green-500 rounded"></div>
|
||||||
@@ -439,16 +505,23 @@ export default function SeatsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/30 p-6 rounded-lg overflow-x-auto">
|
<div className="bg-gray-50 dark:bg-gray-700/30 p-6 rounded-lg overflow-x-auto border border-gray-200 dark:border-gray-700 w-fit">
|
||||||
{renderCoachSeats(selectedCoachData, isBedCoach)}
|
{validSeats.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<p>No seats available in this coach</p>
|
||||||
|
<p className="text-sm mt-2">Please select a different coach</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
renderCoachSeats(selectedCoachData, (selectedCoachData.seatClass?.toLowerCase().includes('bed') || selectedCoachData.mode?.toLowerCase().includes('bed')))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="lg:col-span-1">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 sticky top-4">
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 sticky top-6">
|
||||||
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Selection summary</h3>
|
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Selection summary</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
Select {passengers.length} seat(s) for your passengers
|
Select {passengers.length} seat(s) for your passengers
|
||||||
|
|||||||
@@ -526,7 +526,7 @@ export default function ProfilePage() {
|
|||||||
value={settings.preferredOrigin}
|
value={settings.preferredOrigin}
|
||||||
onChange={(e) => setSettings({ ...settings, preferredOrigin: e.target.value })}
|
onChange={(e) => setSettings({ ...settings, preferredOrigin: e.target.value })}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
placeholder="e.g., Addis Ababa"
|
placeholder="e.g., Lebu"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user