diff --git a/apps/edr-passenger-api/prisma/migrations/20260718000001_add_route_stop_planned_times/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260718000001_add_route_stop_planned_times/migration.sql new file mode 100644 index 000000000..012ec84db --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260718000001_add_route_stop_planned_times/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedArrivalTime" INTEGER; +ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedDepartureTime" INTEGER; diff --git a/apps/edr-passenger-api/prisma/migrations/20260718000002_change_route_stop_planned_times_to_datetime/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260718000002_change_route_stop_planned_times_to_datetime/migration.sql new file mode 100644 index 000000000..4ff235be2 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260718000002_change_route_stop_planned_times_to_datetime/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "passenger"."RouteStop" DROP COLUMN "plannedArrivalTime"; +ALTER TABLE "passenger"."RouteStop" DROP COLUMN "plannedDepartureTime"; +ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedArrivalTime" TIMESTAMP(3); +ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedDepartureTime" TIMESTAMP(3); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 601410fae..62f658967 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1051,6 +1051,8 @@ model RouteStop { sequence Int distanceKm Float? checkinMinutesBefore Int? + plannedArrivalTime DateTime? + plannedDepartureTime DateTime? createdAt DateTime @default(now()) route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) diff --git a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts index d7257f179..95b3a916b 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts @@ -128,4 +128,13 @@ export class BookPackageDto { /** Number of child passengers (<5 years). Derived from passengers array if omitted. */ @ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() @Min(0) childCount?: number; + + /** + * SeatHold UUID returned by POST /seats/hold when the user selected seats on the + * seatmap before proceeding to book. When provided, the hold's expiry is extended + * to the payment deadline so the specific seat stays reserved on the seatmap for + * the full payment window, matching the behaviour of normal bookings. + */ + @ApiPropertyOptional({ description: 'SeatHold ID from seatmap selection' }) + @IsOptional() @IsUUID() holdId?: string; } diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index 1528b2967..c1a30f243 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -6,6 +6,7 @@ import { Currency } from '@prisma/client'; import { BookingsService } from '../bookings/bookings.service'; import { GuestBookingService } from '../bookings/guest-booking.service'; import { AuditService } from '../../common/audit.service'; +import { computePaymentDeadline, CUTOFF_MINUTES } from '../../common/utils/payment-deadline.utils'; /** Package-specific fare rules */ const PKG_MAX_ADULTS = 5; @@ -382,7 +383,10 @@ export class PackagesService { async book(dto: BookPackageDto, passengerId?: string) { const pkg = await this.prisma.travelPackage.findUnique({ where: { id: dto.packageId }, - include: { priceTiers: true }, + include: { + priceTiers: true, + outboundSchedule: { select: { departureAt: true, route: { select: { checkinMinutesBefore: true } } } }, + }, }); if (!pkg) throw new NotFoundException('Package not found'); if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking'); @@ -477,6 +481,27 @@ export class PackagesService { ]); }); + // Extend the seatmap SeatHold (if one was passed) to the payment deadline so the + // specific seat remains visually reserved on the seatmap during the full payment + // window — matching the behaviour of normal bookings (which call confirmSeats). + if (dto.holdId) { + const dep = (pkg as any).outboundSchedule?.departureAt as Date | undefined; + if (dep) { + const checkinMinutes = (pkg as any).outboundSchedule?.route?.checkinMinutesBefore ?? CUTOFF_MINUTES; + const paymentDeadline = computePaymentDeadline(booking.createdAt as Date, dep, checkinMinutes); + const hold = await this.prisma.seatHold.findUnique({ + where: { id: dto.holdId }, + select: { expiresAt: true }, + }); + if (hold && paymentDeadline > hold.expiresAt) { + await this.prisma.seatHold.update({ + where: { id: dto.holdId }, + data: { expiresAt: paymentDeadline }, + }); + } + } + } + return { ...booking, fareBreakdown: { 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 d5695c94d..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, }; } @@ -96,79 +106,115 @@ export class ReportsService { include: { coachAssignments: { include: { coach: { include: { seats: true } } } }, bookings: { - where: { status: { in: ['CONFIRMED', 'BOARDED'] } }, + 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.filter((s: any) => s.leg === 1).length, 0, + const tripData = schedules.map((schedule) => { + const totalSeats = schedule.coachAssignments.reduce( + (sum, a) => sum + a.coach.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 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", }; } @@ -176,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, }; } @@ -214,34 +263,48 @@ export class ReportsService { }, }, bookings: { - where: { status: { in: ['CONFIRMED', 'BOARDED'] } }, + where: { status: { in: ["CONFIRMED", "BOARDED"] } }, include: { seats: { - where: { leg: 1 }, + 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, }); @@ -250,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, @@ -313,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/schedules/routes.dto.ts b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts index f2db36e7c..fefa26ad1 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts @@ -7,6 +7,8 @@ export class RouteStopInputDto { @ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number; @ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number; @ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; + @ApiPropertyOptional({ example: '2026-06-15T06:30:00Z', description: 'Template planned arrival time at this stop. Only the time-of-day (EAT) is used when auto-populating new schedules. Omit for first stop.' }) @IsOptional() @IsDateString() plannedArrivalTime?: string; + @ApiPropertyOptional({ example: '2026-06-15T06:45:00Z', description: 'Template planned departure time from this stop. Only the time-of-day (EAT) is used when auto-populating new schedules. Omit for last stop.' }) @IsOptional() @IsDateString() plannedDepartureTime?: string; } export class CreateRouteDto { @@ -37,6 +39,8 @@ export class AddRouteStopDto { @ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number; @ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number; @ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; + @ApiPropertyOptional({ example: '2026-06-15T06:30:00Z', description: 'Template planned arrival time at this stop (only time-of-day is used)' }) @IsOptional() @IsDateString() plannedArrivalTime?: string; + @ApiPropertyOptional({ example: '2026-06-15T06:45:00Z', description: 'Template planned departure time from this stop (only time-of-day is used)' }) @IsOptional() @IsDateString() plannedDepartureTime?: string; } export class UpdateRouteDto { diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index 58e647180..dcbdbc3b0 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -37,6 +37,8 @@ export class RoutesService { sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, + plannedArrivalTime: s.plannedArrivalTime ? new Date(s.plannedArrivalTime) : null, + plannedDepartureTime: s.plannedDepartureTime ? new Date(s.plannedDepartureTime) : null, })), }, }, @@ -106,6 +108,8 @@ export class RoutesService { sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, + plannedArrivalTime: s.plannedArrivalTime ?? null, + plannedDepartureTime: s.plannedDepartureTime ?? null, })), }); } @@ -225,6 +229,8 @@ export class RoutesService { sequence: dto.sequence, distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null, checkinMinutesBefore: dto.checkinMinutesBefore ?? null, + plannedArrivalTime: dto.plannedArrivalTime ?? null, + plannedDepartureTime: dto.plannedDepartureTime ?? null, }, }); } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 5a648307d..12f918edd 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -65,6 +65,9 @@ export class UpdateScheduleDto { @ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus; @ApiPropertyOptional({ type: [CoachAssignmentDto], description: 'List of coaches to assign' }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => CoachAssignmentDto) coaches?: CoachAssignmentDto[]; @ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean; + @ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Planned times per stop — when provided, replaces all existing stop times for the schedule' }) + @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) + plannedTimes?: PlannedStopTimeDto[]; } export class UpdateStopTimeDto { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 7cca548aa..ecdf51666 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -133,26 +133,62 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - const totalDuration = arr.getTime() - dep.getTime(); - const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + const hasRouteTimes = route.stops.some( + s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null, + ); - plannedTimes = route.stops.map((stop, index) => { - let stopTime: Date; - if (index === 0) { - stopTime = dep; - } else if (index === route.stops.length - 1) { - stopTime = arr; - } else { - const stopDistance = stop.distanceKm || 0; - const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); - stopTime = new Date(dep.getTime() + totalDuration * progress); - } - return { - sequence: stop.sequence, - plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), - plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), + if (hasRouteTimes) { + // Extract EAT time-of-day from a template DateTime and anchor to the schedule's EAT date. + const EAT_MS = 3 * 60 * 60 * 1000; + const depEATMs = dep.getTime() + EAT_MS; + const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000); + const eatMidnightUTC = dep.getTime() - depMsIntoDay; + + const templateToScheduleUTC = (templateDt: Date): Date => { + // Pull the time-of-day in EAT from the template DateTime + const templateEATMs = templateDt.getTime() + EAT_MS; + const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000); + const candidate = new Date(eatMidnightUTC + timeOfDayMs); + // Overnight: if the stop time lands before departure, move to next day + if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000); + return candidate; }; - }); + + plannedTimes = route.stops.map((stop, index) => { + const arrDt: Date | null = (stop as any).plannedArrivalTime ?? null; + const depDt: Date | null = (stop as any).plannedDepartureTime ?? null; + return { + sequence: stop.sequence, + plannedArrivalAt: index > 0 && arrDt != null + ? templateToScheduleUTC(arrDt).toISOString() + : undefined, + plannedDepartureAt: index < route.stops.length - 1 && depDt != null + ? templateToScheduleUTC(depDt).toISOString() + : undefined, + }; + }); + } else { + const totalDuration = arr.getTime() - dep.getTime(); + const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + + plannedTimes = route.stops.map((stop, index) => { + let stopTime: Date; + if (index === 0) { + stopTime = dep; + } else if (index === route.stops.length - 1) { + stopTime = arr; + } else { + const stopDistance = stop.distanceKm || 0; + const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); + stopTime = new Date(dep.getTime() + totalDuration * progress); + } + return { + sequence: stop.sequence, + plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), + plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), + }; + }); + } } const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence)); @@ -304,26 +340,59 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - const totalDuration = arr.getTime() - dep.getTime(); - const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + const hasRouteTimes = route.stops.some( + s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null, + ); - plannedTimes = route.stops.map((stop, index) => { - let stopTime: Date; - if (index === 0) { - stopTime = dep; - } else if (index === route.stops.length - 1) { - stopTime = arr; - } else { - const stopDistance = stop.distanceKm || 0; - const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); - stopTime = new Date(dep.getTime() + totalDuration * progress); - } - return { - sequence: stop.sequence, - plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), - plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), + if (hasRouteTimes) { + const EAT_MS = 3 * 60 * 60 * 1000; + const depEATMs = dep.getTime() + EAT_MS; + const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000); + const eatMidnightUTC = dep.getTime() - depMsIntoDay; + + const templateToScheduleUTC = (templateDt: Date): Date => { + const templateEATMs = templateDt.getTime() + EAT_MS; + const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000); + const candidate = new Date(eatMidnightUTC + timeOfDayMs); + if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000); + return candidate; }; - }); + + plannedTimes = route.stops.map((stop, index) => { + const arrDt: Date | null = (stop as any).plannedArrivalTime ?? null; + const depDt: Date | null = (stop as any).plannedDepartureTime ?? null; + return { + sequence: stop.sequence, + plannedArrivalAt: index > 0 && arrDt != null + ? templateToScheduleUTC(arrDt).toISOString() + : undefined, + plannedDepartureAt: index < route.stops.length - 1 && depDt != null + ? templateToScheduleUTC(depDt).toISOString() + : undefined, + }; + }); + } else { + const totalDuration = arr.getTime() - dep.getTime(); + const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + + plannedTimes = route.stops.map((stop, index) => { + let stopTime: Date; + if (index === 0) { + stopTime = dep; + } else if (index === route.stops.length - 1) { + stopTime = arr; + } else { + const stopDistance = stop.distanceKm || 0; + const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); + stopTime = new Date(dep.getTime() + totalDuration * progress); + } + return { + sequence: stop.sequence, + plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), + plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), + }; + }); + } } const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); @@ -678,6 +747,12 @@ export class SchedulesService { } } + if (dto.plannedTimes && dto.plannedTimes.length > 0 && schedule.routeId) { + await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } }); + const plannedTimesMap = Object.fromEntries(dto.plannedTimes.map(t => [t.sequence, t])); + await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap); + } + return this.getSchedule(id); } 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 587b458de..91af5e11d 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -617,9 +617,6 @@ export class SeatsService { async getBlockedSeats() { const blocks = await this.prisma.seatBlock.findMany({ - where: { - NOT: { reason: { startsWith: 'MAINTENANCE:' } }, - }, include: { seat: { include: { coach: { select: { number: true } } } }, }, diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 2355485d4..4767c7eba 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -146,6 +146,7 @@ export class TasksService { await Promise.all([ this.sendPaymentReminders(now), this.cancelExpiredPendingBookings(now), + this.cancelExpiredPendingPackageBookings(now), ]); } @@ -333,6 +334,78 @@ export class TasksService { } } + // ── Cancel PackageBookings whose payment deadline has passed ────────────── + private async cancelExpiredPendingPackageBookings(now: Date) { + const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000); + const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000); + + const expiredBookings = await this.prisma.packageBooking.findMany({ + where: { + status: 'PENDING_PAYMENT', + OR: [ + { createdAt: { lte: twoHoursAgo } }, + { package: { outboundSchedule: { departureAt: { lte: departureCutoff } } } }, + ], + }, + include: { + package: { + include: { + outboundSchedule: { + include: { route: { select: { checkinMinutesBefore: true } } }, + }, + }, + }, + }, + }); + + let cancelledCount = 0; + + for (const booking of expiredBookings) { + try { + const createdAt = booking.createdAt as Date; + const dep = (booking.package as any).outboundSchedule.departureAt as Date; + const checkinMinutes = (booking.package as any).outboundSchedule.route?.checkinMinutesBefore ?? CUTOFF_MINUTES; + const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes); + if (now < paymentDeadline) continue; + + // Revert the tier's seat counters that were incremented when the booking was created. + const seatsReserved = booking.adultCount + Math.max(0, booking.childCount - booking.adultCount); + await this.prisma.packagePriceTier.update({ + where: { id: booking.priceTierId }, + data: { + bookedSeats: { decrement: seatsReserved }, + availableSeats: { increment: seatsReserved }, + }, + }); + + await this.prisma.packageBooking.update({ + where: { id: booking.id }, + data: { status: 'CANCELLED' }, + }); + + const message = + `EDR: Your package booking ${booking.bookingRef} ` + + `(departs ${fmtTime(dep)}) has been cancelled ` + + `because payment was not completed by ${fmtTime(paymentDeadline)}.`; + + if (booking.contactPhone) { + await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); + } + + this.logger.log(`Auto-cancelled package booking: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`); + cancelledCount++; + } catch (err) { + this.logger.error( + `Auto-cancel failed for package booking ${(booking as any).bookingRef}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + if (cancelledCount > 0) { + this.logger.log(`Auto-cancelled ${cancelledCount} expired pending package booking(s)`); + } + } + // ───────────────────────────────────────────────────────────────────────── // Daily at 02:00 EAT: purge expired/stale records to enforce data retention. // ───────────────────────────────────────────────────────────────────────── diff --git a/apps/edr-passenger-web/backoffice/package.json b/apps/edr-passenger-web/backoffice/package.json index 858fcd781..9d4327a42 100644 --- a/apps/edr-passenger-web/backoffice/package.json +++ b/apps/edr-passenger-web/backoffice/package.json @@ -19,6 +19,7 @@ "lucide-react": "^0.446.0", "next": "^14.2.0", "react": "^18.3.1", + "react-day-picker": "^9.14.0", "react-dom": "^18.3.1", "recharts": "^2.12.0", "socket.io-client": "^4.8.3", 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 5306fba00..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,8 +222,12 @@ 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. +

@@ -174,15 +236,25 @@ function DashboardPageContent() { {/* 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' }, + { + label: "Regular", + value: stats?.totalNormalBookings ?? 0, + href: "/bookings", + }, + { + label: "Package", + value: stats?.totalPackageBookings ?? 0, + href: "/package-bookings", + }, ]} /> {/* Tickets card hidden temporarily */} @@ -193,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 @@ -222,7 +303,9 @@ function DashboardPageContent() { {/* Revenue breakdown */}
-

Revenue Breakdown

+

+ Revenue Breakdown +

{statsLoading ? (

Loading…

) : !normalRows.length && !packageRows.length ? ( @@ -252,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 7be5b9b2e..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}

} @@ -25,72 +57,139 @@ function StatCard({ label, value, sub, icon: Icon, color }: { label: string; val interface PassengerRow { bookingRef: string; + bookingStatus: string; passengerName: string; - coachSeat: string; - origin: string; - destination: string; - departureAt: string | null; + 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'; +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 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 = ['#', 'Name', 'Coach·Seat', 'Origin', 'Destination', 'Date', 'Booking Ref']; - const rows = passengerList.map((p, i) => [ - String(i + 1), p.passengerName, p.coachSeat, p.origin, p.destination, - p.departureAt ? formatDateTime(p.departureAt) : '—', p.bookingRef, - ].map(v => `"${String(v).replace(/"/g, '""')}"`)); - downloadCsv([headers.join(','), ...rows.map(r => r.join(','))].join('\n'), `passengers-${scheduleId}.csv`); + 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 */}
@@ -98,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 && ( @@ -131,205 +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) => ( - - - - - - - - ))} - -
CoachTypeBookedTotalRate
CoachTypeSeatsBookedOccupancy
Coach {c.coachNumber}{c.coachType}{c.booked}{c.totalSeats}{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) => ( - - ))} - - - - - - - - - - - - - - {report.byClass.map((c: any, i: number) => ( - - - - - - - ))} - -
ClassBookedTotalRate
{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 ( - + + {data.byCoach.map((c) => ( + + + + + - - - ); - })} - -
StationPassengersShare
+ {c.coachNumber} + + {c.coachType} + + {c.totalSeats} + + {c.booked} +
-
- {o.stationName} +
+
+
+ + {c.occupancyRate}% +
{o.passengers}{pct}%
- ) : ( -

No boarding station data

- )} + ))} + + +
+
+ +
+
+

+ 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

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

Passengers by Alighting Station

- {report.byDestination.length > 0 ? ( + {/* Passenger List tab */} + {tab === "list" && ( +
+ setListSearch(e.target.value)} + /> +
- - - - + + + + + + + + + - - {report.byDestination.map((d: any, i: number) => { - const pct = report.summary.totalPassengers > 0 - ? ((d.passengers / report.summary.totalPassengers) * 100).toFixed(1) - : '0'; - return ( - - - - - - ); - })} + + {filteredList.map((p, i) => ( + + + + + + + + + + + ))} + {filteredList.length === 0 && ( + + + + )}
StationPassengersShare
#NameCategoryID / PassportSeatCoachBooking RefStatus
-
-
- {d.stationName} -
-
{d.passengers}{pct}%
+ {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} + +
+ No passengers found +
- ) : ( -

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/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index feca7e3b5..5fa655a5e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -10,6 +10,14 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { routesApi } from '@/lib/api/routes'; import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api'; +import DateTimePicker from '@/components/ui/DateTimePicker'; + +// EAT ↔ UTC helpers (same as schedules page) +const EAT_MS = 3 * 60 * 60 * 1000; +const isoToEAT = (iso: string): string => + new Date(new Date(iso).getTime() + EAT_MS).toISOString().slice(0, 16); +const eatToISO = (local: string): string => + new Date(new Date(local + ':00Z').getTime() - EAT_MS).toISOString(); interface RouteStop { stationId: string; @@ -17,6 +25,8 @@ interface RouteStop { distanceKm?: number; distanceFromOrigin?: number; checkinMinutesBefore?: number; + plannedArrivalTime?: string; + plannedDepartureTime?: string; } type Tab = 'routes' | 'coaches'; @@ -175,6 +185,8 @@ export default function RoutesPage() { const [destinationDistance, setDestinationDistance] = useState(undefined); const [originCheckinMinutes, setOriginCheckinMinutes] = useState(undefined); const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState(undefined); + const [originDepartureTime, setOriginDepartureTime] = useState(''); + const [destinationArrivalTime, setDestinationArrivalTime] = useState(''); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null }); const [search, setSearch] = useState(''); const queryClient = useQueryClient(); @@ -245,18 +257,27 @@ export default function RoutesPage() { // distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm) const stopsArray = [ - { stationId: originStationId, sequence: 1, distanceKm: 0, checkinMinutesBefore: originCheckinMinutes ?? undefined }, + { + stationId: originStationId, + sequence: 1, + distanceKm: 0, + checkinMinutesBefore: originCheckinMinutes ?? undefined, + plannedDepartureTime: originDepartureTime ? eatToISO(originDepartureTime) : undefined, + }, ...sortedMiddleStops.map((stop, idx) => ({ stationId: stop.stationId, sequence: idx + 2, distanceKm: stop.distanceFromOrigin || 0, checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined, + plannedArrivalTime: stop.plannedArrivalTime ? eatToISO(stop.plannedArrivalTime) : undefined, + plannedDepartureTime: stop.plannedDepartureTime ? eatToISO(stop.plannedDepartureTime) : undefined, })), { stationId: destinationStationId, sequence: sortedMiddleStops.length + 2, distanceKm: destinationDistance || 0, checkinMinutesBefore: destinationCheckinMinutes ?? undefined, + plannedArrivalTime: destinationArrivalTime ? eatToISO(destinationArrivalTime) : undefined, }, ]; @@ -387,8 +408,10 @@ export default function RoutesPage() { const destStop = routeStops[routeStops.length - 1]; setOriginStationId(originStop.stationId); setOriginCheckinMinutes(originStop.checkinMinutesBefore ?? undefined); + setOriginDepartureTime(originStop.plannedDepartureTime ? isoToEAT(originStop.plannedDepartureTime) : ''); setDestinationStationId(destStop.stationId); setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined); + setDestinationArrivalTime(destStop.plannedArrivalTime ? isoToEAT(destStop.plannedArrivalTime) : ''); setDestinationDistance(destStop.distanceKm || 0); setStops(routeStops.slice(1, -1).map((s: any) => ({ stationId: s.stationId, @@ -396,6 +419,8 @@ export default function RoutesPage() { distanceKm: s.distanceKm, distanceFromOrigin: s.distanceKm || 0, checkinMinutesBefore: s.checkinMinutesBefore ?? undefined, + plannedArrivalTime: s.plannedArrivalTime ? isoToEAT(s.plannedArrivalTime) : '', + plannedDepartureTime: s.plannedDepartureTime ? isoToEAT(s.plannedDepartureTime) : '', }))); } setShowModal(true); @@ -433,8 +458,10 @@ export default function RoutesPage() { setEditingRoute(null); setOriginStationId(''); setOriginCheckinMinutes(undefined); + setOriginDepartureTime(''); setDestinationStationId(''); setDestinationCheckinMinutes(undefined); + setDestinationArrivalTime(''); setDestinationDistance(undefined); setStops([]); setShowModal(true); @@ -519,7 +546,7 @@ export default function RoutesPage() { setSearch(''); }} title={`${editingRoute ? 'Edit' : 'Add'} Route`} - size="lg" + size="xl" >
{editingRoute && ( @@ -665,7 +692,7 @@ export default function RoutesPage() {
- Drag to rearrange · Cutoff min overrides route check-in window per stop (leave blank to inherit) + Drag to rearrange · Cutoff overrides check-in · Arr/Dep time sets default times (auto-filled on schedule creation)
@@ -683,7 +710,7 @@ export default function RoutesPage() { Select origin station above )}
-
+
-
0 km
+
+ +
+
0 km
{stops.map((stop, index) => ( @@ -729,7 +764,7 @@ export default function RoutesPage() { ))}
-
+
-
+
updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)} min={1} title="Check-in cutoff override (minutes) for this stop" />
+
+ updateStop(index, 'plannedDepartureTime', v)} + placeholder="Dep time" + label="Planned Departure" + /> +
+
+ updateStop(index, 'plannedArrivalTime', v)} + placeholder="Arr time" + label="Planned Arrival" + /> +
-
+
{destinationStationId && ( )}
-
+
+
+ {destinationStationId && ( + + )} +
+
{destinationStationId && ( + new Date(new Date(iso).getTime() + EAT_MS).toISOString().slice(0, 16); +// EAT "YYYY-MM-DDTHH:mm" → UTC ISO string for API submission +const eatToISO = (local: string): string => + new Date(new Date(local + ':00Z').getTime() - EAT_MS).toISOString(); +// Extract HH:mm in EAT from a UTC ISO datetime (e.g. route stop planned time) +const isoToEATTimePart = (iso: string): string | null => { + if (!iso) return null; + const eatMs = new Date(iso).getTime() + EAT_MS; + const msIntoDay = eatMs % (24 * 60 * 60 * 1000); + const h = Math.floor(msIntoDay / 3600000); + const m = Math.floor((msIntoDay % 3600000) / 60000); + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +}; + interface Schedule { id: string; trainId: string; @@ -24,6 +43,13 @@ interface Schedule { destinationStation?: { id: string; name: string }; coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>; isPackageOnly?: boolean; + stopTimes?: Array<{ + sequence: number; + stationId: string; + plannedDepartureAt: string | null; + plannedArrivalAt: string | null; + station?: { name: string }; + }>; } interface Train { @@ -72,6 +98,8 @@ export default function SchedulesPage() { const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]); + const [addStopTimes, setAddStopTimes] = useState<{ sequence: number; stationName: string; plannedArrivalAt: string; plannedDepartureAt: string }[]>([]); + const [editStopTimes, setEditStopTimes] = useState<{ sequence: number; stationName: string; plannedArrivalAt: string; plannedDepartureAt: string }[]>([]); const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({ queryKey: ['route-coaches', addForm.routeId], @@ -79,12 +107,42 @@ export default function SchedulesPage() { enabled: !!addForm.routeId, }); + const { data: addRouteDetail } = useQuery({ + queryKey: ['route-detail', addForm.routeId], + queryFn: () => apiClient.get(`/routes/${addForm.routeId}`), + enabled: !!addForm.routeId, + }); + + const { data: editRouteDetail } = useQuery({ + queryKey: ['route-detail', editingSchedule?.routeId], + queryFn: () => apiClient.get(`/routes/${editingSchedule!.routeId}`), + enabled: !!editingSchedule?.routeId, + }); + useEffect(() => { if (!addForm.routeId) { setAddCoachRows([]); return; } const rows: any[] = Array.isArray(singleRouteTemplate) ? singleRouteTemplate : (singleRouteTemplate as any)?.coaches ?? []; setAddCoachRows(rows.length ? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })) : []); }, [singleRouteTemplate, addForm.routeId]); + useEffect(() => { + const stops: any[] = (addRouteDetail as any)?.stops ?? []; + if (!stops.length) { setAddStopTimes([]); return; } + + const eatDateStr = addForm.departureAt ? addForm.departureAt.slice(0, 10) : null; + + setAddStopTimes(stops.map((s: any) => { + const arrTimePart = s.plannedArrivalTime ? isoToEATTimePart(s.plannedArrivalTime) : null; + const depTimePart = s.plannedDepartureTime ? isoToEATTimePart(s.plannedDepartureTime) : null; + return { + sequence: s.sequence, + stationName: s.station?.name ?? `Stop ${s.sequence}`, + plannedArrivalAt: eatDateStr && arrTimePart ? `${eatDateStr}T${arrTimePart}` : '', + plannedDepartureAt: eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : '', + }; + })); + }, [addRouteDetail, addForm.departureAt]); + // Fetch route coach template when route changes const { data: routeTemplate, isLoading: templateLoading } = useQuery({ queryKey: ['route-coaches', bulkForm.routeId], @@ -110,6 +168,24 @@ export default function SchedulesPage() { isPackageOnly: false, }); + useEffect(() => { + const stops: any[] = (editRouteDetail as any)?.stops ?? []; + if (!stops.length || !editingSchedule) return; + const hasRouteTimes = stops.some((s: any) => s.plannedArrivalTime || s.plannedDepartureTime); + if (!hasRouteTimes) return; + const eatDateStr = editForm.departureAt ? editForm.departureAt.slice(0, 10) : null; + setEditStopTimes(stops.map((s: any) => { + const arrTimePart = s.plannedArrivalTime ? isoToEATTimePart(s.plannedArrivalTime) : null; + const depTimePart = s.plannedDepartureTime ? isoToEATTimePart(s.plannedDepartureTime) : null; + return { + sequence: s.sequence, + stationName: s.station?.name ?? `Stop ${s.sequence}`, + plannedArrivalAt: eatDateStr && arrTimePart ? `${eatDateStr}T${arrTimePart}` : '', + plannedDepartureAt: eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : '', + }; + })); + }, [editRouteDetail, editingSchedule?.id, editForm.departureAt]); + const [filters, setFilters] = useState({ search: '', trainId: '', @@ -175,6 +251,7 @@ export default function SchedulesPage() { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); + setAddStopTimes([]); setError(null); }, onError: (err: any) => { @@ -189,6 +266,7 @@ export default function SchedulesPage() { queryClient.invalidateQueries({ queryKey: ['schedules'] }); setShowEditModal(false); setEditingSchedule(null); + setEditStopTimes([]); setError(null); }, onError: (err: any) => { @@ -255,15 +333,30 @@ export default function SchedulesPage() { const handleAddSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); - const dep = new Date(addForm.departureAt); - const arr = new Date(addForm.arrivalAt); - if (arr <= dep) { setError('Arrival must be after departure'); return; } + if (!addForm.departureAt || !addForm.arrivalAt) { + setError('Please select departure and arrival date & time'); + return; + } + if (new Date(addForm.arrivalAt + ':00Z') <= new Date(addForm.departureAt + ':00Z')) { + setError('Arrival must be after departure'); return; + } + + const filledStops = addStopTimes.filter(s => s.plannedDepartureAt || s.plannedArrivalAt); + const plannedTimes = filledStops.length === addStopTimes.length && addStopTimes.length > 0 + ? addStopTimes.map(s => ({ + sequence: s.sequence, + ...(s.plannedArrivalAt ? { plannedArrivalAt: eatToISO(s.plannedArrivalAt) } : {}), + ...(s.plannedDepartureAt ? { plannedDepartureAt: eatToISO(s.plannedDepartureAt) } : {}), + })) + : undefined; + const validCoaches = addCoachRows.filter((r) => r.coachId); await createScheduleMutation.mutateAsync({ trainId: addForm.trainId, routeId: addForm.routeId, - departureAt: dep.toISOString(), - arrivalAt: arr.toISOString(), + departureAt: eatToISO(addForm.departureAt), + arrivalAt: eatToISO(addForm.arrivalAt), + ...(plannedTimes ? { plannedTimes } : {}), ...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }), }); }; @@ -274,24 +367,35 @@ export default function SchedulesPage() { if (!editingSchedule) return; - // Convert local datetime-local values to UTC for API - const depLocal = new Date(editForm.departureAt); - const arrLocal = new Date(editForm.arrivalAt); - - if (arrLocal <= depLocal) { + if (!editForm.departureAt || !editForm.arrivalAt) { + setError('Please select departure and arrival date & time'); + return; + } + + if (new Date(editForm.arrivalAt + ':00Z') <= new Date(editForm.departureAt + ':00Z')) { setError('Arrival time must be after departure time'); return; } + const filledEditStops = editStopTimes.filter(s => s.plannedDepartureAt || s.plannedArrivalAt); + const editPlannedTimes = filledEditStops.length === editStopTimes.length && editStopTimes.length > 0 + ? editStopTimes.map(s => ({ + sequence: s.sequence, + ...(s.plannedArrivalAt ? { plannedArrivalAt: eatToISO(s.plannedArrivalAt) } : {}), + ...(s.plannedDepartureAt ? { plannedDepartureAt: eatToISO(s.plannedDepartureAt) } : {}), + })) + : undefined; + const payload: any = { - departureAt: depLocal.toISOString(), - arrivalAt: arrLocal.toISOString(), + departureAt: eatToISO(editForm.departureAt), + arrivalAt: eatToISO(editForm.arrivalAt), status: editForm.status, isPackageOnly: editForm.isPackageOnly, coaches: editForm.coachIds.map((coachId: string, idx: number) => ({ coachId, positionNumber: idx + 1, })), + ...(editPlannedTimes ? { plannedTimes: editPlannedTimes } : {}), }; await updateScheduleMutation.mutateAsync({ @@ -327,26 +431,26 @@ export default function SchedulesPage() { const handleEditClick = (schedule: Schedule) => { setEditingSchedule(schedule); - // Convert UTC dates to local time for datetime-local input - // datetime-local expects local time (no timezone info) - const dep = new Date(schedule.departureAt); - const arr = new Date(schedule.arrivalAt); - - // Convert to local time by adding the timezone offset - const depLocal = new Date(dep.getTime() + dep.getTimezoneOffset() * 60000); - const arrLocal = new Date(arr.getTime() + arr.getTimezoneOffset() * 60000); - - // Format for datetime-local input (YYYY-MM-DDTHH:mm) - const depStr = depLocal.toISOString().slice(0, 16); - const arrStr = arrLocal.toISOString().slice(0, 16); - setEditForm({ - departureAt: depStr, - arrivalAt: arrStr, + departureAt: isoToEAT(schedule.departureAt), + arrivalAt: isoToEAT(schedule.arrivalAt), status: schedule.status, coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [], isPackageOnly: schedule.isPackageOnly ?? false, }); + + if (schedule.stopTimes && schedule.stopTimes.length > 0) { + const toDatetimeLocal = (iso: string | null) => iso ? isoToEAT(iso) : ''; + setEditStopTimes(schedule.stopTimes.map(st => ({ + sequence: st.sequence, + stationName: st.station?.name ?? `Stop ${st.sequence}`, + plannedArrivalAt: toDatetimeLocal(st.plannedArrivalAt), + plannedDepartureAt: toDatetimeLocal(st.plannedDepartureAt), + }))); + } else { + setEditStopTimes([]); + } + setError(null); setShowEditModal(true); }; @@ -672,7 +776,7 @@ export default function SchedulesPage() { { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }} + onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setAddStopTimes([]); setError(null); }} title="Add Schedule" size="lg" > @@ -699,14 +803,114 @@ export default function SchedulesPage() {
- setAddForm({ ...addForm, departureAt: e.target.value })} required /> + setAddForm({ ...addForm, departureAt: v })} + placeholder="Select departure" + />
- setAddForm({ ...addForm, arrivalAt: e.target.value })} required /> + setAddForm({ ...addForm, arrivalAt: v })} + placeholder="Select arrival" + />
+ {addStopTimes.length > 0 && ( +
+
+
+ +

+ Set planned times for each stop. Leave all blank to auto-generate from distance. +

+
+ +
+
+ + + + + + + + + + + {addStopTimes.map((stop, i) => { + const isFirst = i === 0; + const isLast = i === addStopTimes.length - 1; + return ( + + + + + + + ); + })} + +
#StationPlanned ArrivalPlanned Departure
{stop.sequence}{stop.stationName} + {isFirst ? ( + + ) : ( + { + const updated = [...addStopTimes]; + updated[i] = { ...updated[i], plannedArrivalAt: v }; + setAddStopTimes(updated); + }} + placeholder="Pick arrival" + /> + )} + + {isLast ? ( + + ) : ( + { + const updated = [...addStopTimes]; + updated[i] = { ...updated[i], plannedDepartureAt: v }; + setAddStopTimes(updated); + }} + placeholder="Pick departure" + /> + )} +
+
+
+ )} +
@@ -1006,6 +1210,7 @@ export default function SchedulesPage() { onClose={() => { setShowEditModal(false); setEditingSchedule(null); + setEditStopTimes([]); setError(null); }} title={`Edit Schedule: ${editingSchedule?.originStation?.name ?? ''} → ${editingSchedule?.destinationStation?.name ?? ''}`} @@ -1022,23 +1227,19 @@ export default function SchedulesPage() {
- setEditForm({ ...editForm, departureAt: e.target.value })} - className="input" - required + onChange={(v) => setEditForm({ ...editForm, departureAt: v })} + placeholder="Select departure" />
- setEditForm({ ...editForm, arrivalAt: e.target.value })} - className="input" - required + onChange={(v) => setEditForm({ ...editForm, arrivalAt: v })} + placeholder="Select arrival" />
@@ -1072,6 +1273,101 @@ export default function SchedulesPage() {
+ {editStopTimes.length > 0 && ( +
+
+
+ +

+ Edit planned times for each stop. All stops must be filled to update. +

+
+ +
+
+ + + + + + + + + + + {editStopTimes.map((stop, i) => { + const isFirst = i === 0; + const isLast = i === editStopTimes.length - 1; + return ( + + + + + + + ); + })} + +
#StationPlanned ArrivalPlanned Departure
{stop.sequence}{stop.stationName} + {isFirst ? ( + + ) : ( + { + const updated = [...editStopTimes]; + updated[i] = { ...updated[i], plannedArrivalAt: v }; + setEditStopTimes(updated); + }} + placeholder="Pick arrival" + /> + )} + + {isLast ? ( + + ) : ( + { + const updated = [...editStopTimes]; + updated[i] = { ...updated[i], plannedDepartureAt: v }; + setEditStopTimes(updated); + }} + placeholder="Pick departure" + /> + )} +
+
+
+ )} +
@@ -1129,6 +1425,7 @@ export default function SchedulesPage() { onClick={() => { setShowEditModal(false); setEditingSchedule(null); + setEditStopTimes([]); setError(null); }} > diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 00e594601..9d5c62414 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -80,13 +80,13 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'Master Data', items: [ - { name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.stations.view }, - { name: 'Trains', href: '/trains', icon: Train, permission: PERMS.trains.view }, - { name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.coaches.view }, - { name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.seats.view }, - { name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view }, - { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view }, - { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view }, + { name: 'Stations', href: '/stations', icon: MapPin }, + { name: 'Trains', href: '/trains', icon: Train }, + { name: 'Coaches', href: '/coaches', icon: Grid3x3 }, + { name: 'Seats', href: '/seats', icon: Armchair }, + { name: 'Classes', href: '/classes', icon: Settings }, + { name: 'Routes', href: '/routes', icon: Route }, + { name: 'Schedules', href: '/schedules', icon: Calendar }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx new file mode 100644 index 000000000..6e71fc268 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx @@ -0,0 +1,358 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { DayPicker } from 'react-day-picker'; +import { ChevronLeft, ChevronRight, Calendar, ChevronUp, ChevronDown, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface DateTimePickerProps { + value: string; // YYYY-MM-DDTHH:mm (datetime-local format) + onChange: (value: string) => void; + required?: boolean; + id?: string; + placeholder?: string; + label?: string; +} + +function parseLocalString(s: string) { + if (!s) return null; + const [datePart, timePart] = s.split('T'); + if (!datePart || !timePart) return null; + const [yyyy, mm, dd] = datePart.split('-').map(Number); + const [h, m] = timePart.split(':').map(Number); + if (isNaN(yyyy) || isNaN(mm) || isNaN(dd) || isNaN(h) || isNaN(m)) return null; + const period: 'AM' | 'PM' = h >= 12 ? 'PM' : 'AM'; + const hours12 = h % 12 === 0 ? 12 : h % 12; + const date = new Date(yyyy, mm - 1, dd); + return { date, hours12, minutes: m, period }; +} + +function toLocalString(date: Date, hours12: number, minutes: number, period: 'AM' | 'PM') { + let h = hours12 % 12; + if (period === 'PM') h += 12; + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + const hh = String(h).padStart(2, '0'); + const min = String(minutes).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}T${hh}:${min}`; +} + +function formatDisplay(parsed: ReturnType): string { + if (!parsed) return ''; + const { date, hours12, minutes, period } = parsed; + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const dateStr = `${months[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`; + const timeStr = `${String(hours12).padStart(2, '0')}:${String(minutes).padStart(2, '0')} ${period}`; + return `${dateStr} ${timeStr}`; +} + +export default function DateTimePicker({ + value, + onChange, + id, + placeholder = 'Select date & time', + label, +}: DateTimePickerProps) { + const [open, setOpen] = useState(false); + const [mounted, setMounted] = useState(false); + + useEffect(() => { setMounted(true); }, []); + + const parsed = parseLocalString(value); + const [selectedDate, setSelectedDate] = useState(parsed?.date); + const [hours12, setHours12] = useState(parsed?.hours12 ?? 12); + const [minutes, setMinutes] = useState(parsed?.minutes ?? 0); + const [period, setPeriod] = useState<'AM' | 'PM'>(parsed?.period ?? 'AM'); + + // Sync internal state when value changes externally + useEffect(() => { + const p = parseLocalString(value); + if (p) { + setSelectedDate(p.date); + setHours12(p.hours12); + setMinutes(p.minutes); + setPeriod(p.period); + } + }, [value]); + + // Close on Escape + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [open]); + + const emit = useCallback( + (date: Date | undefined, h: number, m: number, p: 'AM' | 'PM') => { + if (!date) return; + onChange(toLocalString(date, h, m, p)); + }, + [onChange], + ); + + const handleDaySelect = (date: Date | undefined) => { + setSelectedDate(date); + if (date) emit(date, hours12, minutes, period); + }; + + const cycleHour = (dir: 1 | -1) => { + const next = hours12 + dir; + const h = next > 12 ? 1 : next < 1 ? 12 : next; + setHours12(h); + emit(selectedDate, h, minutes, period); + }; + + const cycleMinute = (dir: 1 | -1) => { + const next = minutes + dir; + const m = next > 59 ? 0 : next < 0 ? 59 : next; + setMinutes(m); + emit(selectedDate, hours12, m, period); + }; + + const togglePeriod = (p: 'AM' | 'PM') => { + setPeriod(p); + emit(selectedDate, hours12, minutes, p); + }; + + const handleHourInput = (raw: string) => { + const h = parseInt(raw); + if (isNaN(h)) return; + const clamped = Math.max(1, Math.min(12, h)); + setHours12(clamped); + emit(selectedDate, clamped, minutes, period); + }; + + const handleMinuteInput = (raw: string) => { + const m = parseInt(raw); + if (isNaN(m)) return; + const clamped = Math.max(0, Math.min(59, m)); + setMinutes(clamped); + emit(selectedDate, hours12, clamped, period); + }; + + const modal = open && mounted ? createPortal( +
+ {/* Backdrop */} +
setOpen(false)} + /> + + {/* Panel */} +
+ {/* Header */} +
+

+ {label ?? placeholder} +

+ +
+ + {/* Calendar */} + + orientation === 'left' ? ( + + ) : ( + + ), + DayButton: ({ day, modifiers, className, ...props }) => ( + + handleHourInput(e.target.value)} + onFocus={e => e.target.select()} + className="w-10 text-center text-base font-mono border border-border rounded-lg py-1.5 bg-background focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + +
+ + : + + {/* Minute spinner */} +
+ + handleMinuteInput(e.target.value)} + onFocus={e => e.target.select()} + className="w-10 text-center text-base font-mono border border-border rounded-lg py-1.5 bg-background focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + +
+ + {/* AM / PM */} +
+ + +
+
+
+ + {/* Confirm */} + +
+
, + document.body, + ) : null; + + const displayText = parsed ? formatDisplay(parsed) : placeholder; + + return ( +
+ + {modal} +
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 108b45d8b..6ace365ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -953,6 +953,9 @@ importers: react: specifier: ^18.3.1 version: 18.3.1 + react-day-picker: + specifier: ^9.14.0 + version: 9.14.0(react@18.3.1) react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) @@ -22123,6 +22126,14 @@ snapshots: date-fns: 3.6.0 react: 19.2.6 + react-day-picker@9.14.0(react@18.3.1): + dependencies: + '@date-fns/tz': 1.5.0 + '@tabby_ai/hijri-converter': 1.0.5 + date-fns: 4.4.0 + date-fns-jalali: 4.1.0-0 + react: 18.3.1 + react-day-picker@9.14.0(react@19.2.6): dependencies: '@date-fns/tz': 1.5.0