diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts index 62a1a6461..f7e015e11 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts @@ -13,8 +13,8 @@ export class DashboardService { async getBackofficeStats() { const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] = await Promise.all([ - this.prisma.booking.count(), - this.prisma.booking.count({ where: { packageId: { not: null } } }), + this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }), + this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, packageId: { not: null } } }), this.prisma.ticket.count(), this.prisma.passenger.count(), this.prisma.seat.count({ where: { status: 'BLOCKED' } }), diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index a4da5b208..7d832b8c9 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -1,38 +1,50 @@ -import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; -import { ReportsService } from './reports.service'; -import { GenerateReportDto } from './reports.dto'; -import { PassengerStaff } from '../../common/passenger-guards'; -import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; +import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger"; +import { ReportsService } from "./reports.service"; +import { GenerateReportDto } from "./reports.dto"; +import { PassengerStaff } from "../../common/passenger-guards"; +import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; -@ApiTags('Reports') -@Controller('reports') +@ApiTags("Reports") +@Controller("reports") @PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin]) -@ApiBearerAuth('IAM-auth') +@ApiBearerAuth("IAM-auth") export class ReportsController { constructor(private service: ReportsService) {} - @Post('generate') - @ApiOperation({ summary: 'Generate operational report' }) + @Post("generate") + @ApiOperation({ summary: "Generate operational report" }) generateReport(@Body() dto: GenerateReportDto) { return this.service.generateReport(dto); } - @Get('passengers') - @ApiOperation({ summary: 'Passengers report for a specific schedule' }) - getOccupancyReport(@Query('scheduleId') scheduleId: string) { + @Get("schedules") + @ApiOperation({ summary: "List schedules for the passengers report picker" }) + listSchedulesForPicker() { + return this.service.listSchedulesForPicker(); + } + + @Get("passengers/list") + @ApiOperation({ summary: "Flat passenger list for a specific schedule" }) + getPassengerList(@Query("scheduleId") scheduleId: string) { + return this.service.getPassengerList(scheduleId); + } + + @Get("passengers") + @ApiOperation({ summary: "Passengers report for a specific schedule" }) + getOccupancyReport(@Query("scheduleId") scheduleId: string) { return this.service.getOccupancyBySchedule(scheduleId); } - @Get(':reportId') - @ApiOperation({ summary: 'Get report by ID' }) - getReport(@Param('reportId') reportId: string) { + @Get(":reportId") + @ApiOperation({ summary: "Get report by ID" }) + getReport(@Param("reportId") reportId: string) { return this.service.getReport(reportId); } @Get() - @ApiOperation({ summary: 'List reports' }) - listReports(@Query('type') type?: string) { + @ApiOperation({ summary: "List reports" }) + listReports(@Query("type") type?: string) { return this.service.listReports(type); } } diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 12f48b41f..9c16ff5b0 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -1,8 +1,8 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource } from 'typeorm'; -import { PrismaService } from '../../common/prisma.service'; -import { GenerateReportDto, ReportType } from './reports.dto'; +import { Injectable, Logger } from "@nestjs/common"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource } from "typeorm"; +import { PrismaService } from "../../common/prisma.service"; +import { GenerateReportDto, ReportType } from "./reports.dto"; @Injectable() export class ReportsService { @@ -15,7 +15,7 @@ export class ReportsService { async generateReport(dto: GenerateReportDto) { const dateFrom = new Date(dto.dateFrom); dateFrom.setHours(0, 0, 0, 0); - + const dateTo = new Date(dto.dateTo); dateTo.setHours(23, 59, 59, 999); @@ -28,7 +28,11 @@ export class ReportsService { data = await this.generateOccupancyReport(dateFrom, dateTo); break; case ReportType.AGENT_SALES: - data = await this.generateAgentSalesReport(dateFrom, dateTo, dto.agentId); + data = await this.generateAgentSalesReport( + dateFrom, + dateTo, + dto.agentId, + ); break; case ReportType.CANCELLATIONS: data = await this.generateCancellationsReport(dateFrom, dateTo); @@ -45,8 +49,8 @@ export class ReportsService { reportType: dto.reportType, dateFrom, dateTo, - data - } + data, + }, }); return { reportId: report.id, reportType: dto.reportType, data }; @@ -56,37 +60,43 @@ export class ReportsService { // Fetch all bookings in date range, regardless of status const bookings = await this.prisma.booking.findMany({ where: { - createdAt: { gte: dateFrom, lte: dateTo } + createdAt: { gte: dateFrom, lte: dateTo }, }, - include: { paymentIntent: true } + include: { paymentIntent: true }, }); const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0); - const byPaymentMethod = bookings.reduce((acc, b) => { - const method = b.paymentIntent?.method ?? 'UNKNOWN'; - acc[method] = (acc[method] || 0) + b.totalMinor; - return acc; - }, {} as Record); + const byPaymentMethod = bookings.reduce( + (acc, b) => { + const method = b.paymentIntent?.method ?? "UNKNOWN"; + acc[method] = (acc[method] || 0) + b.totalMinor; + return acc; + }, + {} as Record, + ); // Group by date for charts - const byDate = bookings.reduce((acc, b) => { - const date = b.createdAt.toISOString().split('T')[0]; - if (!acc[date]) { - acc[date] = { totalMinor: 0, count: 0 }; - } - acc[date].totalMinor += b.totalMinor; - acc[date].count += 1; - return acc; - }, {} as Record); + const byDate = bookings.reduce( + (acc, b) => { + const date = b.createdAt.toISOString().split("T")[0]; + if (!acc[date]) { + acc[date] = { totalMinor: 0, count: 0 }; + } + acc[date].totalMinor += b.totalMinor; + acc[date].count += 1; + return acc; + }, + {} as Record, + ); return { totalBookings: bookings.length, totalRevenueMinor: totalRevenue, totalRevenue: totalRevenue / 100, - currency: 'ETB', + currency: "ETB", byPaymentMethod, byDate, - cancellationRate: 0 + cancellationRate: 0, }; } @@ -95,75 +105,116 @@ export class ReportsService { where: { departureAt: { gte: dateFrom, lte: dateTo } }, include: { coachAssignments: { include: { coach: { include: { seats: true } } } }, - bookings: { include: { seats: true } }, + bookings: { + where: { status: { in: ["CONFIRMED", "BOARDED"] } }, + include: { seats: true }, + }, }, }); - const tripData = schedules.map(schedule => { - const totalSeats = schedule.coachAssignments.reduce((sum, a) => sum + a.coach.seats.length, 0); - const bookedSeats = schedule.bookings.reduce((sum, b) => sum + b.seats.length, 0); - const occupancyRate = totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0; - return { scheduleId: schedule.id, departureAt: schedule.departureAt, totalSeats, bookedSeats, occupancyRate: +occupancyRate.toFixed(2) }; + const tripData = schedules.map((schedule) => { + const totalSeats = schedule.coachAssignments.reduce( + (sum, a) => sum + a.coach.seats.length, + 0, + ); + const bookedSeats = schedule.bookings.reduce( + (sum, b) => + sum + b.seats.filter((s: any) => s.scheduleId === schedule.id).length, + 0, + ); + const occupancyRate = + totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0; + return { + scheduleId: schedule.id, + departureAt: schedule.departureAt, + totalSeats, + bookedSeats, + occupancyRate: +occupancyRate.toFixed(2), + }; }); - const avgOccupancy = tripData.length > 0 ? tripData.reduce((sum, t) => sum + t.occupancyRate, 0) / tripData.length : 0; - return { totalSchedules: schedules.length, averageOccupancyRate: +avgOccupancy.toFixed(2), schedules: tripData }; + const avgOccupancy = + tripData.length > 0 + ? tripData.reduce((sum, t) => sum + t.occupancyRate, 0) / + tripData.length + : 0; + return { + totalSchedules: schedules.length, + averageOccupancyRate: +avgOccupancy.toFixed(2), + schedules: tripData, + }; } - private async generateAgentSalesReport(dateFrom: Date, dateTo: Date, agentId?: string) { + private async generateAgentSalesReport( + dateFrom: Date, + dateTo: Date, + agentId?: string, + ) { const agentBookings = await this.prisma.agentBooking.findMany({ where: { createdAt: { gte: dateFrom, lte: dateTo }, - ...(agentId ? { agentId } : {}) + ...(agentId ? { agentId } : {}), }, include: { agent: { select: { id: true, iamUserId: true, agentCode: true } }, - booking: true - } + booking: true, + }, }); - const iamUserIds = [...new Set( - agentBookings.map(ab => ab.agent.iamUserId).filter(Boolean) as string[] - )]; - const iamRows = iamUserIds.length > 0 - ? await this.dataSource.query<{ id: string; name: { en?: string; am?: string } | null }[]>( - `SELECT id, name FROM iam.users WHERE id = ANY($1)`, - [iamUserIds], - ) - : []; - const iamMap = new Map(iamRows.map(r => [r.id, r])); + const iamUserIds = [ + ...new Set( + agentBookings + .map((ab) => ab.agent.iamUserId) + .filter(Boolean) as string[], + ), + ]; + const iamRows = + iamUserIds.length > 0 + ? await this.dataSource.query< + { id: string; name: { en?: string; am?: string } | null }[] + >(`SELECT id, name FROM iam.users WHERE id = ANY($1)`, [iamUserIds]) + : []; + const iamMap = new Map(iamRows.map((r) => [r.id, r])); - const byAgent = agentBookings.reduce((acc, ab) => { - const iam = ab.agent.iamUserId ? iamMap.get(ab.agent.iamUserId) : undefined; - const agentName = iam?.name?.en ?? iam?.name?.am ?? ab.agent.agentCode; - if (!acc[agentName]) { - acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 }; - } - acc[agentName].bookings += 1; - acc[agentName].revenueMinor += ab.booking.totalMinor; - acc[agentName].cashCollected += ab.cashReceived ?? 0; - return acc; - }, {} as Record); + const byAgent = agentBookings.reduce( + (acc, ab) => { + const iam = ab.agent.iamUserId + ? iamMap.get(ab.agent.iamUserId) + : undefined; + const agentName = iam?.name?.en ?? iam?.name?.am ?? ab.agent.agentCode; + if (!acc[agentName]) { + acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 }; + } + acc[agentName].bookings += 1; + acc[agentName].revenueMinor += ab.booking.totalMinor; + acc[agentName].cashCollected += ab.cashReceived ?? 0; + return acc; + }, + {} as Record, + ); return { totalAgentBookings: agentBookings.length, - byAgent + byAgent, }; } private async generateCancellationsReport(dateFrom: Date, dateTo: Date) { const cancellations = await this.prisma.bookingCancellation.findMany({ where: { createdAt: { gte: dateFrom, lte: dateTo } }, - include: { booking: true } + include: { booking: true }, }); - const totalRefunded = cancellations.reduce((sum, c) => sum + c.refundAmount, 0); + const totalRefunded = cancellations.reduce( + (sum, c) => sum + c.refundAmount, + 0, + ); return { totalCancellations: cancellations.length, totalRefundedMinor: totalRefunded, totalRefunded: totalRefunded / 100, - currency: 'ETB' + currency: "ETB", }; } @@ -171,23 +222,26 @@ export class ReportsService { const payments = await this.prisma.paymentIntent.findMany({ where: { createdAt: { gte: dateFrom, lte: dateTo }, - status: 'SUCCEEDED' - } + status: "SUCCEEDED", + }, }); - const byMethod = payments.reduce((acc, p) => { - const method = p.method; - if (!acc[method]) { - acc[method] = { count: 0, totalMinor: 0 }; - } - acc[method].count += 1; - acc[method].totalMinor += p.amountMinor; - return acc; - }, {} as Record); + const byMethod = payments.reduce( + (acc, p) => { + const method = p.method; + if (!acc[method]) { + acc[method] = { count: 0, totalMinor: 0 }; + } + acc[method].count += 1; + acc[method].totalMinor += p.amountMinor; + return acc; + }, + {} as Record, + ); return { totalPayments: payments.length, - byMethod + byMethod, }; } @@ -209,33 +263,48 @@ export class ReportsService { }, }, bookings: { - where: { status: { in: ['CONFIRMED', 'BOARDED'] } }, + where: { status: { in: ["CONFIRMED", "BOARDED"] } }, include: { seats: { + where: { scheduleId }, include: { seat: { include: { coach: { include: { coachType: true } } } }, }, }, }, }, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } }, }, }); if (!schedule) return null; - const totalSeats = (schedule as any).coachAssignments.reduce((s: number, a: any) => s + a.coach.seats.length, 0); - const allBookingSeats = (schedule as any).bookings.flatMap((b: any) => b.seats); + const totalSeats = (schedule as any).coachAssignments.reduce( + (s: number, a: any) => s + a.coach.seats.length, + 0, + ); + const allBookingSeats = (schedule as any).bookings.flatMap( + (b: any) => b.seats, + ); const totalPassengers = allBookingSeats.length; - const occupancyRate = totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0; + const occupancyRate = + totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0; // Per-coach breakdown - const coachMap = new Map(); + const coachMap = new Map< + string, + { + coachNumber: string; + coachType: string; + totalSeats: number; + booked: number; + } + >(); for (const assignment of (schedule as any).coachAssignments) { const c = assignment.coach; coachMap.set(c.id, { coachNumber: c.number, - coachType: (c as any).coachType?.name ?? 'Unknown', + coachType: (c as any).coachType?.name ?? "Unknown", totalSeats: c.seats.length, booked: 0, }); @@ -244,56 +313,91 @@ export class ReportsService { const coachId = bs.seat?.coachId; if (coachId && coachMap.has(coachId)) coachMap.get(coachId)!.booked++; } - const byCoach = [...coachMap.values()].map(c => ({ + const byCoach = [...coachMap.values()].map((c) => ({ ...c, - occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0, + occupancyRate: + c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0, })); // Per-origin station breakdown (using booking's originStationId) - const originMap = new Map(); + const originMap = new Map< + string, + { stationName: string; passengers: number } + >(); for (const booking of (schedule as any).bookings) { const stationId = booking.originStationId ?? schedule.originStationId; - const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name - ?? (schedule as any).originStation?.name - ?? stationId; - if (!originMap.has(stationId)) originMap.set(stationId, { stationName, passengers: 0 }); + const stationName = + (schedule as any).stopTimes.find( + (st: any) => st.stationId === stationId, + )?.station?.name ?? + (schedule as any).originStation?.name ?? + stationId; + if (!originMap.has(stationId)) + originMap.set(stationId, { stationName, passengers: 0 }); originMap.get(stationId)!.passengers += booking.seats.length; } - const byOrigin = [...originMap.values()].sort((a, b) => b.passengers - a.passengers); + const byOrigin = [...originMap.values()].sort( + (a, b) => b.passengers - a.passengers, + ); // Per-destination station breakdown - const destMap = new Map(); + const destMap = new Map< + string, + { stationName: string; passengers: number } + >(); for (const booking of (schedule as any).bookings) { - const stationId = booking.destinationStationId ?? schedule.destinationStationId; - const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name - ?? (schedule as any).destinationStation?.name - ?? stationId; - if (!destMap.has(stationId)) destMap.set(stationId, { stationName, passengers: 0 }); + const stationId = + booking.destinationStationId ?? schedule.destinationStationId; + const stationName = + (schedule as any).stopTimes.find( + (st: any) => st.stationId === stationId, + )?.station?.name ?? + (schedule as any).destinationStation?.name ?? + stationId; + if (!destMap.has(stationId)) + destMap.set(stationId, { stationName, passengers: 0 }); destMap.get(stationId)!.passengers += booking.seats.length; } - const byDestination = [...destMap.values()].sort((a, b) => b.passengers - a.passengers); + const byDestination = [...destMap.values()].sort( + (a, b) => b.passengers - a.passengers, + ); // Per-class breakdown - const classMap = new Map(); + const classMap = new Map< + string, + { className: string; totalSeats: number; booked: number } + >(); for (const assignment of (schedule as any).coachAssignments) { - const typeName = (assignment.coach as any).coachType?.name ?? 'Unknown'; - if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 }); + const typeName = (assignment.coach as any).coachType?.name ?? "Unknown"; + if (!classMap.has(typeName)) + classMap.set(typeName, { + className: typeName, + totalSeats: 0, + booked: 0, + }); classMap.get(typeName)!.totalSeats += assignment.coach.seats.length; } for (const bs of allBookingSeats) { - const typeName = bs.seat?.coach?.coachType?.name ?? 'Unknown'; - if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 }); + const typeName = bs.seat?.coach?.coachType?.name ?? "Unknown"; + if (!classMap.has(typeName)) + classMap.set(typeName, { + className: typeName, + totalSeats: 0, + booked: 0, + }); classMap.get(typeName)!.booked++; } - const byClass = [...classMap.values()].map(c => ({ + const byClass = [...classMap.values()].map((c) => ({ ...c, - occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0, + occupancyRate: + c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0, })); return { schedule: { id: schedule.id, - trainName: (schedule as any).train?.name ?? (schedule as any).train?.number, + trainName: + (schedule as any).train?.name ?? (schedule as any).train?.number, origin: (schedule as any).originStation?.name, destination: (schedule as any).destinationStation?.name, departureAt: schedule.departureAt, @@ -307,15 +411,68 @@ export class ReportsService { }; } + async listSchedulesForPicker() { + const schedules = await this.prisma.trainSchedule.findMany({ + select: { + id: true, + departureAt: true, + train: { select: { number: true } }, + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + }, + orderBy: { departureAt: "desc" }, + take: 200, + }); + return schedules.map((s) => ({ + id: s.id, + label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" })}`, + })); + } + + async getPassengerList(scheduleId: string) { + const seats = await this.prisma.bookingSeat.findMany({ + where: { + scheduleId, + booking: { status: { in: ["CONFIRMED", "BOARDED"] } }, + }, + include: { + booking: { select: { bookingRef: true, status: true } }, + seat: { + include: { + coach: { + select: { number: true, coachType: { select: { name: true } } }, + }, + }, + }, + }, + orderBy: [{ seat: { coach: { number: "asc" } } }], + }); + return seats.map((bs) => ({ + bookingRef: bs.booking.bookingRef, + bookingStatus: bs.booking.status, + passengerName: bs.passengerName, + passengerCategory: bs.passengerCategory, + idDocumentType: bs.idDocumentType, + idDocumentNumber: bs.idDocumentNumber, + passportNumber: bs.passportNumber, + passportCountry: bs.passportCountry, + seatLabel: bs.seatLabelSnapshot, + coachNumber: bs.seat?.coach?.number ?? null, + coachType: (bs.seat?.coach as any)?.coachType?.name ?? null, + })); + } + async getReport(reportId: string) { - return this.prisma.operationalReport.findUnique({ where: { id: reportId } }); + return this.prisma.operationalReport.findUnique({ + where: { id: reportId }, + }); } async listReports(reportType?: string) { return this.prisma.operationalReport.findMany({ where: reportType ? { reportType } : {}, - orderBy: { createdAt: 'desc' }, - take: 50 + orderBy: { createdAt: "desc" }, + take: 50, }); } } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 834973c71..c66dca06d 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -31,6 +31,16 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; export class SeatsController { constructor(private service: SeatsService) {} + // ── Blocked Seats ───────────────────────────────────────────────────────── + @Get('blocks') + @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'List all blocked seats with reason and coach info' }) + @ApiResponse({ status: 200, description: 'Blocked seat records' }) + getBlockedSeats() { + return this.service.getBlockedSeats(); + } + // ── Coach Availability ──────────────────────────────────────────────────── @Get('coaches/:scheduleId') @SetMetadata('isPublic', true) diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 3d87c8331..91af5e11d 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -615,6 +615,26 @@ export class SeatsService { await this.prisma.journey.deleteMany({ where: { bookingId } as any }); } + async getBlockedSeats() { + const blocks = await this.prisma.seatBlock.findMany({ + include: { + seat: { include: { coach: { select: { number: true } } } }, + }, + orderBy: { blockedAt: 'desc' }, + }); + return blocks.map(b => ({ + id: b.id, + seatId: b.seatId, + seatNumber: b.seat.seatNumber, + coachNumber: b.seat.coach.number, + scheduleId: b.scheduleId, + reason: b.reason, + blockedBy: b.blockedBy, + blockedAt: b.blockedAt, + unblockAt: b.unblockAt, + })); + } + async getCoachesWithAvailability(scheduleId: string, originStationId?: string, destinationStationId?: string) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index 2e5063fc0..b248fd68a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -1,48 +1,77 @@ -'use client'; +"use client"; -import { useQuery } from '@tanstack/react-query'; -import { PermissionGuard } from '@/components/layout/PermissionGuard'; -import { PERMS } from '@/lib/permissions'; -import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight, ScanLine } from 'lucide-react'; -import { dashboardApi } from '@/lib/api/dashboard'; -import { apiClient } from '@/lib/api-client'; -import { formatCurrency } from '@/lib/utils'; -import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'; -import Link from 'next/link'; +import { useQuery } from "@tanstack/react-query"; +import { PermissionGuard } from "@/components/layout/PermissionGuard"; +import { PERMS } from "@/lib/permissions"; +import { + Ticket, + AlertCircle, + BookOpen, + Banknote, + ArrowRight, + ScanLine, +} from "lucide-react"; +import { dashboardApi } from "@/lib/api/dashboard"; +import { apiClient } from "@/lib/api-client"; +import { formatCurrency } from "@/lib/utils"; +import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts"; +import Link from "next/link"; -const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']; +const COLORS = ["#2563eb", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6"]; function StatCard({ - icon, iconBg, label, total, loading, rows, href, + icon, + iconBg, + label, + total, + loading, + rows, + href, }: { icon: React.ReactNode; iconBg: string; label: string; total: number; loading: boolean; - rows: { label: string; value: number; icon?: React.ReactNode; href: string }[]; + rows: { + label: string; + value: number; + icon?: React.ReactNode; + href: string; + }[]; href: string; }) { return (
{icon}
- {label} + + {label} +

- {loading ? '—' : total.toLocaleString()} + {loading ? "—" : total.toLocaleString()}

{rows.map((r) => (
- {r.icon}{r.label} - - {loading ? '—' : r.value.toLocaleString()} + + {r.icon} + {r.label} + + + {loading ? "—" : r.value.toLocaleString()}
))}
- + View all
@@ -50,7 +79,12 @@ function StatCard({ } function RevenueSection({ - label, bookingCount, rows, subtotal, loading, renderRow, + label, + bookingCount, + rows, + subtotal, + loading, + renderRow, }: { label: React.ReactNode; bookingCount: number; @@ -66,16 +100,22 @@ function RevenueSection({ {label} - {loading ? '—' : bookingCount.toLocaleString()} bookings + {loading ? "—" : bookingCount.toLocaleString()} bookings - {rows.length === 0 - ?

No revenue yet

- : rows.map(renderRow)} + {rows.length === 0 ? ( +

No revenue yet

+ ) : ( + rows.map(renderRow) + )} {rows.length > 0 && (
- Subtotal - {formatCurrency(subtotal, 'ETB')} + + Subtotal + + + {formatCurrency(subtotal, "ETB")} +
)} @@ -84,19 +124,25 @@ function RevenueSection({ function DashboardPageContent() { const { data: exchangeRates = [] } = useQuery({ - queryKey: ['currencies'], - queryFn: () => apiClient.get('/currencies'), - select: (d: any) => (Array.isArray(d) ? d : d?.data ?? d?.items ?? []), + queryKey: ["currencies"], + queryFn: () => apiClient.get("/currencies"), + select: (d: any) => (Array.isArray(d) ? d : (d?.data ?? d?.items ?? [])), }); const toEtbRate = (currency: string): number | null => { - if (currency === 'ETB') return 1; - const r = exchangeRates.find((x: any) => x.fromCurrency === 'ETB' && x.toCurrency === currency); + if (currency === "ETB") return 1; + const r = exchangeRates.find( + (x: any) => x.fromCurrency === "ETB" && x.toCurrency === currency, + ); return r ? 1 / r.rate : null; }; - const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({ - queryKey: ['backoffice-stats'], + const { + data: stats, + isLoading: statsLoading, + error: statsError, + } = useQuery({ + queryKey: ["backoffice-stats"], queryFn: dashboardApi.getBackofficeStats, retry: 1, staleTime: 30000, @@ -104,7 +150,7 @@ function DashboardPageContent() { }); const { data: paymentMethods } = useQuery({ - queryKey: ['payment-methods'], + queryKey: ["payment-methods"], queryFn: dashboardApi.getPaymentMethods, retry: 1, }); @@ -121,20 +167,31 @@ function DashboardPageContent() { const packageGrand = calcGrand(packageRows); const overallGrand = normalGrand + packageGrand; - const renderRevenueRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => { + const renderRevenueRow = ({ + currency, + totalMinor, + }: { + currency: string; + totalMinor: number; + }) => { const rate = toEtbRate(currency); const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null; return ( -
+
- {currency} + + {currency} +
{formatCurrency(totalMinor, currency)} - {currency !== 'ETB' && etbMinor !== null && ( + {currency !== "ETB" && etbMinor !== null && ( - ({formatCurrency(etbMinor, 'ETB')}) + ({formatCurrency(etbMinor, "ETB")}) )} @@ -147,15 +204,16 @@ function DashboardPageContent() {

Dashboard

-

Welcome back! Here's your operational summary.

+

+ Welcome back! Here's your operational summary. +

- - Boarding + + Boarding
@@ -164,39 +222,42 @@ function DashboardPageContent() {
-

Some data may be outdated

-

Unable to fetch live data. Showing cached or sample information.

+

+ Some data may be outdated +

+

+ Unable to fetch live data. Showing cached or sample information. +

)} {/* Stat cards */} -
+
} + icon={ + + } iconBg="bg-blue-100 dark:bg-blue-900/30" label="Bookings" total={stats?.totalBookings ?? 0} loading={statsLoading} href="/bookings" rows={[ - { label: 'Regular', value: stats?.totalNormalBookings ?? 0, href: '/bookings' }, - { label: 'Package', value: stats?.totalPackageBookings ?? 0, href: '/package-bookings' }, - ]} - /> - } - iconBg="bg-emerald-100 dark:bg-emerald-900/30" - label="Tickets" - total={stats?.totalTickets ?? 0} - loading={statsLoading} - href="/tickets" - rows={[ - { label: 'Regular', value: stats?.totalNormalTickets ?? 0, href: '/tickets' }, - { label: 'Package', value: stats?.totalPackageTickets ?? 0, href: '/tickets' }, + { + label: "Regular", + value: stats?.totalNormalBookings ?? 0, + href: "/bookings", + }, + { + label: "Package", + value: stats?.totalPackageBookings ?? 0, + href: "/package-bookings", + }, ]} /> + {/* Tickets card hidden temporarily */} {/* Revenue card */}
@@ -204,26 +265,35 @@ function DashboardPageContent() {
- Revenue + + Revenue +
{statsLoading ? (

Loading…

) : ( <>

- {formatCurrency(overallGrand, 'ETB')} + {formatCurrency(overallGrand, "ETB")}

Regular

-

{formatCurrency(normalGrand, 'ETB')}

+

+ {formatCurrency(normalGrand, "ETB")} +

Package

-

{formatCurrency(packageGrand, 'ETB')}

+

+ {formatCurrency(packageGrand, "ETB")} +

- + View payments @@ -233,7 +303,9 @@ function DashboardPageContent() { {/* Revenue breakdown */}
-

Revenue Breakdown

+

+ Revenue Breakdown +

{statsLoading ? (

Loading…

) : !normalRows.length && !packageRows.length ? ( @@ -263,12 +335,25 @@ function DashboardPageContent() { {/* Payment Methods Distribution */} {paymentMethods && paymentMethods.length > 0 && (
-

Payment Methods Distribution

+

+ Payment Methods Distribution +

- + {paymentMethods.map((_: any, index: number) => ( - + ))} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index 8ff54da6a..0170a3b8e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -1,21 +1,53 @@ -'use client'; +"use client"; -import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { Users, Armchair, TrendingUp, Train, Download } from 'lucide-react'; -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'; -import { apiClient } from '@/lib/api-client'; -import { formatDateTime } from '@/lib/utils'; -import ActionButton from '@/components/ui/ActionButton'; +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Users, Armchair, TrendingUp, Train, Download } from "lucide-react"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Cell, +} from "recharts"; +import { apiClient } from "@/lib/api-client"; +import { formatDateTime } from "@/lib/utils"; +import ActionButton from "@/components/ui/ActionButton"; -const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4']; +const COLORS = [ + "#10b981", + "#3b82f6", + "#f59e0b", + "#8b5cf6", + "#ef4444", + "#06b6d4", +]; -function StatCard({ label, value, sub, icon: Icon, color }: { label: string; value: string | number; sub?: string; icon: any; color: string }) { +function StatCard({ + label, + value, + sub, + icon: Icon, + color, +}: { + label: string; + value: string | number; + sub?: string; + icon: any; + color: string; +}) { return (
-

{label}

-
+

+ {label} +

+
+ +

{value}

{sub &&

{sub}

} @@ -23,47 +55,141 @@ function StatCard({ label, value, sub, icon: Icon, color }: { label: string; val ); } +interface PassengerRow { + bookingRef: string; + bookingStatus: string; + passengerName: string; + passengerCategory: string; + idDocumentType: string | null; + idDocumentNumber: string | null; + passportNumber: string | null; + passportCountry: string | null; + seatLabel: string | null; + coachNumber: string | null; + coachType: string | null; +} + +type Tab = "occupancy" | "list"; + export default function PassengersReportPage() { - const [scheduleId, setScheduleId] = useState(''); + const [scheduleId, setScheduleId] = useState(""); + const [tab, setTab] = useState("occupancy"); + const [listSearch, setListSearch] = useState(""); - const { data: schedules = [] } = useQuery({ - queryKey: ['schedules-list'], - queryFn: () => apiClient.get('/schedules'), - select: (d: any) => d?.items ?? (Array.isArray(d) ? d : []), + const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery< + ScheduleOption[] + >({ + queryKey: ["report-schedules"], + queryFn: () => apiClient.get("/reports/schedules"), }); + const schedules = schedulesRaw ?? []; - const { data, isFetching } = useQuery({ - queryKey: ['occupancy-report', scheduleId], - queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`), + const { data, isLoading, isError } = useQuery({ + queryKey: ["passengers-report", scheduleId], + queryFn: () => + apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`), enabled: !!scheduleId, }); - const report = data as any; + const { data: passengerList = [], isLoading: listLoading } = useQuery< + PassengerRow[] + >({ + queryKey: ["passengers-list", scheduleId], + queryFn: () => + apiClient.get(`/reports/passengers/list?scheduleId=${scheduleId}`), + enabled: !!scheduleId, + }); - const doExport = () => { - if (!report) return; - const rows = [ - ['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy %'], - ...report.byCoach.map((c: any) => [c.coachNumber, c.coachType, c.totalSeats, c.booked, c.occupancyRate]), - ]; - const csv = rows.map(r => r.map((v: any) => `"${v}"`).join(',')).join('\n'); - const blob = new Blob([csv], { type: 'text/csv' }); + const filteredList = listSearch.trim() + ? passengerList.filter( + (p) => + p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) || + p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()) || + (p.idDocumentNumber ?? "") + .toLowerCase() + .includes(listSearch.toLowerCase()) || + (p.passportNumber ?? "") + .toLowerCase() + .includes(listSearch.toLowerCase()), + ) + : passengerList; + + const downloadCsv = (csv: string, filename: string) => { + const blob = new Blob([csv], { type: "text/csv" }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); + const a = document.createElement("a"); a.href = url; - a.download = `occupancy-${scheduleId}-${new Date().toISOString().split('T')[0]}.csv`; + a.download = filename; a.click(); URL.revokeObjectURL(url); }; + const doExportOccupancy = () => { + if (!data) return; + const rows = data.byCoach.map((c) => [ + c.coachNumber, + c.coachType, + String(c.totalSeats), + String(c.booked), + `${c.occupancyRate}%`, + ]); + downloadCsv( + [ + ["Coach", "Type", "Total Seats", "Booked", "Occupancy"].join(","), + ...rows.map((r) => r.join(",")), + ].join("\n"), + `occupancy-${scheduleId}.csv`, + ); + }; + + const doExportList = () => { + if (!passengerList.length) return; + const headers = [ + "Booking Ref", + "Status", + "Name", + "Category", + "ID Type", + "ID Number", + "Passport", + "Country", + "Seat", + "Coach", + "Class", + ]; + const rows = passengerList.map((p) => + [ + p.bookingRef, + p.bookingStatus, + p.passengerName, + p.passengerCategory, + p.idDocumentType ?? "", + p.idDocumentNumber ?? "", + p.passportNumber ?? "", + p.passportCountry ?? "", + p.seatLabel ?? "", + p.coachNumber ?? "", + p.coachType ?? "", + ].map((v) => `"${String(v).replace(/"/g, '""')}"`), + ); + downloadCsv( + [headers.join(","), ...rows.map((r) => r.join(","))].join("\n"), + `passengers-${scheduleId}.csv`, + ); + }; + return (
-

Passengers Report

-

Select a schedule to view passenger occupancy breakdown

+

+ Passengers Report +

+

+ Select a schedule to view passenger occupancy breakdown +

- {/* Schedule Selector */} + {/* Schedule selector */}
@@ -71,27 +197,54 @@ export default function PassengersReportPage() {
- {isFetching &&

Loading…

} - {report && ( - + {data && tab === "occupancy" && ( + + Export CSV + + )} + {passengerList.length > 0 && tab === "list" && ( + Export CSV )}
+ {(isLoading || listLoading) && ( +

Loading…

+ )} + {isError && ( +

Failed to load report.

+ )}
{isFetching && ( -
Loading passengers data…
+
+ Loading passengers data… +
)} {report && ( @@ -104,204 +257,305 @@ export default function PassengersReportPage() {

{report.schedule.trainName}

- {report.schedule.origin} → {report.schedule.destination} · Departure: {formatDateTime(report.schedule.departureAt)} + {report.schedule.origin} → {report.schedule.destination} · + Departure: {formatDateTime(report.schedule.departureAt)}

- {/* Summary Cards */} -
- - - + {/* Tabs */} +
+ +
-
- {/* By Coach */} -
-

Occupancy by Coach

- {report.byCoach.length > 0 ? ( - <> - - - - `${v}%`} tick={{ fontSize: 11 }} /> - `Coach ${v}`} /> - [`${v}%`, 'Occupancy']} /> - - {report.byCoach.map((_: any, i: number) => ( - - ))} - - - - + {/* Occupancy tab */} + {tab === "occupancy" && ( +
+
+
+
+

+ Total Seats +

+
+ +
+
+

+ {data.summary.totalSeats} +

+
+
+
+

+ Passengers +

+
+ +
+
+

+ {data.summary.totalPassengers} +

+
+
+
+

+ Occupancy Rate +

+
+ +
+
+

+ {data.summary.occupancyRate}% +

+
+
+
+
+
+ +
+

+ By Coach +

+
+
- - - - - - + + + + + + - - {report.byCoach.map((c: any, i: number) => ( - - - - - - + + {data.byCoach.map((c) => ( + + + + + + ))}
CoachTypeBookedTotalRate
CoachTypeSeatsBookedOccupancy
Coach {c.coachNumber}{c.coachType}{c.booked}{c.totalSeats}{c.occupancyRate}%
+ {c.coachNumber} + + {c.coachType} + + {c.totalSeats} + + {c.booked} + +
+
+
+
+ + {c.occupancyRate}% + +
+
- - ) : ( -

No coach data

- )} -
+
+
- {/* By Class */} -
-

Occupancy by Class

- {report.byClass.length > 0 ? ( - <> - - - - - `${v}%`} tick={{ fontSize: 11 }} /> - [`${v}%`, 'Occupancy']} /> - - {report.byClass.map((_: any, i: number) => ( - - ))} - - - - - - - - - - +
+
+

+ By Class +

+
+ {data.byClass.map((c) => ( +
+
+ {c.className} + + {c.booked}/{c.totalSeats} + +
+
+
+
+
+ + {c.occupancyRate}% + +
+
+ ))} +
+
+
+

+ By Boarding Station +

+
+ {data.byOrigin.map((o) => ( +
+ + {o.stationName} + + + {o.passengers} + +
+ ))} + {data.byOrigin.length === 0 && ( +

No data

+ )} +
+
+
+

+ By Alighting Station +

+
+ {data.byDestination.map((d) => ( +
+ + {d.stationName} + + + {d.passengers} + +
+ ))} + {data.byDestination.length === 0 && ( +

No data

+ )} +
+
+
+
+ )} + + {/* Passenger List tab */} + {tab === "list" && ( +
+ setListSearch(e.target.value)} + /> +
+
ClassBookedTotalRate
+ + + + + + + + + + + + + + {filteredList.map((p, i) => ( + + + + + + + + + - - - {report.byClass.map((c: any, i: number) => ( - - - - - - - ))} - -
#NameCategoryID / PassportSeatCoachBooking RefStatus
+ {i + 1} + + {p.passengerName} + + + {p.passengerCategory} + + + {p.idDocumentNumber ?? p.passportNumber ?? "—"} + {p.passportCountry && ( + + ({p.passportCountry}) + + )} + + {p.seatLabel ?? "—"} + + {p.coachNumber ?? "—"} + {p.coachType && ( + + ({p.coachType}) + + )} + + {p.bookingRef} + + + {p.bookingStatus} + +
{c.className}{c.booked}{c.totalSeats}{c.occupancyRate}%
- - ) : ( -

No class data

- )} -
- - {/* By Origin */} -
-

Passengers by Boarding Station

- {report.byOrigin.length > 0 ? ( - - - - - - - - - - {report.byOrigin.map((o: any, i: number) => { - const pct = report.summary.totalPassengers > 0 - ? ((o.passengers / report.summary.totalPassengers) * 100).toFixed(1) - : '0'; - return ( - - - - - - ); - })} + ))} + {filteredList.length === 0 && ( + + + + )}
StationPassengersShare
-
-
- {o.stationName} -
-
{o.passengers}{pct}%
+ No passengers found +
- ) : ( -

No boarding station data

- )} +
- - {/* By Destination */} -
-

Passengers by Alighting Station

- {report.byDestination.length > 0 ? ( - - - - - - - - - - {report.byDestination.map((d: any, i: number) => { - const pct = report.summary.totalPassengers > 0 - ? ((d.passengers / report.summary.totalPassengers) * 100).toFixed(1) - : '0'; - return ( - - - - - - ); - })} - -
StationPassengersShare
-
-
- {d.stationName} -
-
{d.passengers}{pct}%
- ) : ( -

No alighting station data

- )} -
-
+ )} )} {!report && !isFetching && scheduleId && ( -
No data found for this schedule.
+
+ No data found for this schedule. +
)} {!scheduleId && ( diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx index 08b4f2462..b861bce18 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx @@ -1,13 +1,19 @@ -'use client'; +"use client"; -import { useState, useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { Download, Armchair, CheckCircle, Clock, AlertCircle, Ban } from 'lucide-react'; -import { bookingsApi } from '@/lib/api'; -import { dashboardApi } from '@/lib/api/dashboard'; -import Badge from '@/components/ui/Badge'; -import ActionButton from '@/components/ui/ActionButton'; -import { formatDateTime, formatCurrency } from '@/lib/utils'; +import { useState, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Download, + Armchair, + CheckCircle, + Clock, + AlertCircle, + Ban, +} from "lucide-react"; +import { bookingsApi, seatsApi } from "@/lib/api"; +import Badge from "@/components/ui/Badge"; +import ActionButton from "@/components/ui/ActionButton"; +import { formatDateTime, formatCurrency } from "@/lib/utils"; interface SeatRow { bookingRef: string; @@ -28,12 +34,15 @@ interface SeatRow { const HOLD_DURATION_MS = 5 * 60 * 1000; function getReleaseAt(booking: any, seat: any): string | null { - const paymentStatus = booking.paymentIntent?.status || 'PENDING'; - if (paymentStatus === 'SUCCEEDED' || paymentStatus === 'COMPLETED') return null; - if (booking.status === 'CONFIRMED') return null; + const paymentStatus = booking.paymentIntent?.status || "PENDING"; + if (paymentStatus === "SUCCEEDED" || paymentStatus === "COMPLETED") + return null; + if (booking.status === "CONFIRMED") return null; if (seat?.holdExpiresAt) return seat.holdExpiresAt; if (booking.createdAt) { - return new Date(new Date(booking.createdAt).getTime() + HOLD_DURATION_MS).toISOString(); + return new Date( + new Date(booking.createdAt).getTime() + HOLD_DURATION_MS, + ).toISOString(); } return null; } @@ -44,17 +53,21 @@ function isExpired(releaseAt: string | null): boolean { } export default function SeatStatusReportPage() { - const [statusFilter, setStatusFilter] = useState<'ALL' | 'PAID' | 'UNPAID'>('ALL'); - const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">( + "ALL", + ); + const [search, setSearch] = useState(""); - const { data: stats } = useQuery({ - queryKey: ['backoffice-stats'], - queryFn: dashboardApi.getBackofficeStats, - staleTime: 30000, + const { data: blockedSeats = [] } = useQuery({ + queryKey: ["blocked-seats"], + queryFn: () => + seatsApi + .getBlocked() + .then((r: any) => (Array.isArray(r) ? r : (r?.data ?? []))), }); const { data: bookingsData, isLoading } = useQuery({ - queryKey: ['seat-report-bookings'], + queryKey: ["seat-report-bookings"], queryFn: () => bookingsApi.getAll({ pageSize: 1000 }), }); @@ -63,25 +76,30 @@ export default function SeatStatusReportPage() { const result: SeatRow[] = []; for (const booking of bookings) { - if (booking.status === 'CANCELLED') continue; + if (booking.status === "CANCELLED") continue; const seats: any[] = booking.seats || []; - const paymentStatus = booking.paymentIntent?.status || 'PENDING'; + const paymentStatus = booking.paymentIntent?.status || "PENDING"; for (const seat of seats) { result.push({ - bookingRef: booking.bookingRef || '—', - passengerName: seat.passengerName || seat.name || booking.passengerNames?.[0] || '—', - seatNumber: seat.seat?.seatNumber || seat.seatNumber || '—', - coachNumber: seat.seat?.coach?.number || seat.coach || '—', + bookingRef: booking.bookingRef || "—", + passengerName: + seat.passengerName || + seat.name || + booking.passengerNames?.[0] || + "—", + seatNumber: seat.seat?.seatNumber || seat.seatNumber || "—", + coachNumber: seat.seat?.coach?.number || seat.coach || "—", fareMinor: seat.fareMinor ?? 0, - currency: booking.currency || 'ETB', + currency: booking.currency || "ETB", paymentStatus, bookingStatus: booking.status, bookedAt: booking.createdAt, releaseAt: getReleaseAt(booking, seat), - scheduleOrigin: booking.schedule?.originStation?.name || '—', - scheduleDestination: booking.schedule?.destinationStation?.name || '—', - scheduleDeparture: booking.schedule?.departureAt || '', + scheduleOrigin: booking.schedule?.originStation?.name || "—", + scheduleDestination: + booking.schedule?.destinationStation?.name || "—", + scheduleDeparture: booking.schedule?.departureAt || "", }); } } @@ -91,9 +109,10 @@ export default function SeatStatusReportPage() { const filtered = useMemo(() => { return rows.filter((r) => { - const isPaid = r.paymentStatus === 'SUCCEEDED' || r.paymentStatus === 'COMPLETED'; - if (statusFilter === 'PAID' && !isPaid) return false; - if (statusFilter === 'UNPAID' && isPaid) return false; + const isPaid = + r.paymentStatus === "SUCCEEDED" || r.paymentStatus === "COMPLETED"; + if (statusFilter === "PAID" && !isPaid) return false; + if (statusFilter === "UNPAID" && isPaid) return false; if (search) { const q = search.toLowerCase(); return ( @@ -108,17 +127,29 @@ export default function SeatStatusReportPage() { }, [rows, statusFilter, search]); const paidCount = rows.filter( - (r) => r.paymentStatus === 'SUCCEEDED' || r.paymentStatus === 'COMPLETED' + (r) => r.paymentStatus === "SUCCEEDED" || r.paymentStatus === "COMPLETED", ).length; const unpaidCount = rows.length - paidCount; const expiredCount = rows.filter((r) => isExpired(r.releaseAt)).length; const doExport = () => { - if (!filtered.length) { alert('No data to export'); return; } + if (!filtered.length) { + alert("No data to export"); + return; + } const headers = [ - 'Booking Ref', 'Passenger', 'Seat', 'Coach', 'Fare', - 'Payment Status', 'Booking Status', 'Booked At', 'Release At', - 'Origin', 'Destination', 'Departure', + "Booking Ref", + "Passenger", + "Seat", + "Coach", + "Fare", + "Payment Status", + "Booking Status", + "Booked At", + "Release At", + "Origin", + "Destination", + "Departure", ]; const csvRows = filtered.map((r) => [ r.bookingRef, @@ -128,21 +159,21 @@ export default function SeatStatusReportPage() { formatCurrency(r.fareMinor, r.currency), r.paymentStatus, r.bookingStatus, - r.bookedAt ? formatDateTime(r.bookedAt) : '—', - r.releaseAt ? formatDateTime(r.releaseAt) : '—', + r.bookedAt ? formatDateTime(r.bookedAt) : "—", + r.releaseAt ? formatDateTime(r.releaseAt) : "—", r.scheduleOrigin, r.scheduleDestination, - r.scheduleDeparture ? formatDateTime(r.scheduleDeparture) : '—', + r.scheduleDeparture ? formatDateTime(r.scheduleDeparture) : "—", ]); const csv = [ - headers.map((h) => `"${h}"`).join(','), - ...csvRows.map((row) => row.map((v) => `"${v}"`).join(',')), - ].join('\n'); - const blob = new Blob([csv], { type: 'text/csv' }); + headers.map((h) => `"${h}"`).join(","), + ...csvRows.map((row) => row.map((v) => `"${v}"`).join(",")), + ].join("\n"); + const blob = new Blob([csv], { type: "text/csv" }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); + const a = document.createElement("a"); a.href = url; - a.download = `seat-status-report-${new Date().toISOString().split('T')[0]}.csv`; + a.download = `seat-status-report-${new Date().toISOString().split("T")[0]}.csv`; a.click(); URL.revokeObjectURL(url); }; @@ -150,9 +181,12 @@ export default function SeatStatusReportPage() { return (
-

Seat Status Report

+

+ Seat Status Report +

- Track booked seats — paid vs unpaid, booking times, and hold release times + Track booked seats — paid vs unpaid, booking times, and hold release + times

@@ -161,11 +195,15 @@ export default function SeatStatusReportPage() {
-

Paid Seats

+

+ Paid Seats +

{paidCount}

-

Payment confirmed

+

+ Payment confirmed +

@@ -174,11 +212,15 @@ export default function SeatStatusReportPage() {
-

Unpaid Seats

+

+ Unpaid Seats +

{unpaidCount}

-

Awaiting payment

+

+ Awaiting payment +

@@ -187,11 +229,15 @@ export default function SeatStatusReportPage() {
-

Expired Holds

+

+ Expired Holds +

{expiredCount}

-

Hold time passed, not paid

+

+ Hold time passed, not paid +

@@ -200,14 +246,38 @@ export default function SeatStatusReportPage() {
-

Blocked Seats

-

- {stats?.blockedSeatsCount ?? '—'} +

+ Blocked Seats +

+

+ {blockedSeats.length} +

+

+ Manually blocked

-

Globally blocked

+ {blockedSeats.length > 0 && ( +
+ {blockedSeats.map((b: any) => ( +
+ + Seat {b.seatNumber} · Coach {b.coachNumber} + + + {b.reason} + +
+ ))} +
+ )}
@@ -229,18 +299,27 @@ export default function SeatStatusReportPage() {
- + Export CSV
- {isLoading &&

Loading...

} + {isLoading && ( +

Loading...

+ )}
{/* Table */} @@ -250,14 +329,14 @@ export default function SeatStatusReportPage() { {[ - 'Booking Ref', - 'Passenger', - 'Seat / Coach', - 'Fare', - 'Payment', - 'Booked At', - 'Release At', - 'Route', + "Booking Ref", + "Passenger", + "Seat / Coach", + "Fare", + "Payment", + "Booked At", + "Release At", + "Route", ].map((h) => ( {filtered.map((row, i) => { const isPaid = - row.paymentStatus === 'SUCCEEDED' || row.paymentStatus === 'COMPLETED'; + row.paymentStatus === "SUCCEEDED" || + row.paymentStatus === "COMPLETED"; const expired = isExpired(row.releaseAt); return ( {row.bookingRef} - {row.passengerName} + + {row.passengerName} + {row.seatNumber} - {row.coachNumber !== '—' && ( - · Coach {row.coachNumber} + {row.coachNumber !== "—" && ( + + {" "} + · Coach {row.coachNumber} + )} {formatCurrency(row.fareMinor, row.currency)} - - {isPaid ? 'PAID' : row.paymentStatus} + + {isPaid ? "PAID" : row.paymentStatus} - {row.bookedAt ? formatDateTime(row.bookedAt) : '—'} + {row.bookedAt ? formatDateTime(row.bookedAt) : "—"} {isPaid ? ( @@ -308,13 +396,13 @@ export default function SeatStatusReportPage() { - {expired ? '⚠ ' : '⏱ '} + {expired ? "⚠ " : "⏱ "} {formatDateTime(row.releaseAt)} - {expired && ' (expired)'} + {expired && " (expired)"} ) : ( @@ -323,7 +411,9 @@ export default function SeatStatusReportPage() { {row.scheduleOrigin} → {row.scheduleDestination} {row.scheduleDeparture && ( -
{formatDateTime(row.scheduleDeparture)}
+
+ {formatDateTime(row.scheduleDeparture)} +
)} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 2453eafa2..1b917afe2 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -151,6 +151,7 @@ export const seatsApi = { return apiClient.get(`/seats/seatmap/${scheduleId}${params}`); }, getBySchedule: (scheduleId: string) => apiClient.get(`/seats/schedule/${scheduleId}`), + getBlocked: () => apiClient.get('/seats/blocks'), hold: (data: any) => apiClient.post('/seats/hold', data), release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`), block: (seatId: string, data: any) => apiClient.post(`/seats/${seatId}/block`, data),