From d9ae1c7f764e12150fde93a62df7503e3769b738 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 4 Jun 2026 08:19:36 +0300 Subject: [PATCH] Passenger apss UI and UX updates --- .../modules/bookings/bookings.controller.ts | 33 +++ .../src/modules/bookings/bookings.service.ts | 54 ++++ .../src/modules/fleet/fleet.controller.ts | 8 + .../src/modules/fleet/fleet.service.ts | 6 + .../passengers/passengers.controller.ts | 35 ++- .../modules/passengers/passengers.service.ts | 48 ++++ .../src/modules/tickets/tickets.controller.ts | 27 +- .../src/modules/tickets/tickets.service.ts | 54 ++++ .../backoffice/src/app/bookings/page.tsx | 225 ++++++++++++++++- .../backoffice/src/app/coaches/page.tsx | 25 +- .../backoffice/src/app/login/page.tsx | 2 +- .../backoffice/src/app/passengers/page.tsx | 231 +++++++++++++++++- .../backoffice/src/app/routes/page.tsx | 31 ++- .../backoffice/src/app/seat-classes/page.tsx | 27 +- .../backoffice/src/app/stations/page.tsx | 31 ++- .../backoffice/src/app/tickets/page.tsx | 95 +++++-- .../backoffice/src/app/trains/page.tsx | 53 ++++ .../src/components/layout/Sidebar.tsx | 3 +- .../src/components/ui/ConfirmDialog.tsx | 65 +++++ .../backoffice/src/components/ui/Modal.tsx | 8 +- .../backoffice/src/lib/api/index.ts | 4 + .../backoffice/src/lib/utils.ts | 21 +- .../src/app/booking/auth-check/page.tsx | 10 +- .../src/app/booking/confirmation/page.tsx | 12 +- .../src/app/booking/passengers/page.tsx | 6 +- .../portal/src/app/booking/payment/page.tsx | 16 +- .../portal/src/app/booking/results/page.tsx | 16 +- .../portal/src/app/booking/review/page.tsx | 10 +- .../portal/src/app/booking/seats/page.tsx | 12 +- .../portal/src/app/guide/page.tsx | 4 +- .../portal/src/app/layout.tsx | 2 +- .../portal/src/app/login/page.tsx | 6 +- .../portal/src/components/AppHeader.tsx | 2 +- .../src/components/ProgressIndicator.tsx | 2 +- 34 files changed, 1071 insertions(+), 113 deletions(-) create mode 100644 apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx 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/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 05cceeaf6..8630aecdd 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 @@ -87,14 +87,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}

@@ -161,7 +161,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')}`; @@ -263,7 +263,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 e24d971c9..4cf123068 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,7 +343,7 @@ export default function PassengersPage() {
-

Passenger Details

+

Passenger details

{fields.map((field, index) => { @@ -401,7 +401,7 @@ export default function PassengersPage() { onClick={() => toggleForm(index)} className="btn-primary" > - Enter Details Manually + Enter details manually
) : ( @@ -655,7 +655,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..e74c5b550 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 @@ -161,9 +161,9 @@ export default function PaymentPage() {
-

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..c3054f23b 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

@@ -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 ? ( ) : ( 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..55882a914 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 @@ -329,7 +329,7 @@ export default function ReviewPage() {
-

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/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index 4b3b01143..d48bb6e2b 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 @@ -173,12 +173,12 @@ export default function SeatsPage() {
-

Select Seats

+

Select seats

-

Select Coach

+

Select coach

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

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

+

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

{isLoading ? (

Loading seats...

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

Selection Summary

+

Selection summary

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

@@ -293,14 +293,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/guide/page.tsx b/apps/edr-passenger-web/portal/src/app/guide/page.tsx index 5e48615ea..3297d1389 100644 --- a/apps/edr-passenger-web/portal/src/app/guide/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/guide/page.tsx @@ -88,7 +88,7 @@ export default function HowToGuidePage() {
-

3. Enter Passenger Details

+

3. Enter passenger details

You can sign in for a faster experience or continue as a guest.

@@ -113,7 +113,7 @@ export default function HowToGuidePage() {
-

4. Select Seats

+

4. Select seats

Choose your preferred seats from the interactive seat map. Available seats are shown in green.

diff --git a/apps/edr-passenger-web/portal/src/app/layout.tsx b/apps/edr-passenger-web/portal/src/app/layout.tsx index 04def853f..0deb26481 100644 --- a/apps/edr-passenger-web/portal/src/app/layout.tsx +++ b/apps/edr-passenger-web/portal/src/app/layout.tsx @@ -4,7 +4,7 @@ import { Providers } from './providers'; import AppHeader from '@/components/AppHeader'; export const metadata: Metadata = { - title: 'EDR Passenger Portal - Book Your Train Journey', + title: 'EDR Passenger Portal - Book your train journey', description: 'Book train tickets on the Ethio-Djibouti Railway', }; diff --git a/apps/edr-passenger-web/portal/src/app/login/page.tsx b/apps/edr-passenger-web/portal/src/app/login/page.tsx index c1246287d..b387d9e41 100644 --- a/apps/edr-passenger-web/portal/src/app/login/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/login/page.tsx @@ -47,8 +47,8 @@ function LoginContent() {
-

Sign In

-

Welcome back to EDR Platform

+

Sign in

+

Welcome back

@@ -86,7 +86,7 @@ function LoginContent() {
diff --git a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx index 145b5daca..48b4042e7 100644 --- a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx @@ -38,7 +38,7 @@ export default function AppHeader() { diff --git a/apps/edr-passenger-web/portal/src/components/ProgressIndicator.tsx b/apps/edr-passenger-web/portal/src/components/ProgressIndicator.tsx index 218ae3034..21bce57be 100644 --- a/apps/edr-passenger-web/portal/src/components/ProgressIndicator.tsx +++ b/apps/edr-passenger-web/portal/src/components/ProgressIndicator.tsx @@ -15,7 +15,7 @@ const steps: Step[] = [ { id: 'seats', name: 'Seats', href: '/booking/seats' }, { id: 'review', name: 'Review', href: '/booking/review' }, { id: 'payment', name: 'Payment', href: '/booking/payment' }, - { id: 'confirmation', name: 'Done', href: '/booking/confirmation' }, + { id: 'confirmation', name: 'Confirmation', href: '/booking/confirmation' }, ]; interface ProgressIndicatorProps {