Merge branch 'alpha' into dev

This commit is contained in:
Stephanos A.
2026-07-19 08:39:08 +03:00
committed by GitHub
22 changed files with 2096 additions and 592 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedArrivalTime" INTEGER;
ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedDepartureTime" INTEGER;

View File

@@ -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);

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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: {

View File

@@ -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);
}
}

View File

@@ -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<string, number>);
const byPaymentMethod = bookings.reduce(
(acc, b) => {
const method = b.paymentIntent?.method ?? "UNKNOWN";
acc[method] = (acc[method] || 0) + b.totalMinor;
return acc;
},
{} as Record<string, number>,
);
// 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<string, any>);
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<string, any>,
);
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<string, any>);
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<string, any>,
);
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<string, any>);
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<string, any>,
);
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<string, { coachNumber: string; coachType: string; totalSeats: number; booked: number }>();
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<string, { stationName: string; passengers: number }>();
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<string, { stationName: string; passengers: number }>();
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<string, { className: string; totalSeats: number; booked: number }>();
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,
});
}
}

View File

@@ -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 {

View File

@@ -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,
},
});
}

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -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 } } } },
},

View File

@@ -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.
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -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",

View File

@@ -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 (
<div className="card flex flex-col gap-3">
<div className="flex items-center gap-2">
<div className={`rounded-lg ${iconBg} p-1.5`}>{icon}</div>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{label}</span>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
</div>
<p className="text-3xl font-bold text-foreground tabular-nums">
{loading ? '—' : total.toLocaleString()}
{loading ? "—" : total.toLocaleString()}
</p>
<div className="flex flex-col gap-2 border-t border-border pt-3">
{rows.map((r) => (
<div key={r.label} className="flex items-center justify-between">
<span className="flex items-center gap-1 text-xs text-muted-foreground">{r.icon}{r.label}</span>
<Link href={r.href} className="text-sm font-semibold text-foreground tabular-nums hover:text-primary transition-colors">
{loading ? '—' : r.value.toLocaleString()}
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{r.icon}
{r.label}
</span>
<Link
href={r.href}
className="text-sm font-semibold text-foreground tabular-nums hover:text-primary transition-colors"
>
{loading ? "—" : r.value.toLocaleString()}
</Link>
</div>
))}
</div>
<Link href={href} className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1">
<Link
href={href}
className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1"
>
View all <ArrowRight className="h-3 w-3" />
</Link>
</div>
@@ -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}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{loading ? '—' : bookingCount.toLocaleString()} bookings
{loading ? "—" : bookingCount.toLocaleString()} bookings
</span>
</div>
{rows.length === 0
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
: rows.map(renderRow)}
{rows.length === 0 ? (
<p className="text-xs text-muted-foreground py-1">No revenue yet</p>
) : (
rows.map(renderRow)
)}
{rows.length > 0 && (
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2 mt-1">
<span className="text-xs font-semibold text-muted-foreground">Subtotal</span>
<span className="text-sm font-bold text-foreground tabular-nums">{formatCurrency(subtotal, 'ETB')}</span>
<span className="text-xs font-semibold text-muted-foreground">
Subtotal
</span>
<span className="text-sm font-bold text-foreground tabular-nums">
{formatCurrency(subtotal, "ETB")}
</span>
</div>
)}
</div>
@@ -84,19 +124,25 @@ function RevenueSection({
function DashboardPageContent() {
const { data: exchangeRates = [] } = useQuery<any[]>({
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 (
<div key={currency} className="flex items-center justify-between rounded-md bg-muted/20 px-3 py-2">
<div
key={currency}
className="flex items-center justify-between rounded-md bg-muted/20 px-3 py-2"
>
<div className="flex items-center gap-1.5">
<Banknote className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">{currency}</span>
<span className="text-sm font-medium text-foreground">
{currency}
</span>
</div>
<span className="text-sm font-semibold text-foreground tabular-nums">
{formatCurrency(totalMinor, currency)}
{currency !== 'ETB' && etbMinor !== null && (
{currency !== "ETB" && etbMinor !== null && (
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
({formatCurrency(etbMinor, 'ETB')})
({formatCurrency(etbMinor, "ETB")})
</span>
)}
</span>
@@ -147,15 +204,16 @@ function DashboardPageContent() {
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground mt-1">Welcome back! Here&apos;s your operational summary.</p>
<p className="text-muted-foreground mt-1">
Welcome back! Here&apos;s your operational summary.
</p>
</div>
<Link
href="/boarding"
className="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
title="Boarding / Gate Scan"
className="flex items-center gap-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 text-sm font-medium transition-colors"
>
<ScanLine className="h-4 w-4 text-[rgb(20,113,76)]" />
<span className="hidden sm:inline">Boarding</span>
<ScanLine className="h-4 w-4" />
Boarding
</Link>
</div>
@@ -164,8 +222,12 @@ function DashboardPageContent() {
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
<div>
<h3 className="font-semibold text-orange-800 dark:text-orange-200">Some data may be outdated</h3>
<p className="text-sm text-orange-700 dark:text-orange-300">Unable to fetch live data. Showing cached or sample information.</p>
<h3 className="font-semibold text-orange-800 dark:text-orange-200">
Some data may be outdated
</h3>
<p className="text-sm text-orange-700 dark:text-orange-300">
Unable to fetch live data. Showing cached or sample information.
</p>
</div>
</div>
</div>
@@ -174,15 +236,25 @@ function DashboardPageContent() {
{/* Stat cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StatCard
icon={<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />}
icon={
<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />
}
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() {
<div className="rounded-lg bg-amber-100 dark:bg-amber-900/30 p-1.5">
<Banknote className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</div>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Revenue</span>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Revenue
</span>
</div>
{statsLoading ? (
<p className="text-muted-foreground text-sm">Loading</p>
) : (
<>
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums">
{formatCurrency(overallGrand, 'ETB')}
{formatCurrency(overallGrand, "ETB")}
</p>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Regular</p>
<p className="text-sm font-semibold text-foreground tabular-nums">{formatCurrency(normalGrand, 'ETB')}</p>
<p className="text-sm font-semibold text-foreground tabular-nums">
{formatCurrency(normalGrand, "ETB")}
</p>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Package</p>
<p className="text-sm font-semibold text-foreground tabular-nums">{formatCurrency(packageGrand, 'ETB')}</p>
<p className="text-sm font-semibold text-foreground tabular-nums">
{formatCurrency(packageGrand, "ETB")}
</p>
</div>
</div>
<Link href="/payments" className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1">
<Link
href="/payments"
className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1"
>
View payments <ArrowRight className="h-3 w-3" />
</Link>
</>
@@ -222,7 +303,9 @@ function DashboardPageContent() {
{/* Revenue breakdown */}
<div className="card">
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground mb-4">Revenue Breakdown</h2>
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground mb-4">
Revenue Breakdown
</h2>
{statsLoading ? (
<p className="text-muted-foreground text-sm">Loading</p>
) : !normalRows.length && !packageRows.length ? (
@@ -252,12 +335,25 @@ function DashboardPageContent() {
{/* Payment Methods Distribution */}
{paymentMethods && paymentMethods.length > 0 && (
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Payment Methods Distribution</h2>
<h2 className="mb-4 text-lg font-semibold text-foreground">
Payment Methods Distribution
</h2>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie data={paymentMethods} dataKey="count" nameKey="method" cx="50%" cy="50%" outerRadius={80} label>
<Pie
data={paymentMethods}
dataKey="count"
nameKey="method"
cx="50%"
cy="50%"
outerRadius={80}
label
>
{paymentMethods.map((_: any, index: number) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
<Cell
key={`cell-${index}`}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
<Tooltip />

View File

@@ -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 (
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{label}</p>
<div className={`rounded-lg p-1.5 ${color}`}><Icon className="h-4 w-4" /></div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</p>
<div className={`rounded-lg p-1.5 ${color}`}>
<Icon className="h-4 w-4" />
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">{value}</p>
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
@@ -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<Tab>("occupancy");
const [listSearch, setListSearch] = useState("");
const { data: schedules = [] } = useQuery<any[]>({
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<PassengersReport>({
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 (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Passengers Report</h1>
<p className="text-muted-foreground mt-1">Select a schedule to view passenger occupancy breakdown</p>
<h1 className="text-3xl font-bold text-foreground">
Passengers Report
</h1>
<p className="text-muted-foreground mt-1">
Select a schedule to view passenger occupancy breakdown
</p>
</div>
{/* Schedule Selector */}
{/* Schedule selector */}
<div className="card">
<div className="flex flex-wrap items-end gap-4">
<div className="flex-1 min-w-64">
@@ -98,27 +197,54 @@ export default function PassengersReportPage() {
<select
className="input"
value={scheduleId}
onChange={(e) => setScheduleId(e.target.value)}
onChange={(e) => {
setScheduleId(e.target.value);
setTab("occupancy");
setListSearch("");
}}
disabled={loadingSchedules}
>
<option value=""> Select a schedule </option>
{schedules.map((s: any) => (
<option value="">
{loadingSchedules ? "Loading schedules…" : "Select a schedule…"}
</option>
{schedules.map((s) => (
<option key={s.id} value={s.id}>
{s.train?.name ?? s.train?.number ?? 'Train'} · {s.originStation?.name} {s.destinationStation?.name} · {s.departureAt ? new Date(s.departureAt).toLocaleString() : ''}
{s.label}
</option>
))}
</select>
</div>
{isFetching && <p className="text-sm text-muted-foreground self-center">Loading</p>}
{report && (
<ActionButton icon={Download} variant="secondary" onClick={doExport}>
{data && tab === "occupancy" && (
<ActionButton
icon={Download}
variant="secondary"
onClick={doExportOccupancy}
>
Export CSV
</ActionButton>
)}
{passengerList.length > 0 && tab === "list" && (
<ActionButton
icon={Download}
variant="secondary"
onClick={doExportList}
>
Export CSV
</ActionButton>
)}
</div>
{(isLoading || listLoading) && (
<p className="text-xs text-muted-foreground mt-2">Loading</p>
)}
{isError && (
<p className="text-xs text-red-500 mt-2">Failed to load report.</p>
)}
</div>
{isFetching && (
<div className="card py-12 text-center text-muted-foreground">Loading passengers data</div>
<div className="card py-12 text-center text-muted-foreground">
Loading passengers data
</div>
)}
{report && (
@@ -131,205 +257,305 @@ export default function PassengersReportPage() {
<div>
<p className="font-semibold">{report.schedule.trainName}</p>
<p className="text-sm text-muted-foreground">
{report.schedule.origin} {report.schedule.destination} · Departure: {formatDateTime(report.schedule.departureAt)}
{report.schedule.origin} {report.schedule.destination} ·
Departure: {formatDateTime(report.schedule.departureAt)}
</p>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<StatCard
label="Total Seats"
value={report.summary.totalSeats}
icon={Armchair}
color="bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400"
/>
<StatCard
label="Total Passengers"
value={report.summary.totalPassengers}
icon={Users}
color="bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400"
/>
<StatCard
label="Occupancy Rate"
value={`${report.summary.occupancyRate}%`}
sub={`${report.summary.totalSeats - report.summary.totalPassengers} seats available`}
icon={TrendingUp}
color="bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400"
/>
{/* Tabs */}
<div className="border-b border-border flex">
<button
onClick={() => setTab("occupancy")}
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === "occupancy" ? "border-emerald-500 text-emerald-600 dark:text-emerald-400" : "border-transparent text-muted-foreground hover:text-foreground"}`}
>
Occupancy
</button>
<button
onClick={() => setTab("list")}
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === "list" ? "border-emerald-500 text-emerald-600 dark:text-emerald-400" : "border-transparent text-muted-foreground hover:text-foreground"}`}
>
Passenger List
{passengerList.length > 0 ? ` (${passengerList.length})` : ""}
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* By Coach */}
<div className="card">
<h3 className="text-base font-semibold mb-4">Occupancy by Coach</h3>
{report.byCoach.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={report.byCoach} layout="vertical" margin={{ left: 8 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
<YAxis type="category" dataKey="coachNumber" tick={{ fontSize: 11 }} width={56} tickFormatter={(v) => `Coach ${v}`} />
<Tooltip formatter={(v: number) => [`${v}%`, 'Occupancy']} />
<Bar dataKey="occupancyRate" radius={[0, 3, 3, 0]}>
{report.byCoach.map((_: any, i: number) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<table className="w-full mt-3 text-sm">
{/* Occupancy tab */}
{tab === "occupancy" && (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Total Seats
</p>
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5">
<Armchair className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">
{data.summary.totalSeats}
</p>
</div>
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Passengers
</p>
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5">
<Users className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">
{data.summary.totalPassengers}
</p>
</div>
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Occupancy Rate
</p>
<div className="rounded-lg bg-purple-100 dark:bg-purple-900/30 p-1.5">
<BarChart3 className="h-4 w-4 text-purple-600 dark:text-purple-400" />
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">
{data.summary.occupancyRate}%
</p>
<div className="w-full bg-muted rounded-full h-1.5 mt-1">
<div
className="bg-purple-500 h-1.5 rounded-full"
style={{ width: `${data.summary.occupancyRate}%` }}
/>
</div>
</div>
</div>
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
By Coach
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground border-b border-border">
<th className="text-left py-1.5 font-medium">Coach</th>
<th className="text-left py-1.5 font-medium">Type</th>
<th className="text-right py-1.5 font-medium">Booked</th>
<th className="text-right py-1.5 font-medium">Total</th>
<th className="text-right py-1.5 font-medium">Rate</th>
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
<th className="pb-2 pr-4">Coach</th>
<th className="pb-2 pr-4">Type</th>
<th className="pb-2 pr-4 text-right">Seats</th>
<th className="pb-2 pr-4 text-right">Booked</th>
<th className="pb-2">Occupancy</th>
</tr>
</thead>
<tbody>
{report.byCoach.map((c: any, i: number) => (
<tr key={i} className="border-b border-border/50 last:border-0">
<td className="py-1.5 font-mono font-semibold">Coach {c.coachNumber}</td>
<td className="py-1.5 text-muted-foreground">{c.coachType}</td>
<td className="py-1.5 text-right tabular-nums">{c.booked}</td>
<td className="py-1.5 text-right tabular-nums text-muted-foreground">{c.totalSeats}</td>
<td className="py-1.5 text-right tabular-nums font-semibold">{c.occupancyRate}%</td>
</tr>
))}
</tbody>
</table>
</>
) : (
<p className="text-sm text-muted-foreground">No coach data</p>
)}
</div>
{/* By Class */}
<div className="card">
<h3 className="text-base font-semibold mb-4">Occupancy by Class</h3>
{report.byClass.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={report.byClass}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="className" tick={{ fontSize: 11 }} />
<YAxis domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
<Tooltip formatter={(v: number) => [`${v}%`, 'Occupancy']} />
<Bar dataKey="occupancyRate" radius={[3, 3, 0, 0]}>
{report.byClass.map((_: any, i: number) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<table className="w-full mt-3 text-sm">
<thead>
<tr className="text-xs text-muted-foreground border-b border-border">
<th className="text-left py-1.5 font-medium">Class</th>
<th className="text-right py-1.5 font-medium">Booked</th>
<th className="text-right py-1.5 font-medium">Total</th>
<th className="text-right py-1.5 font-medium">Rate</th>
</tr>
</thead>
<tbody>
{report.byClass.map((c: any, i: number) => (
<tr key={i} className="border-b border-border/50 last:border-0">
<td className="py-1.5 font-medium">{c.className}</td>
<td className="py-1.5 text-right tabular-nums">{c.booked}</td>
<td className="py-1.5 text-right tabular-nums text-muted-foreground">{c.totalSeats}</td>
<td className="py-1.5 text-right tabular-nums font-semibold">{c.occupancyRate}%</td>
</tr>
))}
</tbody>
</table>
</>
) : (
<p className="text-sm text-muted-foreground">No class data</p>
)}
</div>
{/* By Origin */}
<div className="card">
<h3 className="text-base font-semibold mb-4">Passengers by Boarding Station</h3>
{report.byOrigin.length > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground border-b border-border">
<th className="text-left py-1.5 font-medium">Station</th>
<th className="text-right py-1.5 font-medium">Passengers</th>
<th className="text-right py-1.5 font-medium">Share</th>
</tr>
</thead>
<tbody>
{report.byOrigin.map((o: any, i: number) => {
const pct = report.summary.totalPassengers > 0
? ((o.passengers / report.summary.totalPassengers) * 100).toFixed(1)
: '0';
return (
<tr key={i} className="border-b border-border/50 last:border-0">
<tbody className="divide-y divide-border">
{data.byCoach.map((c) => (
<tr key={c.coachNumber} className="hover:bg-muted/30">
<td className="py-2 pr-4 font-semibold">
{c.coachNumber}
</td>
<td className="py-2 pr-4 text-muted-foreground">
{c.coachType}
</td>
<td className="py-2 pr-4 text-right tabular-nums">
{c.totalSeats}
</td>
<td className="py-2 pr-4 text-right tabular-nums">
{c.booked}
</td>
<td className="py-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: COLORS[i % COLORS.length] }} />
{o.stationName}
<div className="flex-1 bg-muted rounded-full h-1.5">
<div
className="bg-emerald-500 h-1.5 rounded-full"
style={{ width: `${c.occupancyRate}%` }}
/>
</div>
<span className="tabular-nums text-xs w-10 text-right">
{c.occupancyRate}%
</span>
</div>
</td>
<td className="py-2 text-right tabular-nums font-semibold">{o.passengers}</td>
<td className="py-2 text-right tabular-nums text-muted-foreground">{pct}%</td>
</tr>
);
})}
</tbody>
</table>
) : (
<p className="text-sm text-muted-foreground">No boarding station data</p>
)}
))}
</tbody>
</table>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
By Class
</h3>
<div className="space-y-3">
{data.byClass.map((c) => (
<div key={c.className}>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium">{c.className}</span>
<span className="tabular-nums text-muted-foreground">
{c.booked}/{c.totalSeats}
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full"
style={{ width: `${c.occupancyRate}%` }}
/>
</div>
<span className="text-xs tabular-nums w-10 text-right">
{c.occupancyRate}%
</span>
</div>
</div>
))}
</div>
</div>
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
By Boarding Station
</h3>
<div className="space-y-2">
{data.byOrigin.map((o) => (
<div
key={o.stationName}
className="flex justify-between text-sm"
>
<span className="text-muted-foreground truncate">
{o.stationName}
</span>
<span className="font-semibold tabular-nums ml-2">
{o.passengers}
</span>
</div>
))}
{data.byOrigin.length === 0 && (
<p className="text-xs text-muted-foreground">No data</p>
)}
</div>
</div>
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
By Alighting Station
</h3>
<div className="space-y-2">
{data.byDestination.map((d) => (
<div
key={d.stationName}
className="flex justify-between text-sm"
>
<span className="text-muted-foreground truncate">
{d.stationName}
</span>
<span className="font-semibold tabular-nums ml-2">
{d.passengers}
</span>
</div>
))}
{data.byDestination.length === 0 && (
<p className="text-xs text-muted-foreground">No data</p>
)}
</div>
</div>
</div>
</div>
)}
{/* By Destination */}
<div className="card">
<h3 className="text-base font-semibold mb-4">Passengers by Alighting Station</h3>
{report.byDestination.length > 0 ? (
{/* Passenger List tab */}
{tab === "list" && (
<div className="card space-y-4">
<input
type="text"
className="input max-w-sm"
placeholder="Search by name, booking ref or ID…"
value={listSearch}
onChange={(e) => setListSearch(e.target.value)}
/>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground border-b border-border">
<th className="text-left py-1.5 font-medium">Station</th>
<th className="text-right py-1.5 font-medium">Passengers</th>
<th className="text-right py-1.5 font-medium">Share</th>
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
<th className="pb-2 pr-4">#</th>
<th className="pb-2 pr-4">Name</th>
<th className="pb-2 pr-4">Category</th>
<th className="pb-2 pr-4">ID / Passport</th>
<th className="pb-2 pr-4">Seat</th>
<th className="pb-2 pr-4">Coach</th>
<th className="pb-2 pr-4">Booking Ref</th>
<th className="pb-2">Status</th>
</tr>
</thead>
<tbody>
{report.byDestination.map((d: any, i: number) => {
const pct = report.summary.totalPassengers > 0
? ((d.passengers / report.summary.totalPassengers) * 100).toFixed(1)
: '0';
return (
<tr key={i} className="border-b border-border/50 last:border-0">
<td className="py-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: COLORS[i % COLORS.length] }} />
{d.stationName}
</div>
</td>
<td className="py-2 text-right tabular-nums font-semibold">{d.passengers}</td>
<td className="py-2 text-right tabular-nums text-muted-foreground">{pct}%</td>
</tr>
);
})}
<tbody className="divide-y divide-border">
{filteredList.map((p, i) => (
<tr
key={`${p.bookingRef}-${i}`}
className="hover:bg-muted/30"
>
<td className="py-2 pr-4 text-muted-foreground tabular-nums">
{i + 1}
</td>
<td className="py-2 pr-4 font-medium">
{p.passengerName}
</td>
<td className="py-2 pr-4">
<span
className={`text-xs font-semibold px-1.5 py-0.5 rounded ${p.passengerCategory === "CHILD" ? "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"}`}
>
{p.passengerCategory}
</span>
</td>
<td className="py-2 pr-4 text-muted-foreground text-xs">
{p.idDocumentNumber ?? p.passportNumber ?? "—"}
{p.passportCountry && (
<span className="ml-1 text-muted-foreground/60">
({p.passportCountry})
</span>
)}
</td>
<td className="py-2 pr-4 font-mono text-xs">
{p.seatLabel ?? "—"}
</td>
<td className="py-2 pr-4 text-muted-foreground">
{p.coachNumber ?? "—"}
{p.coachType && (
<span className="ml-1 text-xs text-muted-foreground/60">
({p.coachType})
</span>
)}
</td>
<td className="py-2 pr-4 font-mono text-xs">
{p.bookingRef}
</td>
<td className="py-2">
<span
className={`text-xs font-semibold px-1.5 py-0.5 rounded ${p.bookingStatus === "BOARDED" ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400" : "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"}`}
>
{p.bookingStatus}
</span>
</td>
</tr>
))}
{filteredList.length === 0 && (
<tr>
<td
colSpan={8}
className="py-8 text-center text-sm text-muted-foreground"
>
No passengers found
</td>
</tr>
)}
</tbody>
</table>
) : (
<p className="text-sm text-muted-foreground">No alighting station data</p>
)}
</div>
</div>
)}
</>
)}
{!report && !isFetching && scheduleId && (
<div className="card py-12 text-center text-muted-foreground">No data found for this schedule.</div>
<div className="card py-12 text-center text-muted-foreground">
No data found for this schedule.
</div>
)}
{!scheduleId && (

View File

@@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Seat Status Report</h1>
<h1 className="text-3xl font-bold text-foreground">
Seat Status Report
</h1>
<p className="text-muted-foreground mt-1">
Track booked seats paid vs unpaid, booking times, and hold release times
Track booked seats paid vs unpaid, booking times, and hold release
times
</p>
</div>
@@ -161,11 +195,15 @@ export default function SeatStatusReportPage() {
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Paid Seats</p>
<p className="text-muted-foreground text-sm font-medium">
Paid Seats
</p>
<p className="text-2xl font-bold mt-2 text-green-600 dark:text-green-400">
{paidCount}
</p>
<p className="text-xs text-muted-foreground mt-1">Payment confirmed</p>
<p className="text-xs text-muted-foreground mt-1">
Payment confirmed
</p>
</div>
<CheckCircle className="h-8 w-8 text-green-500 opacity-30" />
</div>
@@ -174,11 +212,15 @@ export default function SeatStatusReportPage() {
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Unpaid Seats</p>
<p className="text-muted-foreground text-sm font-medium">
Unpaid Seats
</p>
<p className="text-2xl font-bold mt-2 text-amber-600 dark:text-amber-400">
{unpaidCount}
</p>
<p className="text-xs text-muted-foreground mt-1">Awaiting payment</p>
<p className="text-xs text-muted-foreground mt-1">
Awaiting payment
</p>
</div>
<Clock className="h-8 w-8 text-amber-500 opacity-30" />
</div>
@@ -187,11 +229,15 @@ export default function SeatStatusReportPage() {
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Expired Holds</p>
<p className="text-muted-foreground text-sm font-medium">
Expired Holds
</p>
<p className="text-2xl font-bold mt-2 text-red-600 dark:text-red-400">
{expiredCount}
</p>
<p className="text-xs text-muted-foreground mt-1">Hold time passed, not paid</p>
<p className="text-xs text-muted-foreground mt-1">
Hold time passed, not paid
</p>
</div>
<AlertCircle className="h-8 w-8 text-red-500 opacity-30" />
</div>
@@ -200,14 +246,38 @@ export default function SeatStatusReportPage() {
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Blocked Seats</p>
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">
{stats?.blockedSeatsCount ?? '—'}
<p className="text-muted-foreground text-sm font-medium">
Blocked Seats
</p>
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">
{blockedSeats.length}
</p>
<p className="text-xs text-muted-foreground mt-1">
Manually blocked
</p>
<p className="text-xs text-muted-foreground mt-1">Globally blocked</p>
</div>
<Ban className="h-8 w-8 text-slate-500 opacity-30" />
</div>
{blockedSeats.length > 0 && (
<div className="mt-3 border-t border-border pt-3 flex flex-col gap-1 max-h-32 overflow-y-auto">
{blockedSeats.map((b: any) => (
<div
key={b.id}
className="flex items-center justify-between text-xs"
>
<span className="font-medium text-foreground">
Seat {b.seatNumber} · Coach {b.coachNumber}
</span>
<span
className="text-muted-foreground truncate max-w-24"
title={b.reason}
>
{b.reason}
</span>
</div>
))}
</div>
)}
</div>
</div>
@@ -229,18 +299,27 @@ export default function SeatStatusReportPage() {
<select
className="input"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as 'ALL' | 'PAID' | 'UNPAID')}
onChange={(e) =>
setStatusFilter(e.target.value as "ALL" | "PAID" | "UNPAID")
}
>
<option value="ALL">All Seats</option>
<option value="PAID">Paid Only</option>
<option value="UNPAID">Unpaid Only</option>
</select>
</div>
<ActionButton icon={Download} variant="secondary" onClick={doExport} disabled={isLoading}>
<ActionButton
icon={Download}
variant="secondary"
onClick={doExport}
disabled={isLoading}
>
Export CSV
</ActionButton>
</div>
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading...</p>}
{isLoading && (
<p className="text-xs text-muted-foreground mt-2">Loading...</p>
)}
</div>
{/* Table */}
@@ -250,14 +329,14 @@ export default function SeatStatusReportPage() {
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{[
'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) => (
<th
key={h}
@@ -271,7 +350,8 @@ export default function SeatStatusReportPage() {
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{filtered.map((row, i) => {
const isPaid =
row.paymentStatus === 'SUCCEEDED' || row.paymentStatus === 'COMPLETED';
row.paymentStatus === "SUCCEEDED" ||
row.paymentStatus === "COMPLETED";
const expired = isExpired(row.releaseAt);
return (
<tr
@@ -281,23 +361,31 @@ export default function SeatStatusReportPage() {
<td className="px-4 py-3 text-sm font-mono font-semibold whitespace-nowrap">
{row.bookingRef}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{row.passengerName}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{row.passengerName}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
<span className="font-semibold">{row.seatNumber}</span>
{row.coachNumber !== '—' && (
<span className="text-muted-foreground"> · Coach {row.coachNumber}</span>
{row.coachNumber !== "—" && (
<span className="text-muted-foreground">
{" "}
· Coach {row.coachNumber}
</span>
)}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{formatCurrency(row.fareMinor, row.currency)}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<Badge variant="status" status={isPaid ? 'PAID' : row.paymentStatus}>
{isPaid ? 'PAID' : row.paymentStatus}
<Badge
variant="status"
status={isPaid ? "PAID" : row.paymentStatus}
>
{isPaid ? "PAID" : row.paymentStatus}
</Badge>
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap text-muted-foreground">
{row.bookedAt ? formatDateTime(row.bookedAt) : '—'}
{row.bookedAt ? formatDateTime(row.bookedAt) : "—"}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{isPaid ? (
@@ -308,13 +396,13 @@ export default function SeatStatusReportPage() {
<span
className={
expired
? 'text-red-600 dark:text-red-400 text-xs font-semibold'
: 'text-amber-600 dark:text-amber-400 text-xs font-medium'
? "text-red-600 dark:text-red-400 text-xs font-semibold"
: "text-amber-600 dark:text-amber-400 text-xs font-medium"
}
>
{expired ? '' : ''}
{expired ? "" : ""}
{formatDateTime(row.releaseAt)}
{expired && ' (expired)'}
{expired && " (expired)"}
</span>
) : (
<span className="text-muted-foreground text-xs"></span>
@@ -323,7 +411,9 @@ export default function SeatStatusReportPage() {
<td className="px-4 py-3 text-sm whitespace-nowrap text-muted-foreground">
{row.scheduleOrigin} {row.scheduleDestination}
{row.scheduleDeparture && (
<div className="text-xs">{formatDateTime(row.scheduleDeparture)}</div>
<div className="text-xs">
{formatDateTime(row.scheduleDeparture)}
</div>
)}
</td>
</tr>

View File

@@ -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<number | undefined>(undefined);
const [originCheckinMinutes, setOriginCheckinMinutes] = useState<number | undefined>(undefined);
const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState<number | undefined>(undefined);
const [originDepartureTime, setOriginDepartureTime] = useState<string>('');
const [destinationArrivalTime, setDestinationArrivalTime] = useState<string>('');
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"
>
<form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
{editingRoute && (
@@ -665,7 +692,7 @@ export default function RoutesPage() {
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-3">
<label className="label mb-0">Route Stops</label>
<span className="text-xs text-muted-foreground">Drag to rearrange · <span className="font-medium">Cutoff min</span> overrides route check-in window per stop (leave blank to inherit)</span>
<span className="text-xs text-muted-foreground">Drag to rearrange · <span className="font-medium">Cutoff</span> overrides check-in · <span className="font-medium">Arr/Dep time</span> sets default times (auto-filled on schedule creation)</span>
</div>
<div className="space-y-2">
@@ -683,7 +710,7 @@ export default function RoutesPage() {
<span className="text-muted-foreground">Select origin station above</span>
)}
</div>
<div className="w-28 flex-shrink-0">
<div className="w-24 flex-shrink-0">
<input
type="number"
className="input input-sm"
@@ -694,7 +721,15 @@ export default function RoutesPage() {
title="Check-in cutoff override (minutes) for this stop"
/>
</div>
<div className="text-sm text-muted-foreground w-12 text-right flex-shrink-0">0 km</div>
<div className="w-44 flex-shrink-0">
<DateTimePicker
value={originDepartureTime}
onChange={setOriginDepartureTime}
placeholder="Dep time"
label="Planned Departure"
/>
</div>
<div className="text-sm text-muted-foreground w-10 text-right flex-shrink-0">0 km</div>
</div>
{stops.map((stop, index) => (
@@ -729,7 +764,7 @@ export default function RoutesPage() {
))}
</select>
</div>
<div className="w-28">
<div className="w-20">
<input
type="number"
className="input input-sm"
@@ -741,17 +776,33 @@ export default function RoutesPage() {
required
/>
</div>
<div className="w-28">
<div className="w-20">
<input
type="number"
className="input input-sm"
placeholder="cutoff min"
placeholder="cutoff"
value={stop.checkinMinutesBefore ?? ''}
onChange={(e) => updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)}
min={1}
title="Check-in cutoff override (minutes) for this stop"
/>
</div>
<div className="w-44">
<DateTimePicker
value={stop.plannedDepartureTime ?? ''}
onChange={(v) => updateStop(index, 'plannedDepartureTime', v)}
placeholder="Dep time"
label="Planned Departure"
/>
</div>
<div className="w-44">
<DateTimePicker
value={stop.plannedArrivalTime ?? ''}
onChange={(v) => updateStop(index, 'plannedArrivalTime', v)}
placeholder="Arr time"
label="Planned Arrival"
/>
</div>
<button
type="button"
onClick={() => removeStop(index)}
@@ -790,7 +841,7 @@ export default function RoutesPage() {
<span className="text-muted-foreground">Select destination station above</span>
)}
</div>
<div className="w-28">
<div className="w-24">
{destinationStationId && (
<input
type="number"
@@ -803,7 +854,18 @@ export default function RoutesPage() {
/>
)}
</div>
<div className="w-28">
<div className="w-44" />
<div className="w-44">
{destinationStationId && (
<DateTimePicker
value={destinationArrivalTime}
onChange={setDestinationArrivalTime}
placeholder="Arr time"
label="Planned Arrival"
/>
)}
</div>
<div className="w-24">
{destinationStationId && (
<input
type="number"

View File

@@ -7,10 +7,29 @@ import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import DateTimePicker from '@/components/ui/DateTimePicker';
import { apiClient } from '@/lib/api-client';
import { routeCoachTemplatesApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
// EAT is UTC+3. Convert without depending on the browser's own timezone.
const EAT_MS = 3 * 60 * 60 * 1000;
// UTC ISO string → EAT "YYYY-MM-DDTHH:mm" for DateTimePicker display
const isoToEAT = (iso: string): string =>
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<any>(`/routes/${addForm.routeId}`),
enabled: !!addForm.routeId,
});
const { data: editRouteDetail } = useQuery({
queryKey: ['route-detail', editingSchedule?.routeId],
queryFn: () => apiClient.get<any>(`/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<HTMLFormElement>) => {
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() {
<Modal
isOpen={showAddModal}
onClose={() => { 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() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Departure *</label>
<input type="datetime-local" className="input" value={addForm.departureAt} onChange={(e) => setAddForm({ ...addForm, departureAt: e.target.value })} required />
<DateTimePicker
value={addForm.departureAt}
onChange={(v) => setAddForm({ ...addForm, departureAt: v })}
placeholder="Select departure"
/>
</div>
<div>
<label className="label">Arrival *</label>
<input type="datetime-local" className="input" value={addForm.arrivalAt} onChange={(e) => setAddForm({ ...addForm, arrivalAt: e.target.value })} required />
<DateTimePicker
value={addForm.arrivalAt}
onChange={(v) => setAddForm({ ...addForm, arrivalAt: v })}
placeholder="Select arrival"
/>
</div>
</div>
{addStopTimes.length > 0 && (
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-3">
<div>
<label className="label mb-0">Stop Times</label>
<p className="text-xs text-muted-foreground mt-0.5">
Set planned times for each stop. Leave all blank to auto-generate from distance.
</p>
</div>
<button
type="button"
className="text-xs text-primary underline"
onClick={() => {
const dep = addForm.departureAt ? new Date(addForm.departureAt + ':00Z') : null;
const arr = addForm.arrivalAt ? new Date(addForm.arrivalAt + ':00Z') : null;
if (!dep || !arr || arr <= dep) return;
const stops = (addRouteDetail as any)?.stops ?? [];
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = stops[stops.length - 1]?.distanceKm || 1;
setAddStopTimes(addStopTimes.map((s, i) => {
const stop = stops.find((st: any) => st.sequence === s.sequence);
const dist = stop?.distanceKm ?? 0;
const t = new Date(dep.getTime() + (dist / totalDistance) * totalDuration);
const fmt = t.toISOString().slice(0, 16);
return {
...s,
plannedArrivalAt: i === 0 ? '' : fmt,
plannedDepartureAt: i === addStopTimes.length - 1 ? '' : fmt,
};
}));
}}
>
Auto-fill from departure/arrival
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground border-b">
<th className="pb-2 pr-3 font-medium">#</th>
<th className="pb-2 pr-3 font-medium">Station</th>
<th className="pb-2 pr-3 font-medium">Planned Arrival</th>
<th className="pb-2 font-medium">Planned Departure</th>
</tr>
</thead>
<tbody className="divide-y">
{addStopTimes.map((stop, i) => {
const isFirst = i === 0;
const isLast = i === addStopTimes.length - 1;
return (
<tr key={stop.sequence}>
<td className="py-2 pr-3 text-muted-foreground">{stop.sequence}</td>
<td className="py-2 pr-3 font-medium whitespace-nowrap">{stop.stationName}</td>
<td className="py-2 pr-3 min-w-[200px]">
{isFirst ? (
<span className="text-xs text-muted-foreground italic"></span>
) : (
<DateTimePicker
value={stop.plannedArrivalAt}
onChange={(v) => {
const updated = [...addStopTimes];
updated[i] = { ...updated[i], plannedArrivalAt: v };
setAddStopTimes(updated);
}}
placeholder="Pick arrival"
/>
)}
</td>
<td className="py-2 min-w-[200px]">
{isLast ? (
<span className="text-xs text-muted-foreground italic"></span>
) : (
<DateTimePicker
value={stop.plannedDepartureAt}
onChange={(v) => {
const updated = [...addStopTimes];
updated[i] = { ...updated[i], plannedDepartureAt: v };
setAddStopTimes(updated);
}}
placeholder="Pick departure"
/>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-2">
<label className="label mb-0">Coaches</label>
@@ -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() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Departure Date & Time *</label>
<input
type="datetime-local"
<DateTimePicker
value={editForm.departureAt}
onChange={(e) => setEditForm({ ...editForm, departureAt: e.target.value })}
className="input"
required
onChange={(v) => setEditForm({ ...editForm, departureAt: v })}
placeholder="Select departure"
/>
</div>
<div>
<label className="label">Arrival Date & Time *</label>
<input
type="datetime-local"
<DateTimePicker
value={editForm.arrivalAt}
onChange={(e) => setEditForm({ ...editForm, arrivalAt: e.target.value })}
className="input"
required
onChange={(v) => setEditForm({ ...editForm, arrivalAt: v })}
placeholder="Select arrival"
/>
</div>
</div>
@@ -1072,6 +1273,101 @@ export default function SchedulesPage() {
</label>
</div>
{editStopTimes.length > 0 && (
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-3">
<div>
<label className="label mb-0">Stop Times</label>
<p className="text-xs text-muted-foreground mt-0.5">
Edit planned times for each stop. All stops must be filled to update.
</p>
</div>
<button
type="button"
className="text-xs text-primary underline"
onClick={() => {
const dep = editForm.departureAt ? new Date(editForm.departureAt + ':00Z') : null;
const arr = editForm.arrivalAt ? new Date(editForm.arrivalAt + ':00Z') : null;
if (!dep || !arr || arr <= dep) return;
const stops = (editRouteDetail as any)?.stops ?? [];
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = stops[stops.length - 1]?.distanceKm || 1;
setEditStopTimes(editStopTimes.map((s, i) => {
const stop = stops.find((st: any) => st.sequence === s.sequence);
const dist = stops.length > 0 ? (stop?.distanceKm ?? 0) : 0;
const progress = stops.length > 0 && totalDistance > 0
? dist / totalDistance
: i / Math.max(editStopTimes.length - 1, 1);
const t = new Date(dep.getTime() + totalDuration * progress);
const fmt = t.toISOString().slice(0, 16);
return {
...s,
plannedArrivalAt: i === 0 ? '' : fmt,
plannedDepartureAt: i === editStopTimes.length - 1 ? '' : fmt,
};
}));
}}
>
Auto-fill from departure/arrival
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground border-b">
<th className="pb-2 pr-3 font-medium">#</th>
<th className="pb-2 pr-3 font-medium">Station</th>
<th className="pb-2 pr-3 font-medium">Planned Arrival</th>
<th className="pb-2 font-medium">Planned Departure</th>
</tr>
</thead>
<tbody className="divide-y">
{editStopTimes.map((stop, i) => {
const isFirst = i === 0;
const isLast = i === editStopTimes.length - 1;
return (
<tr key={stop.sequence}>
<td className="py-2 pr-3 text-muted-foreground">{stop.sequence}</td>
<td className="py-2 pr-3 font-medium whitespace-nowrap">{stop.stationName}</td>
<td className="py-2 pr-3 min-w-[200px]">
{isFirst ? (
<span className="text-xs text-muted-foreground italic"></span>
) : (
<DateTimePicker
value={stop.plannedArrivalAt}
onChange={(v) => {
const updated = [...editStopTimes];
updated[i] = { ...updated[i], plannedArrivalAt: v };
setEditStopTimes(updated);
}}
placeholder="Pick arrival"
/>
)}
</td>
<td className="py-2 min-w-[200px]">
{isLast ? (
<span className="text-xs text-muted-foreground italic"></span>
) : (
<DateTimePicker
value={stop.plannedDepartureAt}
onChange={(v) => {
const updated = [...editStopTimes];
updated[i] = { ...updated[i], plannedDepartureAt: v };
setEditStopTimes(updated);
}}
placeholder="Pick departure"
/>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
<div>
<div className="flex items-center justify-between mb-2">
<label className="label">Coaches (Optional)</label>
@@ -1129,6 +1425,7 @@ export default function SchedulesPage() {
onClick={() => {
setShowEditModal(false);
setEditingSchedule(null);
setEditStopTimes([]);
setError(null);
}}
>

View File

@@ -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 },
]
},
{

View File

@@ -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<typeof parseLocalString>): 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<Date | undefined>(parsed?.date);
const [hours12, setHours12] = useState<number>(parsed?.hours12 ?? 12);
const [minutes, setMinutes] = useState<number>(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(
<div
className="fixed inset-0 flex items-center justify-center p-4"
style={{ zIndex: 10050 }}
>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
{/* Panel */}
<div className="relative bg-background border border-border rounded-2xl shadow-2xl p-5 w-80 animate-fade-up">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-foreground">
{label ?? placeholder}
</h3>
<button
type="button"
onClick={() => setOpen(false)}
className="h-7 w-7 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Calendar */}
<DayPicker
mode="single"
selected={selectedDate}
onSelect={handleDaySelect}
showOutsideDays
classNames={{
root: 'w-full',
months: 'w-full',
month: 'w-full',
month_caption: 'flex items-center justify-between mb-3',
caption_label: 'text-sm font-semibold text-foreground',
nav: 'flex items-center gap-1',
button_previous: [
'h-7 w-7 rounded-lg flex items-center justify-center',
'text-muted-foreground hover:text-foreground hover:bg-muted transition-colors',
].join(' '),
button_next: [
'h-7 w-7 rounded-lg flex items-center justify-center',
'text-muted-foreground hover:text-foreground hover:bg-muted transition-colors',
].join(' '),
month_grid: 'w-full border-collapse',
weekdays: 'flex w-full mb-1',
weekday: 'flex-1 text-center text-xs font-medium text-muted-foreground py-1',
weeks: '',
week: 'flex w-full mt-0.5',
day: 'flex-1 flex items-center justify-center p-0',
day_button: [
'h-8 w-8 text-xs rounded-lg flex items-center justify-center',
'transition-colors hover:bg-muted cursor-pointer',
].join(' '),
selected: '',
today: '',
outside: 'opacity-30',
disabled: 'opacity-20 cursor-not-allowed',
hidden: 'invisible',
range_start: '',
range_end: '',
range_middle: '',
focused: 'ring-1 ring-primary/50',
chevron: '',
dropdowns: '',
dropdown: '',
dropdown_root: '',
footer: '',
months_dropdown: '',
week_number: '',
week_number_header: '',
years_dropdown: '',
weeks_after_enter: '',
weeks_after_exit: '',
weeks_before_enter: '',
weeks_before_exit: '',
}}
components={{
Chevron: ({ orientation }) =>
orientation === 'left' ? (
<ChevronLeft className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
),
DayButton: ({ day, modifiers, className, ...props }) => (
<button
{...props}
className={cn(
className,
modifiers.selected && 'bg-primary text-primary-foreground font-semibold',
modifiers.today && !modifiers.selected && 'text-primary font-bold',
)}
/>
),
}}
/>
{/* Time picker */}
<div className="mt-3 pt-3 border-t border-border">
<p className="text-xs font-medium text-muted-foreground mb-3">Time</p>
<div className="flex items-center gap-3">
{/* Hour spinner */}
<div className="flex flex-col items-center gap-0.5">
<button
type="button"
onClick={() => cycleHour(-1)}
className="h-6 w-10 flex items-center justify-center rounded hover:bg-muted text-muted-foreground transition-colors"
>
<ChevronUp className="h-3.5 w-3.5" />
</button>
<input
type="text"
inputMode="numeric"
value={String(hours12).padStart(2, '0')}
onChange={e => 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"
/>
<button
type="button"
onClick={() => cycleHour(1)}
className="h-6 w-10 flex items-center justify-center rounded hover:bg-muted text-muted-foreground transition-colors"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</div>
<span className="text-2xl font-bold text-foreground leading-none mb-0.5">:</span>
{/* Minute spinner */}
<div className="flex flex-col items-center gap-0.5">
<button
type="button"
onClick={() => cycleMinute(-1)}
className="h-6 w-10 flex items-center justify-center rounded hover:bg-muted text-muted-foreground transition-colors"
>
<ChevronUp className="h-3.5 w-3.5" />
</button>
<input
type="text"
inputMode="numeric"
value={String(minutes).padStart(2, '0')}
onChange={e => 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"
/>
<button
type="button"
onClick={() => cycleMinute(1)}
className="h-6 w-10 flex items-center justify-center rounded hover:bg-muted text-muted-foreground transition-colors"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</div>
{/* AM / PM */}
<div className="flex flex-col gap-1.5 ml-auto">
<button
type="button"
onClick={() => togglePeriod('AM')}
className={cn(
'px-4 py-1.5 text-sm font-semibold rounded-lg border transition-colors',
period === 'AM'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-muted-foreground border-border hover:bg-muted',
)}
>
AM
</button>
<button
type="button"
onClick={() => togglePeriod('PM')}
className={cn(
'px-4 py-1.5 text-sm font-semibold rounded-lg border transition-colors',
period === 'PM'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-muted-foreground border-border hover:bg-muted',
)}
>
PM
</button>
</div>
</div>
</div>
{/* Confirm */}
<button
type="button"
onClick={() => setOpen(false)}
className="mt-4 w-full btn btn-primary text-sm py-2"
>
Confirm
</button>
</div>
</div>,
document.body,
) : null;
const displayText = parsed ? formatDisplay(parsed) : placeholder;
return (
<div className="relative">
<button
type="button"
id={id}
onClick={() => setOpen(true)}
className={cn(
'input flex items-center gap-2 text-left cursor-pointer',
!parsed && 'text-muted-foreground',
)}
>
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="flex-1 text-sm">{displayText}</span>
</button>
{modal}
</div>
);
}

11
pnpm-lock.yaml generated
View File

@@ -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