mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 13:10:56 +00:00
Passenger apss UI and UX updates
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<any>(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [bookingToDelete, setBookingToDelete] = useState<any>(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() {
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{successMessage && (
|
||||
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">
|
||||
✓ {successMessage}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
|
||||
Error loading bookings: {error instanceof Error ? error.message : 'Unknown error'}
|
||||
@@ -166,6 +212,163 @@ export default function BookingsPage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Booking Details Modal */}
|
||||
<Modal
|
||||
isOpen={!!selectedBooking}
|
||||
onClose={() => setSelectedBooking(null)}
|
||||
title="Booking Details"
|
||||
size="xl"
|
||||
>
|
||||
{selectedBooking && (
|
||||
<div className="space-y-6">
|
||||
{/* Booking Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Booking Reference</label>
|
||||
<p className="text-lg font-semibold font-mono">{selectedBooking.bookingRef}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Status</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant="status" status={selectedBooking.status}>
|
||||
{selectedBooking.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Booking Type</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.bookingType || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Created</label>
|
||||
<p className="text-lg font-semibold">{formatDateTime(selectedBooking.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Passenger Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Passenger Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Name</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.passenger?.fullName || selectedBooking.contactEmail || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Email</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.contactEmail || selectedBooking.passenger?.email || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Phone</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.contactPhone || selectedBooking.passenger?.phone || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Passenger ID</label>
|
||||
<p className="text-sm font-mono">{selectedBooking.passengerId || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Booking Details */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Journey Details</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Adults</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.adultCount || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Children</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.childCount || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Schedule ID</label>
|
||||
<p className="text-sm font-mono">{selectedBooking.scheduleId || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Promo Code</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.promoCode || 'None'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Payment Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Payment Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Amount</label>
|
||||
<p className="text-lg font-semibold">{formatCurrency(selectedBooking.totalMinor, selectedBooking.currency)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Payment Status</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant="status" status={selectedBooking.paymentIntent?.status || 'PENDING'}>
|
||||
{selectedBooking.paymentIntent?.status || 'PENDING'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Paid At</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.paidAt ? formatDateTime(selectedBooking.paidAt) : 'Not paid'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Display Currency</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.displayCurrency || selectedBooking.currency}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Additional Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Additional Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Source</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.source || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Last Updated</label>
|
||||
<p className="text-lg font-semibold">{formatDateTime(selectedBooking.updatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => setSelectedBooking(null)}
|
||||
>
|
||||
Close
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirmOpen}
|
||||
onClose={() => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<any>(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() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => 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 */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function LoginPage() {
|
||||
disabled={loading}
|
||||
className="btn btn-primary w-full disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -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<any>(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() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => 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 */}
|
||||
<Modal
|
||||
isOpen={!!selectedPassenger}
|
||||
onClose={() => setSelectedPassenger(null)}
|
||||
title="Passenger Details"
|
||||
size="xl"
|
||||
>
|
||||
{selectedPassenger && (
|
||||
<div className="space-y-6">
|
||||
{/* Personal Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Personal Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Full Name</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.fullName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Date of Birth</label>
|
||||
<p className="text-lg font-semibold">
|
||||
{selectedPassenger.dateOfBirth ? formatDate(selectedPassenger.dateOfBirth) : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Gender</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.gender || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Nationality</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.nationality || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Contact Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Contact Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Email</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.email || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Phone</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.phone || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Identification */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Identification</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">National ID</label>
|
||||
<p className="text-lg font-mono font-semibold">{selectedPassenger.nationalId || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Passport Number</label>
|
||||
<p className="text-lg font-mono font-semibold">{selectedPassenger.passportNumber || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Passport Country</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.passportCountry || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Verification Status</label>
|
||||
<div className="mt-1">
|
||||
<Badge
|
||||
variant="status"
|
||||
status={selectedPassenger.nationalId ? 'CONFIRMED' : 'PENDING'}
|
||||
>
|
||||
{selectedPassenger.nationalId ? 'Verified' : 'Unverified'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Account Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Account Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Passenger ID</label>
|
||||
<p className="text-sm font-mono">{selectedPassenger.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">User ID</label>
|
||||
<p className="text-sm font-mono">{selectedPassenger.userId || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loyalty & Wallet (if available) */}
|
||||
{(selectedPassenger.loyalty || selectedPassenger.wallet) && (
|
||||
<>
|
||||
<hr className="border-muted" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{selectedPassenger.loyalty && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Loyalty Account</h3>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Tier</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.loyalty.tier || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Points Balance</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.loyalty.pointsBalance || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedPassenger.wallet && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Wallet</h3>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Balance</label>
|
||||
<p className="text-lg font-semibold">
|
||||
{(selectedPassenger.wallet.balanceMinor / 100).toFixed(2)} {selectedPassenger.wallet.currency}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Timestamps */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Timestamps</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Created</label>
|
||||
<p className="text-sm">{selectedPassenger.createdAt ? formatDateTime(selectedPassenger.createdAt) : 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Last Updated</label>
|
||||
<p className="text-sm">{selectedPassenger.updatedAt ? formatDateTime(selectedPassenger.updatedAt) : 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => setSelectedPassenger(null)}
|
||||
>
|
||||
Close
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<number | undefined>(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 */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => 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 */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
@@ -261,6 +280,12 @@ export default function RoutesPage() {
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
|
||||
{editingRoute && (
|
||||
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-3 text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<p className="font-semibold">⚠ Warning</p>
|
||||
<p className="mt-1">Editing this route may impact schedules, trips, and bookings that reference it. Proceed with caution.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Origin Station *</label>
|
||||
|
||||
@@ -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<any>(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() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Seat Classes</h1>
|
||||
<h1 className="text-2xl font-bold text-foreground">Classes</h1>
|
||||
<p className="text-muted-foreground">Manage seat class configurations</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -132,6 +139,18 @@ export default function SeatClassesPage() {
|
||||
emptyMessage="No seat classes found"
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => 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 */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
|
||||
@@ -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 { stationsApi } from '@/lib/api';
|
||||
import { Station } from '@/types';
|
||||
|
||||
@@ -14,6 +15,7 @@ export default function StationsPage() {
|
||||
const [filters, setFilters] = useState({ search: '', country: '', operational: '' });
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingStation, setEditingStation] = useState<any>(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 */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => 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 */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
@@ -234,6 +253,12 @@ export default function StationsPage() {
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{editingStation && (
|
||||
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-3 text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<p className="font-semibold">⚠ Warning</p>
|
||||
<p className="mt-1">Editing this station may impact routes, schedules, and bookings that reference it. Proceed with caution.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Station Code *</label>
|
||||
|
||||
@@ -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<any>(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 */}
|
||||
<div className="card">
|
||||
{successMessage && (
|
||||
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">
|
||||
✓ {successMessage}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
|
||||
Error loading tickets: {error instanceof Error ? error.message : 'Unknown error'}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="label">Search</label>
|
||||
@@ -191,6 +242,22 @@ export default function TicketsPage() {
|
||||
loading={isLoading}
|
||||
emptyMessage="No tickets found"
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirmOpen}
|
||||
onClose={() => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<TrainType | null>(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 */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => 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 */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
@@ -160,6 +207,12 @@ export default function TrainsPage() {
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{editingTrain && (
|
||||
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-3 text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<p className="font-semibold">⚠ Warning</p>
|
||||
<p className="mt-1">Editing this train may impact schedules and bookings that reference it. Proceed with caution.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Train Number *</label>
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
{isDanger && (
|
||||
<AlertCircle className="h-6 w-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<p className="text-foreground">{message}</p>
|
||||
</div>
|
||||
{warning && (
|
||||
<div className="rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 flex gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-semibold text-amber-900 dark:text-amber-200 text-sm">Warning</p>
|
||||
<p className="text-amber-800 dark:text-amber-300 text-sm mt-1">{warning}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton variant="secondary" onClick={onClose} disabled={isLoading}>
|
||||
{cancelText}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant={isDanger ? 'danger' : 'primary'}
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Processing...' : confirmText}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -35,8 +35,8 @@ export default function Modal({ isOpen, onClose, title, children, size = 'md' }:
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={onClose} />
|
||||
<div className={`relative w-full ${sizeClasses[size]} rounded-lg bg-background p-6 shadow-xl`}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className={`relative w-full ${sizeClasses[size]} rounded-lg bg-background shadow-xl flex flex-col max-h-[90vh]`}>
|
||||
<div className="sticky top-0 bg-background border-b border-muted px-6 py-4 flex items-center justify-between z-10">
|
||||
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -45,7 +45,9 @@ export default function Modal({ isOpen, onClose, title, children, size = 'md' }:
|
||||
<X className="h-5 w-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
<div className="overflow-y-auto flex-1 px-6 py-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<any>(`/bookings/${id}`),
|
||||
cancel: (id: string, data?: any) => apiClient.post<any>(`/bookings/${id}/cancel`, data),
|
||||
modify: (id: string, data: any) => apiClient.patch<any>(`/bookings/${id}`, data),
|
||||
checkUsage: (id: string) => apiClient.get<any>(`/bookings/${id}/usage`),
|
||||
};
|
||||
|
||||
// Passengers API
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function AuthCheckPage() {
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12 animate-fade-in">
|
||||
<h1 className="section-title">Continue Your Booking</h1>
|
||||
<h1 className="section-title">Continue your booking</h1>
|
||||
<p className="section-subtitle mt-2">
|
||||
Sign in to access saved profiles or continue as a guest
|
||||
</p>
|
||||
@@ -50,7 +50,7 @@ export default function AuthCheckPage() {
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-primary to-primary-700 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg group-hover:shadow-xl transition-shadow">
|
||||
<LogIn className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">Sign In</h2>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">Sign in</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6 text-balance">
|
||||
Access your saved passenger profiles and booking history for faster checkout
|
||||
</p>
|
||||
@@ -78,7 +78,7 @@ export default function AuthCheckPage() {
|
||||
</div>
|
||||
|
||||
<button className="btn-primary w-full">
|
||||
Sign In to Continue
|
||||
Sign in to continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,7 +92,7 @@ export default function AuthCheckPage() {
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-600 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg group-hover:shadow-xl transition-shadow">
|
||||
<UserPlus className="w-10 h-10 text-gray-700 dark:text-gray-300" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">Continue as Guest</h2>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">Continue as guest</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6 text-balance">
|
||||
Book without an account. You can create one after completing your booking
|
||||
</p>
|
||||
@@ -120,7 +120,7 @@ export default function AuthCheckPage() {
|
||||
</div>
|
||||
|
||||
<button className="btn-secondary w-full">
|
||||
Continue as Guest
|
||||
Continue as guest
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,14 +87,14 @@ export default function ConfirmationPage() {
|
||||
<CheckCircle className="w-12 h-12 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">Booking Confirmed!</h1>
|
||||
<h1 className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">Booking confirmed!</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-lg">Your train tickets are ready</p>
|
||||
</div>
|
||||
|
||||
{/* PNR Card */}
|
||||
<div className="card mb-6 bg-gradient-to-r from-primary to-primary-600 dark:from-primary-700 dark:to-primary-900 text-white">
|
||||
<div className="text-center">
|
||||
<p className="text-sm opacity-90 mb-2">Booking Reference (PNR)</p>
|
||||
<p className="text-sm opacity-90 mb-2">Booking reference (PNR)</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<span className="text-5xl font-bold tracking-widest">{pnr}</span>
|
||||
<button
|
||||
@@ -119,12 +119,12 @@ export default function ConfirmationPage() {
|
||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
|
||||
<Train className="w-6 h-6 text-primary dark:text-primary-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">Trip Details</h2>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">Trip details</h2>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Train Number</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Train number</p>
|
||||
<p className="font-semibold text-lg text-gray-900 dark:text-gray-100">{selectedSchedule?.trainNumber}</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -161,7 +161,7 @@ export default function ConfirmationPage() {
|
||||
|
||||
{/* Tickets */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Your Tickets</h2>
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Your tickets</h2>
|
||||
<div className="space-y-4">
|
||||
{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
|
||||
</button>
|
||||
|
||||
{/* Info Notices */}
|
||||
|
||||
@@ -343,7 +343,7 @@ export default function PassengersPage() {
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Passenger Details</h1>
|
||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Passenger details</h1>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{fields.map((field, index) => {
|
||||
@@ -401,7 +401,7 @@ export default function PassengersPage() {
|
||||
onClick={() => toggleForm(index)}
|
||||
className="btn-primary"
|
||||
>
|
||||
Enter Details Manually
|
||||
Enter details manually
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -655,7 +655,7 @@ export default function PassengersPage() {
|
||||
Back
|
||||
</button>
|
||||
<button type="submit" className="btn-primary flex-1" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Continue to Seat Selection'}
|
||||
{saving ? 'Saving...' : 'Continue to seat selection'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -161,9 +161,9 @@ export default function PaymentPage() {
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-2 text-gray-900 dark:text-gray-100">Complete Payment</h1>
|
||||
<h1 className="text-3xl font-bold mb-2 text-gray-900 dark:text-gray-100">Complete payment</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Booking Reference: <span className="font-bold text-primary">{pnr}</span>
|
||||
Booking reference: <span className="font-bold text-primary">{pnr}</span>
|
||||
</p>
|
||||
|
||||
{/* Payment Processing Overlay */}
|
||||
@@ -173,14 +173,14 @@ export default function PaymentPage() {
|
||||
{paymentMutation.isSuccess ? (
|
||||
<>
|
||||
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Payment Successful!</h3>
|
||||
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Payment successful!</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">Generating your tickets...</p>
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin mx-auto" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="w-16 h-16 text-primary animate-spin mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Processing Payment</h3>
|
||||
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Processing payment</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">Please wait while we process your payment...</p>
|
||||
</>
|
||||
)}
|
||||
@@ -190,7 +190,7 @@ export default function PaymentPage() {
|
||||
|
||||
{/* Order Summary */}
|
||||
<div className="card mb-6">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Order Summary</h2>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Order summary</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Route</span>
|
||||
@@ -212,7 +212,7 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span className="text-gray-900 dark:text-gray-100">Total Amount</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">Total amount</span>
|
||||
<span className="text-primary dark:text-gray-100">
|
||||
ETB {(totalAmount / 100).toFixed(2)}
|
||||
</span>
|
||||
@@ -223,7 +223,7 @@ export default function PaymentPage() {
|
||||
|
||||
{/* Payment Methods */}
|
||||
<div className="card mb-6">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Select Payment Method</h2>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Select payment method</h2>
|
||||
<div className="space-y-3">
|
||||
{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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -138,12 +138,12 @@ export default function ResultsPage() {
|
||||
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">No Trains Found</h2>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">No trains found</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
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. <br /> Try adjusting your dates or route.
|
||||
</p>
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary">
|
||||
Modify Search
|
||||
Modify search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,18 +153,18 @@ export default function ResultsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8 md:py-12">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<button
|
||||
onClick={() => router.push(buildSearchUrl())}
|
||||
className="btn-ghost mb-4 flex items-center gap-2"
|
||||
className="btn-ghost px-0 py-4 flex items-center gap-2"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Modify Search
|
||||
Modify search
|
||||
</button>
|
||||
<h1 className="section-title">Available Trains</h1>
|
||||
<h1 className="section-title">Available trains</h1>
|
||||
<div className="flex flex-wrap items-center gap-4 text-gray-600 dark:text-gray-400 mt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
@@ -267,7 +267,7 @@ export default function ResultsPage() {
|
||||
onClick={() => toggleExpanded(scheduleId)}
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<span>View Classes</span>
|
||||
<span>Select class</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
|
||||
@@ -329,7 +329,7 @@ export default function ReviewPage() {
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Review Your Booking</h1>
|
||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Review your booking</h1>
|
||||
|
||||
{seatHold && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
|
||||
@@ -341,7 +341,7 @@ export default function ReviewPage() {
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Trip Details</h2>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Trip details</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Train</span>
|
||||
@@ -391,10 +391,10 @@ export default function ReviewPage() {
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Fare Breakdown</h2>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Fare breakdown</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Base Fare</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">Base fare</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">ETB {(baseFare / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-lg font-bold border-t border-gray-200 dark:border-gray-700 pt-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'}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -173,12 +173,12 @@ export default function SeatsPage() {
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Select Seats</h1>
|
||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Select seats</h1>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="card mb-4">
|
||||
<h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select Coach</h3>
|
||||
<h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select coach</h3>
|
||||
{selectedSchedule?.selectedSeatClassName && (
|
||||
<div className="mb-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing coaches for: <span className="font-semibold text-primary">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</span>
|
||||
@@ -208,7 +208,7 @@ export default function SeatsPage() {
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Seat Map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}</h3>
|
||||
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}</h3>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<p>Loading seats...</p>
|
||||
@@ -265,7 +265,7 @@ export default function SeatsPage() {
|
||||
|
||||
<div>
|
||||
<div className="card sticky top-4">
|
||||
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Selection Summary</h3>
|
||||
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Selection summary</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Select {passengers.length} seat(s) for your passengers
|
||||
</p>
|
||||
@@ -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
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAutoAssign}
|
||||
disabled={holdMutation.isPending}
|
||||
className="btn-secondary w-full"
|
||||
>
|
||||
{holdMutation.isPending ? 'Assigning...' : 'Auto-Assign Seats'}
|
||||
{holdMutation.isPending ? 'Assigning...' : 'Auto-assign seats'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function HowToGuidePage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">3. Enter Passenger Details</h3>
|
||||
<h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">3. Enter passenger details</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-3">
|
||||
You can sign in for a faster experience or continue as a guest.
|
||||
</p>
|
||||
@@ -113,7 +113,7 @@ export default function HowToGuidePage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">4. Select Seats</h3>
|
||||
<h3 className="text-xl font-semibold mb-2 text-gray-900 dark:text-gray-100">4. Select seats</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-3">
|
||||
Choose your preferred seats from the interactive seat map. Available seats are shown in green.
|
||||
</p>
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ function LoginContent() {
|
||||
<div className="flex justify-center mb-4">
|
||||
<Train className="w-12 h-12 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Sign In</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">Welcome back to EDR Platform</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Sign in</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">Welcome back</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
@@ -86,7 +86,7 @@ function LoginContent() {
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function AppHeader() {
|
||||
<Link
|
||||
href="/login"
|
||||
className="p-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
title="Sign In"
|
||||
title="Sign in"
|
||||
>
|
||||
<LogIn className="w-5 h-5" />
|
||||
</Link>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user