From a25ee133395eea6cc845f54a9cdb6f448b54502d Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 17 Jun 2026 22:23:24 +0300 Subject: [PATCH] Updated to address requests and UAT feedback --- .../src/modules/bookings/bookings.service.ts | 4 +- .../modules/passengers/passengers.service.ts | 5 +- .../src/modules/tickets/tickets.controller.ts | 22 +-- .../src/modules/tickets/tickets.service.ts | 71 +++++--- .../backoffice/src/app/bookings/page.tsx | 163 ++++++++++++------ .../backoffice/src/app/passengers/page.tsx | 85 +++++++-- .../backoffice/src/app/payments/page.tsx | 158 ++++++++++++++--- .../backoffice/src/app/seats/page.tsx | 2 +- .../backoffice/src/app/tickets/page.tsx | 151 ++++++++-------- 9 files changed, 450 insertions(+), 211 deletions(-) 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 3e0d6a61a..b40a5c0ee 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -202,7 +202,7 @@ export class BookingsService { } if (status) where.status = status; - if (returnLegStatus) where.returnLegStatus = returnLegStatus; + if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus; const [items, total] = await Promise.all([ this.prisma.booking.findMany({ @@ -233,6 +233,8 @@ export class BookingsService { contactPhone: booking.contactPhone, bookingType: booking.bookingType, returnLegStatus: (booking as any).returnLegStatus ?? null, + adultCount: booking.adultCount, + childCount: booking.childCount, createdAt: booking.createdAt, passenger: booking.passenger?.user, schedule: { 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 12d9e5626..7171b56ad 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -21,10 +21,11 @@ export class PassengersService { const { search, verified, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - const where: any = {}; + const where: any = { user: { role: 'PASSENGER' } }; if (search) { where.user = { + ...where.user, OR: [ { fullName: { contains: search, mode: 'insensitive' } }, { email: { contains: search, mode: 'insensitive' } }, @@ -68,7 +69,7 @@ export class PassengersService { userId: passenger.userId, fullName: user.fullName, email: user.email, - phone: user.phone, + phone: user.phone?.startsWith('+guest-') ? null : user.phone, nationalId: user.nationalId, nationality: user.nationality, dateOfBirth: user.dateOfBirth ?? null, 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 568801213..d4d68ad1f 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -31,18 +31,27 @@ export class TicketsController { @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'List all tickets with optional filters' }) @ApiQuery({ name: 'search', required: false }) - @ApiQuery({ name: 'status', required: false, description: 'ACTIVE | USED | CANCELLED' }) + @ApiQuery({ name: 'status', required: false }) + @ApiQuery({ name: 'originStationId', required: false }) + @ApiQuery({ name: 'destinationStationId', required: false }) + @ApiQuery({ name: 'arrivalDate', required: false }) @ApiQuery({ name: 'skip', required: false }) @ApiQuery({ name: 'take', required: false }) listTickets( @Query('search') search?: string, @Query('status') status?: string, + @Query('originStationId') originStationId?: string, + @Query('destinationStationId') destinationStationId?: string, + @Query('arrivalDate') arrivalDate?: string, @Query('skip') skip?: string, @Query('take') take?: string, ) { return this.service.listTickets({ search, status, + originStationId, + destinationStationId, + arrivalDate, skip: skip ? parseInt(skip) : 0, take: take ? parseInt(take) : 50, }); @@ -60,17 +69,8 @@ export class TicketsController { } @Get(':bookingRef') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') @ApiOperation({ - summary: 'Get ticket with QR code and passenger details', - description: `Returns ticket information including: -- QR code for gate scanning -- Barcode for offline validation -- Passenger details (name, age category, nationality) -- Journey details (origin, destination, seat, coach) -- Fare breakdown with currency -- PDF download link` + summary: 'Get ticket with QR code and passenger details (public)', }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); 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 83abf0cc2..2c57a59e3 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -14,7 +14,7 @@ interface OfflineValidation { export class TicketsService { constructor(private prisma: PrismaService) {} - async listTickets(filters: { search?: string; status?: string; skip: number; take: number }) { + async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) { const where: any = {}; if (filters.search) { where.OR = [ @@ -24,7 +24,19 @@ export class TicketsService { ]; } if (filters.status) { - where.booking = { status: filters.status }; + where.booking = { ...where.booking, status: filters.status }; + } + if (filters.originStationId) { + where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } }; + } + if (filters.destinationStationId) { + where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } }; + } + if (filters.arrivalDate) { + const start = new Date(filters.arrivalDate); + const end = new Date(filters.arrivalDate); + end.setDate(end.getDate() + 1); + where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } }; } const tickets = await this.prisma.ticket.findMany({ where, @@ -32,7 +44,7 @@ export class TicketsService { booking: { include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, - seats: { include: { seat: { include: { coach: true } } } }, + seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, passenger: { include: { user: true } }, }, }, @@ -235,7 +247,16 @@ export class TicketsService { }; } - async validate(bookingRef: string, validatorId: string, gateId?: string, leg?: string) { + async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) { + // Accept either a ticket UUID or a bookingRef + let bookingRef = ticketIdOrRef; + const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ticketIdOrRef); + if (isUuid) { + const ticket = await this.prisma.ticket.findUnique({ where: { id: ticketIdOrRef }, select: { bookingRef: true } }); + if (!ticket) throw new NotFoundException('Ticket not found'); + bookingRef = ticket.bookingRef; + } + const resolvedValidatorId = validatorId || 'BACKOFFICE'; const booking = await this.prisma.booking.findUnique({ where: { bookingRef } }); if (!booking) throw new NotFoundException('Booking not found'); const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } }); @@ -247,11 +268,10 @@ export class TicketsService { // ── ONE_WAY / TRANSIT (single scan) ─────────────────────────────────── if (type === 'ONE_WAY') { if (ticket.validatedAt) { - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } }); - throw new BadRequestException('Ticket already validated'); + return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true }; } - await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } }); + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } }); return { validated: true, ticketId: ticket.id, validatedAt: now }; } @@ -264,28 +284,32 @@ export class TicketsService { const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); const alreadyValidated = logs.some(l => l.leg === resolvedLeg); if (alreadyValidated) { - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); throw new BadRequestException(`${resolvedLeg} already validated`); } - if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } // ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ──────────────────────── if (type === 'ROUND_TRIP') { - const resolvedLeg = (leg ?? 'OUTBOUND').toUpperCase(); + let resolvedLeg = (leg ?? '').toUpperCase(); + // Auto-detect next unused leg when called from backoffice without a leg param + if (!resolvedLeg) { + resolvedLeg = !(booking as any).outboundBoardedAt ? 'OUTBOUND' : 'RETURN'; + } const bookingData: Record = {}; if (resolvedLeg === 'OUTBOUND') { if ((booking as any).outboundBoardedAt) { - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any }); throw new BadRequestException('Outbound leg already used'); } bookingData.outboundBoardedAt = now; - if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); + if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); } else if (resolvedLeg === 'RETURN') { if ((booking as any).returnBoardedAt) { - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any }); throw new BadRequestException('Return leg already used'); } bookingData.returnBoardedAt = now; @@ -298,7 +322,7 @@ export class TicketsService { else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY'; await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } @@ -311,7 +335,7 @@ export class TicketsService { } const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); if (logs.some(l => l.leg === resolvedLeg)) { - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); throw new BadRequestException(`${resolvedLeg} already validated`); } const bookingData: Record = {}; @@ -327,18 +351,17 @@ export class TicketsService { else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_ONLY'; if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); - if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } // Fallback for unknown booking types — single scan if (ticket.validatedAt) { - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } }); - throw new BadRequestException('Ticket already validated'); + return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true }; } - await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } }); + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } }); return { validated: true, ticketId: ticket.id, validatedAt: now }; } @@ -354,7 +377,7 @@ export class TicketsService { where: { scheduleId: tripId, status: 'CONFIRMED' }, include: { ticket: true, - seats: { include: { seat: { include: { coach: true } } } }, + seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, passenger: { include: { user: true } }, }, }); 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 36fd20281..7b43fb677 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -24,6 +24,19 @@ export default function BookingsPage() { const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [bookingToDelete, setBookingToDelete] = useState(null); const [successMessage, setSuccessMessage] = useState(''); + const [exportModalOpen, setExportModalOpen] = useState(false); + const [exportDateFrom, setExportDateFrom] = useState(''); + const [exportDateTo, setExportDateTo] = useState(''); + const [exportColumns, setExportColumns] = useState>({ + bookingRef: true, + passenger: true, + status: true, + bookingType: false, + passengerCount: false, + totalMinor: true, + paymentStatus: true, + createdAt: true, + }); const queryClient = useQueryClient(); @@ -80,22 +93,23 @@ export default function BookingsPage() { } }; - const handleExportBookings = async () => { - const selectedColumns = prompt( - 'Select columns to export (comma-separated):\n\n' + - 'Available: bookingRef, passenger, status, bookingType, passengerCount, totalMinor, paymentStatus, createdAt\n\n' + - 'Default: bookingRef, passenger, status, totalMinor, paymentStatus, createdAt', - 'bookingRef, passenger, status, totalMinor, paymentStatus, createdAt' - ); - - if (!selectedColumns) return; - - const cols = selectedColumns.split(',').map(c => c.trim()); + const confirmExport = () => { + const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); + if (cols.length === 0) { alert('Please select at least one column'); return; } + + const exportItems = (data?.items || []).filter((b: any) => { + if (!exportDateFrom && !exportDateTo) return true; + const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null; + if (exportDateFrom && (!d || d < exportDateFrom)) return false; + if (exportDateTo && (!d || d > exportDateTo)) return false; + return true; + }); + const csv = [ cols.join(','), - ...data?.items?.map((booking: any) => { + ...exportItems.map((booking: any) => { const values = cols.map(col => { - switch(col) { + switch (col) { case 'bookingRef': return booking.bookingRef; case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest'; case 'status': return booking.status; @@ -108,28 +122,29 @@ export default function BookingsPage() { } }); return values.map(v => `"${v}"`).join(','); - }) || [] + }), ].join('\n'); - + const blob = new Blob([csv], { type: 'text/csv' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`; a.click(); + setExportModalOpen(false); }; const columns = [ - { - key: 'bookingRef', + { + key: 'bookingRef', label: 'Reference', sortable: true, render: (booking: any) => ( {booking.bookingRef} ), }, - { - key: 'passenger', + { + key: 'passenger', label: 'Passenger', render: (booking: any) => (
@@ -138,32 +153,39 @@ export default function BookingsPage() {
), }, - { - key: 'bookingType', + { + key: 'bookingType', label: 'Type', sortable: true, render: (booking: any) => booking.bookingType || 'ONE_WAY', }, - { + { key: 'passengerCount', label: 'Passengers', - render: (booking: any) => `${(booking.adultCount || 0) + (booking.childCount || 0)}`, + render: (booking: any) => { + const adults = booking.adultCount || 0; + const children = booking.childCount || 0; + if (adults === 0 && children === 0) return '—'; + const parts = [`Adult: ${adults}`]; + if (children > 0) parts.push(`Child: ${children}`); + return parts.join(' / '); + }, }, - { - key: 'status', + { + key: 'status', label: 'Status', render: (booking: any) => ( {booking.status} ), }, - { - key: 'totalMinor', + { + key: 'totalMinor', label: 'Amount', sortable: true, render: (booking: any) => formatCurrency(booking.totalMinor, booking.currency), }, - { - key: 'paymentStatus', + { + key: 'paymentStatus', label: 'Payment', render: (booking: any) => ( @@ -171,8 +193,8 @@ export default function BookingsPage() { ), }, - { - key: 'createdAt', + { + key: 'createdAt', label: 'Created', sortable: true, render: (booking: any) => formatDateTime(booking.createdAt), @@ -208,7 +230,7 @@ export default function BookingsPage() {

Bookings

Manage all passenger bookings

- Export + setExportModalOpen(true)}>Export
@@ -253,7 +275,7 @@ export default function BookingsPage() { loading={isLoading} emptyMessage="No bookings found" /> - + {data?.meta && ( {/* Booking Details Modal */} - setSelectedBooking(null)} - title="Booking Details" - size="xl" - > + setSelectedBooking(null)} title="Booking Details" size="xl"> {selectedBooking && (
- {/* Booking Information */}
@@ -281,9 +297,7 @@ export default function BookingsPage() {
- - {selectedBooking.status} - + {selectedBooking.status}
@@ -298,7 +312,6 @@ export default function BookingsPage() {
- {/* Passenger Information */}

Passenger Information

@@ -323,7 +336,6 @@ export default function BookingsPage() {
- {/* Booking Details */}

Journey Details

@@ -348,7 +360,6 @@ export default function BookingsPage() {
- {/* Payment Information */}

Payment Information

@@ -377,7 +388,6 @@ export default function BookingsPage() {
- {/* Additional Information */}

Additional Information

@@ -393,12 +403,7 @@ export default function BookingsPage() {
- setSelectedBooking(null)} - > - Close - + setSelectedBooking(null)}>Close
)} @@ -407,10 +412,7 @@ export default function BookingsPage() { {/* Delete Confirmation Dialog */} { - setDeleteConfirmOpen(false); - setBookingToDelete(null); - }} + 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.`} @@ -419,6 +421,53 @@ export default function BookingsPage() { isLoading={deleteMutation.isPending} isDanger={true} /> + + {/* Export Modal */} + setExportModalOpen(false)} title="Export Bookings" size="md"> +
+
+
+ + setExportDateFrom(e.target.value)} /> +
+
+ + setExportDateTo(e.target.value)} /> +
+
+ +
+

Select Columns

+
+ {[ + { key: 'bookingRef', label: 'Booking Reference' }, + { key: 'passenger', label: 'Passenger' }, + { key: 'status', label: 'Status' }, + { key: 'bookingType', label: 'Booking Type' }, + { key: 'passengerCount', label: 'Passenger Count' }, + { key: 'totalMinor', label: 'Amount' }, + { key: 'paymentStatus', label: 'Payment Status' }, + { key: 'createdAt', label: 'Created At' }, + ].map((col) => ( + + ))} +
+
+ +
+ setExportModalOpen(false)}>Cancel + Export CSV +
+
+
); } 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 2c8e50a0e..ab588b085 100644 --- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -22,6 +22,12 @@ export default function PassengersPage() { }); const [selectedPassenger, setSelectedPassenger] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null }); + const [exportModalOpen, setExportModalOpen] = useState(false); + const [exportDateFrom, setExportDateFrom] = useState(''); + const [exportDateTo, setExportDateTo] = useState(''); + const [exportColumns, setExportColumns] = useState>({ + fullName: true, email: true, phone: true, gender: true, nationality: true, verified: true, + }); const queryClient = useQueryClient(); @@ -52,22 +58,23 @@ export default function PassengersPage() { console.error('Passengers API Error:', error); } - const handleExportPassengers = async () => { - const selectedColumns = prompt( - 'Select columns to export (comma-separated):\n\n' + - 'Available: fullName, email, phone, dateOfBirth, gender, nationality, verified\n\n' + - 'Default: fullName, email, phone, gender, nationality, verified', - 'fullName, email, phone, gender, nationality, verified' - ); - - if (!selectedColumns) return; - - const cols = selectedColumns.split(',').map(c => c.trim()); + const confirmExportPassengers = () => { + const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); + if (cols.length === 0) { alert('Please select at least one column'); return; } + + const exportItems = (data?.items || []).filter((p: any) => { + if (!exportDateFrom && !exportDateTo) return true; + const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null; + if (exportDateFrom && (!d || d < exportDateFrom)) return false; + if (exportDateTo && (!d || d > exportDateTo)) return false; + return true; + }); + const csv = [ cols.join(','), - ...data?.items?.map((passenger: any) => { + ...exportItems.map((passenger: any) => { const values = cols.map(col => { - switch(col) { + switch (col) { case 'fullName': return passenger.fullName; case 'email': return passenger.email || ''; case 'phone': return passenger.phone || ''; @@ -79,15 +86,16 @@ export default function PassengersPage() { } }); return values.map(v => `"${v}"`).join(','); - }) || [] + }), ].join('\n'); - + const blob = new Blob([csv], { type: 'text/csv' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `passengers-${new Date().toISOString().split('T')[0]}.csv`; a.click(); + setExportModalOpen(false); }; const columns = [ @@ -160,7 +168,7 @@ export default function PassengersPage() {

Manage passenger profiles and verification

- Export + setExportModalOpen(true)}>Export
@@ -381,6 +389,51 @@ export default function PassengersPage() {
)} + {/* Export Modal */} + setExportModalOpen(false)} title="Export Passengers" size="md"> +
+
+
+ + setExportDateFrom(e.target.value)} /> +
+
+ + setExportDateTo(e.target.value)} /> +
+
+ +
+

Select Columns

+
+ {[ + { key: 'fullName', label: 'Full Name' }, + { key: 'email', label: 'Email' }, + { key: 'phone', label: 'Phone' }, + { key: 'dateOfBirth', label: 'Date of Birth' }, + { key: 'gender', label: 'Gender' }, + { key: 'nationality', label: 'Nationality' }, + { key: 'verified', label: 'Verified' }, + ].map((col) => ( + + ))} +
+
+ +
+ setExportModalOpen(false)}>Cancel + Export CSV +
+
+
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx index 69ef22859..2c1c8bcf5 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx @@ -6,27 +6,76 @@ import { Download } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; import { paymentsApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; export default function PaymentsPage() { const [filters, setFilters] = useState({ search: '', status: '', method: '' }); + const [exportModalOpen, setExportModalOpen] = useState(false); + const [exportDateFrom, setExportDateFrom] = useState(''); + const [exportDateTo, setExportDateTo] = useState(''); + const [exportColumns, setExportColumns] = useState>({ + reference: true, booking: true, amount: true, method: true, status: true, createdAt: true, + }); const { data, isLoading } = useQuery({ queryKey: ['payments', filters], - queryFn: () => paymentsApi.getAll(filters), + queryFn: () => paymentsApi.getAll({ + search: filters.search || undefined, + status: filters.status || undefined, + method: filters.method || undefined, + }), }); - const columns = [ - { key: 'reference', label: 'Reference', render: (payment: any) => {payment.reference || payment.id?.substring(0, 8)} }, - { key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' }, - { key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) }, - { key: 'method', label: 'Method', render: (payment: any) => {payment.method} }, - { key: 'status', label: 'Status', render: (payment: any) => {payment.status} }, - { key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) }, - ]; + const confirmExport = () => { + const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); + if (cols.length === 0) { alert('Please select at least one column'); return; } - const actions: any[] = []; + const items = ((data as any)?.items || (Array.isArray(data) ? data : [])) as any[]; + const exportItems = items.filter((p: any) => { + if (!exportDateFrom && !exportDateTo) return true; + const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null; + if (exportDateFrom && (!d || d < exportDateFrom)) return false; + if (exportDateTo && (!d || d > exportDateTo)) return false; + return true; + }); + + const csv = [ + cols.join(','), + ...exportItems.map((payment: any) => { + const values = cols.map(col => { + switch (col) { + case 'reference': return payment.reference || payment.id?.substring(0, 8) || ''; + case 'booking': return payment.booking?.bookingRef || 'N/A'; + case 'amount': return formatCurrency(payment.amountMinor, payment.currency); + case 'method': return payment.method || ''; + case 'status': return payment.status || ''; + case 'createdAt': return payment.createdAt || ''; + default: return ''; + } + }); + return values.map(v => `"${v}"`).join(','); + }), + ].join('\n'); + + const blob = new Blob([csv], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `payments-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + setExportModalOpen(false); + }; + + const columns = [ + { key: 'reference', label: 'Reference', render: (payment: any) => {payment.reference || payment.id?.substring(0, 8)} }, + { key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' }, + { key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) }, + { key: 'method', label: 'Method', render: (payment: any) => {payment.method} }, + { key: 'status', label: 'Status', render: (payment: any) => {payment.status} }, + { key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) }, + ]; return (
@@ -35,36 +84,91 @@ export default function PaymentsPage() {

Payments

Manage payment transactions and refunds

- Export + setExportModalOpen(true)}>Export
- -
- - setFilters({ ...filters, search: e.target.value })} /> -
-
- - -
- +
+ + setFilters({ ...filters, search: e.target.value })} /> +
+
+ + +
+
+ + +
+ + {/* Export Modal */} + setExportModalOpen(false)} title="Export Payments" size="md"> +
+
+
+ + setExportDateFrom(e.target.value)} /> +
+
+ + setExportDateTo(e.target.value)} /> +
+
+ +
+

Select Columns

+
+ {[ + { key: 'reference', label: 'Reference' }, + { key: 'booking', label: 'Booking Reference' }, + { key: 'amount', label: 'Amount' }, + { key: 'method', label: 'Payment Method' }, + { key: 'status', label: 'Status' }, + { key: 'createdAt', label: 'Created At' }, + ].map((col) => ( + + ))} +
+
+ +
+ setExportModalOpen(false)}>Cancel + Export CSV +
+
+
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index 78879e96e..cb26bd431 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -228,7 +228,7 @@ export default function SeatsPage() { const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); - if (isBedCoach && hasBedPositionData) { + if (isBedCoach) { const arrangement = parseSeatArrangement(coach.seatArrangement); const seatsPerRow = arrangement[0] + (arrangement[1] || 0); const allSeatsForLayout = [...validSeats, ...removedSeats]; 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 6a0364473..39bcdb64a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -9,11 +9,11 @@ import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import Modal from '@/components/ui/Modal'; -import { ticketsApi, apiClient, schedulesApi, stationsApi } from '@/lib/api'; +import { ticketsApi, apiClient, stationsApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; export default function TicketsPage() { - const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', tripDate: '' }); + const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' }); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [ticketToDelete, setTicketToDelete] = useState(null); const [boardConfirmOpen, setBoardConfirmOpen] = useState(false); @@ -22,6 +22,8 @@ export default function TicketsPage() { const [detailsModalOpen, setDetailsModalOpen] = useState(false); const [selectedTicket, setSelectedTicket] = useState(null); const [exportModalOpen, setExportModalOpen] = useState(false); + const [exportDateFrom, setExportDateFrom] = useState(''); + const [exportDateTo, setExportDateTo] = useState(''); const [selectedColumns, setSelectedColumns] = useState>({ ticketNumber: true, booking: true, @@ -35,7 +37,15 @@ export default function TicketsPage() { const { data, isLoading, error } = useQuery({ queryKey: ['tickets', filters], - queryFn: () => ticketsApi.getAll({ ...filters, skip: 0, take: 50 }), + queryFn: () => ticketsApi.getAll({ + search: filters.search || undefined, + status: filters.status || undefined, + originStationId: filters.originStationId || undefined, + destinationStationId: filters.destinationStationId || undefined, + arrivalDate: filters.arrivalDate || undefined, + skip: 0, + take: 50, + }), }); const { data: stationsData } = useQuery({ @@ -94,25 +104,31 @@ export default function TicketsPage() { } }; - const handleExportTickets = async () => { - setExportModalOpen(true); - }; - const confirmExport = () => { const cols = Object.entries(selectedColumns) .filter(([, selected]) => selected) .map(([col]) => col); - + if (cols.length === 0) { alert('Please select at least one column'); return; } + const exportItems = (data?.items || []).filter((ticket: any) => { + if (!exportDateFrom && !exportDateTo) return true; + const d = ticket.schedule?.arrivalAt + ? new Date(ticket.schedule.arrivalAt).toISOString().split('T')[0] + : null; + if (exportDateFrom && (!d || d < exportDateFrom)) return false; + if (exportDateTo && (!d || d > exportDateTo)) return false; + return true; + }); + const csv = [ cols.join(','), - ...data?.items?.map((ticket: any) => { + ...exportItems.map((ticket: any) => { const values = cols.map(col => { - switch(col) { + switch (col) { case 'ticketNumber': return ticket.ticketNumber || ''; case 'booking': return ticket.booking?.bookingRef || ''; case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`; @@ -126,9 +142,9 @@ export default function TicketsPage() { } }); return values.map(v => `"${v}"`).join(','); - }) || [] + }), ].join('\n'); - + const blob = new Blob([csv], { type: 'text/csv' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); @@ -175,12 +191,12 @@ export default function TicketsPage() { }, { key: 'seat', - label: 'Seat', + label: 'Seat/Bed', sortable: true, render: (ticket: any) => (
-
Coach {ticket.seat?.coach?.number || 'N/A'} - Seat {ticket.seat?.seatNumber || 'N/A'}
-
{ticket.seat?.coach?.coachType?.name || 'N/A'}
+
{ticket.seat?.coach?.number || 'N/A'} - {ticket.seat?.seatNumber || 'N/A'}
+
{ticket.seat?.coach?.coachType?.type || 'N/A'}
), }, @@ -264,7 +280,7 @@ export default function TicketsPage() {

Tickets

Manage tickets and validations

- Export + setExportModalOpen(true)}>Export
{/* Filters */} @@ -317,12 +333,12 @@ export default function TicketsPage() {
- + setFilters({ ...filters, tripDate: e.target.value })} + value={filters.arrivalDate} + onChange={(e) => setFilters({ ...filters, arrivalDate: e.target.value })} />
@@ -353,10 +369,7 @@ export default function TicketsPage() { {/* Board Confirmation Dialog */} { - setBoardConfirmOpen(false); - setTicketToBoard(null); - }} + onClose={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }} onConfirm={handleConfirmBoard} title="Board Ticket" message={`Are you sure you want to board ticket ${ticketToBoard?.ticketNumber}? This will mark the ticket as USED.`} @@ -368,10 +381,7 @@ export default function TicketsPage() { {/* Delete Confirmation Dialog */} { - setDeleteConfirmOpen(false); - setTicketToDelete(null); - }} + 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.`} @@ -384,10 +394,7 @@ export default function TicketsPage() { {/* Ticket Details Modal */} { - setDetailsModalOpen(false); - setSelectedTicket(null); - }} + onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }} title="Ticket Details" size="lg" > @@ -488,13 +495,7 @@ export default function TicketsPage() { )}
- { - setDetailsModalOpen(false); - setSelectedTicket(null); - }} - > + { setDetailsModalOpen(false); setSelectedTicket(null); }}> Close
@@ -502,49 +503,55 @@ export default function TicketsPage() { )}
- {/* Export Columns Modal */} + {/* Export Modal */} setExportModalOpen(false)} - title="Export Tickets - Select Columns" + title="Export Tickets" size="md" >
-

Select which columns to include in the export

- -
- {[ - { key: 'ticketNumber', label: 'Ticket Number' }, - { key: 'booking', label: 'Booking Reference & Passenger' }, - { key: 'trip', label: 'Trip (Origin → Destination)' }, - { key: 'coach', label: 'Coach Number' }, - { key: 'seat', label: 'Seat Number' }, - { key: 'seatClass', label: 'Seat Class' }, - { key: 'amount', label: 'Amount' }, - { key: 'status', label: 'Status' }, - { key: 'boarded', label: 'Boarded Status' }, - ].map((col) => ( - - ))} +
+
+ + setExportDateFrom(e.target.value)} /> +
+
+ + setExportDateTo(e.target.value)} /> +
+
+ +
+

Select Columns

+
+ {[ + { key: 'ticketNumber', label: 'Ticket Number' }, + { key: 'booking', label: 'Booking Reference & Passenger' }, + { key: 'trip', label: 'Trip (Origin → Destination)' }, + { key: 'coach', label: 'Coach Number' }, + { key: 'seat', label: 'Seat Number' }, + { key: 'seatClass', label: 'Seat Class' }, + { key: 'amount', label: 'Amount' }, + { key: 'status', label: 'Status' }, + { key: 'boarded', label: 'Boarded Status' }, + ].map((col) => ( + + ))} +
- setExportModalOpen(false)}> - Cancel - - - Export CSV - + setExportModalOpen(false)}>Cancel + Export CSV