mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 21:48:18 +00:00
Updated to address requests and UAT feedback
This commit is contained in:
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user