Updated to address requests and UAT feedback

This commit is contained in:
Stephanos A
2026-06-17 22:23:24 +03:00
parent b3904caacb
commit a25ee13339
9 changed files with 450 additions and 211 deletions

View File

@@ -202,7 +202,7 @@ export class BookingsService {
} }
if (status) where.status = status; if (status) where.status = status;
if (returnLegStatus) where.returnLegStatus = returnLegStatus; if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.booking.findMany({ this.prisma.booking.findMany({
@@ -233,6 +233,8 @@ export class BookingsService {
contactPhone: booking.contactPhone, contactPhone: booking.contactPhone,
bookingType: booking.bookingType, bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null, returnLegStatus: (booking as any).returnLegStatus ?? null,
adultCount: booking.adultCount,
childCount: booking.childCount,
createdAt: booking.createdAt, createdAt: booking.createdAt,
passenger: booking.passenger?.user, passenger: booking.passenger?.user,
schedule: { schedule: {

View File

@@ -21,10 +21,11 @@ export class PassengersService {
const { search, verified, page = 1, pageSize = 20 } = filters; const { search, verified, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
const where: any = {}; const where: any = { user: { role: 'PASSENGER' } };
if (search) { if (search) {
where.user = { where.user = {
...where.user,
OR: [ OR: [
{ fullName: { contains: search, mode: 'insensitive' } }, { fullName: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } }, { email: { contains: search, mode: 'insensitive' } },
@@ -68,7 +69,7 @@ export class PassengersService {
userId: passenger.userId, userId: passenger.userId,
fullName: user.fullName, fullName: user.fullName,
email: user.email, email: user.email,
phone: user.phone, phone: user.phone?.startsWith('+guest-') ? null : user.phone,
nationalId: user.nationalId, nationalId: user.nationalId,
nationality: user.nationality, nationality: user.nationality,
dateOfBirth: user.dateOfBirth ?? null, dateOfBirth: user.dateOfBirth ?? null,

View File

@@ -31,18 +31,27 @@ export class TicketsController {
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all tickets with optional filters' }) @ApiOperation({ summary: 'List all tickets with optional filters' })
@ApiQuery({ name: 'search', required: false }) @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: 'skip', required: false })
@ApiQuery({ name: 'take', required: false }) @ApiQuery({ name: 'take', required: false })
listTickets( listTickets(
@Query('search') search?: string, @Query('search') search?: string,
@Query('status') status?: string, @Query('status') status?: string,
@Query('originStationId') originStationId?: string,
@Query('destinationStationId') destinationStationId?: string,
@Query('arrivalDate') arrivalDate?: string,
@Query('skip') skip?: string, @Query('skip') skip?: string,
@Query('take') take?: string, @Query('take') take?: string,
) { ) {
return this.service.listTickets({ return this.service.listTickets({
search, search,
status, status,
originStationId,
destinationStationId,
arrivalDate,
skip: skip ? parseInt(skip) : 0, skip: skip ? parseInt(skip) : 0,
take: take ? parseInt(take) : 50, take: take ? parseInt(take) : 50,
}); });
@@ -60,17 +69,8 @@ export class TicketsController {
} }
@Get(':bookingRef') @Get(':bookingRef')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ @ApiOperation({
summary: 'Get ticket with QR code and passenger details', summary: 'Get ticket with QR code and passenger details (public)',
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`
}) })
getByRef(@Param('bookingRef') ref: string) { getByRef(@Param('bookingRef') ref: string) {
return this.service.getByRef(ref); return this.service.getByRef(ref);

View File

@@ -14,7 +14,7 @@ interface OfflineValidation {
export class TicketsService { export class TicketsService {
constructor(private prisma: PrismaService) {} 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 = {}; const where: any = {};
if (filters.search) { if (filters.search) {
where.OR = [ where.OR = [
@@ -24,7 +24,19 @@ export class TicketsService {
]; ];
} }
if (filters.status) { 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({ const tickets = await this.prisma.ticket.findMany({
where, where,
@@ -32,7 +44,7 @@ export class TicketsService {
booking: { booking: {
include: { include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } }, 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 } }, 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 } }); const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
if (!booking) throw new NotFoundException('Booking not found'); if (!booking) throw new NotFoundException('Booking not found');
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } }); const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
@@ -247,11 +268,10 @@ export class TicketsService {
// ── ONE_WAY / TRANSIT (single scan) ─────────────────────────────────── // ── ONE_WAY / TRANSIT (single scan) ───────────────────────────────────
if (type === 'ONE_WAY') { if (type === 'ONE_WAY') {
if (ticket.validatedAt) { if (ticket.validatedAt) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } }); return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
throw new BadRequestException('Ticket already validated');
} }
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
return { validated: true, ticketId: ticket.id, validatedAt: now }; 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 logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
const alreadyValidated = logs.some(l => l.leg === resolvedLeg); const alreadyValidated = logs.some(l => l.leg === resolvedLeg);
if (alreadyValidated) { 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`); throw new BadRequestException(`${resolvedLeg} already validated`);
} }
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 } });
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 }; return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
} }
// ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ──────────────────────── // ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ────────────────────────
if (type === 'ROUND_TRIP') { 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<string, any> = {}; const bookingData: Record<string, any> = {};
if (resolvedLeg === 'OUTBOUND') { if (resolvedLeg === 'OUTBOUND') {
if ((booking as any).outboundBoardedAt) { 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'); throw new BadRequestException('Outbound leg already used');
} }
bookingData.outboundBoardedAt = now; 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') { } else if (resolvedLeg === 'RETURN') {
if ((booking as any).returnBoardedAt) { 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'); throw new BadRequestException('Return leg already used');
} }
bookingData.returnBoardedAt = now; bookingData.returnBoardedAt = now;
@@ -298,7 +322,7 @@ export class TicketsService {
else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY'; else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); 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 }; 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' } }); const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
if (logs.some(l => l.leg === resolvedLeg)) { 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`); throw new BadRequestException(`${resolvedLeg} already validated`);
} }
const bookingData: Record<string, any> = {}; const bookingData: Record<string, any> = {};
@@ -327,18 +351,17 @@ export class TicketsService {
else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_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 (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 } }); 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, 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 }; return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
} }
// Fallback for unknown booking types — single scan // Fallback for unknown booking types — single scan
if (ticket.validatedAt) { if (ticket.validatedAt) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } }); return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
throw new BadRequestException('Ticket already validated');
} }
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
return { validated: true, ticketId: ticket.id, validatedAt: now }; return { validated: true, ticketId: ticket.id, validatedAt: now };
} }
@@ -354,7 +377,7 @@ export class TicketsService {
where: { scheduleId: tripId, status: 'CONFIRMED' }, where: { scheduleId: tripId, status: 'CONFIRMED' },
include: { include: {
ticket: true, ticket: true,
seats: { include: { seat: { include: { coach: true } } } }, seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } }, passenger: { include: { user: true } },
}, },
}); });

View File

@@ -24,6 +24,19 @@ export default function BookingsPage() {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null); const [bookingToDelete, setBookingToDelete] = useState<any>(null);
const [successMessage, setSuccessMessage] = useState(''); const [successMessage, setSuccessMessage] = useState('');
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
bookingRef: true,
passenger: true,
status: true,
bookingType: false,
passengerCount: false,
totalMinor: true,
paymentStatus: true,
createdAt: true,
});
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -80,22 +93,23 @@ export default function BookingsPage() {
} }
}; };
const handleExportBookings = async () => { const confirmExport = () => {
const selectedColumns = prompt( const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
'Select columns to export (comma-separated):\n\n' + if (cols.length === 0) { alert('Please select at least one column'); return; }
'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 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 cols = selectedColumns.split(',').map(c => c.trim());
const csv = [ const csv = [
cols.join(','), cols.join(','),
...data?.items?.map((booking: any) => { ...exportItems.map((booking: any) => {
const values = cols.map(col => { const values = cols.map(col => {
switch(col) { switch (col) {
case 'bookingRef': return booking.bookingRef; case 'bookingRef': return booking.bookingRef;
case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest'; case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest';
case 'status': return booking.status; case 'status': return booking.status;
@@ -108,7 +122,7 @@ export default function BookingsPage() {
} }
}); });
return values.map(v => `"${v}"`).join(','); return values.map(v => `"${v}"`).join(',');
}) || [] }),
].join('\n'); ].join('\n');
const blob = new Blob([csv], { type: 'text/csv' }); const blob = new Blob([csv], { type: 'text/csv' });
@@ -117,6 +131,7 @@ export default function BookingsPage() {
a.href = url; a.href = url;
a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`; a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`;
a.click(); a.click();
setExportModalOpen(false);
}; };
const columns = [ const columns = [
@@ -147,7 +162,14 @@ export default function BookingsPage() {
{ {
key: 'passengerCount', key: 'passengerCount',
label: 'Passengers', 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',
@@ -208,7 +230,7 @@ export default function BookingsPage() {
<h1 className="text-2xl font-bold">Bookings</h1> <h1 className="text-2xl font-bold">Bookings</h1>
<p className="text-muted-foreground">Manage all passenger bookings</p> <p className="text-muted-foreground">Manage all passenger bookings</p>
</div> </div>
<ActionButton variant="export" icon={Download} onClick={handleExportBookings}>Export</ActionButton> <ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div> </div>
<div className="card"> <div className="card">
@@ -264,15 +286,9 @@ export default function BookingsPage() {
</div> </div>
{/* Booking Details Modal */} {/* Booking Details Modal */}
<Modal <Modal isOpen={!!selectedBooking} onClose={() => setSelectedBooking(null)} title="Booking Details" size="xl">
isOpen={!!selectedBooking}
onClose={() => setSelectedBooking(null)}
title="Booking Details"
size="xl"
>
{selectedBooking && ( {selectedBooking && (
<div className="space-y-6"> <div className="space-y-6">
{/* Booking Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="text-sm font-medium text-muted-foreground">Booking Reference</label> <label className="text-sm font-medium text-muted-foreground">Booking Reference</label>
@@ -281,9 +297,7 @@ export default function BookingsPage() {
<div> <div>
<label className="text-sm font-medium text-muted-foreground">Status</label> <label className="text-sm font-medium text-muted-foreground">Status</label>
<div className="mt-1"> <div className="mt-1">
<Badge variant="status" status={selectedBooking.status}> <Badge variant="status" status={selectedBooking.status}>{selectedBooking.status}</Badge>
{selectedBooking.status}
</Badge>
</div> </div>
</div> </div>
<div> <div>
@@ -298,7 +312,6 @@ export default function BookingsPage() {
<hr className="border-muted" /> <hr className="border-muted" />
{/* Passenger Information */}
<div> <div>
<h3 className="text-lg font-semibold mb-3">Passenger Information</h3> <h3 className="text-lg font-semibold mb-3">Passenger Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -323,7 +336,6 @@ export default function BookingsPage() {
<hr className="border-muted" /> <hr className="border-muted" />
{/* Booking Details */}
<div> <div>
<h3 className="text-lg font-semibold mb-3">Journey Details</h3> <h3 className="text-lg font-semibold mb-3">Journey Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -348,7 +360,6 @@ export default function BookingsPage() {
<hr className="border-muted" /> <hr className="border-muted" />
{/* Payment Information */}
<div> <div>
<h3 className="text-lg font-semibold mb-3">Payment Information</h3> <h3 className="text-lg font-semibold mb-3">Payment Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -377,7 +388,6 @@ export default function BookingsPage() {
<hr className="border-muted" /> <hr className="border-muted" />
{/* Additional Information */}
<div> <div>
<h3 className="text-lg font-semibold mb-3">Additional Information</h3> <h3 className="text-lg font-semibold mb-3">Additional Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -393,12 +403,7 @@ export default function BookingsPage() {
</div> </div>
<div className="flex justify-end gap-2 pt-4"> <div className="flex justify-end gap-2 pt-4">
<ActionButton <ActionButton variant="secondary" onClick={() => setSelectedBooking(null)}>Close</ActionButton>
variant="secondary"
onClick={() => setSelectedBooking(null)}
>
Close
</ActionButton>
</div> </div>
</div> </div>
)} )}
@@ -407,10 +412,7 @@ export default function BookingsPage() {
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirmOpen} isOpen={deleteConfirmOpen}
onClose={() => { onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); }}
setDeleteConfirmOpen(false);
setBookingToDelete(null);
}}
onConfirm={handleConfirmDelete} onConfirm={handleConfirmDelete}
title="Delete Booking" 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.`} 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} isLoading={deleteMutation.isPending}
isDanger={true} isDanger={true}
/> />
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Date From (Created)</label>
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Date To (Created)</label>
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Select Columns</p>
<div className="space-y-2 max-h-56 overflow-y-auto">
{[
{ 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) => (
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
<input
type="checkbox"
checked={exportColumns[col.key] || false}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
</div>
</div>
</Modal>
</div> </div>
); );
} }

View File

@@ -22,6 +22,12 @@ export default function PassengersPage() {
}); });
const [selectedPassenger, setSelectedPassenger] = useState<any>(null); const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: 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<Record<string, boolean>>({
fullName: true, email: true, phone: true, gender: true, nationality: true, verified: true,
});
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -52,22 +58,23 @@ export default function PassengersPage() {
console.error('Passengers API Error:', error); console.error('Passengers API Error:', error);
} }
const handleExportPassengers = async () => { const confirmExportPassengers = () => {
const selectedColumns = prompt( const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
'Select columns to export (comma-separated):\n\n' + if (cols.length === 0) { alert('Please select at least one column'); return; }
'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 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 cols = selectedColumns.split(',').map(c => c.trim());
const csv = [ const csv = [
cols.join(','), cols.join(','),
...data?.items?.map((passenger: any) => { ...exportItems.map((passenger: any) => {
const values = cols.map(col => { const values = cols.map(col => {
switch(col) { switch (col) {
case 'fullName': return passenger.fullName; case 'fullName': return passenger.fullName;
case 'email': return passenger.email || ''; case 'email': return passenger.email || '';
case 'phone': return passenger.phone || ''; case 'phone': return passenger.phone || '';
@@ -79,7 +86,7 @@ export default function PassengersPage() {
} }
}); });
return values.map(v => `"${v}"`).join(','); return values.map(v => `"${v}"`).join(',');
}) || [] }),
].join('\n'); ].join('\n');
const blob = new Blob([csv], { type: 'text/csv' }); const blob = new Blob([csv], { type: 'text/csv' });
@@ -88,6 +95,7 @@ export default function PassengersPage() {
a.href = url; a.href = url;
a.download = `passengers-${new Date().toISOString().split('T')[0]}.csv`; a.download = `passengers-${new Date().toISOString().split('T')[0]}.csv`;
a.click(); a.click();
setExportModalOpen(false);
}; };
const columns = [ const columns = [
@@ -160,7 +168,7 @@ export default function PassengersPage() {
<p className="text-muted-foreground">Manage passenger profiles and verification</p> <p className="text-muted-foreground">Manage passenger profiles and verification</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<ActionButton variant="export" icon={Download} onClick={handleExportPassengers}>Export</ActionButton> <ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div> </div>
</div> </div>
@@ -381,6 +389,51 @@ export default function PassengersPage() {
</div> </div>
)} )}
</Modal> </Modal>
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Passengers" size="md">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Date From (Registered)</label>
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Date To (Registered)</label>
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Select Columns</p>
<div className="space-y-2 max-h-56 overflow-y-auto">
{[
{ 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) => (
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
<input
type="checkbox"
checked={exportColumns[col.key] || false}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExportPassengers}>Export CSV</ActionButton>
</div>
</div>
</Modal>
</div> </div>
); );
} }

View File

@@ -6,27 +6,76 @@ import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { paymentsApi } from '@/lib/api'; import { paymentsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function PaymentsPage() { export default function PaymentsPage() {
const [filters, setFilters] = useState({ search: '', status: '', method: '' }); const [filters, setFilters] = useState({ search: '', status: '', method: '' });
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
reference: true, booking: true, amount: true, method: true, status: true, createdAt: true,
});
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['payments', filters], queryKey: ['payments', filters],
queryFn: () => paymentsApi.getAll(filters), queryFn: () => paymentsApi.getAll({
search: filters.search || undefined,
status: filters.status || undefined,
method: filters.method || undefined,
}),
}); });
const columns = [ const confirmExport = () => {
{ key: 'reference', label: 'Reference', render: (payment: any) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> }, const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
{ key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' }, if (cols.length === 0) { alert('Please select at least one column'); return; }
{ key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) },
{ key: 'method', label: 'Method', render: (payment: any) => <Badge>{payment.method}</Badge> },
{ key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
];
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) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> },
{ 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) => <Badge>{payment.method}</Badge> },
{ key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -35,36 +84,91 @@ export default function PaymentsPage() {
<h1 className="text-2xl font-bold text-foreground">Payments</h1> <h1 className="text-2xl font-bold text-foreground">Payments</h1>
<p className="text-muted-foreground">Manage payment transactions and refunds</p> <p className="text-muted-foreground">Manage payment transactions and refunds</p>
</div> </div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton> <ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div> </div>
<div className="card"> <div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<div> <label className="label">Search</label>
<label className="label">Search</label> <input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} /> </div>
</div> <div>
<div> <label className="label">Status</label>
<label className="label">Status</label> <select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}> <option value="">All Status</option>
<option value="">All Status</option> <option value="PENDING">Pending</option>
<option value="PENDING">Pending</option> <option value="COMPLETED">Completed</option>
<option value="COMPLETED">Completed</option> <option value="FAILED">Failed</option>
<option value="FAILED">Failed</option> </select>
</select> </div>
</div> <div>
<label className="label">Method</label>
<select className="input" value={filters.method} onChange={(e) => setFilters({ ...filters, method: e.target.value })}>
<option value="">All Methods</option>
<option value="TELEBIRR">Telebirr</option>
<option value="CBE_BIRR">CBE Birr</option>
<option value="EBIRR">eBirr</option>
<option value="CARD">Card</option>
<option value="WALLET">Wallet</option>
<option value="CASH">Cash</option>
</select>
</div>
</div> </div>
</div> </div>
<DataTable <DataTable
data={(data as any)?.items || (Array.isArray(data) ? data : [])} data={(data as any)?.items || (Array.isArray(data) ? data : [])}
columns={columns} columns={columns}
actions={actions} actions={[]}
loading={isLoading} loading={isLoading}
emptyMessage="No payments found" emptyMessage="No payments found"
/> />
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Date From</label>
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Date To</label>
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Select Columns</p>
<div className="space-y-2">
{[
{ 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) => (
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
<input
type="checkbox"
checked={exportColumns[col.key] || false}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
</div>
</div>
</Modal>
</div> </div>
); );
} }

View File

@@ -228,7 +228,7 @@ export default function SeatsPage() {
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
if (isBedCoach && hasBedPositionData) { if (isBedCoach) {
const arrangement = parseSeatArrangement(coach.seatArrangement); const arrangement = parseSeatArrangement(coach.seatArrangement);
const seatsPerRow = arrangement[0] + (arrangement[1] || 0); const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
const allSeatsForLayout = [...validSeats, ...removedSeats]; const allSeatsForLayout = [...validSeats, ...removedSeats];

View File

@@ -9,11 +9,11 @@ import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal'; 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'; import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function TicketsPage() { 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 [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [ticketToDelete, setTicketToDelete] = useState<any>(null); const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false); const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
@@ -22,6 +22,8 @@ export default function TicketsPage() {
const [detailsModalOpen, setDetailsModalOpen] = useState(false); const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null); const [selectedTicket, setSelectedTicket] = useState<any>(null);
const [exportModalOpen, setExportModalOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [selectedColumns, setSelectedColumns] = useState<Record<string, boolean>>({ const [selectedColumns, setSelectedColumns] = useState<Record<string, boolean>>({
ticketNumber: true, ticketNumber: true,
booking: true, booking: true,
@@ -35,7 +37,15 @@ export default function TicketsPage() {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ['tickets', filters], 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({ const { data: stationsData } = useQuery({
@@ -94,10 +104,6 @@ export default function TicketsPage() {
} }
}; };
const handleExportTickets = async () => {
setExportModalOpen(true);
};
const confirmExport = () => { const confirmExport = () => {
const cols = Object.entries(selectedColumns) const cols = Object.entries(selectedColumns)
.filter(([, selected]) => selected) .filter(([, selected]) => selected)
@@ -108,11 +114,21 @@ export default function TicketsPage() {
return; 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 = [ const csv = [
cols.join(','), cols.join(','),
...data?.items?.map((ticket: any) => { ...exportItems.map((ticket: any) => {
const values = cols.map(col => { const values = cols.map(col => {
switch(col) { switch (col) {
case 'ticketNumber': return ticket.ticketNumber || ''; case 'ticketNumber': return ticket.ticketNumber || '';
case 'booking': return ticket.booking?.bookingRef || ''; case 'booking': return ticket.booking?.bookingRef || '';
case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`; case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`;
@@ -126,7 +142,7 @@ export default function TicketsPage() {
} }
}); });
return values.map(v => `"${v}"`).join(','); return values.map(v => `"${v}"`).join(',');
}) || [] }),
].join('\n'); ].join('\n');
const blob = new Blob([csv], { type: 'text/csv' }); const blob = new Blob([csv], { type: 'text/csv' });
@@ -175,12 +191,12 @@ export default function TicketsPage() {
}, },
{ {
key: 'seat', key: 'seat',
label: 'Seat', label: 'Seat/Bed',
sortable: true, sortable: true,
render: (ticket: any) => ( render: (ticket: any) => (
<div> <div>
<div className="font-mono font-semibold">Coach {ticket.seat?.coach?.number || 'N/A'} - Seat {ticket.seat?.seatNumber || 'N/A'}</div> <div className="font-mono font-semibold">{ticket.seat?.coach?.number || 'N/A'} - {ticket.seat?.seatNumber || 'N/A'}</div>
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.name || 'N/A'}</div> <div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.type || 'N/A'}</div>
</div> </div>
), ),
}, },
@@ -264,7 +280,7 @@ export default function TicketsPage() {
<h1 className="text-2xl font-bold text-foreground">Tickets</h1> <h1 className="text-2xl font-bold text-foreground">Tickets</h1>
<p className="text-muted-foreground">Manage tickets and validations</p> <p className="text-muted-foreground">Manage tickets and validations</p>
</div> </div>
<ActionButton icon={Download} variant="secondary" onClick={handleExportTickets}>Export</ActionButton> <ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div> </div>
{/* Filters */} {/* Filters */}
@@ -317,12 +333,12 @@ export default function TicketsPage() {
</select> </select>
</div> </div>
<div> <div>
<label className="label">Trip Date</label> <label className="label">Arrival Date</label>
<input <input
type="date" type="date"
className="input" className="input"
value={filters.tripDate} value={filters.arrivalDate}
onChange={(e) => setFilters({ ...filters, tripDate: e.target.value })} onChange={(e) => setFilters({ ...filters, arrivalDate: e.target.value })}
/> />
</div> </div>
<div> <div>
@@ -353,10 +369,7 @@ export default function TicketsPage() {
{/* Board Confirmation Dialog */} {/* Board Confirmation Dialog */}
<ConfirmDialog <ConfirmDialog
isOpen={boardConfirmOpen} isOpen={boardConfirmOpen}
onClose={() => { onClose={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}
setBoardConfirmOpen(false);
setTicketToBoard(null);
}}
onConfirm={handleConfirmBoard} onConfirm={handleConfirmBoard}
title="Board Ticket" title="Board Ticket"
message={`Are you sure you want to board ticket ${ticketToBoard?.ticketNumber}? This will mark the ticket as USED.`} 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 */} {/* Delete Confirmation Dialog */}
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirmOpen} isOpen={deleteConfirmOpen}
onClose={() => { onClose={() => { setDeleteConfirmOpen(false); setTicketToDelete(null); }}
setDeleteConfirmOpen(false);
setTicketToDelete(null);
}}
onConfirm={handleConfirmDelete} onConfirm={handleConfirmDelete}
title="Delete Ticket" title="Delete Ticket"
message={`Are you sure you want to permanently delete ticket ${ticketToDelete?.ticketNumber}? This action cannot be undone.`} 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 */} {/* Ticket Details Modal */}
<Modal <Modal
isOpen={detailsModalOpen} isOpen={detailsModalOpen}
onClose={() => { onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}
setDetailsModalOpen(false);
setSelectedTicket(null);
}}
title="Ticket Details" title="Ticket Details"
size="lg" size="lg"
> >
@@ -488,13 +495,7 @@ export default function TicketsPage() {
)} )}
<div className="flex justify-end gap-2 pt-4"> <div className="flex justify-end gap-2 pt-4">
<ActionButton <ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}>
variant="secondary"
onClick={() => {
setDetailsModalOpen(false);
setSelectedTicket(null);
}}
>
Close Close
</ActionButton> </ActionButton>
</div> </div>
@@ -502,49 +503,55 @@ export default function TicketsPage() {
)} )}
</Modal> </Modal>
{/* Export Columns Modal */} {/* Export Modal */}
<Modal <Modal
isOpen={exportModalOpen} isOpen={exportModalOpen}
onClose={() => setExportModalOpen(false)} onClose={() => setExportModalOpen(false)}
title="Export Tickets - Select Columns" title="Export Tickets"
size="md" size="md"
> >
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-muted-foreground">Select which columns to include in the export</p> <div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Date From (Arrival)</label>
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Date To (Arrival)</label>
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
</div>
</div>
<div className="space-y-3 max-h-96 overflow-y-auto"> <div>
{[ <p className="text-sm font-medium mb-2">Select Columns</p>
{ key: 'ticketNumber', label: 'Ticket Number' }, <div className="space-y-2 max-h-56 overflow-y-auto">
{ key: 'booking', label: 'Booking Reference & Passenger' }, {[
{ key: 'trip', label: 'Trip (Origin → Destination)' }, { key: 'ticketNumber', label: 'Ticket Number' },
{ key: 'coach', label: 'Coach Number' }, { key: 'booking', label: 'Booking Reference & Passenger' },
{ key: 'seat', label: 'Seat Number' }, { key: 'trip', label: 'Trip (Origin → Destination)' },
{ key: 'seatClass', label: 'Seat Class' }, { key: 'coach', label: 'Coach Number' },
{ key: 'amount', label: 'Amount' }, { key: 'seat', label: 'Seat Number' },
{ key: 'status', label: 'Status' }, { key: 'seatClass', label: 'Seat Class' },
{ key: 'boarded', label: 'Boarded Status' }, { key: 'amount', label: 'Amount' },
].map((col) => ( { key: 'status', label: 'Status' },
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer"> { key: 'boarded', label: 'Boarded Status' },
<input ].map((col) => (
type="checkbox" <label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
checked={selectedColumns[col.key] || false} <input
onChange={(e) => type="checkbox"
setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked }) checked={selectedColumns[col.key] || false}
} onChange={(e) => setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked })}
className="w-4 h-4 rounded border-gray-300" className="w-4 h-4 rounded border-gray-300"
/> />
<span className="text-sm font-medium">{col.label}</span> <span className="text-sm font-medium">{col.label}</span>
</label> </label>
))} ))}
</div>
</div> </div>
<div className="flex justify-end gap-2 pt-4 border-t"> <div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}> <ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
Cancel <ActionButton onClick={confirmExport}>Export CSV</ActionButton>
</ActionButton>
<ActionButton onClick={confirmExport}>
Export CSV
</ActionButton>
</div> </div>
</div> </div>
</Modal> </Modal>